@remix-run/render-middleware 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # render-middleware
2
2
 
3
- Request-scoped renderer middleware for Remix. It stores a renderer in `fetch-router` request context so route actions can render responses without passing request-specific rendering details through every action.
3
+ Request-scoped response rendering for Remix. It provides the conventional Remix UI renderer and a low-level escape hatch for custom renderers.
4
4
 
5
5
  ## Features
6
6
 
7
- - **Generic renderers** - Render any input type to a `Response`
8
- - **Request-scoped setup** - Create renderers from the current `RequestContext`
9
- - **Typed context** - Typed `context.render` (or `context.get(Renderer)`)
10
- - **Small runtime** - The package only stores a renderer in request context
7
+ - **Remix UI rendering** - Stream nodes to HTML responses with `render()`
8
+ - **Framework-owned frames** - Resolve nested and targeted `<Frame>` requests through the current router
9
+ - **Client entry assets** - Resolve source-based `clientEntry()` modules and their preloads through an asset server
10
+ - **Typed context** - Preserve renderer input and response option types on `context.render`
11
+ - **Custom renderers** - Install JSON, email, or other response pipelines with `renderWith()`
11
12
 
12
13
  ## Installation
13
14
 
@@ -17,82 +18,96 @@ npm i remix
17
18
 
18
19
  ## Usage
19
20
 
20
- Use `renderWith()` to add a renderer to `context.render` and `context.get(Renderer)`.
21
+ Install `render()` in the router middleware stack. Pass an asset server when components use source-based client entries such as `clientEntry(import.meta.url, Component)`.
21
22
 
22
- ```ts
23
- import { createRouter, type MiddlewareContext } from 'remix/router'
24
- import { renderWith } from 'remix/middleware/render'
23
+ ```tsx
24
+ import { createAssetServer } from 'remix/assets'
25
+ import { render } from 'remix/middleware/render'
26
+ import { staticFiles } from 'remix/middleware/static'
27
+ import { createRouter } from 'remix/router'
28
+ import { Frame } from 'remix/ui'
29
+
30
+ let assets = createAssetServer({
31
+ basePath: '/assets',
32
+ rootDir: process.cwd(),
33
+ allowFiles: ['app/routes.ts', 'app/**/public/**'],
34
+ allowPackages: ['remix'],
35
+ denyFiles: ['app/**/*.test.*'],
36
+ })
25
37
 
26
- const render = renderWith(
27
- (context) =>
28
- function render(value: string, init?: ResponseInit) {
29
- return new Response(`${context.url.pathname}: ${value}`, init)
30
- },
38
+ let router = createRouter({
39
+ middleware: [staticFiles('./public'), render({ assets })],
40
+ })
41
+
42
+ router.get(
43
+ '/assets/*path',
44
+ async ({ request }) =>
45
+ (await assets.fetch(request)) ?? new Response('Not Found', { status: 404 }),
31
46
  )
32
47
 
33
- type AppContext = MiddlewareContext<[typeof render]>
48
+ router.get('/', (context) =>
49
+ context.render(
50
+ <html>
51
+ <body>
52
+ <h1>Dashboard</h1>
53
+ <Frame src="/activity" fallback={<p>Loading activity…</p>} />
54
+ </body>
55
+ </html>,
56
+ ),
57
+ )
58
+ ```
34
59
 
35
- const router = createRouter<AppContext>({
36
- middleware: [render],
37
- })
60
+ `context.render(node, init)` returns an HTML `Response` and preserves the supplied status and headers:
38
61
 
39
- router.get('/hello', (context) => {
40
- return context.render('Hello')
41
- })
62
+ ```tsx
63
+ router.get('/missing', (context) =>
64
+ context.render(<h1>Not found</h1>, {
65
+ status: 404,
66
+ headers: { 'Cache-Control': 'no-store' },
67
+ }),
68
+ )
42
69
  ```
43
70
 
44
- Use `context.render(...)` (or `context.get(Renderer)(...)`).
71
+ The middleware forwards request credentials and session headers to internal frame requests, converts them to safe `GET` requests, follows redirects, preserves application error bodies, propagates frame targets and top-frame URLs, and cancels frame rendering when the original request is aborted.
72
+
73
+ ### Options
45
74
 
46
- Renderers may render any value type, not just UI nodes.
75
+ - **`assets`** - An asset server that resolves source-based client entry IDs to browser module URLs and preload URLs. Omit it when client entries already use public URLs or the app has no client entries.
76
+ - **`onError`** - A callback for server rendering errors. When omitted, the UI renderer uses its default error reporting.
77
+
78
+ ## Custom renderers
79
+
80
+ Use `renderWith()` when the input is not a Remix UI node or the application owns a fully custom response pipeline. The factory runs once per request and may read the current request context.
47
81
 
48
82
  ```ts
49
83
  import { renderWith } from 'remix/middleware/render'
84
+ import { createRouter } from 'remix/router'
50
85
 
51
- const json = renderWith(
86
+ let json = renderWith(
52
87
  () =>
53
88
  function render(data: unknown, init?: ResponseInit) {
54
89
  return Response.json(data, init)
55
90
  },
56
91
  )
57
92
 
58
- router.get('/api', (context) => {
59
- return context.render({ ok: true })
60
- })
61
- ```
62
-
63
- For Remix UI, create a renderer that owns frame resolution and response creation.
64
-
65
- ```tsx
66
- import { createHtmlResponse } from 'remix/response/html'
67
- import { renderWith } from 'remix/middleware/render'
68
- import type { RemixNode } from 'remix/ui'
69
- import { renderToStream } from 'remix/ui/server'
93
+ let router = createRouter({ middleware: [json] })
70
94
 
71
- const render = renderWith(
72
- ({ router, url }) =>
73
- function render(node: RemixNode, init?: ResponseInit) {
74
- let stream = renderToStream(node, {
75
- async resolveFrame(src) {
76
- let response = await router.fetch(new URL(src, url))
95
+ router.get('/api/status', (context) => context.render({ ok: true }))
96
+ ```
77
97
 
78
- if (!response.ok) {
79
- return `<pre>Frame error: ${response.status}</pre>`
80
- }
98
+ Custom renderers are also available through `context.get(Renderer)` when direct-property access is not suitable.
81
99
 
82
- return response.body ?? response.text()
83
- },
84
- })
100
+ ## Related Packages
85
101
 
86
- return createHtmlResponse(stream, init)
87
- },
88
- )
89
- ```
102
+ - [`assets`](https://github.com/remix-run/remix/tree/main/packages/assets) - Source asset compilation and browser module URLs
103
+ - [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Request routing and typed context
104
+ - [`ui`](https://github.com/remix-run/remix/tree/main/packages/ui) - Remix UI components, frames, and server rendering
105
+ - [`response`](https://github.com/remix-run/remix/tree/main/packages/response) - Web `Response` helpers
90
106
 
91
- ## Related Packages
107
+ ## Related Work
92
108
 
93
- - [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Request routing and context
94
- - [`ui`](https://github.com/remix-run/remix/tree/main/packages/ui) - Remix UI rendering primitives
95
- - [`response`](https://github.com/remix-run/remix/tree/main/packages/response) - Response helpers
109
+ - [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
110
+ - [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API)
96
111
 
97
112
  ## License
98
113
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { Renderer, renderWith } from './lib/render.ts';
2
2
  export type { AnyRenderer } from './lib/render.ts';
3
+ export { render } from './lib/render-ui.ts';
4
+ export type { RenderFunction, RenderOptions } from './lib/render-ui.ts';
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AACtD,YAAY,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AACtD,YAAY,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAClD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AAC3C,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA"}
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { Renderer, renderWith } from './lib/render.js';
2
+ export { render } from './lib/render-ui.js';
@@ -0,0 +1,27 @@
1
+ import type { AssetServer } from '@remix-run/assets';
2
+ import type { Middleware } from '@remix-run/fetch-router';
3
+ import { renderToStream } from '@remix-run/ui/server';
4
+ import { type Renderer } from './render.ts';
5
+ type RemixNode = Parameters<typeof renderToStream>[0];
6
+ /** Options for the standard Remix UI renderer. */
7
+ export interface RenderOptions {
8
+ /** Asset server used to turn source-based client entry IDs into browser module and preload URLs. */
9
+ assets?: Pick<AssetServer, 'getHref' | 'getPreloads'>;
10
+ /** Error hook invoked when server rendering fails. */
11
+ onError?: (error: unknown) => void;
12
+ }
13
+ /** Renders a Remix UI node as an HTML response. */
14
+ export type RenderFunction = (node: RemixNode, init?: ResponseInit) => Response;
15
+ /**
16
+ * Adds the standard Remix UI renderer to request context.
17
+ *
18
+ * @param options Rendering integration options.
19
+ * @returns Middleware that installs `context.render(node, init)` for the current request.
20
+ */
21
+ export declare function render(options?: RenderOptions): Middleware<{
22
+ key: typeof Renderer;
23
+ value: RenderFunction;
24
+ property: 'render';
25
+ }>;
26
+ export {};
27
+ //# sourceMappingURL=render-ui.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-ui.d.ts","sourceRoot":"","sources":["../../src/lib/render-ui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,KAAK,EAAE,UAAU,EAAkB,MAAM,yBAAyB,CAAA;AAEzE,OAAO,EAAE,cAAc,EAA4B,MAAM,sBAAsB,CAAA;AAE/E,OAAO,EAAc,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAA;AAGvD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC,CAAC,CAAC,CAAA;AAqCrD,kDAAkD;AAClD,MAAM,WAAW,aAAa;IAC5B,oGAAoG;IACpG,MAAM,CAAC,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,GAAG,aAAa,CAAC,CAAA;IACrD,sDAAsD;IACtD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CACnC;AAED,mDAAmD;AACnD,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,EAAE,YAAY,KAAK,QAAQ,CAAA;AAE/E;;;;;GAKG;AACH,wBAAgB,MAAM,CACpB,OAAO,GAAE,aAAkB,GAC1B,UAAU,CAAC;IAAE,GAAG,EAAE,OAAO,QAAQ,CAAC;IAAC,KAAK,EAAE,cAAc,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC,CAqBjF"}
@@ -0,0 +1,165 @@
1
+ import { createHtmlResponse } from '@remix-run/response/html';
2
+ import { renderToStream } from '@remix-run/ui/server';
3
+ import { renderWith } from './render.js';
4
+ const FRAME_HEADER = 'X-Remix-Frame';
5
+ const FRAME_TARGET_HEADER = 'X-Remix-Target';
6
+ const TOP_FRAME_SRC_HEADER = 'X-Remix-Top-Frame-Src';
7
+ const MAX_FRAME_REDIRECTS = 20;
8
+ const FRAME_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
9
+ const FRAME_REQUEST_HEADERS_TO_REMOVE = [
10
+ 'Connection',
11
+ 'Content-Encoding',
12
+ 'Content-Language',
13
+ 'Content-Length',
14
+ 'Content-Location',
15
+ 'Content-Type',
16
+ 'Expect',
17
+ 'Host',
18
+ 'If-Match',
19
+ 'If-Modified-Since',
20
+ 'If-None-Match',
21
+ 'If-Range',
22
+ 'If-Unmodified-Since',
23
+ 'Keep-Alive',
24
+ 'Range',
25
+ 'TE',
26
+ 'Trailer',
27
+ 'Transfer-Encoding',
28
+ 'Upgrade',
29
+ ];
30
+ // The top frame src header is omitted so cross-origin frames never receive the
31
+ // outer request URL, which may contain private paths or query parameters.
32
+ const CROSS_ORIGIN_FRAME_HEADERS = [
33
+ 'Accept',
34
+ 'Accept-Encoding',
35
+ FRAME_HEADER,
36
+ FRAME_TARGET_HEADER,
37
+ ];
38
+ /**
39
+ * Adds the standard Remix UI renderer to request context.
40
+ *
41
+ * @param options Rendering integration options.
42
+ * @returns Middleware that installs `context.render(node, init)` for the current request.
43
+ */
44
+ export function render(options = {}) {
45
+ return renderWith((context) => {
46
+ let request = context.request;
47
+ let topFrameSrc = getTopFrameSrc(request);
48
+ let onError = request.headers.get(FRAME_HEADER) === 'true' ? () => { } : options.onError;
49
+ return function render(node, init) {
50
+ let stream = renderToStream(node, {
51
+ frameSrc: request.url,
52
+ topFrameSrc,
53
+ signal: request.signal,
54
+ onError,
55
+ resolveFrame: (src, target, frameContext) => resolveFrame(context, src, target, frameContext),
56
+ resolveClientEntry: (entryId, component) => resolveClientEntry(options.assets, entryId, component),
57
+ });
58
+ return createHtmlResponse(stream, init);
59
+ };
60
+ });
61
+ }
62
+ function getTopFrameSrc(request) {
63
+ if (request.headers.get(FRAME_HEADER) !== 'true')
64
+ return request.url;
65
+ return request.headers.get(TOP_FRAME_SRC_HEADER) ?? request.url;
66
+ }
67
+ async function resolveFrame(context, src, target, frameContext) {
68
+ let currentFrameSrc = frameContext?.currentFrameSrc ?? context.request.url;
69
+ let topFrameSrc = frameContext?.topFrameSrc ?? getTopFrameSrc(context.request);
70
+ let frameUrl = new URL(src, currentFrameSrc);
71
+ let headers = createFrameRequestHeaders(context.headers, target, topFrameSrc);
72
+ let response = await followFrameRedirects(context, frameUrl, headers);
73
+ if (!isHtmlResponse(response)) {
74
+ throw new Error('Frame response must be HTML');
75
+ }
76
+ if (response.body != null) {
77
+ return response.body.pipeThrough(new TransformStream(), {
78
+ signal: context.request.signal,
79
+ });
80
+ }
81
+ if (response.ok)
82
+ return '';
83
+ throw new Error(`Failed to resolve frame: ${response.status} ${response.statusText}`.trimEnd());
84
+ }
85
+ function createFrameRequestHeaders(requestHeaders, target, topFrameSrc) {
86
+ let headers = new Headers(requestHeaders);
87
+ for (let name of FRAME_REQUEST_HEADERS_TO_REMOVE) {
88
+ headers.delete(name);
89
+ }
90
+ for (let name of [...headers.keys()]) {
91
+ if (name.startsWith('sec-fetch-'))
92
+ headers.delete(name);
93
+ }
94
+ headers.set('Accept', 'text/html');
95
+ headers.set('Accept-Encoding', 'identity');
96
+ headers.set(FRAME_HEADER, 'true');
97
+ headers.set(TOP_FRAME_SRC_HEADER, topFrameSrc);
98
+ if (target == null) {
99
+ headers.delete(FRAME_TARGET_HEADER);
100
+ }
101
+ else {
102
+ headers.set(FRAME_TARGET_HEADER, target);
103
+ }
104
+ return headers;
105
+ }
106
+ async function followFrameRedirects(context, initialUrl, headers) {
107
+ let url = initialUrl;
108
+ for (let redirectCount = 0; redirectCount <= MAX_FRAME_REDIRECTS; redirectCount++) {
109
+ if (url.origin !== context.url.origin) {
110
+ headers = createCrossOriginFrameHeaders(headers);
111
+ }
112
+ let response = await context.router.fetch(new Request(url, {
113
+ method: 'GET',
114
+ headers,
115
+ signal: context.request.signal,
116
+ }));
117
+ let location = response.headers.get('Location');
118
+ if (location == null || !FRAME_REDIRECT_STATUSES.has(response.status)) {
119
+ return response;
120
+ }
121
+ if (redirectCount === MAX_FRAME_REDIRECTS) {
122
+ throw new Error(`Too many frame redirects while resolving ${initialUrl.href}`);
123
+ }
124
+ await response.body?.cancel();
125
+ url = new URL(location, url);
126
+ }
127
+ throw new Error(`Too many frame redirects while resolving ${initialUrl.href}`);
128
+ }
129
+ function createCrossOriginFrameHeaders(headers) {
130
+ let crossOriginHeaders = new Headers();
131
+ for (let name of CROSS_ORIGIN_FRAME_HEADERS) {
132
+ let value = headers.get(name);
133
+ if (value != null)
134
+ crossOriginHeaders.set(name, value);
135
+ }
136
+ return crossOriginHeaders;
137
+ }
138
+ async function resolveClientEntry(assets, entryId, component) {
139
+ let hashIndex = entryId.lastIndexOf('#');
140
+ let sourceId = hashIndex === -1 ? entryId : entryId.slice(0, hashIndex);
141
+ let explicitExportName = hashIndex === -1 ? '' : entryId.slice(hashIndex + 1);
142
+ let exportName = explicitExportName || component.name;
143
+ if (sourceId.startsWith('file:')) {
144
+ if (assets == null) {
145
+ throw new Error('clientEntry() cannot use a file: source entry ID without an asset server. Pass the asset server to render({ assets }).');
146
+ }
147
+ if (!exportName)
148
+ throw createMissingExportNameError(entryId, true);
149
+ let [href, preloads] = await Promise.all([
150
+ assets.getHref(sourceId),
151
+ assets.getPreloads(sourceId),
152
+ ]);
153
+ return { href, exportName, preloads };
154
+ }
155
+ if (!exportName)
156
+ throw createMissingExportNameError(entryId, assets != null);
157
+ return { href: sourceId, exportName };
158
+ }
159
+ function createMissingExportNameError(entryId, hasAssets) {
160
+ let example = hasAssets ? 'import.meta.url + "#ExportName"' : '"/js/module.js#ExportName"';
161
+ return new Error(`clientEntry() requires either an export name in the entry ID (e.g., ${example}) or a named component function. Received "${entryId}".`);
162
+ }
163
+ function isHtmlResponse(response) {
164
+ return (response.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase() === 'text/html');
165
+ }
@@ -18,7 +18,7 @@ export interface Renderer<input = unknown, responseInit = ResponseInit> {
18
18
  export type AnyRenderer = Renderer<never, never>;
19
19
  /**
20
20
  * Context key used to read the current request renderer with `context.get(Renderer)`.
21
- * The `renderWith()` middleware also installs the renderer as `context.render`.
21
+ * Both `render()` and `renderWith()` also install the renderer as `context.render`.
22
22
  */
23
23
  export declare const Renderer: {
24
24
  defaultValue?: AnyRenderer;
@@ -1,7 +1,7 @@
1
1
  import { createContextKey } from '@remix-run/fetch-router';
2
2
  /**
3
3
  * Context key used to read the current request renderer with `context.get(Renderer)`.
4
- * The `renderWith()` middleware also installs the renderer as `context.render`.
4
+ * Both `render()` and `renderWith()` also install the renderer as `context.render`.
5
5
  */
6
6
  export const Renderer = createContextKey();
7
7
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@remix-run/render-middleware",
3
- "version": "0.1.5",
4
- "description": "Middleware for adding request-scoped renderers to fetch-router context",
3
+ "version": "0.2.0",
4
+ "description": "Conventional Remix UI and custom request-scoped render middleware",
5
5
  "author": "Michael Jackson <mjijackson@gmail.com>",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -29,10 +29,13 @@
29
29
  "@types/node": "^24.6.0",
30
30
  "typescript": "^7.0.2",
31
31
  "@remix-run/fetch-router": "0.21.0",
32
- "@remix-run/test": "0.6.0",
33
- "@remix-run/assert": "0.3.0"
32
+ "@remix-run/assert": "0.3.0",
33
+ "@remix-run/test": "0.6.0"
34
34
  },
35
35
  "dependencies": {
36
+ "@remix-run/assets": "^0.6.0",
37
+ "@remix-run/ui": "^0.8.0",
38
+ "@remix-run/response": "^0.3.8",
36
39
  "@remix-run/fetch-router": "^0.21.0"
37
40
  },
38
41
  "keywords": [
package/src/index.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export { Renderer, renderWith } from './lib/render.ts'
2
2
  export type { AnyRenderer } from './lib/render.ts'
3
+ export { render } from './lib/render-ui.ts'
4
+ export type { RenderFunction, RenderOptions } from './lib/render-ui.ts'
@@ -0,0 +1,236 @@
1
+ import type { AssetServer } from '@remix-run/assets'
2
+ import type { Middleware, RequestContext } from '@remix-run/fetch-router'
3
+ import { createHtmlResponse } from '@remix-run/response/html'
4
+ import { renderToStream, type ResolveFrameContext } from '@remix-run/ui/server'
5
+
6
+ import { renderWith, type Renderer } from './render.ts'
7
+
8
+ type RenderRequestContext = Pick<RequestContext, 'headers' | 'request' | 'router' | 'url'>
9
+ type RemixNode = Parameters<typeof renderToStream>[0]
10
+
11
+ const FRAME_HEADER = 'X-Remix-Frame'
12
+ const FRAME_TARGET_HEADER = 'X-Remix-Target'
13
+ const TOP_FRAME_SRC_HEADER = 'X-Remix-Top-Frame-Src'
14
+ const MAX_FRAME_REDIRECTS = 20
15
+ const FRAME_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
16
+ const FRAME_REQUEST_HEADERS_TO_REMOVE = [
17
+ 'Connection',
18
+ 'Content-Encoding',
19
+ 'Content-Language',
20
+ 'Content-Length',
21
+ 'Content-Location',
22
+ 'Content-Type',
23
+ 'Expect',
24
+ 'Host',
25
+ 'If-Match',
26
+ 'If-Modified-Since',
27
+ 'If-None-Match',
28
+ 'If-Range',
29
+ 'If-Unmodified-Since',
30
+ 'Keep-Alive',
31
+ 'Range',
32
+ 'TE',
33
+ 'Trailer',
34
+ 'Transfer-Encoding',
35
+ 'Upgrade',
36
+ ] as const
37
+ // The top frame src header is omitted so cross-origin frames never receive the
38
+ // outer request URL, which may contain private paths or query parameters.
39
+ const CROSS_ORIGIN_FRAME_HEADERS = [
40
+ 'Accept',
41
+ 'Accept-Encoding',
42
+ FRAME_HEADER,
43
+ FRAME_TARGET_HEADER,
44
+ ] as const
45
+
46
+ /** Options for the standard Remix UI renderer. */
47
+ export interface RenderOptions {
48
+ /** Asset server used to turn source-based client entry IDs into browser module and preload URLs. */
49
+ assets?: Pick<AssetServer, 'getHref' | 'getPreloads'>
50
+ /** Error hook invoked when server rendering fails. */
51
+ onError?: (error: unknown) => void
52
+ }
53
+
54
+ /** Renders a Remix UI node as an HTML response. */
55
+ export type RenderFunction = (node: RemixNode, init?: ResponseInit) => Response
56
+
57
+ /**
58
+ * Adds the standard Remix UI renderer to request context.
59
+ *
60
+ * @param options Rendering integration options.
61
+ * @returns Middleware that installs `context.render(node, init)` for the current request.
62
+ */
63
+ export function render(
64
+ options: RenderOptions = {},
65
+ ): Middleware<{ key: typeof Renderer; value: RenderFunction; property: 'render' }> {
66
+ return renderWith((context) => {
67
+ let request = context.request
68
+ let topFrameSrc = getTopFrameSrc(request)
69
+ let onError = request.headers.get(FRAME_HEADER) === 'true' ? () => {} : options.onError
70
+
71
+ return function render(node: RemixNode, init?: ResponseInit): Response {
72
+ let stream = renderToStream(node, {
73
+ frameSrc: request.url,
74
+ topFrameSrc,
75
+ signal: request.signal,
76
+ onError,
77
+ resolveFrame: (src, target, frameContext) =>
78
+ resolveFrame(context, src, target, frameContext),
79
+ resolveClientEntry: (entryId, component) =>
80
+ resolveClientEntry(options.assets, entryId, component),
81
+ })
82
+
83
+ return createHtmlResponse(stream, init)
84
+ }
85
+ })
86
+ }
87
+
88
+ function getTopFrameSrc(request: Request): string {
89
+ if (request.headers.get(FRAME_HEADER) !== 'true') return request.url
90
+ return request.headers.get(TOP_FRAME_SRC_HEADER) ?? request.url
91
+ }
92
+
93
+ async function resolveFrame(
94
+ context: RenderRequestContext,
95
+ src: string,
96
+ target?: string,
97
+ frameContext?: ResolveFrameContext,
98
+ ): Promise<string | ReadableStream<Uint8Array>> {
99
+ let currentFrameSrc = frameContext?.currentFrameSrc ?? context.request.url
100
+ let topFrameSrc = frameContext?.topFrameSrc ?? getTopFrameSrc(context.request)
101
+ let frameUrl = new URL(src, currentFrameSrc)
102
+ let headers = createFrameRequestHeaders(context.headers, target, topFrameSrc)
103
+ let response = await followFrameRedirects(context, frameUrl, headers)
104
+
105
+ if (!isHtmlResponse(response)) {
106
+ throw new Error('Frame response must be HTML')
107
+ }
108
+
109
+ if (response.body != null) {
110
+ return response.body.pipeThrough(new TransformStream(), {
111
+ signal: context.request.signal,
112
+ })
113
+ }
114
+ if (response.ok) return ''
115
+
116
+ throw new Error(`Failed to resolve frame: ${response.status} ${response.statusText}`.trimEnd())
117
+ }
118
+
119
+ function createFrameRequestHeaders(
120
+ requestHeaders: Headers,
121
+ target: string | undefined,
122
+ topFrameSrc: string,
123
+ ): Headers {
124
+ let headers = new Headers(requestHeaders)
125
+
126
+ for (let name of FRAME_REQUEST_HEADERS_TO_REMOVE) {
127
+ headers.delete(name)
128
+ }
129
+ for (let name of [...headers.keys()]) {
130
+ if (name.startsWith('sec-fetch-')) headers.delete(name)
131
+ }
132
+
133
+ headers.set('Accept', 'text/html')
134
+ headers.set('Accept-Encoding', 'identity')
135
+ headers.set(FRAME_HEADER, 'true')
136
+ headers.set(TOP_FRAME_SRC_HEADER, topFrameSrc)
137
+
138
+ if (target == null) {
139
+ headers.delete(FRAME_TARGET_HEADER)
140
+ } else {
141
+ headers.set(FRAME_TARGET_HEADER, target)
142
+ }
143
+
144
+ return headers
145
+ }
146
+
147
+ async function followFrameRedirects(
148
+ context: RenderRequestContext,
149
+ initialUrl: URL,
150
+ headers: Headers,
151
+ ): Promise<Response> {
152
+ let url = initialUrl
153
+
154
+ for (let redirectCount = 0; redirectCount <= MAX_FRAME_REDIRECTS; redirectCount++) {
155
+ if (url.origin !== context.url.origin) {
156
+ headers = createCrossOriginFrameHeaders(headers)
157
+ }
158
+
159
+ let response = await context.router.fetch(
160
+ new Request(url, {
161
+ method: 'GET',
162
+ headers,
163
+ signal: context.request.signal,
164
+ }),
165
+ )
166
+ let location = response.headers.get('Location')
167
+
168
+ if (location == null || !FRAME_REDIRECT_STATUSES.has(response.status)) {
169
+ return response
170
+ }
171
+ if (redirectCount === MAX_FRAME_REDIRECTS) {
172
+ throw new Error(`Too many frame redirects while resolving ${initialUrl.href}`)
173
+ }
174
+
175
+ await response.body?.cancel()
176
+ url = new URL(location, url)
177
+ }
178
+
179
+ throw new Error(`Too many frame redirects while resolving ${initialUrl.href}`)
180
+ }
181
+
182
+ function createCrossOriginFrameHeaders(headers: Headers): Headers {
183
+ let crossOriginHeaders = new Headers()
184
+
185
+ for (let name of CROSS_ORIGIN_FRAME_HEADERS) {
186
+ let value = headers.get(name)
187
+ if (value != null) crossOriginHeaders.set(name, value)
188
+ }
189
+
190
+ return crossOriginHeaders
191
+ }
192
+
193
+ async function resolveClientEntry(
194
+ assets: Pick<AssetServer, 'getHref' | 'getPreloads'> | undefined,
195
+ entryId: string,
196
+ component: { readonly name: string },
197
+ ): Promise<{ href: string; exportName: string; preloads?: string[] }> {
198
+ let hashIndex = entryId.lastIndexOf('#')
199
+ let sourceId = hashIndex === -1 ? entryId : entryId.slice(0, hashIndex)
200
+ let explicitExportName = hashIndex === -1 ? '' : entryId.slice(hashIndex + 1)
201
+ let exportName = explicitExportName || component.name
202
+
203
+ if (sourceId.startsWith('file:')) {
204
+ if (assets == null) {
205
+ throw new Error(
206
+ 'clientEntry() cannot use a file: source entry ID without an asset server. Pass the asset server to render({ assets }).',
207
+ )
208
+ }
209
+ if (!exportName) throw createMissingExportNameError(entryId, true)
210
+
211
+ let [href, preloads] = await Promise.all([
212
+ assets.getHref(sourceId),
213
+ assets.getPreloads(sourceId),
214
+ ])
215
+
216
+ return { href, exportName, preloads }
217
+ }
218
+
219
+ if (!exportName) throw createMissingExportNameError(entryId, assets != null)
220
+
221
+ return { href: sourceId, exportName }
222
+ }
223
+
224
+ function createMissingExportNameError(entryId: string, hasAssets: boolean): Error {
225
+ let example = hasAssets ? 'import.meta.url + "#ExportName"' : '"/js/module.js#ExportName"'
226
+
227
+ return new Error(
228
+ `clientEntry() requires either an export name in the entry ID (e.g., ${example}) or a named component function. Received "${entryId}".`,
229
+ )
230
+ }
231
+
232
+ function isHtmlResponse(response: Response): boolean {
233
+ return (
234
+ response.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase() === 'text/html'
235
+ )
236
+ }
package/src/lib/render.ts CHANGED
@@ -21,7 +21,7 @@ export type AnyRenderer = Renderer<never, never>
21
21
 
22
22
  /**
23
23
  * Context key used to read the current request renderer with `context.get(Renderer)`.
24
- * The `renderWith()` middleware also installs the renderer as `context.render`.
24
+ * Both `render()` and `renderWith()` also install the renderer as `context.render`.
25
25
  */
26
26
  export const Renderer: { defaultValue?: AnyRenderer } = createContextKey<AnyRenderer>()
27
27