Skip to content

Commit

Permalink
fix: handling http response headers (#106)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
lazarv authored Dec 23, 2024
1 parent b6bf710 commit 5e1cd79
Show file tree
Hide file tree
Showing 18 changed files with 524 additions and 147 deletions.
92 changes: 90 additions & 2 deletions docs/src/pages/en/(pages)/framework/http.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default function MyComponent() {
## Response
</Link>

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";
Expand Down Expand Up @@ -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";
Expand All @@ -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 <p>Headers: {JSON.stringify(headers())}</p>;
};
```

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 <p>Headers: {JSON.stringify(headers())}</p>;
};
```

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 <p>Check the response headers!</p>;
}
```

> **Note:** Keep in mind that HTTP headers are case-insensitive!
<Link name="cookies">
## Cookies
</Link>
Expand Down Expand Up @@ -277,3 +321,47 @@ export default function MyComponent() {
return <p>Outlet: {outlet}</p>;
}
```

<Link name="render-lock">
## Render Lock
</Link>

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 <p>Render lock</p>;
}
```

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 <p>Render lock</p>;
}
```
6 changes: 5 additions & 1 deletion packages/react-server/lib/build/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
3 changes: 3 additions & 0 deletions packages/react-server/lib/dev/ssr-handler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
MEMORY_CACHE_CONTEXT,
MODULE_LOADER,
REDIRECT_CONTEXT,
RENDER,
RENDER_CONTEXT,
RENDER_STREAM,
SERVER_CONTEXT,
Expand Down Expand Up @@ -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$?.();
Expand All @@ -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);
Expand Down
16 changes: 8 additions & 8 deletions packages/react-server/lib/handlers/error.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand All @@ -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);
Expand Down Expand Up @@ -163,10 +166,7 @@ export default async function errorHandler(err) {
</html>`,
{
...httpStatus,
headers: {
"Content-Type": "text/html; charset=utf-8",
...(getContext(HTTP_HEADERS) ?? {}),
},
headers,
}
);
} catch (e) {
Expand Down
100 changes: 56 additions & 44 deletions packages/react-server/lib/plugins/file-router/entrypoint.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <Component {...match} />;
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 <Page {...match} />;
}
11 changes: 7 additions & 4 deletions packages/react-server/lib/start/ssr-handler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
POSTPONE_STATE,
PRELUDE_HTML,
REDIRECT_CONTEXT,
RENDER,
RENDER_CONTEXT,
RENDER_STREAM,
SERVER_CONTEXT,
Expand Down Expand Up @@ -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,
});
};

Expand Down Expand Up @@ -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$?.();
Expand Down
Loading

0 comments on commit 5e1cd79

Please sign in to comment.