@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/dist/client.d.ts CHANGED
@@ -2,12 +2,12 @@
2
2
  /**
3
3
  * Client-side utilities for vite-ember-ssr.
4
4
  *
5
- * Currently the client Ember app boots normally and replaces the
6
- * SSR-rendered content. True DOM hydration is planned for a future
7
- * phase.
8
- *
9
- * For now, the SSR content provides the initial visual while client
10
- * JavaScript loads, parses, and Ember boots.
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
- * Removes the SSR-rendered content from the DOM so the client Ember
38
- * app can render into a clean `<body>`. This prevents the "double
39
- * render" where both server-rendered HTML and client-rendered HTML
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
- * ```gts
52
- * import { cleanupSSRContent } from '@st-h/vite-ember-ssr/client';
41
+ * Use this when you need to branch on rehydrate vs. plain boot yourself.
42
+ * In most cases, prefer {@link bootRehydrated}.
53
43
  *
54
- * <template>
55
- * {{cleanupSSRContent}}
56
- * {{outlet}}
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 cleanupSSRContent(): void;
48
+ declare function shouldRehydrate(): boolean;
64
49
  /**
65
- * Checks if the current page was server-side rendered by looking
66
- * for SSR boundary markers in the DOM.
50
+ * Minimal interface satisfied by an Ember Application class.
67
51
  */
68
- declare function isSSRRendered(): boolean;
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
- * Checks whether the current page was rendered with rehydration mode.
58
+ * Boots the client Ember application, rehydrating the server-rendered
59
+ * DOM when one is present.
71
60
  *
72
- * Returns `true` when the server (or SSG build) injected the
73
- * `window.__vite_ember_ssr_rehydrate__` flag. Use this in your client
74
- * entry point to decide whether to boot Ember in rehydrate mode or
75
- * with a normal boot:
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 { shouldRehydrate, installShoebox } from '@st-h/vite-ember-ssr/client';
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 shouldRehydrate(): boolean;
81
+ declare function bootRehydrated(Application: ApplicationClass, config: {
82
+ APP?: Record<string, unknown>;
83
+ rootURL?: string;
84
+ }): void;
94
85
  //#endregion
95
- export { cleanupSSRContent, cleanupShoebox, installShoebox, isSSRRendered, shouldRehydrate };
86
+ export { bootRehydrated, cleanupShoebox, installShoebox, shouldRehydrate };
96
87
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","names":[],"sources":["../src/client.ts"],"mappings":";;AAoDA;;;;;AA8FA;;;;;AAqCA;;;;;AAyBA;;;;;AA4BA;;;;;iBAxLgB,cAAA,CAAA;;;;;;;iBA8FA,cAAA,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAqCA,iBAAA,CAAA;;;;;iBAyBA,aAAA,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;iBA4BA,eAAA,CAAA"}
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
- * Removes the SSR-rendered content from the DOM so the client Ember
82
- * app can render into a clean `<body>`. This prevents the "double
83
- * render" where both server-rendered HTML and client-rendered HTML
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
- * Removes everything between (and including) the SSR boundary markers:
87
- * <script type="x/boundary" id="ssr-body-start">
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
- * **Call this from your application template** rather than from
92
- * `entry.ts` this ensures removal happens at the moment Ember
93
- * renders, avoiding a flash of no content:
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 cleanupSSRContent() {
108
- const start = document.getElementById("ssr-body-start");
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
- * Checks whether the current page was rendered with rehydration mode.
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
- * Returns `true` when the server (or SSG build) injected the
132
- * `window.__vite_ember_ssr_rehydrate__` flag. Use this in your client
133
- * entry point to decide whether to boot Ember in rehydrate mode or
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 { shouldRehydrate, installShoebox } from '@st-h/vite-ember-ssr/client';
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 shouldRehydrate() {
153
- return window.__vite_ember_ssr_rehydrate__ === true;
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 { cleanupSSRContent, cleanupShoebox, installShoebox, isSSRRendered, shouldRehydrate };
133
+ export { bootRehydrated, cleanupShoebox, installShoebox, shouldRehydrate };
157
134
 
158
135
  //# sourceMappingURL=client.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * Client-side utilities for vite-ember-ssr.\n *\n * Currently the client Ember app boots normally and replaces the\n * SSR-rendered content. True DOM hydration is planned for a future\n * phase.\n *\n * For now, the SSR content provides the initial visual while client\n * JavaScript loads, parses, and Ember boots.\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// ─── SSR Content Cleanup ─────────────────────────────────────────────\n\n/**\n * Removes the SSR-rendered content from the DOM so the client Ember\n * app can render into a clean `<body>`. This prevents the \"double\n * render\" where both server-rendered HTML and client-rendered HTML\n * are visible simultaneously.\n *\n * Removes everything between (and including) the SSR boundary markers:\n * <script type=\"x/boundary\" id=\"ssr-body-start\">\n * ...server rendered content...\n * <script type=\"x/boundary\" id=\"ssr-body-end\">\n *\n * **Call this from your application template** rather than from\n * `entry.ts` this ensures removal happens at the moment Ember\n * renders, avoiding a flash of no content:\n *\n * ```gts\n * import { cleanupSSRContent } from '@st-h/vite-ember-ssr/client';\n *\n * <template>\n * {{cleanupSSRContent}}\n * {{outlet}}\n * </template>\n * ```\n *\n * Only used in cleanup mode (default). Not needed when using\n * `rehydrate: true` — in that mode Glimmer reuses the existing DOM.\n */\nexport function cleanupSSRContent(): void {\n const start = document.getElementById('ssr-body-start');\n const end = document.getElementById('ssr-body-end');\n\n if (!start || !end) {\n return; // Not an SSR-rendered page\n }\n\n // Remove all nodes between start and end markers (inclusive)\n const parent = start.parentNode;\n if (!parent) return;\n\n let node: ChildNode | null = start;\n while (node) {\n const next: ChildNode | null = node.nextSibling;\n parent.removeChild(node);\n if (node === end) break;\n node = next;\n }\n}\n\n/**\n * Checks if the current page was server-side rendered by looking\n * for SSR boundary markers in the DOM.\n */\nexport function isSSRRendered(): boolean {\n return document.getElementById('ssr-body-start') !== null;\n}\n\n/**\n * Checks whether the current page was rendered with rehydration mode.\n *\n * Returns `true` when the server (or SSG build) injected the\n * `window.__vite_ember_ssr_rehydrate__` flag. Use this in your client\n * entry point to decide whether to boot Ember in rehydrate mode or\n * with a normal boot:\n *\n * ```ts\n * import { shouldRehydrate, installShoebox } from '@st-h/vite-ember-ssr/client';\n *\n * installShoebox();\n *\n * const app = Application.create({ ...config.APP, autoboot: false });\n *\n * app.visit(window.location.pathname + window.location.search, {\n * ...(shouldRehydrate() ? { _renderMode: 'rehydrate' } : {}),\n * });\n * ```\n *\n * This is especially important for SSG apps where only prerendered\n * routes carry the flag — non-SSG routes will boot normally without\n * attempting rehydration (which would fail with no serialized DOM).\n */\nexport function shouldRehydrate(): boolean {\n return (\n (window as unknown as Record<string, unknown>)\n .__vite_ember_ssr_rehydrate__ === true\n );\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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgChB,SAAgB,oBAA0B;CACxC,MAAM,QAAQ,SAAS,eAAe,iBAAiB;CACvD,MAAM,MAAM,SAAS,eAAe,eAAe;AAEnD,KAAI,CAAC,SAAS,CAAC,IACb;CAIF,MAAM,SAAS,MAAM;AACrB,KAAI,CAAC,OAAQ;CAEb,IAAI,OAAyB;AAC7B,QAAO,MAAM;EACX,MAAM,OAAyB,KAAK;AACpC,SAAO,YAAY,KAAK;AACxB,MAAI,SAAS,IAAK;AAClB,SAAO;;;;;;;AAQX,SAAgB,gBAAyB;AACvC,QAAO,SAAS,eAAe,iBAAiB,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BvD,SAAgB,kBAA2B;AACzC,QACG,OACE,iCAAiC"}
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"}