From 5e1cd79d1ffb674e396786e038c0957c31053bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Mon, 23 Dec 2024 13:14:03 +0100 Subject: [PATCH] fix: handling http response headers (#106) Moves route matching and route component loading into middleware when using the file-system based router to remove async implementation in the React Server Component entrypoint of the router. Changes RSC payload handling to use the same approach as in the HTML SSR renderer to handle async components better. Introduces a rendering lock mechanism to suspend sending the response to be able to handle response headers in async components until the lock is released. Refactors HTTP response header handling to use `Headers` directly and introduces helper functions to set, append, delete, or clear response headers in the HTTP context. Fixes an issue with accessing the `Response` sent to the client when using `useResponse` in an async React Server Component. Adds test cases for above changes and updates the docs to include the new helpers and to give more details about how to handle response headers. #100 --- docs/src/pages/en/(pages)/framework/http.mdx | 92 +++++++++- packages/react-server/lib/build/server.mjs | 6 +- packages/react-server/lib/dev/ssr-handler.mjs | 3 + packages/react-server/lib/handlers/error.mjs | 16 +- .../lib/plugins/file-router/entrypoint.jsx | 100 ++++++----- .../react-server/lib/start/ssr-handler.mjs | 11 +- packages/react-server/server/http-headers.mjs | 51 +++++- packages/react-server/server/index.d.ts | 81 ++++++++- packages/react-server/server/index.mjs | 9 +- packages/react-server/server/render-rsc.jsx | 162 +++++++++--------- packages/react-server/server/render.mjs | 41 +++++ packages/react-server/server/request.mjs | 3 +- packages/react-server/server/symbols.mjs | 3 + test/__test__/http.spec.mjs | 38 +++- test/fixtures/http-headers-append.jsx | 8 + test/fixtures/http-headers-set.jsx | 8 + test/fixtures/http-response.jsx | 18 ++ test/fixtures/render-lock.jsx | 21 +++ 18 files changed, 524 insertions(+), 147 deletions(-) create mode 100644 packages/react-server/server/render.mjs create mode 100644 test/fixtures/http-headers-append.jsx create mode 100644 test/fixtures/http-headers-set.jsx create mode 100644 test/fixtures/http-response.jsx create mode 100644 test/fixtures/render-lock.jsx diff --git a/docs/src/pages/en/(pages)/framework/http.mdx b/docs/src/pages/en/(pages)/framework/http.mdx index 2f8703f..ac66bc0 100644 --- a/docs/src/pages/en/(pages)/framework/http.mdx +++ b/docs/src/pages/en/(pages)/framework/http.mdx @@ -60,7 +60,7 @@ export default function MyComponent() { ## Response -With `useResponse()` you can get access to the full HTTP response. +With `useResponse()` you can get access to the full HTTP response. This is only available after the response has been sent to the client, in a React component which was suspended and streamed to the client later than the response was sent. ```jsx import { useResponse } from "@lazarv/react-server"; @@ -143,7 +143,7 @@ export default function MyComponent() { }; ``` -You can also modify the headers of the current response. +You can also modify the headers of the current response by passing an object of key-value pairs: ```jsx import { headers } from "@lazarv/react-server"; @@ -157,6 +157,50 @@ export default function MyComponent() { }; ``` +Or by passing a `Headers` object: + +```jsx +import { headers } from "@lazarv/react-server"; + +export default function MyComponent() { + headers(new Headers({ + "X-My-Header": "My value", + })); + + return

Headers: {JSON.stringify(headers())}

; +}; +``` + +Or by passing an array of key-value pairs: + +```jsx +import { headers } from "@lazarv/react-server"; + +export default function MyComponent() { + headers([ + ["X-My-Header", "My value"], + ]); + + return

Headers: {JSON.stringify(headers())}

; +}; +``` + +Modifying the headers with the `headers()` function will override the headers of the current response. If you want to mutate the response headers directly, you can use three addition helper functions to set, append or delete headers. These functions are `setHeader()`, `appendHeader()` and `deleteHeader()`. + +```jsx +import { setHeader, appendHeader, deleteHeader } from "@lazarv/react-server"; + +export default function MyComponent() { + setHeader("X-My-Header", "My first value"); + appendHeader("X-My-Header", "My second value"); + deleteHeader("X-My-Header"); + + return

Check the response headers!

; +} +``` + +> **Note:** Keep in mind that HTTP headers are case-insensitive! + ## Cookies @@ -277,3 +321,47 @@ export default function MyComponent() { return

Outlet: {outlet}

; } ``` + + +## Render Lock + + +With `useRender()` you can get access to the render lock of the current request. This is useful if you want to lock rendering of a React Server Component while the async function is running or until the lock is released, as React Server Components are rendered using streaming by default. This is especially useful for handling HTTP headers and cookies in an async React Server Component. Without locking the rendering, the headers and cookies will be sent to the client before the async function is finished. When a lock is detected in the rendering process, the rendering will wait for the lock to be released before beginning to send the headers and cookies to the client and starting the streaming of the React Server Component. + +```jsx +import { headers, useRender } from "@lazarv/react-server"; + +export default function MyComponent() { + const { lock } = useRender(); + + await lock(async () => { + // Do something async + await new Promise((resolve) => setTimeout(resolve, 1000)); + headers({ + "x-lock": "works", + }); + }); + + return

Render lock

; +} +``` + +You can also use the `lock()` function to get an `unlock()` function to release the lock later. + +```jsx +import { headers, useRender } from "@lazarv/react-server"; + +export default function MyComponent() { + const { lock } = useRender(); + + const unlock = lock(); + // Do something async + await new Promise((resolve) => setTimeout(resolve, 1000)); + headers({ + "x-lock": "works", + }); + unlock(); + + return

Render lock

; +} +``` diff --git a/packages/react-server/lib/build/server.mjs b/packages/react-server/lib/build/server.mjs index 2856a95..471478d 100644 --- a/packages/react-server/lib/build/server.mjs +++ b/packages/react-server/lib/build/server.mjs @@ -153,7 +153,11 @@ export default async function serverBuild(root, options) { entryFileNames: "[name].mjs", chunkFileNames: "server/[name].[hash].mjs", manualChunks: (id, ...rest) => { - if (id.includes("@lazarv/react-server") && id.endsWith(".mjs")) { + if ( + (id.includes("@lazarv/react-server") || + id.includes(sys.rootDir)) && + id.endsWith(".mjs") + ) { return "@lazarv/react-server"; } return ( diff --git a/packages/react-server/lib/dev/ssr-handler.mjs b/packages/react-server/lib/dev/ssr-handler.mjs index 950d4da..037d876 100644 --- a/packages/react-server/lib/dev/ssr-handler.mjs +++ b/packages/react-server/lib/dev/ssr-handler.mjs @@ -23,6 +23,7 @@ import { MEMORY_CACHE_CONTEXT, MODULE_LOADER, REDIRECT_CONTEXT, + RENDER, RENDER_CONTEXT, RENDER_STREAM, SERVER_CONTEXT, @@ -109,6 +110,7 @@ export default async function ssrHandler(root) { const renderContext = createRenderContext(httpContext); context$(RENDER_CONTEXT, renderContext); + context$(RENDER, render); try { const middlewares = await root_init$?.(); @@ -132,6 +134,7 @@ export default async function ssrHandler(root) { } const styles = collectStylesheets?.(rootModule) ?? []; + styles.unshift(...(getContext(STYLES_CONTEXT) ?? [])); context$(STYLES_CONTEXT, styles); await module_loader_init$?.(ssrLoadModule, moduleCacheStorage); diff --git a/packages/react-server/lib/handlers/error.mjs b/packages/react-server/lib/handlers/error.mjs index 45ddab3..d0b8dbf 100644 --- a/packages/react-server/lib/handlers/error.mjs +++ b/packages/react-server/lib/handlers/error.mjs @@ -101,12 +101,12 @@ function plainResponse(e) { status: 500, statusText: "Internal Server Error", }; + const headers = getContext(HTTP_HEADERS) ?? new Headers(); + headers.set("Content-Type", "text/plain; charset=utf-8"); + return new Response(e?.stack ?? null, { ...httpStatus, - headers: { - "Content-Type": "text/plain; charset=utf-8", - ...(getContext(HTTP_HEADERS) ?? {}), - }, + headers, }); } @@ -130,6 +130,9 @@ export default async function errorHandler(err) { statusText: "Internal Server Error", }; + const headers = getContext(HTTP_HEADERS) ?? new Headers(); + headers.set("Content-Type", "text/html; charset=utf-8"); + const error = await prepareError(err); const viteDevServer = getContext(SERVER_CONTEXT); @@ -163,10 +166,7 @@ export default async function errorHandler(err) { `, { ...httpStatus, - headers: { - "Content-Type": "text/html; charset=utf-8", - ...(getContext(HTTP_HEADERS) ?? {}), - }, + headers, } ); } catch (e) { diff --git a/packages/react-server/lib/plugins/file-router/entrypoint.jsx b/packages/react-server/lib/plugins/file-router/entrypoint.jsx index 6a3af8f..672bf69 100644 --- a/packages/react-server/lib/plugins/file-router/entrypoint.jsx +++ b/packages/react-server/lib/plugins/file-router/entrypoint.jsx @@ -7,9 +7,12 @@ import { routes, } from "@lazarv/react-server/file-router/manifest"; import { useMatch } from "@lazarv/react-server/router"; -import { context$ } from "@lazarv/react-server/server/context.mjs"; +import { context$, getContext } from "@lazarv/react-server/server/context.mjs"; import { ROUTE_MATCH } from "@lazarv/react-server/server/symbols.mjs"; +const PAGE_MATCH = Symbol("PAGE_MATCH"); +const PAGE_COMPONENT = Symbol("PAGE_COMPONENT"); + export async function init$() { return async (context) => { for (const handler of middlewares) { @@ -43,58 +46,67 @@ export async function init$() { (() => {}) )(context); } - }; -} - -export default async function App() { - let match = null; - let Page = () => { - status(404); - return null; - }; - const reactServerOutlet = useOutlet(); - if (reactServerOutlet && reactServerOutlet !== "PAGE_ROOT") { - const outlets = pages.filter( - ([, type, outlet]) => type === "page" && outlet === reactServerOutlet - ); - for (const [path, , , , , lazy] of outlets) { - const match = useMatch(path, { exact: true }); - if (match) { - const { default: Component, init$: page_init$ } = await lazy(); - await page_init$?.(); - return ; + const reactServerOutlet = useOutlet(); + if (reactServerOutlet && reactServerOutlet !== "PAGE_ROOT") { + const outlets = pages.filter( + ([, type, outlet]) => type === "page" && outlet === reactServerOutlet + ); + for (const [path, , , , , lazy] of outlets) { + const match = useMatch(path, { exact: true }); + if (match) { + const { default: Component, init$: page_init$ } = await lazy(); + await page_init$?.(); + context$(PAGE_COMPONENT, Component); + context$(PAGE_MATCH, match); + return; + } } - } - return null; - } - - for (const [path, type, outlet, lazy, src] of pages) { - match = type === "page" && !outlet ? useMatch(path, { exact: true }) : null; - if (match) { - const { default: Component, init$: page_init$ } = await lazy(); - Page = Component; - await page_init$?.(); - break; + context$(PAGE_COMPONENT, null); + return; } - match = type === "page" && outlet ? useMatch(path, { exact: true }) : null; - if (match) { - const [, , , lazy] = - pages.find( - ([, type, outlet, , pageSrc]) => - type === "page" && - !outlet && - dirname(src).includes(dirname(pageSrc)) - ) ?? []; - if (lazy) { + for (const [path, type, outlet, lazy, src] of pages) { + match = + type === "page" && !outlet ? useMatch(path, { exact: true }) : null; + if (match) { const { default: Component, init$: page_init$ } = await lazy(); - Page = Component; await page_init$?.(); + context$(PAGE_COMPONENT, Component); + context$(PAGE_MATCH, match); break; } + + match = + type === "page" && outlet ? useMatch(path, { exact: true }) : null; + if (match) { + const [, , , lazy] = + pages.find( + ([, type, outlet, , pageSrc]) => + type === "page" && + !outlet && + dirname(src).includes(dirname(pageSrc)) + ) ?? []; + if (lazy) { + const { default: Component, init$: page_init$ } = await lazy(); + await page_init$?.(); + context$(PAGE_COMPONENT, Component); + context$(PAGE_MATCH, match); + break; + } + } } - } + }; +} + +export default async function App() { + let match = getContext(PAGE_MATCH) ?? null; + let Page = + getContext(PAGE_COMPONENT) ?? + (() => { + status(404); + return null; + }); return ; } diff --git a/packages/react-server/lib/start/ssr-handler.mjs b/packages/react-server/lib/start/ssr-handler.mjs index 426f802..3db9501 100644 --- a/packages/react-server/lib/start/ssr-handler.mjs +++ b/packages/react-server/lib/start/ssr-handler.mjs @@ -29,6 +29,7 @@ import { POSTPONE_STATE, PRELUDE_HTML, REDIRECT_CONTEXT, + RENDER, RENDER_CONTEXT, RENDER_STREAM, SERVER_CONTEXT, @@ -126,12 +127,13 @@ export default async function ssrHandler(root, options = {}) { status: 500, statusText: "Internal Server Error", }; + + const headers = getContext(HTTP_HEADERS) ?? new Headers(); + headers.set("Content-Type", "text/plain; charset=utf-8"); + return new Response(e?.stack ?? null, { ...httpStatus, - headers: { - "Content-Type": "text/plain; charset=utf-8", - ...(getContext(HTTP_HEADERS) ?? {}), - }, + headers, }); }; @@ -176,6 +178,7 @@ export default async function ssrHandler(root, options = {}) { const renderContext = createRenderContext(httpContext); context$(RENDER_CONTEXT, renderContext); + context$(RENDER, render); try { const middlewares = await root_init$?.(); diff --git a/packages/react-server/server/http-headers.mjs b/packages/react-server/server/http-headers.mjs index 27691a7..8f23189 100644 --- a/packages/react-server/server/http-headers.mjs +++ b/packages/react-server/server/http-headers.mjs @@ -1,8 +1,53 @@ -import { context$ } from "./context.mjs"; +import { context$, getContext } from "./context.mjs"; import { useRequest } from "./request.mjs"; import { HTTP_HEADERS } from "./symbols.mjs"; -export function headers(setHeaders = {}) { - context$(HTTP_HEADERS, setHeaders); +export function headers(setHeaders) { + if (setHeaders) { + const httpHeaders = getContext(HTTP_HEADERS) ?? new Headers(); + + if (setHeaders instanceof Headers) { + for (const [key, value] of setHeaders.entries()) { + httpHeaders.set(key, value); + } + } else if (Array.isArray(setHeaders)) { + for (const [key, value] of setHeaders) { + httpHeaders.set(key, value); + } + } else { + for (const [key, value] of Object.entries(setHeaders)) { + httpHeaders.set(key, value); + } + } + + context$(HTTP_HEADERS, httpHeaders); + } + return useRequest().headers; } + +export function setHeader(key, value) { + const httpHeaders = getContext(HTTP_HEADERS) ?? new Headers(); + httpHeaders.set(key, value); + context$(HTTP_HEADERS, httpHeaders); +} + +export function appendHeader(key, value) { + const httpHeaders = getContext(HTTP_HEADERS) ?? new Headers(); + httpHeaders.append(key, value); + context$(HTTP_HEADERS, httpHeaders); +} + +export function deleteHeader(key) { + const httpHeaders = getContext(HTTP_HEADERS) ?? new Headers(); + httpHeaders.delete(key); + context$(HTTP_HEADERS, httpHeaders); +} + +export function clearHeaders() { + const httpHeaders = getContext(HTTP_HEADERS) ?? new Headers(); + for (const key of httpHeaders.keys()) { + httpHeaders.delete(key); + } + context$(HTTP_HEADERS, httpHeaders); +} diff --git a/packages/react-server/server/index.d.ts b/packages/react-server/server/index.d.ts index 9df2ca5..5d635eb 100644 --- a/packages/react-server/server/index.d.ts +++ b/packages/react-server/server/index.d.ts @@ -198,7 +198,37 @@ export function status(status?: number, statusText?: string): void; * } * ``` */ -export function headers(headers?: Record): void; +export function headers( + headers?: Record | Headers | [string, string][] +): void; + +/** + * Set a response header in the current request context. + * + * @param key - The header key + * @param value - The header value + */ +export function setHeader(key: string, value: string): void; + +/** + * Append a response header in the current request context. + * + * @param key - The header key + * @param value - The header value + */ +export function appendHeader(key: string, value: string): void; + +/** + * Delete a response header in the current request context. + * + * @param key - The header key + */ +export function deleteHeader(key: string): void; + +/** + * Clear all response headers in the current request context. + */ +export function clearHeaders(): void; /** * Get the active outlet when using client navigation. @@ -255,3 +285,52 @@ export interface ReactServerCache { hasExpiry(keys: string[], ttl: number): Promise; delete(keys: string[]): Promise; } + +/** + * This function returns an object which contains helper functions to control the render process. `lock()` enables you to lock the render of the current component to wait for the specified task to complete before sending HTTP headers and cookies to the client. + * + * @returns An object with two methods: `lock(task: () => Promise)` and `lock(): () => void` + */ +export function useRender(): { + /** + * Lock the render of the current component to wait for the specified task to complete before sending HTTP headers and cookies to the client. + * + * @param task - The task to wait for + * + * @example + * + * ```tsx + * import { useRender } from '@lazarv/react-server'; + * + * export async function App() { + * const { lock } = useRender(); + * await lock(async () => { + * await new Promise((resolve) => setTimeout(resolve, 1000)); + * }); + * return

Render lock

; + * } + * ``` + */ + lock(task: () => Promise): Promise; + /** + * Lock the render process and returns a function to unlock it. + * + * @returns A function to unlock the render process + * + * @example + * + * ```tsx + * import { useRender } from '@lazarv/react-server'; + * + * export async function App() { + * const { lock } = useRender(); + * const unlock = lock(); + * await new Promise((resolve) => setTimeout(resolve, 1000)); + * unlock(); + * + * return

Render lock

; + * } + * ``` + */ + lock(): () => void; +}; diff --git a/packages/react-server/server/index.mjs b/packages/react-server/server/index.mjs index f4db256..e6d6784 100644 --- a/packages/react-server/server/index.mjs +++ b/packages/react-server/server/index.mjs @@ -1,6 +1,12 @@ export { useResponseCache, withCache } from "./cache.mjs"; export { cookie, deleteCookie, setCookie } from "./cookies.mjs"; -export { headers } from "./http-headers.mjs"; +export { + headers, + setHeader, + appendHeader, + deleteHeader, + clearHeaders, +} from "./http-headers.mjs"; export { status } from "./http-status.mjs"; export { redirect } from "./redirects.mjs"; export { @@ -18,3 +24,4 @@ export { export { revalidate } from "./revalidate.mjs"; export { invalidate, useCache } from "../memory-cache/index.mjs"; export { reload } from "./reload.mjs"; +export { useRender } from "./render.mjs"; diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx index 730e299..aa556a5 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -2,7 +2,11 @@ import { ReadableStream } from "node:stream/web"; import server from "react-server-dom-webpack/server.edge"; -import { concat, copyBytesFrom } from "@lazarv/react-server/lib/sys.mjs"; +import { + concat, + copyBytesFrom, + immediate, +} from "@lazarv/react-server/lib/sys.mjs"; import { clientReferenceMap } from "@lazarv/react-server/server/client-reference-map.mjs"; import { context$, @@ -23,6 +27,7 @@ import { HTML_CACHE, HTTP_CONTEXT, HTTP_HEADERS, + HTTP_RESPONSE, HTTP_STATUS, IMPORT_MAP, LOGGER_CONTEXT, @@ -30,9 +35,10 @@ import { POSTPONE_STATE, PRELUDE_HTML, REDIRECT_CONTEXT, - RENDER_CONTEXT, RELOAD, + RENDER_CONTEXT, RENDER_STREAM, + RENDER_WAIT, STYLES_CONTEXT, } from "@lazarv/react-server/server/symbols.mjs"; @@ -297,16 +303,7 @@ export async function render(Component) { new Response(responseFromCache.buffer, { status: responseFromCache.status, statusText: responseFromCache.statusText, - headers: { - "content-type": "text/x-component; charset=utf-8", - "cache-control": - context.request.headers.get("cache-control") === - "no-cache" - ? "no-cache" - : "must-revalidate", - "last-modified": lastModified, - ...responseFromCache.headers, - }, + headers: responseFromCache.headers, }) ); } @@ -315,6 +312,23 @@ export async function render(Component) { const stream = new ReadableStream({ type: "bytes", async start(controller) { + const prevHeaders = getContext(HTTP_HEADERS); + context$( + HTTP_HEADERS, + new Headers({ + "content-type": "text/x-component; charset=utf-8", + "cache-control": + context.request.headers.get("cache-control") === "no-cache" + ? "no-cache" + : "must-revalidate", + "last-modified": lastModified, + ...callServerHeaders, + ...(prevHeaders + ? Object.fromEntries(prevHeaders.entries()) + : {}), + }) + ); + const flight = server.renderToReadableStream( app, clientReferenceMap({ remote, origin }) @@ -323,9 +337,26 @@ export async function render(Component) { const reader = flight.getReader(); let done = false; const payload = []; - let breakOnNewLine = false; + const interrupt = new Promise((resolve) => + immediate(() => resolve("interrupt")) + ); + let next = null; while (!done) { - const { value, done: _done } = await reader.read(); + const read = next ? next : reader.read(); + const res = await Promise.race([ + read, + getContext(RENDER_WAIT) ?? interrupt, + ]); + if (res === RENDER_WAIT) { + context$(RENDER_WAIT, null); + next = read; + continue; + } else if (res === "interrupt") { + next = read; + break; + } + next = null; + const { value, done: _done } = res; done = _done; if (value) { const redirect = getContext(REDIRECT_CONTEXT); @@ -335,26 +366,6 @@ export async function render(Component) { } payload.push(copyBytesFrom(value)); - - const endsWithNewLine = value[value.length - 1] === 0x0a; - if (breakOnNewLine && endsWithNewLine) { - break; - } - - const lastNewLine = value.lastIndexOf(0x0a); - if ( - (value[0] === 0x30 && value[1] === 0x3a) || - (lastNewLine > 0 && - lastNewLine < value.length - 2 && - value[lastNewLine + 1] === 0x30 && - value[lastNewLine + 2] === 0x3a) - ) { - if (endsWithNewLine) { - break; - } else { - breakOnNewLine = true; - } - } } } @@ -364,26 +375,20 @@ export async function render(Component) { status: 200, statusText: "OK", }; - const httpHeaders = getContext(HTTP_HEADERS) ?? {}; - resolve( - new Response(stream, { - ...httpStatus, - headers: { - "content-type": "text/x-component; charset=utf-8", - "cache-control": - context.request.headers.get("cache-control") === - "no-cache" - ? "no-cache" - : "must-revalidate", - "last-modified": lastModified, - ...(callServer ? callServerHeaders : {}), - ...httpHeaders, - }, - }) - ); + const headers = getContext(HTTP_HEADERS) ?? new Headers(); + + const response = new Response(stream, { + ...httpStatus, + headers, + }); + context$(HTTP_RESPONSE, response); + resolve(response); while (!done) { - const { value, done: _done } = await reader.read(); + const { value, done: _done } = await (next + ? next + : reader.read()); + next = null; done = _done; if (value) { payload.push(copyBytesFrom(value)); @@ -404,7 +409,7 @@ export async function render(Component) { { ...httpStatus, buffer: concat(payload), - headers: httpHeaders, + headers, } ); }, @@ -429,21 +434,26 @@ export async function render(Component) { new Response(stream, { status: responseFromCache.status, statusText: responseFromCache.statusText, - headers: { - "content-type": "text/html; charset=utf-8", - "cache-control": - context.request.headers.get("cache-control") === - "no-cache" - ? "no-cache" - : "must-revalidate", - "last-modified": lastModified, - ...responseFromCache.headers, - }, + headers: responseFromCache.headers, }) ); } } + const prevHeaders = getContext(HTTP_HEADERS); + context$( + HTTP_HEADERS, + new Headers({ + "content-type": "text/html; charset=utf-8", + "cache-control": + context.request.headers.get("cache-control") === "no-cache" + ? "no-cache" + : "must-revalidate", + "last-modified": lastModified, + ...(prevHeaders ? Object.fromEntries(prevHeaders.entries()) : {}), + }) + ); + const flight = server.renderToReadableStream( app, clientReferenceMap({ remote, origin }), @@ -506,7 +516,7 @@ export async function render(Component) { status: 200, statusText: "OK", }; - const httpHeaders = getContext(HTTP_HEADERS) ?? {}; + const headers = getContext(HTTP_HEADERS) ?? new Headers(); const [responseStream, cacheStream] = stream.tee(); const payload = []; @@ -525,27 +535,17 @@ export async function render(Component) { { ...httpStatus, buffer: concat(payload), - headers: httpHeaders, + headers, } ); })(); - resolve( - new Response(responseStream, { - ...httpStatus, - headers: { - "content-type": "text/html; charset=utf-8", - "cache-control": - context.request.headers.get("cache-control") === - "no-cache" - ? "no-cache" - : "must-revalidate", - "last-modified": lastModified, - ...(callServer ? callServerHeaders : {}), - ...httpHeaders, - }, - }) - ); + const response = new Response(responseStream, { + ...httpStatus, + headers, + }); + context$(HTTP_RESPONSE, response); + resolve(response); }); }, onError(e, digest) { diff --git a/packages/react-server/server/render.mjs b/packages/react-server/server/render.mjs new file mode 100644 index 0000000..7b59a45 --- /dev/null +++ b/packages/react-server/server/render.mjs @@ -0,0 +1,41 @@ +import { immediate } from "../lib/sys.mjs"; +import { context$, getContext } from "./context.mjs"; +import { RENDER, RENDER_WAIT } from "./symbols.mjs"; + +const RENDER_LOCK = Symbol("RENDER_LOCK"); + +export function useRender() { + const render = getContext(RENDER); + + const lock = (fn) => { + context$(RENDER_LOCK, (getContext(RENDER_LOCK) ?? 0) + 1); + let unlock; + const wait = new Promise((resolve) => { + unlock = () => { + immediate(() => { + const lockCount = getContext(RENDER_LOCK); + context$(RENDER_LOCK, lockCount - 1); + if (lockCount === 1) { + resolve(RENDER_WAIT); + } + }); + }; + }); + context$(RENDER_WAIT, wait); + if (!fn) { + return unlock; + } + return new Promise(async (resolve, reject) => { + try { + await fn(); + resolve(); + } catch (error) { + reject(error); + } finally { + unlock(); + } + }); + }; + + return { render, lock }; +} diff --git a/packages/react-server/server/request.mjs b/packages/react-server/server/request.mjs index 9e71352..adff174 100644 --- a/packages/react-server/server/request.mjs +++ b/packages/react-server/server/request.mjs @@ -3,6 +3,7 @@ import { FORM_DATA_PARSER, HTTP_CONTEXT, HTTP_OUTLET, + HTTP_RESPONSE, RENDER_CONTEXT, } from "@lazarv/react-server/server/symbols.mjs"; @@ -40,7 +41,7 @@ export function useRequest() { } export function useResponse() { - return getContext(HTTP_CONTEXT).response; + return getContext(HTTP_RESPONSE); } export async function useFormData(handleFile) { diff --git a/packages/react-server/server/symbols.mjs b/packages/react-server/server/symbols.mjs index b43d293..3d3d235 100644 --- a/packages/react-server/server/symbols.mjs +++ b/packages/react-server/server/symbols.mjs @@ -7,6 +7,7 @@ export const HTTP_CONTEXT = Symbol.for("HTTP_CONTEXT"); export const HTTP_STATUS = Symbol.for("HTTP_STATUS"); export const HTTP_HEADERS = Symbol.for("HTTP_HEADERS"); export const HTTP_OUTLET = Symbol.for("HTTP_OUTLET"); +export const HTTP_RESPONSE = Symbol.for("HTTP_RESPONSE"); export const REDIRECT_CONTEXT = Symbol.for("REDIRECT_CONTEXT"); export const MODULE_LOADER = Symbol.for("MODULE_LOADER"); export const ERROR_CONTEXT = Symbol.for("ERROR_CONTEXT"); @@ -24,8 +25,10 @@ export const STYLES_CONTEXT = Symbol.for("STYLES_CONTEXT"); export const BUILD_OPTIONS = Symbol.for("BUILD_OPTIONS"); export const ACTION_CONTEXT = Symbol.for("ACTION_CONTEXT"); export const RELOAD = Symbol.for("RELOAD"); +export const RENDER = Symbol.for("RENDER"); export const RENDER_STREAM = Symbol.for("RENDER_STREAM"); export const RENDER_CONTEXT = Symbol.for("RENDER_CONTEXT"); +export const RENDER_WAIT = Symbol.for("RENDER_WAIT"); export const WORKER_THREAD = Symbol.for("WORKER_THREAD"); export const PRELUDE_HTML = Symbol.for("PRELUDE_HTML"); export const POSTPONE_STATE = Symbol.for("POSTPONE_STATE"); diff --git a/test/__test__/http.spec.mjs b/test/__test__/http.spec.mjs index 8c75a9a..2c7e736 100644 --- a/test/__test__/http.spec.mjs +++ b/test/__test__/http.spec.mjs @@ -1,4 +1,4 @@ -import { hostname, page, server } from "playground/utils"; +import { hostname, page, server, serverLogs } from "playground/utils"; import { expect, test } from "vitest"; test("http context", async () => { @@ -37,6 +37,15 @@ test("http status", async () => { expect(await page.textContent("body")).toContain("Not Found"); }); +test("http response", async () => { + await server("fixtures/http-response.jsx"); + const response = await page.goto(hostname); + expect(await response.allHeaders()).toMatchObject({ + "x-custom": "custom-value", + }); + expect(serverLogs).toContain("x-custom custom-value"); +}); + test("http response headers", async () => { await server("fixtures/http-headers.jsx"); const response = await page.goto(hostname); @@ -46,6 +55,33 @@ test("http response headers", async () => { }); }); +test("http response headers append", async () => { + await server("fixtures/http-headers-append.jsx"); + const response = await page.goto(hostname); + expect(await response.allHeaders()).toMatchObject({ + "x-custom-header": "custom-value, another-value", + "cache-control": "must-revalidate, max-age=10", + }); +}); + +test("http response headers set", async () => { + await server("fixtures/http-headers-set.jsx"); + const response = await page.goto(hostname); + expect(await response.allHeaders()).toMatchObject({ + "x-custom-header": "another-value", + "cache-control": "max-age=10", + }); +}); + +test("http render lock", async () => { + await server("fixtures/render-lock.jsx"); + const response = await page.goto(hostname); + expect(await response.allHeaders()).toMatchObject({ + "x-wait": "works", + "x-suspend-resume": "works", + }); +}); + test("http cookies", async () => { await server("fixtures/http-cookies.jsx"); const response = await page.goto(hostname); diff --git a/test/fixtures/http-headers-append.jsx b/test/fixtures/http-headers-append.jsx new file mode 100644 index 0000000..6c2ffcc --- /dev/null +++ b/test/fixtures/http-headers-append.jsx @@ -0,0 +1,8 @@ +import { appendHeader } from "@lazarv/react-server"; + +export default function HttpHeadersPage() { + appendHeader("x-custom-header", "custom-value"); + appendHeader("x-custom-header", "another-value"); + appendHeader("cache-control", "max-age=10"); + return

Custom headers

; +} diff --git a/test/fixtures/http-headers-set.jsx b/test/fixtures/http-headers-set.jsx new file mode 100644 index 0000000..cceaa03 --- /dev/null +++ b/test/fixtures/http-headers-set.jsx @@ -0,0 +1,8 @@ +import { setHeader } from "@lazarv/react-server"; + +export default function HttpHeadersPage() { + setHeader("x-custom-header", "custom-value"); + setHeader("x-custom-header", "another-value"); + setHeader("cache-control", "max-age=10"); + return

Custom headers

; +} diff --git a/test/fixtures/http-response.jsx b/test/fixtures/http-response.jsx new file mode 100644 index 0000000..13dfe54 --- /dev/null +++ b/test/fixtures/http-response.jsx @@ -0,0 +1,18 @@ +import { Suspense } from "react"; +import { useResponse, setHeader } from "@lazarv/react-server"; + +async function Response() { + await new Promise((resolve) => setTimeout(resolve, 200)); + const res = useResponse(); + console.log("x-custom", res.headers.get("x-custom")); + return
HTTP Response
; +} + +export default function App() { + setHeader("x-custom", "custom-value"); + return ( + Loading...}> + + + ); +} diff --git a/test/fixtures/render-lock.jsx b/test/fixtures/render-lock.jsx new file mode 100644 index 0000000..4ce28c3 --- /dev/null +++ b/test/fixtures/render-lock.jsx @@ -0,0 +1,21 @@ +import { headers, useRender } from "@lazarv/react-server"; + +export default async function App() { + const { lock } = useRender(); + + await lock(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + headers({ + "x-wait": "works", + }); + }); + + const unlock = lock(); + await new Promise((resolve) => setTimeout(resolve, 100)); + headers({ + "x-suspend-resume": "works", + }); + unlock(); + + return
Hello World
; +}