@remix-run/render-middleware 0.1.4 → 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 +71 -56
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/render-ui.d.ts +27 -0
- package/dist/lib/render-ui.d.ts.map +1 -0
- package/dist/lib/render-ui.js +165 -0
- package/dist/lib/render.d.ts +4 -2
- package/dist/lib/render.d.ts.map +1 -1
- package/dist/lib/render.js +1 -1
- package/package.json +13 -10
- package/src/index.ts +2 -0
- package/src/lib/render-ui.ts +236 -0
- package/src/lib/render.ts +2 -2
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# render-middleware
|
|
2
2
|
|
|
3
|
-
Request-scoped
|
|
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
|
-
- **
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
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
|
-
|
|
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
|
-
```
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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
|
-
|
|
27
|
-
(
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
middleware: [render],
|
|
37
|
-
})
|
|
60
|
+
`context.render(node, init)` returns an HTML `Response` and preserves the supplied status and headers:
|
|
38
61
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
},
|
|
84
|
-
})
|
|
100
|
+
## Related Packages
|
|
85
101
|
|
|
86
|
-
|
|
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
|
|
107
|
+
## Related Work
|
|
92
108
|
|
|
93
|
-
- [
|
|
94
|
-
- [
|
|
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
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
-
export { Renderer, renderWith } from
|
|
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
|
+
}
|
package/dist/lib/render.d.ts
CHANGED
|
@@ -18,9 +18,11 @@ 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
|
-
*
|
|
21
|
+
* Both `render()` and `renderWith()` also install the renderer as `context.render`.
|
|
22
22
|
*/
|
|
23
|
-
export declare const Renderer:
|
|
23
|
+
export declare const Renderer: {
|
|
24
|
+
defaultValue?: AnyRenderer;
|
|
25
|
+
};
|
|
24
26
|
type RendererFactory<renderer extends AnyRenderer> = (context: RequestContext<any, any>) => renderer;
|
|
25
27
|
/**
|
|
26
28
|
* Adds a renderer to request context.
|
package/dist/lib/render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/lib/render.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,yBAAyB,CAAA;AAEhG;;GAEG;AACH,MAAM,WAAW,QAAQ,CAAC,KAAK,GAAG,OAAO,EAAE,YAAY,GAAG,YAAY;IACpE;;;;;;OAMG;IACH,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,YAAY,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAClE;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,CAAA;AAEhD;;;GAGG;AACH,eAAO,MAAM,QAAQ,
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/lib/render.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,yBAAyB,CAAA;AAEhG;;GAEG;AACH,MAAM,WAAW,QAAQ,CAAC,KAAK,GAAG,OAAO,EAAE,YAAY,GAAG,YAAY;IACpE;;;;;;OAMG;IACH,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,YAAY,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAClE;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,CAAA;AAEhD;;;GAGG;AACH,eAAO,MAAM,QAAQ,EAAE;IAAE,YAAY,CAAC,EAAE,WAAW,CAAA;CAAoC,CAAA;AAEvF,KAAK,eAAe,CAAC,QAAQ,SAAS,WAAW,IAAI,CAAC,OAAO,EAAE,cAAc,CAAC,GAAG,EAAE,GAAG,CAAC,KAAK,QAAQ,CAAA;AAEpG;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,CAAC,QAAQ,SAAS,WAAW,EAC3D,cAAc,EAAE,eAAe,CAAC,QAAQ,CAAC,GACxC,UAAU,CAAC;IAAE,GAAG,EAAE,OAAO,QAAQ,CAAC;IAAC,KAAK,EAAE,QAAQ,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC,CAK3E"}
|
package/dist/lib/render.js
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
4
|
-
"description": "
|
|
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": {
|
|
@@ -27,13 +27,16 @@
|
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^24.6.0",
|
|
30
|
-
"
|
|
31
|
-
"@remix-run/fetch-router": "0.
|
|
30
|
+
"typescript": "^7.0.2",
|
|
31
|
+
"@remix-run/fetch-router": "0.21.0",
|
|
32
32
|
"@remix-run/assert": "0.3.0",
|
|
33
|
-
"@remix-run/test": "0.
|
|
33
|
+
"@remix-run/test": "0.6.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@remix-run/
|
|
36
|
+
"@remix-run/assets": "^0.6.0",
|
|
37
|
+
"@remix-run/ui": "^0.8.0",
|
|
38
|
+
"@remix-run/response": "^0.3.8",
|
|
39
|
+
"@remix-run/fetch-router": "^0.21.0"
|
|
37
40
|
},
|
|
38
41
|
"keywords": [
|
|
39
42
|
"fetch",
|
|
@@ -44,10 +47,10 @@
|
|
|
44
47
|
"response"
|
|
45
48
|
],
|
|
46
49
|
"scripts": {
|
|
47
|
-
"build": "
|
|
50
|
+
"build": "tsc -p tsconfig.build.json",
|
|
48
51
|
"clean": "git clean -fdX",
|
|
49
|
-
"test": "remix
|
|
50
|
-
"test:bun": "bun x --bun remix
|
|
51
|
-
"typecheck": "
|
|
52
|
+
"test": "remix test",
|
|
53
|
+
"test:bun": "bun x --bun remix test",
|
|
54
|
+
"typecheck": "tsc --noEmit"
|
|
52
55
|
}
|
|
53
56
|
}
|
package/src/index.ts
CHANGED
|
@@ -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,9 +21,9 @@ 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
|
-
*
|
|
24
|
+
* Both `render()` and `renderWith()` also install the renderer as `context.render`.
|
|
25
25
|
*/
|
|
26
|
-
export const Renderer = createContextKey<AnyRenderer>()
|
|
26
|
+
export const Renderer: { defaultValue?: AnyRenderer } = createContextKey<AnyRenderer>()
|
|
27
27
|
|
|
28
28
|
type RendererFactory<renderer extends AnyRenderer> = (context: RequestContext<any, any>) => renderer
|
|
29
29
|
|