@webjsdev/server 0.7.2

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/ssr.js ADDED
@@ -0,0 +1,1258 @@
1
+ import { pathToFileURL, fileURLToPath } from 'node:url';
2
+ import { resolve } from 'node:path';
3
+ import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy } from '@webjsdev/core';
4
+ import { importMapTag } from './importmap.js';
5
+ import { readToken, newToken, cookieHeader } from './csrf.js';
6
+ import { transitiveDeps } from './module-graph.js';
7
+
8
+ /**
9
+ * SSR a matched page route to a Response.
10
+ *
11
+ * Mirrors NextJs semantics:
12
+ * - Page + layout default exports can be async.
13
+ * - `metadata` named export on layouts/pages is merged (page > innermost layout > … > root).
14
+ * - `notFound()` and `redirect()` thrown anywhere in the chain are caught
15
+ * and converted to 404 or 3xx responses.
16
+ * - On a render error we walk up the chain looking for the nearest `error.js`
17
+ * and render that instead (falls back to a plain error page).
18
+ *
19
+ * @param {import('./router.js').PageRoute} route
20
+ * @param {Record<string,string>} params
21
+ * @param {URL} url
22
+ * @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('./module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string> }} opts
23
+ * @returns {Promise<Response>}
24
+ */
25
+ export async function ssrPage(route, params, url, opts) {
26
+ const ctx = {
27
+ params,
28
+ searchParams: Object.fromEntries(url.searchParams.entries()),
29
+ url: url.toString(),
30
+ };
31
+
32
+ // Collect metadata across layouts (outermost first) then page.
33
+ const metadata = await collectMetadata(route, ctx, opts.dev);
34
+
35
+ try {
36
+ const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set() };
37
+ // Parse the partial-nav "have" header from the client. The header
38
+ // lists comma-separated marker paths the client already has rendered
39
+ // in its DOM. The server walks the target route's layout chain
40
+ // innermost → outermost and SHORT-CIRCUITS at the first match -
41
+ // returning only the content below that layout, wrapped in the
42
+ // matched layout's marker pair. Real wire-byte savings: the outer
43
+ // layouts' HTML is never re-serialized for same-shell navigations.
44
+ const haveHeader = opts.req?.headers.get('x-webjs-have') || '';
45
+ const have = haveHeader
46
+ ? new Set(haveHeader.split(',').map((s) => s.trim()).filter(Boolean))
47
+ : null;
48
+ const body = await renderChain(route, ctx, opts.dev, suspenseCtx, have);
49
+ // Module URLs for the page + every layout in its chain. These ride
50
+ // the importmap; the browser fetches each file as it walks the
51
+ // import graph. Combined with the modulepreload hints below, this
52
+ // is the Rails 7+ / Hotwire pattern: per-file ESM, no bundling,
53
+ // HTTP/2 multiplex on the wire.
54
+ const moduleUrls = [route.file, ...route.layouts].map((f) => toUrlPath(f, opts.appDir));
55
+ // Emit <link rel="modulepreload"> for every custom element that
56
+ // actually rendered PLUS their transitive dependencies (from the
57
+ // module graph). URLs are deduplicated so the browser never sees
58
+ // the same preload twice. Lazy components are excluded from
59
+ // preloads and instead loaded via IntersectionObserver when they
60
+ // enter the viewport.
61
+ const { eager: eagerComponents, lazy: lazyComponents } =
62
+ componentPreloads(suspenseCtx.usedComponents, opts.appDir);
63
+ const preloads = deduplicatedPreloads(
64
+ eagerComponents,
65
+ moduleUrls,
66
+ opts.moduleGraph,
67
+ [route.file, ...route.layouts],
68
+ opts.appDir,
69
+ opts.serverFiles,
70
+ );
71
+ // Extract CSP nonce from request headers (if present).
72
+ const nonce = opts.req ? getNonce(opts.req) : undefined;
73
+ const wrapOpts = {
74
+ metadata,
75
+ moduleUrls,
76
+ dev: opts.dev,
77
+ streaming: suspenseCtx.pending.length > 0,
78
+ preloads,
79
+ lazyComponents,
80
+ nonce,
81
+ };
82
+ // buildDocumentParts picks up a user-supplied <!doctype><html>…</html>
83
+ // shell from the body when present; otherwise auto-emits the framework
84
+ // shell. Either way the returned `prefix` ends just past the open <body>
85
+ // and `closer` is the matching `</body></html>`.
86
+ const { prefix, streamBody, closer } = buildDocumentParts(body, wrapOpts);
87
+ return streamingHtmlResponse(
88
+ prefix,
89
+ streamBody,
90
+ closer,
91
+ suspenseCtx,
92
+ 200,
93
+ opts.req,
94
+ url,
95
+ metadata,
96
+ );
97
+ } catch (err) {
98
+ if (isRedirect(err)) {
99
+ const e = /** @type any */ (err);
100
+ return new Response(null, { status: e.status || 307, headers: { location: e.url } });
101
+ }
102
+ if (isNotFound(err)) {
103
+ const html = await ssrNotFoundHtml(null, opts);
104
+ return htmlResponse(html, 404, opts.req, url);
105
+ }
106
+ // Try nearest error.js (innermost → outermost).
107
+ for (let i = route.errors.length - 1; i >= 0; i--) {
108
+ try {
109
+ const mod = await loadModule(route.errors[i], opts.dev);
110
+ if (!mod.default) continue;
111
+ const tree = await mod.default({ ...ctx, error: err });
112
+ const body = await renderToString(tree);
113
+ const moduleUrls = [route.file, ...route.layouts].map((f) => toUrlPath(f, opts.appDir));
114
+ const html = wrapInDocument(body, { metadata, moduleUrls, dev: opts.dev });
115
+ return htmlResponse(html, 500, opts.req, url);
116
+ } catch (nested) {
117
+ // fall through to next error boundary
118
+ }
119
+ }
120
+ // Default: dev shows stack, prod shows a terse message (no stack trace leaks).
121
+ console.error('[webjs] unhandled render error:', err);
122
+ const body = opts.dev
123
+ ? `<h1>Server error</h1><pre style="white-space:pre-wrap">${escapeHtml(
124
+ err instanceof Error ? err.stack || err.message : String(err)
125
+ )}</pre>`
126
+ : `<h1>Server error</h1><p>Something went wrong. Please try again.</p>`;
127
+ return htmlResponse(
128
+ wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev }),
129
+ 500,
130
+ opts.req,
131
+ url
132
+ );
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 404 response for unmatched routes.
138
+ * @param {string | null} notFoundFile
139
+ * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
140
+ */
141
+ export async function ssrNotFound(notFoundFile, opts) {
142
+ const html = await ssrNotFoundHtml(notFoundFile, opts);
143
+ return htmlResponse(html, 404, opts.req, opts.url);
144
+ }
145
+
146
+ /**
147
+ * Build an HTML Response and, if missing, attach the CSRF cookie.
148
+ * @param {string} html
149
+ * @param {number} status
150
+ * @param {Request | undefined} req
151
+ * @param {URL | undefined} url
152
+ * @param {Record<string, any>} [metadata]
153
+ */
154
+ function htmlResponse(html, status, req, url, metadata) {
155
+ const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
156
+ // Default: no caching. Pages are dynamic by default: the developer
157
+ // opts in to caching explicitly via metadata.cacheControl.
158
+ headers.set('cache-control', metadata?.cacheControl || 'no-store');
159
+ if (req && !readToken(req)) {
160
+ const secure = url ? url.protocol === 'https:' : false;
161
+ headers.append('set-cookie', cookieHeader(newToken(), { secure }));
162
+ }
163
+ return new Response(html, { status, headers });
164
+ }
165
+
166
+ /* ------------ internals ------------ */
167
+
168
+ async function ssrNotFoundHtml(notFoundFile, opts) {
169
+ let body = '<h1>404: Not found</h1>';
170
+ if (notFoundFile) {
171
+ try {
172
+ const mod = await loadModule(notFoundFile, opts.dev);
173
+ if (mod.default) body = await renderToString(await mod.default({}));
174
+ } catch (e) {
175
+ body = `<h1>404: Not found</h1><pre>${escapeHtml(String(e))}</pre>`;
176
+ }
177
+ }
178
+ return wrapInDocument(body, {
179
+ metadata: { title: 'Not found' },
180
+ moduleUrls: [],
181
+ dev: opts.dev,
182
+ });
183
+ }
184
+
185
+ async function renderChain(route, ctx, dev, suspenseCtx, have) {
186
+ const page = await loadModule(route.file, dev);
187
+ if (!page.default) throw new Error(`Page ${route.file} must have a default export`);
188
+ let tree = await page.default(ctx);
189
+
190
+ // If the route has a loading.ts file, wrap the page in a Suspense boundary
191
+ // with the loading content as the fallback. This mirrors NextJs's automatic
192
+ // Suspense wrapping when loading.tsx is present.
193
+ if (route.loadings && route.loadings.length > 0) {
194
+ // Use the innermost (closest) loading file
195
+ const loadingFile = route.loadings[route.loadings.length - 1];
196
+ try {
197
+ const loadingMod = await loadModule(loadingFile, dev);
198
+ if (loadingMod.default) {
199
+ const { Suspense } = await import('@webjsdev/core');
200
+ const fallback = await loadingMod.default(ctx);
201
+ tree = Suspense({ fallback, children: Promise.resolve(tree) });
202
+ }
203
+ } catch { /* loading file failed: skip, render page directly */ }
204
+ }
205
+
206
+ // Wrap each layout's `${children}` interpolation in
207
+ // `<!--wj:children:<segment-path>-->...<!--/wj:children-->` comment
208
+ // markers. The client router walks both old + new DOM for these
209
+ // markers and swaps only the children-slot of the deepest shared
210
+ // layout: preserving outer-layout DOM (and the scroll position of
211
+ // anything inside it: sidenavs, sticky headers, inner scroll
212
+ // containers). Auto-derived from folder structure: no opt-in
213
+ // required from layout authors.
214
+ // X-Webjs-Have optimization: iterate from innermost → outermost and
215
+ // SHORT-CIRCUIT at the first layout whose segment path the client
216
+ // already has rendered. Wrap the accumulated inner tree in that
217
+ // layout's marker pair (so the client can identify the splice
218
+ // target) and return: outer layouts are not rendered at all,
219
+ // saving CPU and wire bytes.
220
+ for (let i = route.layouts.length - 1; i >= 0; i--) {
221
+ const segmentPath = layoutSegmentPath(route.layouts[i]);
222
+ if (have && have.has(segmentPath)) {
223
+ tree = wrapWithChildrenMarker(tree, segmentPath);
224
+ const body = await renderToString(tree, { ssr: true, suspenseCtx });
225
+ return body + (await loadingTemplates(route, ctx, dev));
226
+ }
227
+ const mod = await loadModule(route.layouts[i], dev);
228
+ if (!mod.default) continue;
229
+ tree = await mod.default({
230
+ ...ctx,
231
+ children: wrapWithChildrenMarker(tree, segmentPath),
232
+ });
233
+ }
234
+ const body = await renderToString(tree, { ssr: true, suspenseCtx });
235
+ return body + (await loadingTemplates(route, ctx, dev));
236
+ }
237
+
238
+ /**
239
+ * Render each `loading.{js,ts}` in the route's chain into a hidden
240
+ * `<template id="wj-loading:<segment-path>">`. The client router clones
241
+ * the deepest matching template into the swap slot on nav-start, giving
242
+ * users an instant per-segment skeleton instead of stale content.
243
+ *
244
+ * Each loading file's segment path is the URL prefix it serves: same
245
+ * derivation as layoutSegmentPath but stripping `loading.ext` instead.
246
+ *
247
+ * Errors loading a single file are swallowed so a broken loading.ts in
248
+ * one segment doesn't break the whole response.
249
+ *
250
+ * @param {{ loadings?: string[] }} route
251
+ * @param {Record<string,unknown>} ctx
252
+ * @param {boolean} dev
253
+ * @returns {Promise<string>}
254
+ */
255
+ async function loadingTemplates(route, ctx, dev) {
256
+ if (!route.loadings || route.loadings.length === 0) return '';
257
+ /** @type {string[]} */
258
+ const parts = [];
259
+ for (const file of route.loadings) {
260
+ try {
261
+ const mod = await loadModule(file, dev);
262
+ if (!mod.default) continue;
263
+ const tree = await mod.default(ctx);
264
+ const html = await renderToString(tree, { ssr: true });
265
+ const segmentPath = loadingSegmentPath(file);
266
+ parts.push(`<template id="wj-loading:${segmentPath}">${html}</template>`);
267
+ } catch { /* skip broken loading file */ }
268
+ }
269
+ return parts.join('');
270
+ }
271
+
272
+ /**
273
+ * Like layoutSegmentPath but for `loading.{js,ts}` files. Strips the
274
+ * `loading.ext` filename from the URL path under app/.
275
+ *
276
+ * @param {string} loadingFile
277
+ * @returns {string}
278
+ */
279
+ function loadingSegmentPath(loadingFile) {
280
+ const p = loadingFile
281
+ .replace(/^.*\/app\//, '')
282
+ .replace(/\/?loading\.[jt]sx?$/, '');
283
+ return p === '' ? '/' : '/' + p;
284
+ }
285
+
286
+ /**
287
+ * Derive a layout's segment path from its file path. The path identifies
288
+ * the layout's slot in the layout chain for partial-nav marker matching.
289
+ *
290
+ * app/layout.ts → '/'
291
+ * app/docs/layout.ts → '/docs'
292
+ * app/docs/components/layout.ts → '/docs/components'
293
+ * app/(marketing)/about/layout.ts → '/(marketing)/about'
294
+ *
295
+ * Route groups `(marketing)` are KEPT in the path. They don't appear in
296
+ * URLs but DO scope distinct layouts: two routes at the same URL prefix
297
+ * served by different `(group)` layouts must produce different markers
298
+ * so the client doesn't falsely identify them as a shared layout.
299
+ *
300
+ * @param {string} layoutFile Absolute path to the layout source file.
301
+ * @returns {string}
302
+ */
303
+ function layoutSegmentPath(layoutFile) {
304
+ const p = layoutFile
305
+ .replace(/^.*\/app\//, '')
306
+ .replace(/\/?layout\.[jt]sx?$/, '');
307
+ return p === '' ? '/' : '/' + p;
308
+ }
309
+
310
+ /**
311
+ * Wrap a TemplateResult-or-renderable child in the partial-nav children
312
+ * marker pair. Returns a synthetic TemplateResult: server `renderToString`
313
+ * walks `.strings` and `.values` exactly the same way as for the `html` tag.
314
+ *
315
+ * The marker text lives in `strings` (static template parts), NOT in
316
+ * `values`: `values` get HTML-escaped on render, comments wouldn't survive.
317
+ *
318
+ * @param {unknown} tree A TemplateResult, string, array, or Promise.
319
+ * @param {string} segmentPath The layout's segment path, used as marker id.
320
+ * @returns {{ _$webjs: 'template', strings: string[], values: unknown[] }}
321
+ */
322
+ function wrapWithChildrenMarker(tree, segmentPath) {
323
+ return {
324
+ _$webjs: 'template',
325
+ strings: [
326
+ `<!--wj:children:${segmentPath}-->`,
327
+ `<!--/wj:children-->`,
328
+ ],
329
+ values: [tree],
330
+ };
331
+ }
332
+
333
+ // Re-export for unit testing.
334
+ export {
335
+ layoutSegmentPath as _layoutSegmentPath,
336
+ wrapWithChildrenMarker as _wrapWithChildrenMarker,
337
+ };
338
+
339
+ /**
340
+ * @param {import('./router.js').PageRoute} route
341
+ * @param {Record<string,unknown>} ctx
342
+ * @param {boolean} dev
343
+ */
344
+ async function collectMetadata(route, ctx, dev) {
345
+ /** @type {Record<string, any>} */
346
+ let meta = {};
347
+ // Carry the title template forward across layers. Once an outer layout
348
+ // sets `title: { template, default }`, every deeper layer that supplies
349
+ // a plain string title gets it transformed via the template: matching
350
+ // Next.js App Router semantics.
351
+ /** @type {string | null} */
352
+ let titleTemplate = null;
353
+ for (const file of route.metadataFiles) {
354
+ try {
355
+ const mod = await loadModule(file, dev);
356
+ let m = null;
357
+ if (typeof mod.generateMetadata === 'function') {
358
+ m = await mod.generateMetadata(ctx);
359
+ } else if (mod.metadata) {
360
+ m = mod.metadata;
361
+ }
362
+ // Next.js 14+ split `viewport` out of metadata into its own export
363
+ // (with `themeColor`, `colorScheme`). We support both: the new
364
+ // `export const viewport = {…}` shape merges into metadata.viewport
365
+ // as a string at emit time, and existing `metadata.viewport` keeps
366
+ // working. Likewise for `themeColor` and `colorScheme`.
367
+ let vp = null;
368
+ if (typeof mod.generateViewport === 'function') {
369
+ vp = await mod.generateViewport(ctx);
370
+ } else if (mod.viewport) {
371
+ vp = mod.viewport;
372
+ }
373
+ if (vp && typeof vp === 'object') {
374
+ m = { ...(m || {}), _viewport: { ...(m && m._viewport), ...vp } };
375
+ // Allow `themeColor` / `colorScheme` to live on the viewport export.
376
+ if (typeof vp.themeColor === 'string' && !(m && m.themeColor)) {
377
+ m.themeColor = vp.themeColor;
378
+ }
379
+ if (typeof vp.colorScheme === 'string') m.colorScheme = vp.colorScheme;
380
+ }
381
+ if (!m || typeof m !== 'object') continue;
382
+ // Pre-resolve the title for this layer using the inherited template.
383
+ const resolved = { ...m };
384
+ if (m.title !== undefined) {
385
+ const t = m.title;
386
+ if (typeof t === 'string') {
387
+ resolved.title = titleTemplate ? titleTemplate.replace('%s', t) : t;
388
+ } else if (t && typeof t === 'object') {
389
+ // { template, default, absolute }: `absolute` overrides everything;
390
+ // `template` is captured for deeper layers; `default` is the value
391
+ // used when no deeper layer supplies a plain title string.
392
+ if (typeof t.template === 'string') titleTemplate = t.template;
393
+ if (typeof t.absolute === 'string') {
394
+ resolved.title = t.absolute;
395
+ // `absolute` does NOT clear the template: Next.js propagates
396
+ // it for deeper segments below, but the *current* segment is
397
+ // rendered absolute.
398
+ } else if (typeof t.default === 'string') {
399
+ resolved.title = t.default;
400
+ } else {
401
+ delete resolved.title;
402
+ }
403
+ }
404
+ }
405
+ meta = { ...meta, ...resolved };
406
+ } catch {
407
+ // ignore: metadata collection never fails the request
408
+ }
409
+ }
410
+ return meta;
411
+ }
412
+
413
+ /**
414
+ * Extract leading `<script>`, `<style>`, and `<link>` tags from the body
415
+ * HTML and hoist them into `<head>`. Ensures blocking scripts (e.g.
416
+ * Tailwind runtime, theme bootstrap) run before any body content renders,
417
+ * and that `<link rel="icon">` / `<link rel="stylesheet">` land where
418
+ * browsers reliably honour them.
419
+ *
420
+ * @param {string} headHtml
421
+ * @param {string} bodyHtml
422
+ * @returns {{ head: string, body: string }}
423
+ */
424
+ function hoistHeadTags(headHtml, bodyHtml) {
425
+ const hoisted = [];
426
+ // <script>…</script> and <style>…</style> are paired; <link …> is void.
427
+ const re = /^\s*(<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b[^>]*>)/i;
428
+
429
+ let remaining = bodyHtml;
430
+ let m;
431
+ while ((m = re.exec(remaining)) !== null) {
432
+ hoisted.push(m[1]);
433
+ remaining = remaining.slice(m[0].length);
434
+ }
435
+ if (!hoisted.length) return { head: headHtml, body: bodyHtml };
436
+ const newHead = headHtml.replace('</head>', hoisted.join('\n') + '\n</head>');
437
+ return { head: newHead, body: remaining };
438
+ }
439
+
440
+ // Internal helper re-exported for unit testing.
441
+ export { hoistHeadTags as _hoistHeadTags };
442
+
443
+ /**
444
+ * Detect a user-supplied <!doctype><html>…</html> shell at the top of
445
+ * `body`. Returns the parsed parts when present; otherwise null.
446
+ *
447
+ * The framework owns the shell by default: it auto-emits
448
+ * `<!doctype><html lang="en"><head>…</head><body>` around every page.
449
+ * But the *root layout* (only) may write its own shell to set
450
+ * `<html lang>`, `<html dir>`, `<html data-*>`, `<body class>`, etc.
451
+ * When that happens we keep the user's shell verbatim and splice the
452
+ * framework's required `<head>` tags (importmap, modulepreload, title,
453
+ * meta, og/twitter) into the user's `<head>`. Non-root layouts that
454
+ * try this would produce nested-shell garbage; `webjs check` flags
455
+ * them via the `shell-in-non-root-layout` rule.
456
+ *
457
+ * @param {string} body
458
+ * @returns {{
459
+ * htmlAttrs: string,
460
+ * headAttrs: string,
461
+ * userHead: string,
462
+ * bodyAttrs: string,
463
+ * userBody: string,
464
+ * } | null}
465
+ */
466
+ function extractUserShell(body) {
467
+ // Tolerant: allow optional whitespace, optional <!doctype>, then <html ...>.
468
+ // Capture html attributes (anything between <html and >).
469
+ const htmlOpen = /^\s*(?:<!doctype[^>]*>\s*)?<html\b([^>]*)>\s*([\s\S]*)<\/html>\s*$/i;
470
+ const m = body.match(htmlOpen);
471
+ if (!m) return null;
472
+ const htmlAttrs = m[1] || '';
473
+ const shellInner = m[2];
474
+
475
+ // <head> is optional inside the user's shell: if missing, the
476
+ // framework's head content stands alone. Same for <body>.
477
+ const headRe = /<head\b([^>]*)>([\s\S]*?)<\/head>/i;
478
+ const bodyRe = /<body\b([^>]*)>([\s\S]*?)<\/body>/i;
479
+ const headMatch = shellInner.match(headRe);
480
+ const bodyMatch = shellInner.match(bodyRe);
481
+
482
+ return {
483
+ htmlAttrs,
484
+ headAttrs: headMatch ? (headMatch[1] || '') : '',
485
+ userHead: headMatch ? headMatch[2] : '',
486
+ bodyAttrs: bodyMatch ? (bodyMatch[1] || '') : '',
487
+ // If the user omitted <body>, treat everything outside <head>…</head>
488
+ // as their body content.
489
+ userBody: bodyMatch
490
+ ? bodyMatch[2]
491
+ : (headMatch ? shellInner.replace(headMatch[0], '') : shellInner).trim(),
492
+ };
493
+ }
494
+
495
+ // Re-export for unit testing.
496
+ export { extractUserShell as _extractUserShell };
497
+
498
+ /**
499
+ * Inner-only variant of wrapHead: returns just the meta/title/link/script
500
+ * tags that should live INSIDE <head>, without the surrounding
501
+ * <!doctype><html><head>…</head><body> shell. Used to splice into a
502
+ * user-provided shell from `extractUserShell()`.
503
+ *
504
+ * @param {Parameters<typeof wrapHead>[0]} opts
505
+ * @returns {string}
506
+ */
507
+ function buildHeadInner(opts) {
508
+ // Pull the full prefix and strip the <!doctype><html><head> opening + the
509
+ // closing </head><body> so we're left with the inner tags only. Keeps a
510
+ // single source of truth for what goes in <head>.
511
+ const full = wrapHead({ ...opts, streaming: false });
512
+ const start = full.indexOf('<head>');
513
+ const end = full.indexOf('</head>');
514
+ if (start === -1 || end === -1) return '';
515
+ // +'<head>'.length to skip past the opening tag itself.
516
+ return full.slice(start + '<head>'.length, end).trim();
517
+ }
518
+
519
+ /**
520
+ * Build the prefix/body/closer triple for a rendered layout's body. Single
521
+ * source of truth used by both the buffered (`wrapInDocument`) and
522
+ * streaming (`streamingHtmlResponse`) paths.
523
+ *
524
+ * If `body` starts with a user-supplied <!doctype><html>…</html> shell:
525
+ * - `prefix` opens with the user's `<!doctype><html><head>` (with their
526
+ * attributes), splices the framework's required tags + the user's
527
+ * own head content + auto-hoisted body-positioned head-bound tags,
528
+ * then closes `</head>` and opens `<body>` (with user attributes).
529
+ * - `streamBody` is the user's body content (head-hoist already stripped).
530
+ * - `closer` is `</body></html>`.
531
+ *
532
+ * Otherwise (no user shell): use the framework's auto-emitted shell.
533
+ *
534
+ * @param {string} body
535
+ * @param {Parameters<typeof wrapHead>[0]} wrapOpts
536
+ * @returns {{ prefix: string, streamBody: string, closer: string }}
537
+ */
538
+ function buildDocumentParts(body, wrapOpts) {
539
+ const shell = extractUserShell(body);
540
+ if (shell) {
541
+ const headInner = buildHeadInner(wrapOpts);
542
+ const hoist = collectHoistedHeadTags(shell.userBody);
543
+ const composedHead = [headInner, shell.userHead.trim(), hoist.tags.join('\n')]
544
+ .filter(Boolean)
545
+ .join('\n');
546
+ const prefix =
547
+ `<!doctype html>\n<html${shell.htmlAttrs}>\n<head${shell.headAttrs}>\n` +
548
+ composedHead +
549
+ `\n</head>\n<body${shell.bodyAttrs}>\n`;
550
+ return { prefix, streamBody: hoist.body, closer: `\n</body>\n</html>` };
551
+ }
552
+ // No user shell: framework owns the wrapper.
553
+ const headHtml = wrapHead(wrapOpts);
554
+ const { head, body: bodyOut } = hoistHeadTags(headHtml, body);
555
+ return { prefix: head, streamBody: bodyOut, closer: `\n</body>\n</html>` };
556
+ }
557
+
558
+ // Re-export for unit testing.
559
+ export { buildDocumentParts as _buildDocumentParts };
560
+
561
+ /**
562
+ * Buffered wrapper (error / not-found paths; no Suspense streaming).
563
+ *
564
+ * @param {string} body
565
+ * @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean }} opts
566
+ */
567
+ function wrapInDocument(body, opts) {
568
+ const { prefix, streamBody, closer } = buildDocumentParts(body, { ...opts, streaming: false });
569
+ return prefix + streamBody + closer;
570
+ }
571
+
572
+ /**
573
+ * Strip leading head-bound tags (<script>, <style>, <link>) from a body
574
+ * string. Returns the collected tags + the remaining body. Mirrors what
575
+ * `hoistHeadTags` does but takes/returns plain strings (no head input)
576
+ * so it can be used with a user-provided <head>.
577
+ *
578
+ * @param {string} bodyHtml
579
+ * @returns {{ tags: string[], body: string }}
580
+ */
581
+ function collectHoistedHeadTags(bodyHtml) {
582
+ const tags = [];
583
+ const re = /^\s*(<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b[^>]*>)/i;
584
+ let remaining = bodyHtml;
585
+ let m;
586
+ while ((m = re.exec(remaining)) !== null) {
587
+ tags.push(m[1]);
588
+ remaining = remaining.slice(m[0].length);
589
+ }
590
+ return { tags, body: remaining };
591
+ }
592
+
593
+ /**
594
+ * Produce the `<!doctype…><body>` prefix. If `streaming` is true, injects
595
+ * the tiny client-side resolver that swaps Suspense fallback nodes for
596
+ * streamed-in real content.
597
+ *
598
+ * Also emits `<link rel="modulepreload">` for every component that rendered
599
+ * (breaks the ES-module waterfall without a bundler) and any user-declared
600
+ * `metadata.preload` entries.
601
+ *
602
+ * @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean, streaming: boolean, preloads?: string[], lazyComponents?: Record<string, string>, nonce?: string }} opts
603
+ */
604
+ /**
605
+ * Build an inline `<script>` that exposes server-side environment
606
+ * variables to the browser via `window.process.env`. Two purposes:
607
+ *
608
+ * 1. App code can read `process.env.WEBJS_PUBLIC_X` directly in
609
+ * components (counterpart of Next.js's `NEXT_PUBLIC_` prefix,
610
+ * but without a build step).
611
+ * 2. `process.env.NODE_ENV` is defined for vendor bundles that
612
+ * probe it (lit, react, etc.) so they do not throw
613
+ * ReferenceError in the browser.
614
+ *
615
+ * Only variables whose name starts with `WEBJS_PUBLIC_` are exposed.
616
+ * Other server env vars stay on the server.
617
+ *
618
+ * `</...` sequences in stringified values are escaped so an env value
619
+ * containing `</script>` cannot terminate the inline script tag.
620
+ *
621
+ * @param {{ dev: boolean, nonce?: string, env?: Record<string, string|undefined> }} opts
622
+ * `env` defaults to `process.env`. Override for tests.
623
+ * @returns {string}
624
+ */
625
+ export function publicEnvShim(opts) {
626
+ const source = opts.env || process.env;
627
+ /** @type {Record<string, string>} */
628
+ const env = {};
629
+ for (const [k, v] of Object.entries(source)) {
630
+ if (k.startsWith('WEBJS_PUBLIC_') && v !== undefined) {
631
+ env[k] = String(v);
632
+ }
633
+ }
634
+ env.NODE_ENV = opts.dev ? 'development' : 'production';
635
+ const json = JSON.stringify(env).replace(/<\//g, '<\\/');
636
+ const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
637
+ return `<script${n}>`
638
+ + `window.process=window.process||{};`
639
+ + `window.process.env=Object.assign(window.process.env||{},${json});`
640
+ + `</script>`;
641
+ }
642
+
643
+ function wrapHead(opts) {
644
+ // CSP nonce: if provided, all inline <script> tags get nonce="…" so they
645
+ // pass strict Content-Security-Policy headers. The nonce is extracted from
646
+ // the request's CSP header by the caller.
647
+ const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
648
+
649
+ const imports = opts.moduleUrls.map((u) => `import ${JSON.stringify(u)};`).join('\n');
650
+ const lazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
651
+ ? opts.lazyComponents
652
+ : null;
653
+ const lazyBoot = lazyEntries
654
+ ? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${JSON.stringify(lazyEntries)});`
655
+ : '';
656
+ const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
657
+ const reload = opts.dev ? `<script type="module"${n} src="/__webjs/reload.js"></script>` : '';
658
+ const suspenseBoot = opts.streaming
659
+ ? `<script${n}>(function(){` +
660
+ `function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
661
+ `var b=document.getElementById(id);if(t&&b){b.replaceWith(t.content.cloneNode(true));t.remove();}}` +
662
+ `window.__webjsResolve=r;` +
663
+ `if(typeof MutationObserver!=='undefined'){` +
664
+ `new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){` +
665
+ `if(n.nodeType===1&&n.tagName==='TEMPLATE'&&n.dataset.webjsResolve){r(n.dataset.webjsResolve);}` +
666
+ `});});}).observe(document.documentElement,{childList:true,subtree:true});}` +
667
+ `})()</script>`
668
+ : '';
669
+
670
+ const m = opts.metadata || {};
671
+ const metaTags = [];
672
+ // linkTags is populated by both the metadata emission below (icons,
673
+ // alternates, archives, etc.) AND by the preload block further down.
674
+ // Hoist the declaration so the metadata block can push into it.
675
+ const linkTags = [];
676
+
677
+ // Tiny URL resolver against metadataBase. If metadataBase is set and a
678
+ // value looks like a relative URL (no scheme, no `//` prefix), resolve
679
+ // it. Otherwise return as-is. Used by og:image, twitter:image,
680
+ // alternates.canonical / languages / media.
681
+ const base = typeof m.metadataBase === 'string' ? m.metadataBase : '';
682
+ /** @param {unknown} v */
683
+ const absUrl = (v) => {
684
+ const s = String(v);
685
+ if (!base) return s;
686
+ if (/^https?:\/\//i.test(s) || s.startsWith('//') || s.startsWith('data:')) return s;
687
+ try {
688
+ return new URL(s, base).toString();
689
+ } catch {
690
+ return s;
691
+ }
692
+ };
693
+
694
+ if (m.description) metaTags.push(`<meta name="description" content="${escapeAttr(m.description)}">`);
695
+
696
+ // viewport: support string form (legacy), `metadata.viewport` object form,
697
+ // and the new Next.js 14+ `export const viewport = { … }` shape captured
698
+ // into `_viewport` by collectMetadata.
699
+ let viewportStr = '';
700
+ if (typeof m.viewport === 'string') {
701
+ viewportStr = m.viewport;
702
+ } else if (m.viewport && typeof m.viewport === 'object') {
703
+ viewportStr = serializeViewport(m.viewport);
704
+ } else if (m._viewport && typeof m._viewport === 'object') {
705
+ viewportStr = serializeViewport(m._viewport);
706
+ }
707
+ metaTags.push(`<meta name="viewport" content="${escapeAttr(viewportStr || 'width=device-width,initial-scale=1')}">`);
708
+
709
+ if (m.themeColor) metaTags.push(`<meta name="theme-color" content="${escapeAttr(m.themeColor)}">`);
710
+ if (m.colorScheme) metaTags.push(`<meta name="color-scheme" content="${escapeAttr(m.colorScheme)}">`);
711
+
712
+ // ---- i18n + SEO essentials ----
713
+
714
+ // robots: { index, follow, googleBot, etc. }
715
+ if (m.robots) {
716
+ if (typeof m.robots === 'string') {
717
+ metaTags.push(`<meta name="robots" content="${escapeAttr(m.robots)}">`);
718
+ } else if (typeof m.robots === 'object') {
719
+ const parts = [];
720
+ if (m.robots.index === false) parts.push('noindex');
721
+ else if (m.robots.index === true) parts.push('index');
722
+ if (m.robots.follow === false) parts.push('nofollow');
723
+ else if (m.robots.follow === true) parts.push('follow');
724
+ if (m.robots.noarchive) parts.push('noarchive');
725
+ if (m.robots.nosnippet) parts.push('nosnippet');
726
+ if (m.robots.noimageindex) parts.push('noimageindex');
727
+ if (parts.length) {
728
+ metaTags.push(`<meta name="robots" content="${escapeAttr(parts.join(', '))}">`);
729
+ }
730
+ if (typeof m.robots.googleBot === 'string') {
731
+ metaTags.push(`<meta name="googlebot" content="${escapeAttr(m.robots.googleBot)}">`);
732
+ }
733
+ }
734
+ }
735
+
736
+ // keywords: string | string[]
737
+ if (m.keywords) {
738
+ const kws = Array.isArray(m.keywords) ? m.keywords.join(', ') : String(m.keywords);
739
+ if (kws) metaTags.push(`<meta name="keywords" content="${escapeAttr(kws)}">`);
740
+ }
741
+
742
+ // authors: Array<{ name, url? }> | { name, url? } | string
743
+ if (m.authors) {
744
+ const list = Array.isArray(m.authors) ? m.authors : [m.authors];
745
+ for (const a of list) {
746
+ if (!a) continue;
747
+ const name = typeof a === 'string' ? a : a.name;
748
+ if (!name) continue;
749
+ metaTags.push(`<meta name="author" content="${escapeAttr(name)}">`);
750
+ if (typeof a === 'object' && a.url) {
751
+ metaTags.push(`<link rel="author" href="${escapeAttr(absUrl(a.url))}">`);
752
+ }
753
+ }
754
+ }
755
+
756
+ // Singletons that map 1:1 to <meta name="…">.
757
+ for (const [field, metaName] of [
758
+ ['creator', 'creator'],
759
+ ['publisher', 'publisher'],
760
+ ['applicationName', 'application-name'],
761
+ ['generator', 'generator'],
762
+ ['referrer', 'referrer'],
763
+ ]) {
764
+ if (m[field]) {
765
+ metaTags.push(`<meta name="${metaName}" content="${escapeAttr(String(m[field]))}">`);
766
+ }
767
+ }
768
+
769
+ // ---- Long-tail metadata (the Next.js "everything else") ----
770
+
771
+ // appleWebApp: { capable, title, statusBarStyle, startupImage }
772
+ if (m.appleWebApp && typeof m.appleWebApp === 'object') {
773
+ if (m.appleWebApp.capable !== undefined) {
774
+ metaTags.push(
775
+ `<meta name="apple-mobile-web-app-capable" content="${m.appleWebApp.capable ? 'yes' : 'no'}">`,
776
+ );
777
+ }
778
+ if (m.appleWebApp.title) {
779
+ metaTags.push(`<meta name="apple-mobile-web-app-title" content="${escapeAttr(m.appleWebApp.title)}">`);
780
+ }
781
+ if (m.appleWebApp.statusBarStyle) {
782
+ metaTags.push(
783
+ `<meta name="apple-mobile-web-app-status-bar-style" content="${escapeAttr(m.appleWebApp.statusBarStyle)}">`,
784
+ );
785
+ }
786
+ // startupImage maps to <link rel="apple-touch-startup-image">.
787
+ if (m.appleWebApp.startupImage) {
788
+ const list = Array.isArray(m.appleWebApp.startupImage)
789
+ ? m.appleWebApp.startupImage
790
+ : [m.appleWebApp.startupImage];
791
+ for (const it of list) {
792
+ if (typeof it === 'string') {
793
+ linkTags.push(`<link rel="apple-touch-startup-image" href="${escapeAttr(absUrl(it))}">`);
794
+ } else if (it && it.url) {
795
+ const parts = [`rel="apple-touch-startup-image"`, `href="${escapeAttr(absUrl(it.url))}"`];
796
+ if (it.media) parts.push(`media="${escapeAttr(it.media)}"`);
797
+ linkTags.push(`<link ${parts.join(' ')}>`);
798
+ }
799
+ }
800
+ }
801
+ } else if (m.appleWebApp === true) {
802
+ metaTags.push(`<meta name="apple-mobile-web-app-capable" content="yes">`);
803
+ }
804
+
805
+ // formatDetection: { telephone, address, email, date, … }. All booleans.
806
+ // Disabled detection types append "type=no" to the content string.
807
+ if (m.formatDetection && typeof m.formatDetection === 'object') {
808
+ const parts = [];
809
+ for (const [k, v] of Object.entries(m.formatDetection)) {
810
+ if (v === false) parts.push(`${k}=no`);
811
+ else if (v === true) parts.push(`${k}=yes`);
812
+ }
813
+ if (parts.length) {
814
+ metaTags.push(`<meta name="format-detection" content="${escapeAttr(parts.join(', '))}">`);
815
+ }
816
+ }
817
+
818
+ // itunes: { appId, appArgument? }
819
+ if (m.itunes && typeof m.itunes === 'object' && m.itunes.appId) {
820
+ let content = `app-id=${m.itunes.appId}`;
821
+ if (m.itunes.appArgument) content += `, app-argument=${m.itunes.appArgument}`;
822
+ metaTags.push(`<meta name="apple-itunes-app" content="${escapeAttr(content)}">`);
823
+ }
824
+
825
+ // Plain singleton string fields.
826
+ for (const [field, metaName] of [
827
+ ['category', 'category'],
828
+ ['classification', 'classification'],
829
+ ['abstract', 'abstract'],
830
+ ]) {
831
+ if (m[field]) metaTags.push(`<meta name="${metaName}" content="${escapeAttr(String(m[field]))}">`);
832
+ }
833
+
834
+ // archives / assets / bookmarks: each is string | string[].
835
+ // Standard registered link relations.
836
+ for (const [field, rel] of [
837
+ ['archives', 'archives'],
838
+ ['assets', 'assets'],
839
+ ['bookmarks', 'bookmark'],
840
+ ]) {
841
+ if (m[field]) {
842
+ const list = Array.isArray(m[field]) ? m[field] : [m[field]];
843
+ for (const href of list) {
844
+ linkTags.push(`<link rel="${rel}" href="${escapeAttr(absUrl(href))}">`);
845
+ }
846
+ }
847
+ }
848
+
849
+ // `other` is the typed escape hatch for any arbitrary <meta name="…">
850
+ // entries Next.js (or future webjs) doesn't ship as a typed field.
851
+ // Values can be string, number, or string[] (emits multiple meta tags).
852
+ if (m.other && typeof m.other === 'object') {
853
+ for (const [name, v] of Object.entries(m.other)) {
854
+ const list = Array.isArray(v) ? v : [v];
855
+ for (const item of list) {
856
+ if (item == null) continue;
857
+ metaTags.push(`<meta name="${escapeAttr(name)}" content="${escapeAttr(String(item))}">`);
858
+ }
859
+ }
860
+ }
861
+
862
+ // verification: { google, yandex, yahoo, me }. Each is string OR string[].
863
+ // - google → <meta name="google-site-verification">
864
+ // - yandex → <meta name="yandex-verification">
865
+ // - yahoo → <meta name="y_key"> (Yahoo's unusual canonical name)
866
+ // - me → <meta name="me"> (IndieAuth / personal verification)
867
+ if (m.verification && typeof m.verification === 'object') {
868
+ const verifyKeys = {
869
+ google: 'google-site-verification',
870
+ yandex: 'yandex-verification',
871
+ yahoo: 'y_key',
872
+ me: 'me',
873
+ };
874
+ for (const [field, metaName] of Object.entries(verifyKeys)) {
875
+ const v = m.verification[field];
876
+ if (!v) continue;
877
+ const list = Array.isArray(v) ? v : [v];
878
+ for (const item of list) {
879
+ metaTags.push(`<meta name="${metaName}" content="${escapeAttr(String(item))}">`);
880
+ }
881
+ }
882
+ // `verification.other` allows arbitrary <meta name="…"> entries.
883
+ if (m.verification.other && typeof m.verification.other === 'object') {
884
+ for (const [name, v] of Object.entries(m.verification.other)) {
885
+ const list = Array.isArray(v) ? v : [v];
886
+ for (const item of list) {
887
+ metaTags.push(`<meta name="${escapeAttr(name)}" content="${escapeAttr(String(item))}">`);
888
+ }
889
+ }
890
+ }
891
+ }
892
+
893
+ if (m.openGraph && typeof m.openGraph === 'object') {
894
+ for (const [k, v] of Object.entries(m.openGraph)) {
895
+ const out = k === 'image' || k === 'url' ? absUrl(v) : String(v);
896
+ metaTags.push(`<meta property="og:${escapeAttr(k)}" content="${escapeAttr(out)}">`);
897
+ }
898
+ }
899
+ // Twitter card tags. Twitter falls back to og:* when these are absent
900
+ // but won't upgrade to summary_large_image without an explicit
901
+ // twitter:card entry.
902
+ if (m.twitter && typeof m.twitter === 'object') {
903
+ for (const [k, v] of Object.entries(m.twitter)) {
904
+ const out = k === 'image' ? absUrl(v) : String(v);
905
+ metaTags.push(`<meta name="twitter:${escapeAttr(k)}" content="${escapeAttr(out)}">`);
906
+ }
907
+ }
908
+
909
+ // Preload hints: page modules themselves + every discovered component
910
+ // module, then any custom `metadata.preload` entries (fonts, images, etc.)
911
+ // (linkTags array was declared earlier so the metadata block above can
912
+ // push icons / canonical / hreflang / archives / etc. into it.)
913
+ for (const url of opts.moduleUrls) {
914
+ linkTags.push(`<link rel="modulepreload" href="${escapeAttr(url)}">`);
915
+ }
916
+ for (const url of opts.preloads || []) {
917
+ linkTags.push(`<link rel="modulepreload" href="${escapeAttr(url)}">`);
918
+ }
919
+ if (Array.isArray(m.preload)) {
920
+ for (const p of m.preload) {
921
+ if (!p || !p.href) continue;
922
+ const attrs = Object.entries(p)
923
+ .map(([k, v]) => `${k}="${escapeAttr(String(v))}"`)
924
+ .join(' ');
925
+ linkTags.push(`<link rel="preload" ${attrs}>`);
926
+ }
927
+ }
928
+
929
+ // icons: { icon, apple, shortcut, other }. Each entry can be a string
930
+ // (URL), an object { url, sizes?, type? }, or an array of those.
931
+ // - icon → <link rel="icon">
932
+ // - apple → <link rel="apple-touch-icon">
933
+ // - shortcut→ <link rel="shortcut icon">
934
+ // - other → <link rel="…" href="…"> using the entry's `rel` field
935
+ if (m.icons) {
936
+ const buckets = typeof m.icons === 'string' || Array.isArray(m.icons)
937
+ ? { icon: m.icons }
938
+ : m.icons;
939
+ /** @param {string} rel @param {unknown} entry */
940
+ const pushIcon = (rel, entry) => {
941
+ if (!entry) return;
942
+ const items = Array.isArray(entry) ? entry : [entry];
943
+ for (const it of items) {
944
+ if (!it) continue;
945
+ if (typeof it === 'string') {
946
+ linkTags.push(`<link rel="${rel}" href="${escapeAttr(absUrl(it))}">`);
947
+ } else if (typeof it === 'object' && it.url) {
948
+ const parts = [`rel="${rel}"`, `href="${escapeAttr(absUrl(it.url))}"`];
949
+ if (it.sizes) parts.push(`sizes="${escapeAttr(it.sizes)}"`);
950
+ if (it.type) parts.push(`type="${escapeAttr(it.type)}"`);
951
+ linkTags.push(`<link ${parts.join(' ')}>`);
952
+ }
953
+ }
954
+ };
955
+ pushIcon('icon', buckets.icon);
956
+ pushIcon('apple-touch-icon', buckets.apple);
957
+ pushIcon('shortcut icon', buckets.shortcut);
958
+ // `other` is the catch-all: array of { rel, url, ...attrs }.
959
+ if (buckets.other) {
960
+ const others = Array.isArray(buckets.other) ? buckets.other : [buckets.other];
961
+ for (const o of others) {
962
+ if (!o || !o.rel || !o.url) continue;
963
+ const parts = [`rel="${escapeAttr(o.rel)}"`, `href="${escapeAttr(absUrl(o.url))}"`];
964
+ if (o.sizes) parts.push(`sizes="${escapeAttr(o.sizes)}"`);
965
+ if (o.type) parts.push(`type="${escapeAttr(o.type)}"`);
966
+ linkTags.push(`<link ${parts.join(' ')}>`);
967
+ }
968
+ }
969
+ }
970
+
971
+ // manifest: a string URL → <link rel="manifest">
972
+ if (typeof m.manifest === 'string') {
973
+ linkTags.push(`<link rel="manifest" href="${escapeAttr(absUrl(m.manifest))}">`);
974
+ }
975
+
976
+ // alternates: { canonical, languages: { '<hreflang>': url }, media: { '<media>': url } }
977
+ // Mirrors Next.js's metadata.alternates surface. Relative values are resolved
978
+ // against metadataBase.
979
+ if (m.alternates && typeof m.alternates === 'object') {
980
+ if (m.alternates.canonical) {
981
+ linkTags.push(`<link rel="canonical" href="${escapeAttr(absUrl(m.alternates.canonical))}">`);
982
+ }
983
+ if (m.alternates.languages && typeof m.alternates.languages === 'object') {
984
+ for (const [hreflang, href] of Object.entries(m.alternates.languages)) {
985
+ linkTags.push(
986
+ `<link rel="alternate" hreflang="${escapeAttr(hreflang)}" href="${escapeAttr(absUrl(href))}">`,
987
+ );
988
+ }
989
+ }
990
+ if (m.alternates.media && typeof m.alternates.media === 'object') {
991
+ for (const [media, href] of Object.entries(m.alternates.media)) {
992
+ linkTags.push(
993
+ `<link rel="alternate" media="${escapeAttr(media)}" href="${escapeAttr(absUrl(href))}">`,
994
+ );
995
+ }
996
+ }
997
+ if (m.alternates.types && typeof m.alternates.types === 'object') {
998
+ // alternates.types: { 'application/rss+xml': '/rss.xml' }
999
+ for (const [type, href] of Object.entries(m.alternates.types)) {
1000
+ linkTags.push(
1001
+ `<link rel="alternate" type="${escapeAttr(type)}" href="${escapeAttr(absUrl(href))}">`,
1002
+ );
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ const title = m.title || 'webjs app';
1008
+
1009
+ return `<!doctype html>
1010
+ <html lang="en">
1011
+ <head>
1012
+ <meta charset="utf-8">
1013
+ ${metaTags.join('\n')}
1014
+ <title>${escapeHtml(title)}</title>
1015
+ ${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}
1016
+ ${importMapTag()}
1017
+ ${linkTags.join('\n')}
1018
+ ${boot}
1019
+ ${reload}
1020
+ ${suspenseBoot}
1021
+ </head>
1022
+ <body>
1023
+ `;
1024
+ }
1025
+
1026
+ /**
1027
+ * Translate a Set of custom element tag names used on the page into browser
1028
+ * URLs for modulepreload. Components that didn't pass a module URL to
1029
+ * `register()` are skipped silently (no harm, just no preload hint).
1030
+ *
1031
+ * Returns separate eager and lazy lists. Lazy components (static lazy = true)
1032
+ * are NOT preloaded: they're loaded by the IntersectionObserver-based
1033
+ * lazy-loader when the element enters the viewport.
1034
+ *
1035
+ * @param {Set<string>} usedTags
1036
+ * @param {string} appDir
1037
+ * @returns {{ eager: string[], lazy: Record<string, string> }}
1038
+ */
1039
+ function componentPreloads(usedTags, appDir) {
1040
+ const eager = [];
1041
+ /** @type {Record<string, string>} */
1042
+ const lazy = {};
1043
+ for (const tag of usedTags) {
1044
+ const fileUrl = lookupModuleUrl(tag);
1045
+ if (!fileUrl) continue;
1046
+ try {
1047
+ const abs = fileURLToPath(fileUrl);
1048
+ if (!abs.startsWith(appDir)) continue;
1049
+ const url = toUrlPath(abs, appDir);
1050
+ if (isLazy(tag)) {
1051
+ lazy[tag] = url;
1052
+ } else {
1053
+ eager.push(url);
1054
+ }
1055
+ } catch { /* ignore */ }
1056
+ }
1057
+ return { eager, lazy };
1058
+ }
1059
+
1060
+ /**
1061
+ * Merge component preloads with transitive dependencies from the module
1062
+ * graph, then deduplicate against the already-imported module URLs.
1063
+ *
1064
+ * @param {string[]} componentUrls direct component module URLs
1065
+ * @param {string[]} moduleUrls boot script imports (page + layouts)
1066
+ * @param {import('./module-graph.js').ModuleGraph | undefined} graph
1067
+ * @param {string[]} entryFiles absolute paths of page + layout files
1068
+ * @param {string} appDir
1069
+ * @returns {string[]}
1070
+ */
1071
+ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appDir, serverFiles) {
1072
+ const seen = new Set(moduleUrls);
1073
+ const result = [];
1074
+
1075
+ // Server-only modules are never useful to preload: they're imported by
1076
+ // pages/layouts on the server, or surfaced to client components as
1077
+ // generated RPC stubs that load lazily on first call. Preloading them
1078
+ // wastes a roundtrip and pollutes the network tab with server-named files.
1079
+ //
1080
+ // Detection is belt-and-suspenders: filename suffix catches `.server.*`;
1081
+ // the `serverFiles` set (built from the action index) also catches files
1082
+ // that opted in via `'use server'` directive without the suffix.
1083
+ const byName = (url) => /\.server\.m?[jt]s$/.test(url);
1084
+ const byIndex = serverFiles
1085
+ ? (abs) => (serverFiles.has ? serverFiles.has(abs) : false)
1086
+ : () => false;
1087
+
1088
+ // Add direct component URLs
1089
+ for (const url of componentUrls) {
1090
+ if (seen.has(url) || byName(url)) continue;
1091
+ seen.add(url);
1092
+ result.push(url);
1093
+ }
1094
+
1095
+ // Add transitive deps from the module graph
1096
+ if (graph) {
1097
+ // Combine entry files + component files for graph lookup
1098
+ const allEntries = [...entryFiles];
1099
+ for (const url of componentUrls) {
1100
+ // Convert URL back to absolute path for graph lookup
1101
+ const abs = resolve(appDir, url.startsWith('/') ? url.slice(1) : url);
1102
+ allEntries.push(abs);
1103
+ }
1104
+ const deps = transitiveDeps(graph, allEntries, appDir);
1105
+ for (const dep of deps) {
1106
+ if (byIndex(dep)) continue;
1107
+ const url = toUrlPath(dep, appDir);
1108
+ if (seen.has(url) || byName(url)) continue;
1109
+ seen.add(url);
1110
+ result.push(url);
1111
+ }
1112
+ }
1113
+
1114
+ return result;
1115
+ }
1116
+
1117
+ /**
1118
+ * Build a streaming Response. Degrades to a single-flush response when
1119
+ * there are no pending Suspense boundaries.
1120
+ *
1121
+ * @param {string} headHtml
1122
+ * @param {string} bodyHtml
1123
+ * @param {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} ctx
1124
+ * @param {number} status
1125
+ * @param {Request | undefined} req
1126
+ * @param {URL | undefined} url
1127
+ * @param {Record<string, any>} [metadata]
1128
+ */
1129
+ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata) {
1130
+ const encoder = new TextEncoder();
1131
+ const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
1132
+ // Default: no caching. Pages are dynamic by default: the developer
1133
+ // opts in to caching explicitly via metadata.cacheControl.
1134
+ headers.set('cache-control', metadata?.cacheControl || 'no-store');
1135
+ if (req && !readToken(req)) {
1136
+ const secure = url ? url.protocol === 'https:' : false;
1137
+ headers.append('set-cookie', cookieHeader(newToken(), { secure }));
1138
+ }
1139
+
1140
+ if (!ctx.pending.length) {
1141
+ return new Response(prefix + bodyHtml + closer, { status, headers });
1142
+ }
1143
+
1144
+ const stream = new ReadableStream({
1145
+ async start(controller) {
1146
+ controller.enqueue(encoder.encode(prefix + bodyHtml));
1147
+ try {
1148
+ // Loop: resolve all currently-pending promises in parallel; nested
1149
+ // Suspense inside resolved content adds more pending entries.
1150
+ while (ctx.pending.length) {
1151
+ const batch = ctx.pending.slice();
1152
+ ctx.pending.length = 0;
1153
+ const settled = await Promise.all(
1154
+ batch.map(async (p) => {
1155
+ try {
1156
+ const resolved = await p.promise;
1157
+ const sub = { pending: [], nextId: ctx.nextId };
1158
+ const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
1159
+ ctx.nextId = sub.nextId;
1160
+ for (const n of sub.pending) ctx.pending.push(n);
1161
+ return { id: p.id, html };
1162
+ } catch (e) {
1163
+ const msg = e instanceof Error ? e.message : String(e);
1164
+ return { id: p.id, html: `<p>error: ${escapeHtml(msg)}</p>` };
1165
+ }
1166
+ })
1167
+ );
1168
+ for (const r of settled) {
1169
+ // Emit just the <template>: the MutationObserver-based resolver
1170
+ // in the boot script detects it and swaps it into the placeholder.
1171
+ // Falls back to the __webjsResolve global for browsers without MO.
1172
+ const chunk =
1173
+ `<template data-webjs-resolve="${r.id}">${r.html}</template>` +
1174
+ `<script>window.__webjsResolve&&__webjsResolve("${r.id}")</script>`;
1175
+ controller.enqueue(encoder.encode(chunk));
1176
+ }
1177
+ }
1178
+ } finally {
1179
+ controller.enqueue(encoder.encode(closer));
1180
+ controller.close();
1181
+ }
1182
+ },
1183
+ });
1184
+ return new Response(stream, { status, headers });
1185
+ }
1186
+
1187
+ /**
1188
+ * @param {string} file
1189
+ * @param {boolean} dev
1190
+ */
1191
+ async function loadModule(file, dev) {
1192
+ const url = pathToFileURL(file).toString();
1193
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
1194
+ return import(url + bust);
1195
+ }
1196
+
1197
+ /**
1198
+ * @param {string} file
1199
+ * @param {string} appDir
1200
+ */
1201
+ function toUrlPath(file, appDir) {
1202
+ let rel = file.startsWith(appDir) ? file.slice(appDir.length) : file;
1203
+ rel = rel.split('\\').join('/');
1204
+ if (!rel.startsWith('/')) rel = '/' + rel;
1205
+ return rel;
1206
+ }
1207
+
1208
+ /**
1209
+ * Extract a CSP nonce from the request's Content-Security-Policy header.
1210
+ * Matches `'nonce-<base64>'` in the script-src directive.
1211
+ * @param {Request} req
1212
+ * @returns {string | undefined}
1213
+ */
1214
+ function getNonce(req) {
1215
+ const csp = req.headers.get('content-security-policy') || '';
1216
+ const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp);
1217
+ return match ? match[1] : undefined;
1218
+ }
1219
+
1220
+ /** @param {string} s */
1221
+ function escapeHtml(s) {
1222
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
1223
+ }
1224
+ /** @param {string} s */
1225
+ function escapeAttr(s) {
1226
+ return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
1227
+ }
1228
+
1229
+ /**
1230
+ * Serialize a Next.js-shaped viewport object into the comma-separated
1231
+ * `content` string the meta tag expects. Recognised fields:
1232
+ * width, height, initialScale, minimumScale, maximumScale,
1233
+ * userScalable, viewportFit, interactiveWidget.
1234
+ * Other fields (themeColor, colorScheme) live on their own meta tags
1235
+ * and are handled by the caller: skipped here.
1236
+ *
1237
+ * @param {Record<string, unknown>} v
1238
+ * @returns {string}
1239
+ */
1240
+ function serializeViewport(v) {
1241
+ const parts = [];
1242
+ /** @param {string} key @param {string} prop */
1243
+ const push = (key, prop) => {
1244
+ if (v[prop] !== undefined && v[prop] !== null && v[prop] !== '') {
1245
+ parts.push(`${key}=${v[prop]}`);
1246
+ }
1247
+ };
1248
+ push('width', 'width');
1249
+ push('height', 'height');
1250
+ push('initial-scale', 'initialScale');
1251
+ push('minimum-scale', 'minimumScale');
1252
+ push('maximum-scale', 'maximumScale');
1253
+ if (v.userScalable === false) parts.push('user-scalable=no');
1254
+ else if (v.userScalable === true) parts.push('user-scalable=yes');
1255
+ push('viewport-fit', 'viewportFit');
1256
+ push('interactive-widget', 'interactiveWidget');
1257
+ return parts.join(',');
1258
+ }