@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/dist/client.d.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Client-side utilities for vite-ember-ssr.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* The library always renders pages with Glimmer rehydration markers, so
|
|
6
|
+
* the client boots with `_renderMode: 'rehydrate'` and Glimmer attaches
|
|
7
|
+
* to the existing DOM instead of replacing it. The {@link bootRehydrated}
|
|
8
|
+
* helper takes care of choosing rehydrate vs. a normal boot, since some
|
|
9
|
+
* pages (e.g. dev mode without SSR, or non-prerendered SSG routes) will
|
|
10
|
+
* not carry the rehydrate flag.
|
|
11
11
|
*/
|
|
12
12
|
/**
|
|
13
13
|
* Installs the shoebox fetch interceptor on the client.
|
|
@@ -34,63 +34,54 @@ declare function installShoebox(): boolean;
|
|
|
34
34
|
*/
|
|
35
35
|
declare function cleanupShoebox(): void;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* are visible simultaneously.
|
|
41
|
-
*
|
|
42
|
-
* Removes everything between (and including) the SSR boundary markers:
|
|
43
|
-
* <script type="x/boundary" id="ssr-body-start">
|
|
44
|
-
* ...server rendered content...
|
|
45
|
-
* <script type="x/boundary" id="ssr-body-end">
|
|
46
|
-
*
|
|
47
|
-
* **Call this from your application template** rather than from
|
|
48
|
-
* `entry.ts` — this ensures removal happens at the moment Ember
|
|
49
|
-
* renders, avoiding a flash of no content:
|
|
37
|
+
* Returns `true` when the current page was rendered with rehydration
|
|
38
|
+
* markers by the server (or the SSG build), i.e. when the server set
|
|
39
|
+
* `window.__vite_ember_ssr_rehydrate__`.
|
|
50
40
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
41
|
+
* Use this when you need to branch on rehydrate vs. plain boot yourself.
|
|
42
|
+
* In most cases, prefer {@link bootRehydrated}.
|
|
53
43
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* </template>
|
|
58
|
-
* ```
|
|
59
|
-
*
|
|
60
|
-
* Only used in cleanup mode (default). Not needed when using
|
|
61
|
-
* `rehydrate: true` — in that mode Glimmer reuses the existing DOM.
|
|
44
|
+
* Returns `false` for pages that were not rendered by the server, e.g.
|
|
45
|
+
* a dev page hit without an SSR middleware, or an SSG app navigating to
|
|
46
|
+
* a non-prerendered route.
|
|
62
47
|
*/
|
|
63
|
-
declare function
|
|
48
|
+
declare function shouldRehydrate(): boolean;
|
|
64
49
|
/**
|
|
65
|
-
*
|
|
66
|
-
* for SSR boundary markers in the DOM.
|
|
50
|
+
* Minimal interface satisfied by an Ember Application class.
|
|
67
51
|
*/
|
|
68
|
-
|
|
52
|
+
interface ApplicationClass {
|
|
53
|
+
create(options: Record<string, unknown>): {
|
|
54
|
+
visit?(url: string, options: Record<string, unknown>): Promise<unknown> | unknown;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
69
57
|
/**
|
|
70
|
-
*
|
|
58
|
+
* Boots the client Ember application, rehydrating the server-rendered
|
|
59
|
+
* DOM when one is present.
|
|
71
60
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
61
|
+
* Behaviour:
|
|
62
|
+
* - If {@link shouldRehydrate} returns `true`, creates the application
|
|
63
|
+
* with `autoboot: false` and calls `app.visit(url, { _renderMode: 'rehydrate' })`
|
|
64
|
+
* so Glimmer attaches to the existing DOM instead of replacing it.
|
|
65
|
+
* - Otherwise, calls `Application.create(config.APP)` for a normal boot.
|
|
76
66
|
*
|
|
67
|
+
* The visit URL is derived from `window.location.pathname + search`
|
|
68
|
+
* with the configured `rootURL` stripped, matching what `Application.create`
|
|
69
|
+
* would have used internally.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
77
72
|
* ```ts
|
|
78
|
-
* import
|
|
73
|
+
* import Application from './app.ts';
|
|
74
|
+
* import config from './config/environment.ts';
|
|
75
|
+
* import { bootRehydrated, installShoebox } from 'vite-ember-ssr/client';
|
|
79
76
|
*
|
|
80
77
|
* installShoebox();
|
|
81
|
-
*
|
|
82
|
-
* const app = Application.create({ ...config.APP, autoboot: false });
|
|
83
|
-
*
|
|
84
|
-
* app.visit(window.location.pathname + window.location.search, {
|
|
85
|
-
* ...(shouldRehydrate() ? { _renderMode: 'rehydrate' } : {}),
|
|
86
|
-
* });
|
|
78
|
+
* bootRehydrated(Application, config);
|
|
87
79
|
* ```
|
|
88
|
-
*
|
|
89
|
-
* This is especially important for SSG apps where only prerendered
|
|
90
|
-
* routes carry the flag — non-SSG routes will boot normally without
|
|
91
|
-
* attempting rehydration (which would fail with no serialized DOM).
|
|
92
80
|
*/
|
|
93
|
-
declare function
|
|
81
|
+
declare function bootRehydrated(Application: ApplicationClass, config: {
|
|
82
|
+
APP?: Record<string, unknown>;
|
|
83
|
+
rootURL?: string;
|
|
84
|
+
}): void;
|
|
94
85
|
//#endregion
|
|
95
|
-
export {
|
|
86
|
+
export { bootRehydrated, cleanupShoebox, installShoebox, shouldRehydrate };
|
|
96
87
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","names":[],"sources":["../src/client.ts"],"mappings":";;AAoDA;;;;;AA8FA;;;;;
|
|
1
|
+
{"version":3,"file":"client.d.ts","names":[],"sources":["../src/client.ts"],"mappings":";;AAoDA;;;;;AA8FA;;;;;AAsBA;;;;;AAKC;;;;;;;;;;iBAzHe,cAAA,CAAA;;;;;;;iBA8FA,cAAA,CAAA;AAiEhB;;;;;;;;;;;;AAAA,iBA3CgB,eAAA,CAAA;;;;UAUN,gBAAA;EACR,MAAA,CAAO,OAAA,EAAS,MAAA;IACd,KAAA,EACE,GAAA,UACA,OAAA,EAAS,MAAA,oBACR,OAAA;EAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;iBA4BS,cAAA,CACd,WAAA,EAAa,gBAAA,EACb,MAAA;EAAU,GAAA,GAAM,MAAA;EAAyB,OAAA;AAAA"}
|
package/dist/client.js
CHANGED
|
@@ -78,81 +78,58 @@ function cleanupShoebox() {
|
|
|
78
78
|
_shoeboxMap = null;
|
|
79
79
|
}
|
|
80
80
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* are visible simultaneously.
|
|
81
|
+
* Returns `true` when the current page was rendered with rehydration
|
|
82
|
+
* markers by the server (or the SSG build), i.e. when the server set
|
|
83
|
+
* `window.__vite_ember_ssr_rehydrate__`.
|
|
85
84
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* ...server rendered content...
|
|
89
|
-
* <script type="x/boundary" id="ssr-body-end">
|
|
85
|
+
* Use this when you need to branch on rehydrate vs. plain boot yourself.
|
|
86
|
+
* In most cases, prefer {@link bootRehydrated}.
|
|
90
87
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* ```gts
|
|
96
|
-
* import { cleanupSSRContent } from '@st-h/vite-ember-ssr/client';
|
|
97
|
-
*
|
|
98
|
-
* <template>
|
|
99
|
-
* {{cleanupSSRContent}}
|
|
100
|
-
* {{outlet}}
|
|
101
|
-
* </template>
|
|
102
|
-
* ```
|
|
103
|
-
*
|
|
104
|
-
* Only used in cleanup mode (default). Not needed when using
|
|
105
|
-
* `rehydrate: true` — in that mode Glimmer reuses the existing DOM.
|
|
88
|
+
* Returns `false` for pages that were not rendered by the server, e.g.
|
|
89
|
+
* a dev page hit without an SSR middleware, or an SSG app navigating to
|
|
90
|
+
* a non-prerendered route.
|
|
106
91
|
*/
|
|
107
|
-
function
|
|
108
|
-
|
|
109
|
-
const end = document.getElementById("ssr-body-end");
|
|
110
|
-
if (!start || !end) return;
|
|
111
|
-
const parent = start.parentNode;
|
|
112
|
-
if (!parent) return;
|
|
113
|
-
let node = start;
|
|
114
|
-
while (node) {
|
|
115
|
-
const next = node.nextSibling;
|
|
116
|
-
parent.removeChild(node);
|
|
117
|
-
if (node === end) break;
|
|
118
|
-
node = next;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Checks if the current page was server-side rendered by looking
|
|
123
|
-
* for SSR boundary markers in the DOM.
|
|
124
|
-
*/
|
|
125
|
-
function isSSRRendered() {
|
|
126
|
-
return document.getElementById("ssr-body-start") !== null;
|
|
92
|
+
function shouldRehydrate() {
|
|
93
|
+
return window.__vite_ember_ssr_rehydrate__ === true;
|
|
127
94
|
}
|
|
128
95
|
/**
|
|
129
|
-
*
|
|
96
|
+
* Boots the client Ember application, rehydrating the server-rendered
|
|
97
|
+
* DOM when one is present.
|
|
98
|
+
*
|
|
99
|
+
* Behaviour:
|
|
100
|
+
* - If {@link shouldRehydrate} returns `true`, creates the application
|
|
101
|
+
* with `autoboot: false` and calls `app.visit(url, { _renderMode: 'rehydrate' })`
|
|
102
|
+
* so Glimmer attaches to the existing DOM instead of replacing it.
|
|
103
|
+
* - Otherwise, calls `Application.create(config.APP)` for a normal boot.
|
|
130
104
|
*
|
|
131
|
-
*
|
|
132
|
-
* `
|
|
133
|
-
*
|
|
134
|
-
* with a normal boot:
|
|
105
|
+
* The visit URL is derived from `window.location.pathname + search`
|
|
106
|
+
* with the configured `rootURL` stripped, matching what `Application.create`
|
|
107
|
+
* would have used internally.
|
|
135
108
|
*
|
|
109
|
+
* @example
|
|
136
110
|
* ```ts
|
|
137
|
-
* import
|
|
111
|
+
* import Application from './app.ts';
|
|
112
|
+
* import config from './config/environment.ts';
|
|
113
|
+
* import { bootRehydrated, installShoebox } from 'vite-ember-ssr/client';
|
|
138
114
|
*
|
|
139
115
|
* installShoebox();
|
|
140
|
-
*
|
|
141
|
-
* const app = Application.create({ ...config.APP, autoboot: false });
|
|
142
|
-
*
|
|
143
|
-
* app.visit(window.location.pathname + window.location.search, {
|
|
144
|
-
* ...(shouldRehydrate() ? { _renderMode: 'rehydrate' } : {}),
|
|
145
|
-
* });
|
|
116
|
+
* bootRehydrated(Application, config);
|
|
146
117
|
* ```
|
|
147
|
-
*
|
|
148
|
-
* This is especially important for SSG apps where only prerendered
|
|
149
|
-
* routes carry the flag — non-SSG routes will boot normally without
|
|
150
|
-
* attempting rehydration (which would fail with no serialized DOM).
|
|
151
118
|
*/
|
|
152
|
-
function
|
|
153
|
-
|
|
119
|
+
function bootRehydrated(Application, config) {
|
|
120
|
+
if (!shouldRehydrate()) {
|
|
121
|
+
Application.create(config.APP ?? {});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const app = Application.create({
|
|
125
|
+
...config.APP ?? {},
|
|
126
|
+
autoboot: false
|
|
127
|
+
});
|
|
128
|
+
const rootURL = config.rootURL ?? "/";
|
|
129
|
+
const url = (window.location.pathname + window.location.search).replace(rootURL, "/");
|
|
130
|
+
app.visit?.(url, { _renderMode: "rehydrate" });
|
|
154
131
|
}
|
|
155
132
|
//#endregion
|
|
156
|
-
export {
|
|
133
|
+
export { bootRehydrated, cleanupShoebox, installShoebox, shouldRehydrate };
|
|
157
134
|
|
|
158
135
|
//# sourceMappingURL=client.js.map
|
package/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * Client-side utilities for vite-ember-ssr.\n *\n *
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * Client-side utilities for vite-ember-ssr.\n *\n * The library always renders pages with Glimmer rehydration markers, so\n * the client boots with `_renderMode: 'rehydrate'` and Glimmer attaches\n * to the existing DOM instead of replacing it. The {@link bootRehydrated}\n * helper takes care of choosing rehydrate vs. a normal boot, since some\n * pages (e.g. dev mode without SSR, or non-prerendered SSG routes) will\n * not carry the rehydrate flag.\n */\n\n// ─── Shoebox Types ───────────────────────────────────────────────────\n\n/**\n * A captured fetch response transferred from the server.\n * Must match the ShoeboxEntry interface in server.ts.\n */\ninterface ShoeboxEntry {\n url: string;\n status: number;\n statusText: string;\n headers: Record<string, string>;\n body: string;\n}\n\nconst SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';\n\n// ─── Shoebox: Client-Side Fetch Replay ───────────────────────────────\n\n/** Original fetch function, saved before monkey-patching */\nlet _originalFetch: typeof fetch | null = null;\n\n/** Map of URL → { entry, refCount } for reference-counted consumption */\nlet _shoeboxMap: Map<string, { entry: ShoeboxEntry; refCount: number }> | null =\n null;\n\n/**\n * Installs the shoebox fetch interceptor on the client.\n *\n * Reads the shoebox data from the server-injected <script> tag,\n * removes the tag from the DOM, and monkey-patches globalThis.fetch\n * to serve cached responses for URLs that match shoebox entries.\n *\n * Each entry is reference-counted: concurrent fetch calls to the same\n * URL all receive the shoebox response. The entry is removed only when\n * the last concurrent consumer has been served.\n *\n * Call this BEFORE creating the Ember application, typically as the\n * first thing in your client entry point.\n *\n * @returns true if shoebox data was found and installed, false otherwise\n */\nexport function installShoebox(): boolean {\n const scriptEl = document.getElementById(SHOEBOX_SCRIPT_ID);\n if (!scriptEl) {\n return false;\n }\n\n // Parse the shoebox data\n let entries: ShoeboxEntry[];\n try {\n entries = JSON.parse(scriptEl.textContent ?? '[]');\n } catch {\n // Malformed shoebox data — skip\n scriptEl.remove();\n return false;\n }\n\n // Remove the script tag from the DOM\n scriptEl.remove();\n\n if (entries.length === 0) {\n return false;\n }\n\n // Build the lookup map with ref counts\n _shoeboxMap = new Map();\n for (const entry of entries) {\n _shoeboxMap.set(entry.url, { entry, refCount: 1 });\n }\n\n // Save the original fetch and install our interceptor\n _originalFetch = globalThis.fetch;\n\n globalThis.fetch = function shoeboxFetch(\n input: RequestInfo | URL,\n init?: RequestInit,\n ): Promise<Response> {\n // Only intercept GET requests (or requests with no method, which default to GET)\n const method = init?.method?.toUpperCase() ?? 'GET';\n if (method !== 'GET' || !_shoeboxMap || _shoeboxMap.size === 0) {\n return _originalFetch!(input, init);\n }\n\n // Resolve the URL string for matching\n let url: string;\n try {\n if (typeof input === 'string') {\n url = new URL(input, globalThis.location?.href).href;\n } else if (input instanceof URL) {\n url = input.href;\n } else if (input instanceof Request) {\n url = input.url;\n } else {\n return _originalFetch!(input, init);\n }\n } catch {\n return _originalFetch!(input, init);\n }\n\n const cached = _shoeboxMap.get(url);\n if (!cached) {\n return _originalFetch!(input, init);\n }\n\n // Decrement ref count and remove if exhausted\n cached.refCount--;\n if (cached.refCount <= 0) {\n _shoeboxMap.delete(url);\n }\n\n // Construct a Response from the cached data\n const { entry } = cached;\n const response = new Response(entry.body, {\n status: entry.status,\n statusText: entry.statusText,\n headers: new Headers(entry.headers),\n });\n\n // Auto-cleanup when the map is empty\n if (_shoeboxMap.size === 0) {\n cleanupShoebox();\n }\n\n return Promise.resolve(response);\n };\n\n return true;\n}\n\n/**\n * Restores the original fetch function and cleans up shoebox state.\n *\n * Called automatically when all shoebox entries have been consumed,\n * or can be called manually to force cleanup.\n */\nexport function cleanupShoebox(): void {\n if (_originalFetch) {\n globalThis.fetch = _originalFetch;\n _originalFetch = null;\n }\n _shoeboxMap = null;\n}\n\n// ─── Boot ─────────────────────────────────────────────────────────────\n\n/**\n * Returns `true` when the current page was rendered with rehydration\n * markers by the server (or the SSG build), i.e. when the server set\n * `window.__vite_ember_ssr_rehydrate__`.\n *\n * Use this when you need to branch on rehydrate vs. plain boot yourself.\n * In most cases, prefer {@link bootRehydrated}.\n *\n * Returns `false` for pages that were not rendered by the server, e.g.\n * a dev page hit without an SSR middleware, or an SSG app navigating to\n * a non-prerendered route.\n */\nexport function shouldRehydrate(): boolean {\n return (\n (window as unknown as Record<string, unknown>)\n .__vite_ember_ssr_rehydrate__ === true\n );\n}\n\n/**\n * Minimal interface satisfied by an Ember Application class.\n */\ninterface ApplicationClass {\n create(options: Record<string, unknown>): {\n visit?(\n url: string,\n options: Record<string, unknown>,\n ): Promise<unknown> | unknown;\n };\n}\n\n/**\n * Boots the client Ember application, rehydrating the server-rendered\n * DOM when one is present.\n *\n * Behaviour:\n * - If {@link shouldRehydrate} returns `true`, creates the application\n * with `autoboot: false` and calls `app.visit(url, { _renderMode: 'rehydrate' })`\n * so Glimmer attaches to the existing DOM instead of replacing it.\n * - Otherwise, calls `Application.create(config.APP)` for a normal boot.\n *\n * The visit URL is derived from `window.location.pathname + search`\n * with the configured `rootURL` stripped, matching what `Application.create`\n * would have used internally.\n *\n * @example\n * ```ts\n * import Application from './app.ts';\n * import config from './config/environment.ts';\n * import { bootRehydrated, installShoebox } from 'vite-ember-ssr/client';\n *\n * installShoebox();\n * bootRehydrated(Application, config);\n * ```\n */\nexport function bootRehydrated(\n Application: ApplicationClass,\n config: { APP?: Record<string, unknown>; rootURL?: string },\n): void {\n if (!shouldRehydrate()) {\n Application.create(config.APP ?? {});\n return;\n }\n\n const app = Application.create({\n ...(config.APP ?? {}),\n autoboot: false,\n });\n\n const rootURL = config.rootURL ?? '/';\n const url = (window.location.pathname + window.location.search).replace(\n rootURL,\n '/',\n );\n\n void app.visit?.(url, { _renderMode: 'rehydrate' });\n}\n"],"mappings":";AAyBA,MAAM,oBAAoB;;AAK1B,IAAI,iBAAsC;;AAG1C,IAAI,cACF;;;;;;;;;;;;;;;;;AAkBF,SAAgB,iBAA0B;CACxC,MAAM,WAAW,SAAS,eAAe,kBAAkB;AAC3D,KAAI,CAAC,SACH,QAAO;CAIT,IAAI;AACJ,KAAI;AACF,YAAU,KAAK,MAAM,SAAS,eAAe,KAAK;SAC5C;AAEN,WAAS,QAAQ;AACjB,SAAO;;AAIT,UAAS,QAAQ;AAEjB,KAAI,QAAQ,WAAW,EACrB,QAAO;AAIT,+BAAc,IAAI,KAAK;AACvB,MAAK,MAAM,SAAS,QAClB,aAAY,IAAI,MAAM,KAAK;EAAE;EAAO,UAAU;EAAG,CAAC;AAIpD,kBAAiB,WAAW;AAE5B,YAAW,QAAQ,SAAS,aAC1B,OACA,MACmB;AAGnB,OADe,MAAM,QAAQ,aAAa,IAAI,WAC/B,SAAS,CAAC,eAAe,YAAY,SAAS,EAC3D,QAAO,eAAgB,OAAO,KAAK;EAIrC,IAAI;AACJ,MAAI;AACF,OAAI,OAAO,UAAU,SACnB,OAAM,IAAI,IAAI,OAAO,WAAW,UAAU,KAAK,CAAC;YACvC,iBAAiB,IAC1B,OAAM,MAAM;YACH,iBAAiB,QAC1B,OAAM,MAAM;OAEZ,QAAO,eAAgB,OAAO,KAAK;UAE/B;AACN,UAAO,eAAgB,OAAO,KAAK;;EAGrC,MAAM,SAAS,YAAY,IAAI,IAAI;AACnC,MAAI,CAAC,OACH,QAAO,eAAgB,OAAO,KAAK;AAIrC,SAAO;AACP,MAAI,OAAO,YAAY,EACrB,aAAY,OAAO,IAAI;EAIzB,MAAM,EAAE,UAAU;EAClB,MAAM,WAAW,IAAI,SAAS,MAAM,MAAM;GACxC,QAAQ,MAAM;GACd,YAAY,MAAM;GAClB,SAAS,IAAI,QAAQ,MAAM,QAAQ;GACpC,CAAC;AAGF,MAAI,YAAY,SAAS,EACvB,iBAAgB;AAGlB,SAAO,QAAQ,QAAQ,SAAS;;AAGlC,QAAO;;;;;;;;AAST,SAAgB,iBAAuB;AACrC,KAAI,gBAAgB;AAClB,aAAW,QAAQ;AACnB,mBAAiB;;AAEnB,eAAc;;;;;;;;;;;;;;AAiBhB,SAAgB,kBAA2B;AACzC,QACG,OACE,iCAAiC;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCxC,SAAgB,eACd,aACA,QACM;AACN,KAAI,CAAC,iBAAiB,EAAE;AACtB,cAAY,OAAO,OAAO,OAAO,EAAE,CAAC;AACpC;;CAGF,MAAM,MAAM,YAAY,OAAO;EAC7B,GAAI,OAAO,OAAO,EAAE;EACpB,UAAU;EACX,CAAC;CAEF,MAAM,UAAU,OAAO,WAAW;CAClC,MAAM,OAAO,OAAO,SAAS,WAAW,OAAO,SAAS,QAAQ,QAC9D,SACA,IACD;AAEI,KAAI,QAAQ,KAAK,EAAE,aAAa,aAAa,CAAC"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
//#region src/fetch-middleware.ts
|
|
2
|
+
/**
|
|
3
|
+
* Composes middlewares into a single fetch-compatible function.
|
|
4
|
+
*
|
|
5
|
+
* Middlewares run in array order on the way in and reverse order on the
|
|
6
|
+
* way out (Koa onion). The terminal function is what runs at the centre
|
|
7
|
+
* of the onion — typically the real, unintercepted `fetch`.
|
|
8
|
+
*/
|
|
9
|
+
function compose(middlewares, terminal) {
|
|
10
|
+
return async (input, init) => {
|
|
11
|
+
const initialRequest = new Request(input, init);
|
|
12
|
+
const dispatch = (idx, req) => {
|
|
13
|
+
const mw = middlewares[idx];
|
|
14
|
+
if (!mw) return terminal(req);
|
|
15
|
+
return mw(req, (nextReq) => dispatch(idx + 1, nextReq));
|
|
16
|
+
};
|
|
17
|
+
return dispatch(0, initialRequest);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Middleware that injects the incoming request's `Cookie` header into
|
|
22
|
+
* outbound fetches. The cookie is only added when `URL.host` exactly
|
|
23
|
+
* matches one of `allowedHosts`. A cookie header already set on the
|
|
24
|
+
* request (by app code) is not overwritten.
|
|
25
|
+
*
|
|
26
|
+
* Applies to all HTTP methods — auth cookies must flow on POST/PUT too.
|
|
27
|
+
*
|
|
28
|
+
* @param getCookie Function returning the active per-render cookie
|
|
29
|
+
* config, or `null` when forwarding is disabled for this render.
|
|
30
|
+
*/
|
|
31
|
+
function forwardCookieMiddleware(getCookie) {
|
|
32
|
+
return (request, next) => {
|
|
33
|
+
const cookie = getCookie();
|
|
34
|
+
if (!cookie) return next(request);
|
|
35
|
+
const url = new URL(request.url);
|
|
36
|
+
if (!cookie.allowedHosts.includes(url.host)) return next(request);
|
|
37
|
+
if (request.headers.has("cookie")) return next(request);
|
|
38
|
+
const merged = new Headers(request.headers);
|
|
39
|
+
merged.set("cookie", cookie.value);
|
|
40
|
+
return next(new Request(request, { headers: merged }));
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Middleware that ties outbound fetches to a per-render AbortSignal, so
|
|
45
|
+
* requests still in flight when the render finishes (typically after a
|
|
46
|
+
* `settled()` timeout) are aborted instead of lingering into later
|
|
47
|
+
* renders on the same worker.
|
|
48
|
+
*
|
|
49
|
+
* @param getSignal Function returning the active per-render signal, or
|
|
50
|
+
* `null` when no render-scoped abort is configured.
|
|
51
|
+
*/
|
|
52
|
+
function abortSignalMiddleware(getSignal) {
|
|
53
|
+
return (request, next) => {
|
|
54
|
+
const signal = getSignal();
|
|
55
|
+
if (!signal) return next(request);
|
|
56
|
+
const combined = request.signal ? AbortSignal.any([request.signal, signal]) : signal;
|
|
57
|
+
const result = next(new Request(request, { signal: combined }));
|
|
58
|
+
result.catch(() => {});
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Middleware that captures GET response bodies into a per-render Map so
|
|
64
|
+
* they can be serialized into the HTML for client-side replay. Non-GET
|
|
65
|
+
* methods are passed through unchanged.
|
|
66
|
+
*
|
|
67
|
+
* @param getEntries Function returning the active per-render Map, or
|
|
68
|
+
* `null` when shoebox is disabled for this render.
|
|
69
|
+
*/
|
|
70
|
+
function shoeboxMiddleware(getEntries) {
|
|
71
|
+
return async (request, next) => {
|
|
72
|
+
const entries = getEntries();
|
|
73
|
+
const response = await next(request);
|
|
74
|
+
if (!entries) return response;
|
|
75
|
+
if (request.method.toUpperCase() !== "GET") return response;
|
|
76
|
+
try {
|
|
77
|
+
const clone = response.clone();
|
|
78
|
+
const body = await clone.text();
|
|
79
|
+
const headers = {};
|
|
80
|
+
clone.headers.forEach((v, k) => {
|
|
81
|
+
if (k.toLowerCase() === "set-cookie") return;
|
|
82
|
+
headers[k] = v;
|
|
83
|
+
});
|
|
84
|
+
entries.set(request.url, {
|
|
85
|
+
url: request.url,
|
|
86
|
+
status: clone.status,
|
|
87
|
+
statusText: clone.statusText,
|
|
88
|
+
headers,
|
|
89
|
+
body
|
|
90
|
+
});
|
|
91
|
+
} catch {}
|
|
92
|
+
return response;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
export { shoeboxMiddleware as i, compose as n, forwardCookieMiddleware as r, abortSignalMiddleware as t };
|
|
97
|
+
|
|
98
|
+
//# sourceMappingURL=fetch-middleware-DPLxOLL6.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch-middleware-DPLxOLL6.js","names":[],"sources":["../src/fetch-middleware.ts"],"sourcesContent":["/**\n * Fetch middleware pipeline used during SSR rendering.\n *\n * The render path installs a single `fetchWithMiddleware` function as\n * `globalThis.fetch`. That function dispatches each call through a\n * Koa-style onion of middlewares, each of which may inspect/modify the\n * request, await `next(req)`, and inspect/modify the response.\n *\n * Three middlewares ship with the library:\n * - `forwardCookieMiddleware` — injects the incoming request's `Cookie`\n * header into outbound fetches whose host appears in `allowedHosts`,\n * so SSR can make authenticated calls on behalf of the user without\n * leaking the session cookie to third-party hosts.\n * - `shoeboxMiddleware` — captures GET responses into a per-render\n * Map so they can be serialized into the HTML for the client to\n * replay during rehydration.\n * - `abortSignalMiddleware` — ties outbound fetches to a per-render\n * AbortSignal so requests still in flight when a render finishes are\n * aborted instead of lingering into later renders.\n *\n * Worker mode installs the pipeline once at startup and swaps per-render\n * state via getters; dev mode rebuilds the pipeline per request with\n * closure-captured state.\n */\n\nimport type { ShoeboxEntry, ForwardedCookie } from './server.js';\n\nexport type FetchMiddleware = (\n request: Request,\n next: (request: Request) => Promise<Response>,\n) => Promise<Response>;\n\n/**\n * Composes middlewares into a single fetch-compatible function.\n *\n * Middlewares run in array order on the way in and reverse order on the\n * way out (Koa onion). The terminal function is what runs at the centre\n * of the onion — typically the real, unintercepted `fetch`.\n */\nexport function compose(\n middlewares: FetchMiddleware[],\n terminal: (request: Request) => Promise<Response>,\n): typeof fetch {\n return async (input, init) => {\n const initialRequest = new Request(input, init);\n const dispatch = (idx: number, req: Request): Promise<Response> => {\n const mw = middlewares[idx];\n if (!mw) return terminal(req);\n return mw(req, (nextReq) => dispatch(idx + 1, nextReq));\n };\n return dispatch(0, initialRequest);\n };\n}\n\n/**\n * Middleware that injects the incoming request's `Cookie` header into\n * outbound fetches. The cookie is only added when `URL.host` exactly\n * matches one of `allowedHosts`. A cookie header already set on the\n * request (by app code) is not overwritten.\n *\n * Applies to all HTTP methods — auth cookies must flow on POST/PUT too.\n *\n * @param getCookie Function returning the active per-render cookie\n * config, or `null` when forwarding is disabled for this render.\n */\nexport function forwardCookieMiddleware(\n getCookie: () => ForwardedCookie | null,\n): FetchMiddleware {\n return (request, next) => {\n const cookie = getCookie();\n if (!cookie) return next(request);\n\n const url = new URL(request.url);\n if (!cookie.allowedHosts.includes(url.host)) return next(request);\n\n // Don't overwrite a cookie explicitly set by app code.\n if (request.headers.has('cookie')) return next(request);\n\n const merged = new Headers(request.headers);\n merged.set('cookie', cookie.value);\n return next(new Request(request, { headers: merged }));\n };\n}\n\n/**\n * Middleware that ties outbound fetches to a per-render AbortSignal, so\n * requests still in flight when the render finishes (typically after a\n * `settled()` timeout) are aborted instead of lingering into later\n * renders on the same worker.\n *\n * @param getSignal Function returning the active per-render signal, or\n * `null` when no render-scoped abort is configured.\n */\nexport function abortSignalMiddleware(\n getSignal: () => AbortSignal | null,\n): FetchMiddleware {\n return (request, next) => {\n const signal = getSignal();\n if (!signal) return next(request);\n\n // Preserve any signal app code attached to its own request.\n const combined = request.signal\n ? AbortSignal.any([request.signal, signal])\n : signal;\n const result = next(new Request(request, { signal: combined }));\n\n // A render-scoped abort fires after the app that issued the fetch has\n // been torn down, so its rejection may have nothing left awaiting it.\n // Pre-attach a no-op handler to keep such late rejections from crashing\n // the worker as unhandled; the caller still receives the rejection.\n result.catch(() => {});\n return result;\n };\n}\n\n/**\n * Middleware that captures GET response bodies into a per-render Map so\n * they can be serialized into the HTML for client-side replay. Non-GET\n * methods are passed through unchanged.\n *\n * @param getEntries Function returning the active per-render Map, or\n * `null` when shoebox is disabled for this render.\n */\nexport function shoeboxMiddleware(\n getEntries: () => Map<string, ShoeboxEntry> | null,\n): FetchMiddleware {\n return async (request, next) => {\n // Capture the CURRENT render's entries map before awaiting the response.\n // A fetch left in flight by a timed-out render can resolve while a LATER\n // render is active; calling getEntries() after the await would return\n // that later render's map and leak this response into the wrong render's\n // shoebox. With the reference captured up front, a late write lands in\n // the dead render's map, which is never serialized.\n const entries = getEntries();\n const response = await next(request);\n if (!entries) return response;\n if (request.method.toUpperCase() !== 'GET') return response;\n\n try {\n const clone = response.clone();\n const body = await clone.text();\n const headers: Record<string, string> = {};\n clone.headers.forEach((v, k) => {\n // Never serialize Set-Cookie into the shoebox: it would leak the\n // origin's (often HttpOnly) auth cookie into the rendered HTML, where\n // any script can read it and — for a cached/shared response — hand one\n // user's session to another. The client replays entries as\n // JS-constructed Responses whose Set-Cookie the browser ignores, so it\n // is inert here anyway.\n if (k.toLowerCase() === 'set-cookie') return;\n headers[k] = v;\n });\n entries.set(request.url, {\n url: request.url,\n status: clone.status,\n statusText: clone.statusText,\n headers,\n body,\n });\n } catch {\n /* skip */\n }\n\n return response;\n };\n}\n"],"mappings":";;;;;;;;AAuCA,SAAgB,QACd,aACA,UACc;AACd,QAAO,OAAO,OAAO,SAAS;EAC5B,MAAM,iBAAiB,IAAI,QAAQ,OAAO,KAAK;EAC/C,MAAM,YAAY,KAAa,QAAoC;GACjE,MAAM,KAAK,YAAY;AACvB,OAAI,CAAC,GAAI,QAAO,SAAS,IAAI;AAC7B,UAAO,GAAG,MAAM,YAAY,SAAS,MAAM,GAAG,QAAQ,CAAC;;AAEzD,SAAO,SAAS,GAAG,eAAe;;;;;;;;;;;;;;AAetC,SAAgB,wBACd,WACiB;AACjB,SAAQ,SAAS,SAAS;EACxB,MAAM,SAAS,WAAW;AAC1B,MAAI,CAAC,OAAQ,QAAO,KAAK,QAAQ;EAEjC,MAAM,MAAM,IAAI,IAAI,QAAQ,IAAI;AAChC,MAAI,CAAC,OAAO,aAAa,SAAS,IAAI,KAAK,CAAE,QAAO,KAAK,QAAQ;AAGjE,MAAI,QAAQ,QAAQ,IAAI,SAAS,CAAE,QAAO,KAAK,QAAQ;EAEvD,MAAM,SAAS,IAAI,QAAQ,QAAQ,QAAQ;AAC3C,SAAO,IAAI,UAAU,OAAO,MAAM;AAClC,SAAO,KAAK,IAAI,QAAQ,SAAS,EAAE,SAAS,QAAQ,CAAC,CAAC;;;;;;;;;;;;AAa1D,SAAgB,sBACd,WACiB;AACjB,SAAQ,SAAS,SAAS;EACxB,MAAM,SAAS,WAAW;AAC1B,MAAI,CAAC,OAAQ,QAAO,KAAK,QAAQ;EAGjC,MAAM,WAAW,QAAQ,SACrB,YAAY,IAAI,CAAC,QAAQ,QAAQ,OAAO,CAAC,GACzC;EACJ,MAAM,SAAS,KAAK,IAAI,QAAQ,SAAS,EAAE,QAAQ,UAAU,CAAC,CAAC;AAM/D,SAAO,YAAY,GAAG;AACtB,SAAO;;;;;;;;;;;AAYX,SAAgB,kBACd,YACiB;AACjB,QAAO,OAAO,SAAS,SAAS;EAO9B,MAAM,UAAU,YAAY;EAC5B,MAAM,WAAW,MAAM,KAAK,QAAQ;AACpC,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,QAAQ,OAAO,aAAa,KAAK,MAAO,QAAO;AAEnD,MAAI;GACF,MAAM,QAAQ,SAAS,OAAO;GAC9B,MAAM,OAAO,MAAM,MAAM,MAAM;GAC/B,MAAM,UAAkC,EAAE;AAC1C,SAAM,QAAQ,SAAS,GAAG,MAAM;AAO9B,QAAI,EAAE,aAAa,KAAK,aAAc;AACtC,YAAQ,KAAK;KACb;AACF,WAAQ,IAAI,QAAQ,KAAK;IACvB,KAAK,QAAQ;IACb,QAAQ,MAAM;IACd,YAAY,MAAM;IAClB;IACA;IACD,CAAC;UACI;AAIR,SAAO"}
|