@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/src/client.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Client-side utilities for vite-ember-ssr.
3
3
  *
4
- * Currently the client Ember app boots normally and replaces the
5
- * SSR-rendered content. True DOM hydration is planned for a future
6
- * phase.
7
- *
8
- * For now, the SSR content provides the initial visual while client
9
- * JavaScript loads, parses, and Ember boots.
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
- // ─── SSR Content Cleanup ─────────────────────────────────────────────
155
+ // ─── Boot ─────────────────────────────────────────────────────────────
156
156
 
157
157
  /**
158
- * Removes the SSR-rendered content from the DOM so the client Ember
159
- * app can render into a clean `<body>`. This prevents the "double
160
- * render" where both server-rendered HTML and client-rendered HTML
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
- * **Call this from your application template** rather than from
169
- * `entry.ts` this ensures removal happens at the moment Ember
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
- * ```gts
173
- * import { cleanupSSRContent } from '@st-h/vite-ember-ssr/client';
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 cleanupSSRContent(): void {
185
- const start = document.getElementById('ssr-body-start');
186
- const end = document.getElementById('ssr-body-end');
187
-
188
- if (!start || !end) {
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
- * Checks if the current page was server-side rendered by looking
207
- * for SSR boundary markers in the DOM.
177
+ * Minimal interface satisfied by an Ember Application class.
208
178
  */
209
- export function isSSRRendered(): boolean {
210
- return document.getElementById('ssr-body-start') !== null;
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
- * Checks whether the current page was rendered with rehydration mode.
189
+ * Boots the client Ember application, rehydrating the server-rendered
190
+ * DOM when one is present.
215
191
  *
216
- * Returns `true` when the server (or SSG build) injected the
217
- * `window.__vite_ember_ssr_rehydrate__` flag. Use this in your client
218
- * entry point to decide whether to boot Ember in rehydrate mode or
219
- * with a normal boot:
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 { shouldRehydrate, installShoebox } from '@st-h/vite-ember-ssr/client';
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 shouldRehydrate(): boolean {
238
- return (
239
- (window as unknown as Record<string, unknown>)
240
- .__vite_ember_ssr_rehydrate__ === true
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 '@st-h/vite-ember-ssr/server';
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
- const SSR_BODY_START =
70
- '<script type="x/boundary" id="ssr-body-start"></script>';
71
- const SSR_BODY_END = '<script type="x/boundary" id="ssr-body-end"></script>';
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
- headers: forwardHeaders,
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
- // Shoebox: intercept fetch for this render only.
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 (shoebox || forwardHeaders) {
192
- globalThis.fetch = async (
193
- input: RequestInfo | URL,
194
- init?: RequestInit,
195
- ) => {
196
- // Inject forwarded request headers into outgoing fetches
197
- let effectiveInit = init;
198
- if (forwardHeaders) {
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
- ...(rehydrate ? { _renderMode: 'serialize' as const } : {}),
257
+ _renderMode: 'serialize',
263
258
  };
264
259
 
265
260
  const instance = await app.visit(url, bootOptions);
266
261
 
267
- // Drain Backburner's autorun microtask before reading the DOM.
268
- await new Promise<void>((resolve) => setTimeout(resolve, 0));
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 (shoebox || forwardHeaders) globalThis.fetch = realFetch;
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 = rehydrate
298
- ? '<script>window.__vite_ember_ssr_rehydrate__=true</script>'
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: wrappedBody,
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
+ }