@st-h/vite-ember-ssr 0.2.0-alpha.1 → 0.3.1
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 +257 -416
- package/dist/client.d.ts +42 -51
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -62
- package/dist/client.js.map +1 -1
- package/dist/fetch-middleware-DPLxOLL6.js +98 -0
- package/dist/fetch-middleware-DPLxOLL6.js.map +1 -0
- package/dist/server-DJRlVUcm.d.ts +260 -0
- package/dist/server-DJRlVUcm.d.ts.map +1 -0
- package/dist/server.d.ts +3 -236
- package/dist/server.js +46 -42
- package/dist/server.js.map +1 -1
- package/dist/{vite-plugin-D-W5WQWe.js → vite-plugin-9BSJgEL9.js} +3 -4
- package/dist/vite-plugin-9BSJgEL9.js.map +1 -0
- package/dist/{vite-plugin-CQou_tr5.d.ts → vite-plugin-Dl5DbheW.d.ts} +2 -17
- package/dist/vite-plugin-Dl5DbheW.d.ts.map +1 -0
- package/dist/vite-plugin.d.ts +1 -1
- package/dist/vite-plugin.js +1 -1
- package/dist/worker.d.ts +4 -3
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +69 -54
- package/dist/worker.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +64 -73
- package/src/dev.ts +91 -61
- package/src/fetch-middleware.ts +166 -0
- package/src/server.ts +48 -23
- package/src/vite-plugin.ts +2 -24
- package/src/worker.ts +153 -105
- package/dist/server.d.ts.map +0 -1
- package/dist/vite-plugin-CQou_tr5.d.ts.map +0 -1
- package/dist/vite-plugin-D-W5WQWe.js.map +0 -1
package/src/client.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side utilities for vite-ember-ssr.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* The library always renders pages with Glimmer rehydration markers, so
|
|
5
|
+
* the client boots with `_renderMode: 'rehydrate'` and Glimmer attaches
|
|
6
|
+
* to the existing DOM instead of replacing it. The {@link bootRehydrated}
|
|
7
|
+
* helper takes care of choosing rehydrate vs. a normal boot, since some
|
|
8
|
+
* pages (e.g. dev mode without SSR, or non-prerendered SSG routes) will
|
|
9
|
+
* not carry the rehydrate flag.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
// ─── Shoebox Types ───────────────────────────────────────────────────
|
|
@@ -152,91 +152,82 @@ export function cleanupShoebox(): void {
|
|
|
152
152
|
_shoeboxMap = null;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
// ───
|
|
155
|
+
// ─── Boot ─────────────────────────────────────────────────────────────
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* are visible simultaneously.
|
|
162
|
-
*
|
|
163
|
-
* Removes everything between (and including) the SSR boundary markers:
|
|
164
|
-
* <script type="x/boundary" id="ssr-body-start">
|
|
165
|
-
* ...server rendered content...
|
|
166
|
-
* <script type="x/boundary" id="ssr-body-end">
|
|
158
|
+
* Returns `true` when the current page was rendered with rehydration
|
|
159
|
+
* markers by the server (or the SSG build), i.e. when the server set
|
|
160
|
+
* `window.__vite_ember_ssr_rehydrate__`.
|
|
167
161
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* renders, avoiding a flash of no content:
|
|
162
|
+
* Use this when you need to branch on rehydrate vs. plain boot yourself.
|
|
163
|
+
* In most cases, prefer {@link bootRehydrated}.
|
|
171
164
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* <template>
|
|
176
|
-
* {{cleanupSSRContent}}
|
|
177
|
-
* {{outlet}}
|
|
178
|
-
* </template>
|
|
179
|
-
* ```
|
|
180
|
-
*
|
|
181
|
-
* Only used in cleanup mode (default). Not needed when using
|
|
182
|
-
* `rehydrate: true` — in that mode Glimmer reuses the existing DOM.
|
|
165
|
+
* Returns `false` for pages that were not rendered by the server, e.g.
|
|
166
|
+
* a dev page hit without an SSR middleware, or an SSG app navigating to
|
|
167
|
+
* a non-prerendered route.
|
|
183
168
|
*/
|
|
184
|
-
export function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
return; // Not an SSR-rendered page
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Remove all nodes between start and end markers (inclusive)
|
|
193
|
-
const parent = start.parentNode;
|
|
194
|
-
if (!parent) return;
|
|
195
|
-
|
|
196
|
-
let node: ChildNode | null = start;
|
|
197
|
-
while (node) {
|
|
198
|
-
const next: ChildNode | null = node.nextSibling;
|
|
199
|
-
parent.removeChild(node);
|
|
200
|
-
if (node === end) break;
|
|
201
|
-
node = next;
|
|
202
|
-
}
|
|
169
|
+
export function shouldRehydrate(): boolean {
|
|
170
|
+
return (
|
|
171
|
+
(window as unknown as Record<string, unknown>)
|
|
172
|
+
.__vite_ember_ssr_rehydrate__ === true
|
|
173
|
+
);
|
|
203
174
|
}
|
|
204
175
|
|
|
205
176
|
/**
|
|
206
|
-
*
|
|
207
|
-
* for SSR boundary markers in the DOM.
|
|
177
|
+
* Minimal interface satisfied by an Ember Application class.
|
|
208
178
|
*/
|
|
209
|
-
|
|
210
|
-
|
|
179
|
+
interface ApplicationClass {
|
|
180
|
+
create(options: Record<string, unknown>): {
|
|
181
|
+
visit?(
|
|
182
|
+
url: string,
|
|
183
|
+
options: Record<string, unknown>,
|
|
184
|
+
): Promise<unknown> | unknown;
|
|
185
|
+
};
|
|
211
186
|
}
|
|
212
187
|
|
|
213
188
|
/**
|
|
214
|
-
*
|
|
189
|
+
* Boots the client Ember application, rehydrating the server-rendered
|
|
190
|
+
* DOM when one is present.
|
|
215
191
|
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
192
|
+
* Behaviour:
|
|
193
|
+
* - If {@link shouldRehydrate} returns `true`, creates the application
|
|
194
|
+
* with `autoboot: false` and calls `app.visit(url, { _renderMode: 'rehydrate' })`
|
|
195
|
+
* so Glimmer attaches to the existing DOM instead of replacing it.
|
|
196
|
+
* - Otherwise, calls `Application.create(config.APP)` for a normal boot.
|
|
220
197
|
*
|
|
198
|
+
* The visit URL is derived from `window.location.pathname + search`
|
|
199
|
+
* with the configured `rootURL` stripped, matching what `Application.create`
|
|
200
|
+
* would have used internally.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
221
203
|
* ```ts
|
|
222
|
-
* import
|
|
204
|
+
* import Application from './app.ts';
|
|
205
|
+
* import config from './config/environment.ts';
|
|
206
|
+
* import { bootRehydrated, installShoebox } from 'vite-ember-ssr/client';
|
|
223
207
|
*
|
|
224
208
|
* installShoebox();
|
|
225
|
-
*
|
|
226
|
-
* const app = Application.create({ ...config.APP, autoboot: false });
|
|
227
|
-
*
|
|
228
|
-
* app.visit(window.location.pathname + window.location.search, {
|
|
229
|
-
* ...(shouldRehydrate() ? { _renderMode: 'rehydrate' } : {}),
|
|
230
|
-
* });
|
|
209
|
+
* bootRehydrated(Application, config);
|
|
231
210
|
* ```
|
|
232
|
-
*
|
|
233
|
-
* This is especially important for SSG apps where only prerendered
|
|
234
|
-
* routes carry the flag — non-SSG routes will boot normally without
|
|
235
|
-
* attempting rehydration (which would fail with no serialized DOM).
|
|
236
211
|
*/
|
|
237
|
-
export function
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
212
|
+
export function bootRehydrated(
|
|
213
|
+
Application: ApplicationClass,
|
|
214
|
+
config: { APP?: Record<string, unknown>; rootURL?: string },
|
|
215
|
+
): void {
|
|
216
|
+
if (!shouldRehydrate()) {
|
|
217
|
+
Application.create(config.APP ?? {});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const app = Application.create({
|
|
222
|
+
...(config.APP ?? {}),
|
|
223
|
+
autoboot: false,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const rootURL = config.rootURL ?? '/';
|
|
227
|
+
const url = (window.location.pathname + window.location.search).replace(
|
|
228
|
+
rootURL,
|
|
229
|
+
'/',
|
|
241
230
|
);
|
|
231
|
+
|
|
232
|
+
void app.visit?.(url, { _renderMode: 'rehydrate' });
|
|
242
233
|
}
|
package/src/dev.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Usage:
|
|
12
12
|
* ```js
|
|
13
13
|
* import { createServer } from 'vite';
|
|
14
|
-
* import { createEmberApp, assembleHTML } from '
|
|
14
|
+
* import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
|
|
15
15
|
*
|
|
16
16
|
* const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
|
|
17
17
|
* const app = await createEmberApp('app/app-ssr.ts', {
|
|
@@ -32,9 +32,15 @@ import type {
|
|
|
32
32
|
RenderRouteOptions,
|
|
33
33
|
RenderResult,
|
|
34
34
|
ShoeboxEntry,
|
|
35
|
+
ForwardedCookie,
|
|
35
36
|
EmberApp,
|
|
36
37
|
EmberAppDevOptions,
|
|
37
38
|
} from './server.js';
|
|
39
|
+
import {
|
|
40
|
+
compose,
|
|
41
|
+
forwardCookieMiddleware,
|
|
42
|
+
shoeboxMiddleware,
|
|
43
|
+
} from './fetch-middleware.js';
|
|
38
44
|
|
|
39
45
|
// ─── Constants ────────────────────────────────────────────────────────
|
|
40
46
|
|
|
@@ -66,9 +72,10 @@ const BROWSER_GLOBALS = [
|
|
|
66
72
|
] as const;
|
|
67
73
|
|
|
68
74
|
const SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
|
|
76
|
+
// Warn only once per process — the SSR entry is re-loaded on every render
|
|
77
|
+
// in dev mode, so a per-render warning would flood the console.
|
|
78
|
+
let warnedMissingSettled = false;
|
|
72
79
|
|
|
73
80
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
74
81
|
|
|
@@ -155,6 +162,23 @@ export function createDevEmberApp(
|
|
|
155
162
|
): EmberApp {
|
|
156
163
|
const { ssrLoadModule } = devOptions;
|
|
157
164
|
|
|
165
|
+
// Install browser globals once at startup so module-evaluation-time access
|
|
166
|
+
// (e.g. `@ember/test-helpers` reading `document` at the top level) succeeds
|
|
167
|
+
// even before the first per-request window is created. The per-request
|
|
168
|
+
// window below replaces these globals during the actual render.
|
|
169
|
+
const startupWindow = new Window({
|
|
170
|
+
url: 'http://localhost/',
|
|
171
|
+
width: 1024,
|
|
172
|
+
height: 768,
|
|
173
|
+
settings: {
|
|
174
|
+
disableJavaScriptFileLoading: true,
|
|
175
|
+
disableJavaScriptEvaluation: true,
|
|
176
|
+
disableCSSFileLoading: true,
|
|
177
|
+
navigator: { userAgent: 'vite-ember-ssr' },
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
installGlobals(startupWindow);
|
|
181
|
+
|
|
158
182
|
return {
|
|
159
183
|
async renderRoute(
|
|
160
184
|
url: string,
|
|
@@ -162,9 +186,9 @@ export function createDevEmberApp(
|
|
|
162
186
|
): Promise<RenderResult> {
|
|
163
187
|
const {
|
|
164
188
|
shoebox = false,
|
|
165
|
-
rehydrate = false,
|
|
166
189
|
cssManifest,
|
|
167
|
-
|
|
190
|
+
settledTimeout = 10_000,
|
|
191
|
+
forwardCookie,
|
|
168
192
|
} = renderOptions;
|
|
169
193
|
|
|
170
194
|
// Fresh Window per request — no state bleeds between renders in dev.
|
|
@@ -182,55 +206,23 @@ export function createDevEmberApp(
|
|
|
182
206
|
|
|
183
207
|
const savedGlobals = installGlobals(win);
|
|
184
208
|
|
|
185
|
-
//
|
|
209
|
+
// Build a per-render fetch middleware pipeline. State is captured by
|
|
210
|
+
// closure so each render is fully isolated from the next.
|
|
186
211
|
const realFetch = globalThis.fetch;
|
|
187
212
|
const shoeboxEntries: Map<string, ShoeboxEntry> | null = shoebox
|
|
188
213
|
? new Map()
|
|
189
214
|
: null;
|
|
215
|
+
const cookie: ForwardedCookie | null = forwardCookie ?? null;
|
|
216
|
+
const middlewareActive = shoeboxEntries !== null || cookie !== null;
|
|
190
217
|
|
|
191
|
-
if (
|
|
192
|
-
globalThis.fetch =
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
effectiveInit = { ...init };
|
|
200
|
-
const existingHeaders = new Headers(effectiveInit.headers);
|
|
201
|
-
for (const [key, value] of Object.entries(forwardHeaders)) {
|
|
202
|
-
if (!existingHeaders.has(key)) {
|
|
203
|
-
existingHeaders.set(key, value);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
effectiveInit.headers = existingHeaders;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const request = new Request(input, effectiveInit);
|
|
210
|
-
if (request.method.toUpperCase() !== 'GET')
|
|
211
|
-
return realFetch(request);
|
|
212
|
-
const response = await realFetch(request);
|
|
213
|
-
if (shoeboxEntries) {
|
|
214
|
-
try {
|
|
215
|
-
const clone = response.clone();
|
|
216
|
-
const body = await clone.text();
|
|
217
|
-
const headers: Record<string, string> = {};
|
|
218
|
-
clone.headers.forEach((v: string, k: string) => {
|
|
219
|
-
headers[k] = v;
|
|
220
|
-
});
|
|
221
|
-
shoeboxEntries.set(request.url, {
|
|
222
|
-
url: request.url,
|
|
223
|
-
status: clone.status,
|
|
224
|
-
statusText: clone.statusText,
|
|
225
|
-
headers,
|
|
226
|
-
body,
|
|
227
|
-
});
|
|
228
|
-
} catch {
|
|
229
|
-
/* skip */
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return response;
|
|
233
|
-
};
|
|
218
|
+
if (middlewareActive) {
|
|
219
|
+
globalThis.fetch = compose(
|
|
220
|
+
[
|
|
221
|
+
forwardCookieMiddleware(() => cookie),
|
|
222
|
+
shoeboxMiddleware(() => shoeboxEntries),
|
|
223
|
+
],
|
|
224
|
+
(request) => realFetch(request),
|
|
225
|
+
);
|
|
234
226
|
}
|
|
235
227
|
|
|
236
228
|
let head = '';
|
|
@@ -245,6 +237,7 @@ export function createDevEmberApp(
|
|
|
245
237
|
// Re-load the module on every request so HMR changes are reflected.
|
|
246
238
|
const mod = (await ssrLoadModule(entryPath)) as {
|
|
247
239
|
createSsrApp?: () => EmberApplication;
|
|
240
|
+
settled?: () => Promise<void>;
|
|
248
241
|
};
|
|
249
242
|
if (typeof mod.createSsrApp !== 'function') {
|
|
250
243
|
throw new Error(
|
|
@@ -253,19 +246,60 @@ export function createDevEmberApp(
|
|
|
253
246
|
);
|
|
254
247
|
}
|
|
255
248
|
const app = mod.createSsrApp();
|
|
249
|
+
const appSettled =
|
|
250
|
+
typeof mod.settled === 'function' ? mod.settled : null;
|
|
256
251
|
|
|
257
252
|
const bootOptions: BootOptions = {
|
|
258
253
|
isBrowser: false,
|
|
259
254
|
document: document as unknown as Document,
|
|
260
255
|
rootElement: document.body as unknown as Element,
|
|
261
256
|
shouldRender: true,
|
|
262
|
-
|
|
257
|
+
_renderMode: 'serialize',
|
|
263
258
|
};
|
|
264
259
|
|
|
265
260
|
const instance = await app.visit(url, bootOptions);
|
|
266
261
|
|
|
267
|
-
//
|
|
268
|
-
|
|
262
|
+
// Wait for the app to settle (test waiters, run loop, pending timers).
|
|
263
|
+
// Fallback to a microtask drain when the SSR entry doesn't export it.
|
|
264
|
+
if (appSettled) {
|
|
265
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
266
|
+
try {
|
|
267
|
+
await Promise.race([
|
|
268
|
+
appSettled(),
|
|
269
|
+
new Promise<never>((_, reject) => {
|
|
270
|
+
timer = setTimeout(
|
|
271
|
+
() =>
|
|
272
|
+
reject(
|
|
273
|
+
new Error(
|
|
274
|
+
`settled() timed out after ${settledTimeout}ms`,
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
settledTimeout,
|
|
278
|
+
);
|
|
279
|
+
}),
|
|
280
|
+
]);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
console.warn(
|
|
283
|
+
`[vite-ember-ssr] settled() did not resolve within ${settledTimeout}ms, ` +
|
|
284
|
+
`capturing DOM anyway:`,
|
|
285
|
+
e instanceof Error ? e.message : e,
|
|
286
|
+
);
|
|
287
|
+
} finally {
|
|
288
|
+
if (timer) clearTimeout(timer);
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
if (settledTimeout > 0 && !warnedMissingSettled) {
|
|
292
|
+
warnedMissingSettled = true;
|
|
293
|
+
console.warn(
|
|
294
|
+
'[vite-ember-ssr] settledTimeout is set but the SSR entry does ' +
|
|
295
|
+
'not export `settled` — renders will NOT wait for the app to ' +
|
|
296
|
+
'settle and may capture incomplete HTML. Add ' +
|
|
297
|
+
"`export { settled } from '@ember/test-helpers';` to your " +
|
|
298
|
+
'SSR entry.',
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
302
|
+
}
|
|
269
303
|
|
|
270
304
|
if (cssManifest) {
|
|
271
305
|
cssLinks = buildRouteCssLinks(cssManifest, instance);
|
|
@@ -285,7 +319,7 @@ export function createDevEmberApp(
|
|
|
285
319
|
} catch (e) {
|
|
286
320
|
error = e instanceof Error ? e : new Error(String(e));
|
|
287
321
|
} finally {
|
|
288
|
-
if (
|
|
322
|
+
if (middlewareActive) globalThis.fetch = realFetch;
|
|
289
323
|
restoreGlobals(savedGlobals);
|
|
290
324
|
await win.happyDOM?.close?.();
|
|
291
325
|
}
|
|
@@ -294,17 +328,13 @@ export function createDevEmberApp(
|
|
|
294
328
|
shoeboxEntries && shoeboxEntries.size > 0
|
|
295
329
|
? serializeShoebox(Array.from(shoeboxEntries.values()))
|
|
296
330
|
: '';
|
|
297
|
-
const rehydrateHTML =
|
|
298
|
-
|
|
299
|
-
: '';
|
|
331
|
+
const rehydrateHTML =
|
|
332
|
+
'<script>window.__vite_ember_ssr_rehydrate__=true</script>';
|
|
300
333
|
const fullHead = cssLinks + rehydrateHTML + shoeboxHTML + head;
|
|
301
|
-
const wrappedBody = rehydrate
|
|
302
|
-
? body
|
|
303
|
-
: `${SSR_BODY_START}${body}${SSR_BODY_END}`;
|
|
304
334
|
|
|
305
335
|
return {
|
|
306
336
|
head: fullHead,
|
|
307
|
-
body
|
|
337
|
+
body,
|
|
308
338
|
bodyAttrs,
|
|
309
339
|
statusCode: error ? 500 : 200,
|
|
310
340
|
...(error ? { error } : {}),
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch middleware pipeline used during SSR rendering.
|
|
3
|
+
*
|
|
4
|
+
* The render path installs a single `fetchWithMiddleware` function as
|
|
5
|
+
* `globalThis.fetch`. That function dispatches each call through a
|
|
6
|
+
* Koa-style onion of middlewares, each of which may inspect/modify the
|
|
7
|
+
* request, await `next(req)`, and inspect/modify the response.
|
|
8
|
+
*
|
|
9
|
+
* Three middlewares ship with the library:
|
|
10
|
+
* - `forwardCookieMiddleware` — injects the incoming request's `Cookie`
|
|
11
|
+
* header into outbound fetches whose host appears in `allowedHosts`,
|
|
12
|
+
* so SSR can make authenticated calls on behalf of the user without
|
|
13
|
+
* leaking the session cookie to third-party hosts.
|
|
14
|
+
* - `shoeboxMiddleware` — captures GET responses into a per-render
|
|
15
|
+
* Map so they can be serialized into the HTML for the client to
|
|
16
|
+
* replay during rehydration.
|
|
17
|
+
* - `abortSignalMiddleware` — ties outbound fetches to a per-render
|
|
18
|
+
* AbortSignal so requests still in flight when a render finishes are
|
|
19
|
+
* aborted instead of lingering into later renders.
|
|
20
|
+
*
|
|
21
|
+
* Worker mode installs the pipeline once at startup and swaps per-render
|
|
22
|
+
* state via getters; dev mode rebuilds the pipeline per request with
|
|
23
|
+
* closure-captured state.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ShoeboxEntry, ForwardedCookie } from './server.js';
|
|
27
|
+
|
|
28
|
+
export type FetchMiddleware = (
|
|
29
|
+
request: Request,
|
|
30
|
+
next: (request: Request) => Promise<Response>,
|
|
31
|
+
) => Promise<Response>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Composes middlewares into a single fetch-compatible function.
|
|
35
|
+
*
|
|
36
|
+
* Middlewares run in array order on the way in and reverse order on the
|
|
37
|
+
* way out (Koa onion). The terminal function is what runs at the centre
|
|
38
|
+
* of the onion — typically the real, unintercepted `fetch`.
|
|
39
|
+
*/
|
|
40
|
+
export function compose(
|
|
41
|
+
middlewares: FetchMiddleware[],
|
|
42
|
+
terminal: (request: Request) => Promise<Response>,
|
|
43
|
+
): typeof fetch {
|
|
44
|
+
return async (input, init) => {
|
|
45
|
+
const initialRequest = new Request(input, init);
|
|
46
|
+
const dispatch = (idx: number, req: Request): Promise<Response> => {
|
|
47
|
+
const mw = middlewares[idx];
|
|
48
|
+
if (!mw) return terminal(req);
|
|
49
|
+
return mw(req, (nextReq) => dispatch(idx + 1, nextReq));
|
|
50
|
+
};
|
|
51
|
+
return dispatch(0, initialRequest);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Middleware that injects the incoming request's `Cookie` header into
|
|
57
|
+
* outbound fetches. The cookie is only added when `URL.host` exactly
|
|
58
|
+
* matches one of `allowedHosts`. A cookie header already set on the
|
|
59
|
+
* request (by app code) is not overwritten.
|
|
60
|
+
*
|
|
61
|
+
* Applies to all HTTP methods — auth cookies must flow on POST/PUT too.
|
|
62
|
+
*
|
|
63
|
+
* @param getCookie Function returning the active per-render cookie
|
|
64
|
+
* config, or `null` when forwarding is disabled for this render.
|
|
65
|
+
*/
|
|
66
|
+
export function forwardCookieMiddleware(
|
|
67
|
+
getCookie: () => ForwardedCookie | null,
|
|
68
|
+
): FetchMiddleware {
|
|
69
|
+
return (request, next) => {
|
|
70
|
+
const cookie = getCookie();
|
|
71
|
+
if (!cookie) return next(request);
|
|
72
|
+
|
|
73
|
+
const url = new URL(request.url);
|
|
74
|
+
if (!cookie.allowedHosts.includes(url.host)) return next(request);
|
|
75
|
+
|
|
76
|
+
// Don't overwrite a cookie explicitly set by app code.
|
|
77
|
+
if (request.headers.has('cookie')) return next(request);
|
|
78
|
+
|
|
79
|
+
const merged = new Headers(request.headers);
|
|
80
|
+
merged.set('cookie', cookie.value);
|
|
81
|
+
return next(new Request(request, { headers: merged }));
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Middleware that ties outbound fetches to a per-render AbortSignal, so
|
|
87
|
+
* requests still in flight when the render finishes (typically after a
|
|
88
|
+
* `settled()` timeout) are aborted instead of lingering into later
|
|
89
|
+
* renders on the same worker.
|
|
90
|
+
*
|
|
91
|
+
* @param getSignal Function returning the active per-render signal, or
|
|
92
|
+
* `null` when no render-scoped abort is configured.
|
|
93
|
+
*/
|
|
94
|
+
export function abortSignalMiddleware(
|
|
95
|
+
getSignal: () => AbortSignal | null,
|
|
96
|
+
): FetchMiddleware {
|
|
97
|
+
return (request, next) => {
|
|
98
|
+
const signal = getSignal();
|
|
99
|
+
if (!signal) return next(request);
|
|
100
|
+
|
|
101
|
+
// Preserve any signal app code attached to its own request.
|
|
102
|
+
const combined = request.signal
|
|
103
|
+
? AbortSignal.any([request.signal, signal])
|
|
104
|
+
: signal;
|
|
105
|
+
const result = next(new Request(request, { signal: combined }));
|
|
106
|
+
|
|
107
|
+
// A render-scoped abort fires after the app that issued the fetch has
|
|
108
|
+
// been torn down, so its rejection may have nothing left awaiting it.
|
|
109
|
+
// Pre-attach a no-op handler to keep such late rejections from crashing
|
|
110
|
+
// the worker as unhandled; the caller still receives the rejection.
|
|
111
|
+
result.catch(() => {});
|
|
112
|
+
return result;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Middleware that captures GET response bodies into a per-render Map so
|
|
118
|
+
* they can be serialized into the HTML for client-side replay. Non-GET
|
|
119
|
+
* methods are passed through unchanged.
|
|
120
|
+
*
|
|
121
|
+
* @param getEntries Function returning the active per-render Map, or
|
|
122
|
+
* `null` when shoebox is disabled for this render.
|
|
123
|
+
*/
|
|
124
|
+
export function shoeboxMiddleware(
|
|
125
|
+
getEntries: () => Map<string, ShoeboxEntry> | null,
|
|
126
|
+
): FetchMiddleware {
|
|
127
|
+
return async (request, next) => {
|
|
128
|
+
// Capture the CURRENT render's entries map before awaiting the response.
|
|
129
|
+
// A fetch left in flight by a timed-out render can resolve while a LATER
|
|
130
|
+
// render is active; calling getEntries() after the await would return
|
|
131
|
+
// that later render's map and leak this response into the wrong render's
|
|
132
|
+
// shoebox. With the reference captured up front, a late write lands in
|
|
133
|
+
// the dead render's map, which is never serialized.
|
|
134
|
+
const entries = getEntries();
|
|
135
|
+
const response = await next(request);
|
|
136
|
+
if (!entries) return response;
|
|
137
|
+
if (request.method.toUpperCase() !== 'GET') return response;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const clone = response.clone();
|
|
141
|
+
const body = await clone.text();
|
|
142
|
+
const headers: Record<string, string> = {};
|
|
143
|
+
clone.headers.forEach((v, k) => {
|
|
144
|
+
// Never serialize Set-Cookie into the shoebox: it would leak the
|
|
145
|
+
// origin's (often HttpOnly) auth cookie into the rendered HTML, where
|
|
146
|
+
// any script can read it and — for a cached/shared response — hand one
|
|
147
|
+
// user's session to another. The client replays entries as
|
|
148
|
+
// JS-constructed Responses whose Set-Cookie the browser ignores, so it
|
|
149
|
+
// is inert here anyway.
|
|
150
|
+
if (k.toLowerCase() === 'set-cookie') return;
|
|
151
|
+
headers[k] = v;
|
|
152
|
+
});
|
|
153
|
+
entries.set(request.url, {
|
|
154
|
+
url: request.url,
|
|
155
|
+
status: clone.status,
|
|
156
|
+
statusText: clone.statusText,
|
|
157
|
+
headers,
|
|
158
|
+
body,
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
/* skip */
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return response;
|
|
165
|
+
};
|
|
166
|
+
}
|