@webjsdev/server 0.8.62 → 0.8.64

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.
Files changed (48) hide show
  1. package/index.d.ts +32 -3
  2. package/package.json +2 -2
  3. package/src/browser-entries.js +64 -0
  4. package/src/check/helpers.js +365 -0
  5. package/src/check/rules-actions.js +174 -0
  6. package/src/check/rules-components.js +275 -0
  7. package/src/check/rules-imports.js +166 -0
  8. package/src/check/rules-registry.js +128 -0
  9. package/src/check/rules-routing.js +224 -0
  10. package/src/check/rules-typescript.js +177 -0
  11. package/src/check/rules.js +127 -0
  12. package/src/check/runner-support.js +405 -0
  13. package/src/check/runner.js +113 -0
  14. package/src/check.js +4 -1774
  15. package/src/component-elision.js +1 -1
  16. package/src/dev/config.js +338 -0
  17. package/src/dev/handler.js +1372 -0
  18. package/src/dev/helpers.js +542 -0
  19. package/src/dev/serve.js +840 -0
  20. package/src/dev/server.js +322 -0
  21. package/src/dev-classify.js +174 -0
  22. package/src/dev-reload-worker.js +133 -7
  23. package/src/dev-styles.js +97 -0
  24. package/src/dev.js +22 -3077
  25. package/src/form-dispatch.js +12 -3
  26. package/src/importmap.js +6 -3
  27. package/src/listener-core.js +16 -2
  28. package/src/module-graph.js +112 -7
  29. package/src/rate-limit.js +50 -5
  30. package/src/router.js +11 -1
  31. package/src/ssr/client-router-flag.js +42 -0
  32. package/src/ssr/document.js +361 -0
  33. package/src/ssr/env-shim.js +51 -0
  34. package/src/ssr/escape.js +29 -0
  35. package/src/ssr/head.js +857 -0
  36. package/src/ssr/preloads.js +195 -0
  37. package/src/ssr/render.js +1481 -0
  38. package/src/ssr/responses.js +201 -0
  39. package/src/ssr.js +28 -2445
  40. package/src/vendor/audit.js +400 -0
  41. package/src/vendor/integrity.js +245 -0
  42. package/src/vendor/jspm.js +348 -0
  43. package/src/vendor/manifest.js +96 -0
  44. package/src/vendor/pins.js +892 -0
  45. package/src/vendor/providers.js +35 -0
  46. package/src/vendor/resolver.js +225 -0
  47. package/src/vendor/scanner.js +132 -0
  48. package/src/vendor.js +29 -2371
package/src/ssr.js CHANGED
@@ -1,2453 +1,36 @@
1
- import { pathToFileURL, fileURLToPath } from 'node:url';
2
- import { resolve } from 'node:path';
3
- import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
4
- import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets, buildImportMap } from './importmap.js';
5
- import { withBasePath } from './base-path.js';
6
- import { withAssetHash } from './asset-hash.js';
7
- import { jsonForScriptTag } from './script-tag-json.js';
8
- import { transitiveDeps, bareImports } from './module-graph.js';
9
- import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from './action-seed.js';
10
- import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
11
- import {
12
- readRevalidate,
13
- readHtmlCache,
14
- HTML_CACHE_MARKER,
15
- } from './html-cache.js';
16
- import { requestedFrameId, extractFrameSubtree } from './frame-render.js';
17
- import { makeThenable } from './thenable-params.js';
18
-
19
- /**
20
- * SSR a matched page route to a Response.
21
- *
22
- * Mirrors NextJs semantics:
23
- * - Page + layout default exports can be async.
24
- * - `metadata` named export on layouts/pages is merged (page > innermost layout > … > root).
25
- * - `notFound()` and `redirect()` thrown anywhere in the chain are caught
26
- * and converted to 404 or 3xx responses.
27
- * - On a render error we walk up the chain looking for the nearest `error.js`
28
- * and render that instead (falls back to a plain error page).
29
- *
30
- * @param {import('./router.js').PageRoute} route
31
- * @param {Record<string,string>} params
32
- * @param {URL} url
33
- * @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('./module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string>, actionData?: unknown, status?: number, pageModule?: Record<string, unknown>, cspEnabled?: boolean }} opts
34
- * @returns {Promise<Response>}
35
- */
36
- export async function ssrPage(route, params, url, opts) {
37
- // Server HTML response cache (ISR for no-build, #241). OPT-IN: only a page
38
- // that declares `export const revalidate = N` is ever cached (the page
39
- // module export is the single trigger). The page module is loaded ONCE up
40
- // front to read that window
41
- // and is threaded back through `opts.pageModule` so renderChain reuses the
42
- // same evaluation (no double-load). A cache HIT serves the stored HTML
43
- // without re-running the page function. Skipped entirely (no opt-in read,
44
- // no double behaviour) for the form-action re-render (actionData / a non-200
45
- // status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
46
- // on the request and must not be shared under the full-URL key.
47
- const cacheEligible =
48
- !opts.actionData &&
49
- !opts.status &&
50
- !opts.pageModule &&
51
- !(opts.req && opts.req.headers.get('x-webjs-have'));
52
- let revalidateSeconds = null;
53
- if (cacheEligible) {
54
- try {
55
- const pageMod = await loadModule(route.file, opts.dev);
56
- opts = { ...opts, pageModule: pageMod };
57
- revalidateSeconds = readRevalidate(pageMod);
58
- if (revalidateSeconds !== null) {
59
- const hit = await readHtmlCache(url);
60
- if (hit) {
61
- const cached = cachedHtmlResponse(hit, opts.req, url);
62
- // A cache hit returns before any seed work runs, so it would otherwise
63
- // report nothing at all, which reads as "seeding is broken" when it is
64
- // really the cache answering (#1309). The seed block rides INSIDE the
65
- // cached bytes, so the seeds are exactly as fresh as the HTML.
66
- if (opts.dev) cached.headers.set('X-Webjs-Seed', 'html-cache');
67
- return cached;
68
- }
69
- }
70
- } catch {
71
- // A load / store failure falls through to a normal fresh render: the
72
- // cache is an optimization, never a correctness dependency. Leave
73
- // revalidateSeconds as read so the write path still applies when the
74
- // page loaded but only the store lookup failed.
75
- }
76
- }
77
-
78
- const ctx = {
79
- // params / searchParams are awaitable AND synchronously readable (#848):
80
- // `params.id` still works, `await params` also works (Next 15/16 parity).
81
- // The `then` is non-enumerable so spread / JSON / Object.keys are unchanged.
82
- params: makeThenable(params),
83
- searchParams: makeThenable(Object.fromEntries(url.searchParams.entries())),
84
- url: url.toString(),
85
- // Populated only when this render is the re-render after a failed page
86
- // `action` submission (#244). The page function and every layout receive
87
- // it so they can surface field errors and repopulate inputs from the
88
- // user's submitted values. Undefined on a normal GET render, so GET output
89
- // is byte-identical to before this feature.
90
- actionData: opts.actionData,
91
- };
92
-
93
- // Collect metadata across layouts (outermost first) then page.
94
- const metadata = await collectMetadata(route, ctx, opts.dev);
95
-
96
- try {
97
- const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set(), dev: opts.dev };
98
- // Parse the partial-nav "have" header from the client. The server walks
99
- // the target route's layout chain innermost → outermost and
100
- // SHORT-CIRCUITS at the first FULL match (segment AND route-key),
101
- // returning only the content below that layout, wrapped in the matched
102
- // layout's boundary pair. Real wire-byte savings: the outer layouts'
103
- // HTML is never re-serialized for same-shell navigations.
104
- const haveHeader = opts.req?.headers.get('x-webjs-have') || '';
105
- // Entries are `<segment>:<route-key>` (#1015). The route-key is required
106
- // for a correct short-circuit: a dynamic layout the client holds for
107
- // OTHER params ('/[org]' rendered for org-a on an org-b navigation) must
108
- // be re-rendered and re-shipped, or the client's parent-anchored REPLACE
109
- // has no fresh layout markup to swap in. Split at the LAST ':' (encoded
110
- // route-keys contain no ':'; a hand-authored folder name that smuggles a
111
- // delimiter through the SEGMENT half can only fail to match, which
112
- // degrades to a full render: always correct). An entry with no key (a
113
- // malformed or legacy client) is ignored, degrading the same way.
114
- /** @type {Map<string, string> | null} */
115
- let have = null;
116
- if (haveHeader) {
117
- have = new Map();
118
- for (const entry of haveHeader.split(',')) {
119
- const e = entry.trim();
120
- if (!e) continue;
121
- const cut = e.lastIndexOf(':');
122
- if (cut <= 0 || cut === e.length - 1) continue;
123
- have.set(e.slice(0, cut), e.slice(cut + 1));
124
- }
125
- if (have.size === 0) have = null;
126
- }
127
- // SSR action-result seeding (#472). When enabled, run the whole render
128
- // inside an ambient seed collector so every `'use server'` action a
129
- // component awaits in `async render()` records its (args -> result) for the
130
- // hydration payload. Disabled -> the plain render, byte-identical to before.
131
- let seedCollector = null;
132
- let body;
133
- let reduced = false;
134
- if (seedingEnabled()) {
135
- const seeded = await collectSeeds(() =>
136
- renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule),
137
- );
138
- body = seeded.value.html;
139
- reduced = seeded.value.reduced;
140
- seedCollector = seeded.collector;
141
- } else {
142
- const chain = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
143
- body = chain.html;
144
- reduced = chain.reduced;
145
- }
146
-
147
- // Frame subtree render (#253). A `<webjs-frame src>` self-load (or a
148
- // click-driven frame nav) sends `x-webjs-frame: <id>` and applies ONLY the
149
- // matching `<webjs-frame id>` subtree from the response, discarding the rest
150
- // of the page. So when that header is present AND the requested frame is in
151
- // the rendered output (the "isolable" case), return JUST that subtree: the
152
- // bytes are extracted verbatim from the same full render, so the result is
153
- // BYTE-EQUIVALENT to what the client would slice from a full-page response,
154
- // but the full document shell + all the other regions never go on the wire.
155
- // The frame swap path (applySwap in router-client.js) parses this body and
156
- // does `doc.querySelector('webjs-frame#<id>')`, which finds the lone
157
- // subtree exactly as it would in the full page. A streamed (Suspense)
158
- // render is skipped (its bytes are not yet final). When the frame id is NOT
159
- // found (an auth redirect to a login page, a route that dropped the frame),
160
- // we fall through to the normal full-page render, where the client's
161
- // existing `webjs:frame-missing` fallback handles the absence. A request
162
- // with NO `x-webjs-frame` header never reaches this branch, so a normal
163
- // page request is byte-identical to before this feature.
164
- const frameId = requestedFrameId(opts.req);
165
- if (frameId && suspenseCtx.pending.length === 0) {
166
- const subtree = extractFrameSubtree(body, frameId);
167
- if (subtree !== null) {
168
- const frameRes = htmlResponse(subtree, opts.status || 200, opts.req, url);
169
- // The subtree is sliced by the x-webjs-frame REQUEST header, so a
170
- // shared cache must never serve it to a request that did not send
171
- // one (the same #1009 poisoning shape as the reduced-have case).
172
- frameRes.headers.append('vary', 'X-Webjs-Frame');
173
- // #1009: a subtree sliced from a REDUCED render inherits its variance.
174
- if (reduced) frameRes.headers.append('vary', 'X-Webjs-Have');
175
- // No privateFragment call here on purpose: this response is built
176
- // WITHOUT page metadata, so it is already `no-store` and the call
177
- // would be dead code with nothing to assert against. The property is
178
- // locked by a test instead, which fails if the frame response ever
179
- // starts carrying the page's cacheControl.
180
- return frameRes;
181
- }
182
- }
183
- // Module URLs for the page + every layout in its chain. These ride
184
- // the importmap; the browser fetches each file as it walks the
185
- // import graph. Combined with the modulepreload hints below, this
186
- // is the Rails 7+ / Hotwire pattern: per-file ESM, no bundling,
187
- // HTTP/2 multiplex on the wire.
188
- //
189
- // Inert route modules (a page or layout that does no client work, even
190
- // transitively) are dropped from the boot script: the browser never
191
- // downloads them. The SSR'd HTML is the complete output, and
192
- // progressive enhancement is unaffected, so a fully-static route ships
193
- // zero application JS. The analysis is conservative (anything that
194
- // touches the client router, a signal, an event, an npm import, or a
195
- // shipping component keeps shipping).
196
- //
197
- // Import-only route modules (#605) go one step further: a page / layout
198
- // whose only client relevance is importing shipping components is itself
199
- // dead weight on the client (it never hydrates), so it is dropped and its
200
- // component modules are emitted directly in its place. The component set
201
- // is the FRONTIER of the analyser's path-aware walk (#963): the shipping
202
- // components the module reaches without passing through another shipping
203
- // component. A component imported but only conditionally rendered still
204
- // registers; one nested behind an emitted component is absent here and
205
- // loads via its importer's own imports. Dedup so a component shared
206
- // across the page and a layout (or two layouts) is emitted once.
207
- const inert = opts.inertRouteModules;
208
- const importOnly = opts.importOnlyRouteModules;
209
- const moduleUrls = [];
210
- {
211
- const seen = new Set();
212
- const push = (abs) => {
213
- const u = toUrlPath(abs, opts.appDir);
214
- if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
215
- };
216
- for (const f of [route.file, ...route.layouts]) {
217
- if (inert && inert.has(f)) continue;
218
- const emit = importOnly && importOnly.get(f);
219
- if (emit) emit.forEach(push);
220
- else push(f);
221
- }
222
- }
223
- // instrumentation-client.{js,ts} (#848): import it FIRST in the client boot
224
- // so it runs before app modules (Next's instrumentation-client ordering).
225
- // It ships even on an otherwise-static page (the app opted into client work),
226
- // so it is prepended AFTER the component/page modules are collected.
227
- if (opts.instrumentationClient) {
228
- const u = toUrlPath(opts.instrumentationClient, opts.appDir);
229
- const i = moduleUrls.indexOf(u);
230
- if (i !== -1) moduleUrls.splice(i, 1);
231
- moduleUrls.unshift(u);
232
- }
233
- // Emit <link rel="modulepreload"> for every custom element that
234
- // actually rendered PLUS their transitive dependencies (from the
235
- // module graph). URLs are deduplicated so the browser never sees
236
- // the same preload twice. Lazy components are excluded from
237
- // preloads and instead loaded via IntersectionObserver when they
238
- // enter the viewport.
239
- const { eager: eagerComponents, lazy: lazyComponents } =
240
- componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents);
241
- // The walk roots for BOTH preload passes are the BOOT's actually-shipped
242
- // module set (`moduleUrls`, which already drops inert page/layout modules and
243
- // substitutes an import-only page with its components), NOT the raw route
244
- // entries `[route.file, ...route.layouts]`. Rooting at the raw entries would
245
- // walk a dropped page's SSR-only subtree (a direct import OR a relative
246
- // helper) and hint a `modulepreload` for a module nothing that ships imports,
247
- // an over-fetch (#780, the app-module analog of the #754 vendor over-fetch).
248
- const shippedRoots = moduleUrls.map((u) =>
249
- resolve(opts.appDir, u.startsWith('/') ? u.slice(1) : u));
250
- const preloads = deduplicatedPreloads(
251
- eagerComponents,
252
- moduleUrls,
253
- opts.moduleGraph,
254
- shippedRoots,
255
- opts.appDir,
256
- opts.serverFiles,
257
- opts.elidableComponents,
258
- );
259
- // Vendor modulepreload (#754): flatten the npm CDN waterfall by hinting the
260
- // vendor URLs the page's SHIPPED modules actually import, fetched in parallel
261
- // instead of discovered level by level. Same shipped-module roots as the
262
- // app-module walk above, so a vendor reached ONLY through a dropped module
263
- // (a page's SSR-only direct import OR its SSR-only relative helper) is never
264
- // preloaded (no over-fetch).
265
- const vendorPreloads = vendorPreloadTargets(
266
- reachedVendorSpecifiers(
267
- opts.moduleGraph,
268
- shippedRoots,
269
- eagerComponents,
270
- opts.appDir,
271
- opts.elidableComponents,
272
- opts.serverFiles,
273
- ),
274
- );
275
- // Extract CSP nonce from request headers (if present).
276
- const nonce = opts.req ? getNonce(opts.req) : undefined;
277
- const wrapOpts = {
278
- metadata,
279
- moduleUrls,
280
- dev: opts.dev,
281
- streaming: suspenseCtx.pending.length > 0,
282
- preloads,
283
- vendorPreloads,
284
- lazyComponents,
285
- nonce,
286
- };
287
- // buildDocumentParts picks up a user-supplied <!doctype><html>…</html>
288
- // shell from the body when present; otherwise auto-emits the framework
289
- // shell. Either way the returned `prefix` ends just past the open <body>
290
- // and `closer` is the matching `</body></html>`.
291
- const { prefix, streamBody, closer } = buildDocumentParts(body, wrapOpts);
292
- // Append the SSR action-seed payload (#472) to the non-streamed body so the
293
- // client's first render reads it instead of re-issuing the RPC. Only for a
294
- // fully-buffered (non-streaming) render: a streamed page's deferred
295
- // boundaries resolve AFTER the first flush, so their seeds cannot ride this
296
- // block (those slow regions keep the stale-while-revalidate refetch). An
297
- // empty collector yields '' so the output stays byte-identical.
298
- let outBody = streamBody;
299
- // Dev-only seeding diagnostics (#1309). `off` (seeding disabled) is kept
300
- // DISTINCT from `collected=0` on purpose: the counting lives with the
301
- // collector rather than behind the seed gate, so a seeding-DISABLED app
302
- // never looks like a seeding-BROKEN one.
303
- let seedHeader = 'off';
304
- const streamed = suspenseCtx.pending.length > 0;
305
- if (seedCollector && streamed) {
306
- // A streamed render's deferred boundaries resolve AFTER the first flush,
307
- // so their results cannot ride this block and none is emitted in prod. In
308
- // DEV emit the marker alone, so the client reports the CAUSE instead of
309
- // leaving the developer to guess why every action call went to the network.
310
- seedHeader = `collected=${seedCollector.size}, emitted=0, streamed`;
311
- if (opts.dev) {
312
- const marker = await buildSeedScript(null, { dev: true, reason: 'streamed' });
313
- if (marker) outBody = streamBody + marker;
314
- }
315
- } else if (seedCollector) {
316
- const seedScript = await buildSeedScript(seedCollector, { dev: opts.dev });
317
- if (seedScript) outBody = streamBody + seedScript;
318
- // `emitted` differs from `collected` exactly when the serializer threw and
319
- // dropped the whole block, which is otherwise a completely invisible
320
- // failure. In DEV that throw still emits a marker-only block, so the
321
- // browser can name the cause; it carries no seeds, so it must NOT count as
322
- // emitted or the header would report a total success on the very failure
323
- // it exists to expose.
324
- const dropped = seedScript === SEED_DROP_BLOCK;
325
- seedHeader = `collected=${seedCollector.size}, emitted=${seedScript && !dropped ? seedCollector.size : 0}`;
326
- }
327
- const res = streamingHtmlResponse(
328
- prefix,
329
- outBody,
330
- closer,
331
- suspenseCtx,
332
- // Normally 200. After a failed form-action submission the caller passes
333
- // 422 (or another 4xx) so the re-rendered page with field errors carries
334
- // the right status for both the no-JS reload and the enhanced swap (#244).
335
- opts.status || 200,
336
- opts.req,
337
- url,
338
- metadata,
339
- nonce,
340
- opts.dev,
341
- );
342
- // Dev-only seeding diagnostics (#1309). A miss is indistinguishable from a
343
- // hit from the outside, so an app whose seeding silently broke looks exactly
344
- // like one where it works. Dev only: a production header would publish how
345
- // many server calls a page made, for no benefit.
346
- if (opts.dev) res.headers.set('X-Webjs-Seed', seedHeader);
347
- // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
348
- // outer-layout chrome, so these bytes are only valid for a request that
349
- // sent a matching `have`. Left shared-cacheable, a CDN edge could store the
350
- // reduced body under the URL and serve a chrome-less fragment to a fresh
351
- // full-page navigation (measured live: GET / was 73,534 bytes, GET / + have
352
- // was 57,035, byte-identical headers otherwise).
353
- //
354
- // TWO markings, and the order of trust matters (#1140). `privateFragment`
355
- // is the guarantee: it forbids SHARED storage outright, so no CDN can hold
356
- // this body at all. `Vary` is belt-and-braces for caches that honour it,
357
- // NOT the protection, because Cloudflare and others honour only
358
- // `Accept-Encoding`. Both are scoped to genuinely reduced responses, so a
359
- // normal page's headers and cache key are unchanged.
360
- //
361
- // The internal #241 revalidate cache is already safe by construction:
362
- // `cacheEligible` excludes any request that carries x-webjs-have, so a
363
- // reduced body is never stored under the URL-only key.
364
- if (reduced) {
365
- res.headers.append('vary', 'X-Webjs-Have');
366
- privateFragment(res);
367
- }
368
- // Server HTML cache write (#241). The page opted in via `revalidate`, so
369
- // FLAG this candidate for the response funnel rather than writing here: the
370
- // store decision must see the FINAL response (after segment middleware,
371
- // which may append a per-user Set-Cookie this code can't see yet). The
372
- // funnel re-checks every guard via isCacheableResponse, writes the cache,
373
- // and strips this internal marker. The CSP guard is decided here (the SSR
374
- // side knows whether a nonce was stamped into the body).
375
- if (revalidateSeconds !== null && !opts.cspEnabled) {
376
- res.headers.set(HTML_CACHE_MARKER, String(revalidateSeconds));
377
- }
378
- return res;
379
- } catch (err) {
380
- if (isRedirect(err)) {
381
- const e = /** @type any */ (err);
382
- // A redirect thrown during a GET page/layout render is a GET-to-GET
383
- // navigation (an auth bounce, a gate). 302 Found is the conventional
384
- // code there, so it is the default when the caller did not pick one. An
385
- // explicit `redirect(url, status)` overrides it. (Action redirects, a
386
- // POST, default to 307 in form-dispatch.js so the method is preserved.)
387
- return new Response(null, { status: e.status || 302, headers: { location: e.url } });
388
- }
389
- if (isNotFound(err)) {
390
- // Nearest not-found.{js,ts} in the page's chain wins (#848 fix: previously
391
- // this always rendered the bare default, ignoring even a root not-found);
392
- // fall back to a root global-not-found.{js,ts}, else the default page.
393
- const html = await ssrNotFoundHtml(nearest(route.notFounds) || opts.globalNotFound || null, opts);
394
- return htmlResponse(html, 404, opts.req, url);
395
- }
396
- // forbidden() / unauthorized() sentinels (#848, Next 15/16 parity): render
397
- // the nearest forbidden.{js,ts} / unauthorized.{js,ts} boundary (innermost
398
- // wins), else a default page, at 403 / 401.
399
- if (isForbidden(err)) return ssrForbidden(route, { ...opts, url });
400
- if (isUnauthorized(err)) return ssrUnauthorized(route, { ...opts, url });
401
- // APM / Sentry sink (issue #239): a page render error that becomes a 500
402
- // (an error.js boundary OR the default 500 page) is an unhandled error the
403
- // app should see in its error tracker. Report it best-effort BEFORE
404
- // rendering the boundary, so the sink gets the ORIGINAL error even if the
405
- // boundary itself swallows or transforms it. notFound / redirect are
406
- // sentinels (control flow), not errors, so they are excluded above.
407
- if (typeof opts.onError === 'function') {
408
- try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
409
- }
410
- // Dev error overlay (#264): push a rich frame to the open tab so the
411
- // overlay appears live. Dev-only + best-effort; never affects the response.
412
- if (typeof opts.onDevError === 'function') {
413
- try { opts.onDevError(err); } catch { /* a throwing sink must not affect the response */ }
414
- }
415
- // Error paths still need to honor the request's CSP nonce so the
416
- // error page's boot scripts (when moduleUrls is non-empty) and
417
- // the meta csp-nonce tag both pass strict-CSP enforcement.
418
- const errNonce = opts.req ? getNonce(opts.req) : undefined;
419
- // Try nearest error.js (innermost → outermost).
420
- for (let i = route.errors.length - 1; i >= 0; i--) {
421
- try {
422
- const mod = await loadModule(route.errors[i], opts.dev);
423
- if (!mod.default) continue;
424
- const tree = await mod.default({ ...ctx, error: err });
425
- const body = await renderToString(tree, { ssr: true, dev: opts.dev });
426
- // Apply the SAME inert / import-only substitution as the happy-path
427
- // boot (#963): the error page must not ship a page/layout module the
428
- // normal render drops. Pre-substitution, an import-only page with a
429
- // bare `.server.*` import (legal, it never loads client-side) would
430
- // load here whole and crash the error page's boot on the throw-at-load
431
- // stub, killing the sibling component registrations in the same
432
- // module script.
433
- const errModuleUrls = [];
434
- {
435
- const seen = new Set();
436
- const push = (abs) => {
437
- const u = toUrlPath(abs, opts.appDir);
438
- if (!seen.has(u)) { seen.add(u); errModuleUrls.push(u); }
439
- };
440
- for (const f of [route.file, ...route.layouts]) {
441
- if (opts.inertRouteModules && opts.inertRouteModules.has(f)) continue;
442
- const emit = opts.importOnlyRouteModules && opts.importOnlyRouteModules.get(f);
443
- if (emit) emit.forEach(push);
444
- else push(f);
445
- }
446
- }
447
- const html = wrapInDocument(body, { metadata, moduleUrls: errModuleUrls, dev: opts.dev, nonce: errNonce });
448
- return htmlResponse(html, 500, opts.req, url);
449
- } catch (nested) {
450
- // fall through to next error boundary
451
- }
452
- }
453
- // Root global-error.{js,ts} (#848): the app-wide catch-all, tried after the
454
- // nested error boundaries are exhausted. It renders the FULL document (its
455
- // own <!doctype><html><body>, like the root layout), since a root-layout
456
- // failure is exactly when it fires, so its rendered output is returned
457
- // verbatim without wrapInDocument. Status 500.
458
- if (opts.globalError) {
459
- try {
460
- const mod = await loadModule(opts.globalError, opts.dev);
461
- if (mod.default) {
462
- const tree = await mod.default({ error: err });
463
- const body = await renderToString(tree, { ssr: true, dev: opts.dev });
464
- return htmlResponse(body, 500, opts.req, url);
465
- }
466
- } catch (nested) {
467
- // fall through to the default 500 page
468
- }
469
- }
470
- // Default: dev shows stack, prod shows a terse message (no stack trace leaks).
471
- console.error('[webjs] unhandled render error:', err);
472
- const body = opts.dev
473
- ? `<h1>Server error</h1><pre style="white-space:pre-wrap">${escapeHtml(
474
- err instanceof Error ? err.stack || err.message : String(err)
475
- )}</pre>`
476
- : `<h1>Server error</h1><p>Something went wrong. Please try again.</p>`;
477
- return htmlResponse(
478
- wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev, nonce: errNonce }),
479
- 500,
480
- opts.req,
481
- url
482
- );
483
- }
484
- }
485
-
486
- /**
487
- * 404 response for unmatched routes.
488
- * @param {string | null} notFoundFile
489
- * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
490
- */
491
- export async function ssrNotFound(notFoundFile, opts) {
492
- const html = await ssrNotFoundHtml(notFoundFile, opts);
493
- return htmlResponse(html, 404, opts.req, opts.url);
494
- }
495
-
496
- /**
497
- * 403 response for a thrown forbidden() (#848). Renders the nearest
498
- * forbidden.{js,ts} in the page's chain, else a default 403 page. Shared by the
499
- * page-render catch (ssr.js) and the form-action write path (form-dispatch.js) so
500
- * both behave identically.
501
- * @param {{ forbiddens?: string[] }} route
502
- * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
503
- */
504
- export async function ssrForbidden(route, opts) {
505
- const html = await ssrBoundaryHtml(nearest(route.forbiddens), '403: Forbidden', opts);
506
- return htmlResponse(html, 403, opts.req, opts.url);
507
- }
508
-
509
- /**
510
- * 401 response for a thrown unauthorized() (#848). Renders the nearest
511
- * unauthorized.{js,ts} in the page's chain, else a default 401 page.
512
- * @param {{ unauthorizeds?: string[] }} route
513
- * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
514
- */
515
- export async function ssrUnauthorized(route, opts) {
516
- const html = await ssrBoundaryHtml(nearest(route.unauthorizeds), '401: Unauthorized', opts);
517
- return htmlResponse(html, 401, opts.req, opts.url);
518
- }
519
-
520
- /**
521
- * Downgrade a PARTIAL response so no shared cache can store it (#1140).
522
- *
523
- * A reduced `X-Webjs-Have` body is sliced by a REQUEST header, so it is valid
524
- * only for a client that sent it. It is marked `Vary` for that header, but
525
- * `Vary` is not a guarantee in practice: Cloudflare honours only
526
- * `Accept-Encoding` and several other shared caches are just as selective.
527
- * Since a reduced body otherwise INHERITS the page's `Cache-Control`, a page
528
- * that opted into public caching was handing CDNs a chrome-less fragment under
529
- * the full page's URL, to be served to whoever navigated there next. Measured
530
- * on webjs.dev: 71,759 bytes for the document versus 47,375 for the fragment,
531
- * identical `Cache-Control` on both.
532
- *
533
- * The reduced path is the ONLY caller. A `<webjs-frame>` subtree is sliced by a
534
- * request header too, but its response is built without page metadata, so it is
535
- * already `no-store` and needs no downgrade; that property is locked by a test
536
- * rather than by a call here. A NEW partial-response shape does not get this
537
- * treatment for free: call this from its response site.
538
- *
539
- * `private` fixes that at the source instead of relying on `Vary` being
540
- * respected: no shared cache may store it, while the browser that asked for it
541
- * still may. The freshness directives are preserved, so a returning client
542
- * keeps whatever reuse the page declared. `Vary` stays too, as belt-and-braces
543
- * for caches that do honour it.
544
- *
545
- * The response KEEPS its validator. `private` forbids shared storage, not
546
- * validation, so the funnel still attaches an ETag (see conditional-get.js,
547
- * which stopped excluding `private` for exactly this reason). That matters:
548
- * the router fetches partials with `cache: 'no-cache'` (#1131), so without a
549
- * validator every prefetch and soft navigation would re-download the whole
550
- * fragment instead of being answered 304.
551
- *
552
- * @param {Response} res
553
- */
554
- export function privateFragment(res) {
555
- const cc = res.headers.get('cache-control') || '';
556
- // No header at all is not "nothing to do": a response with no Cache-Control
557
- // is heuristically storable by a shared cache (RFC 9111), which is precisely
558
- // the hazard here. Fail CLOSED.
559
- if (!cc) {
560
- res.headers.set('cache-control', 'private');
561
- return;
562
- }
563
- // Split on commas that are NOT inside a quoted argument: a directive may
564
- // carry one (`no-cache="Set-Cookie, X-Foo"`, `private="x-user"`), and a naive
565
- // split tears those apart.
566
- const directives = [];
567
- let buf = '';
568
- let inQuotes = false;
569
- for (const ch of cc) {
570
- if (ch === '"') inQuotes = !inQuotes;
571
- if (ch === ',' && !inQuotes) { directives.push(buf.trim()); buf = ''; continue; }
572
- buf += ch;
573
- }
574
- directives.push(buf.trim());
575
-
576
- // `name` is everything before the first `=`, trimmed, so `s-maxage = 600`
577
- // and `private="x-user"` are recognised as the directives they are.
578
- const nameOf = (d) => d.split('=')[0].trim().toLowerCase();
579
- // Already unshareable: leave it exactly as the author wrote it. Only a BARE
580
- // `private` counts. The qualified form (`private="x-user"`) marks just the
581
- // NAMED header fields private per RFC 9111, leaving the response itself
582
- // storable by a shared cache, so it must still be downgraded.
583
- const isBare = (d) => !d.includes('=');
584
- if (directives.some((d) => nameOf(d) === 'no-store' || (nameOf(d) === 'private' && isBare(d)))) return;
585
-
586
- // `private` joins the drop list because a bare one is prepended below: a
587
- // qualified `private="x-user"` left in place would emit the directive twice,
588
- // and a cache that resolves a repeat by taking the last occurrence would read
589
- // the qualified form, which per RFC 9111 leaves the response shared-storable.
590
- const SHARED_ONLY = new Set(['public', 's-maxage', 'proxy-revalidate', 'private']);
591
- const kept = directives.filter((d) => d && !SHARED_ONLY.has(nameOf(d)));
592
- kept.unshift('private');
593
- res.headers.set('cache-control', kept.join(', '));
594
- }
595
-
596
- /**
597
- * Build an HTML Response. Sets no cookie: action CSRF is an Origin /
598
- * Sec-Fetch-Site check, so the page response is cookieless (CDN-cacheable).
599
- * @param {string} html
600
- * @param {number} status
601
- * @param {Request | undefined} req
602
- * @param {URL | undefined} url
603
- * @param {Record<string, any>} [metadata]
604
- */
605
- function htmlResponse(html, status, req, url, metadata) {
606
- const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
607
- // Default: no caching. Pages are dynamic by default: the developer opts in
608
- // explicitly via metadata.cacheControl. No non-200 guard here, unlike
609
- // streamingHtmlResponse: every caller of THIS builder passes no metadata, so
610
- // the value is already the no-store default and a guard would be dead code.
611
- headers.set('cache-control', metadata?.cacheControl || 'no-store');
612
- // X-Webjs-Build carries the published build id so the client
613
- // router can detect post-deploy importmap changes on EVERY
614
- // response, including the X-Webjs-Have partial responses that
615
- // omit the head entirely. Empty until the map is authoritatively
616
- // final, so a warming response is reload-safe. See router-client.js
617
- // applySwap and publishedBuildId() in importmap.js.
618
- headers.set('x-webjs-build', publishedBuildId());
619
- headers.set('x-webjs-src', appSourceId());
620
- // Buffered (string) body: opt into the conditional-GET funnel.
621
- // A cacheable page (metadata.cacheControl) gets a weak ETag + 304. The
622
- // funnel excludes only the no-store default; a `private` page IS validated,
623
- // which is what keeps the router's partial responses cheap (#1140).
624
- // See conditional-get.js.
625
- headers.set(BUFFERED_MARKER, '1');
626
- return new Response(html, { status, headers });
627
- }
628
-
629
- /**
630
- * Rebuild a Response from a cached HTML record (#241). The stored body is
631
- * the stable per-page HTML; the per-response varying bits are re-minted
632
- * here so a new visitor still gets them: the published build id is re-read so
633
- * a post-deploy client sees the current id. No cookie is set (action CSRF is
634
- * an Origin / Sec-Fetch-Site check), which is what keeps a cached page
635
- * cookieless and shareable. The BUFFERED marker opts the cached body into
636
- * the conditional-GET funnel exactly as a fresh render does, so a cached
637
- * PUBLIC-cacheable page still 304s. Output is observably identical to the
638
- * fresh render of the same route within the window.
639
- *
640
- * @param {{ body: string, contentType: string, cacheControl: string, status: number }} rec
641
- * @param {Request | undefined} req
642
- * @param {URL | undefined} url
643
- */
644
- function cachedHtmlResponse(rec, req, url) {
645
- const headers = new Headers({ 'content-type': rec.contentType });
646
- headers.set('cache-control', rec.cacheControl);
647
- headers.set('x-webjs-build', publishedBuildId());
648
- headers.set('x-webjs-src', appSourceId());
649
- headers.set(BUFFERED_MARKER, '1');
650
- return new Response(rec.body, { status: rec.status, headers });
651
- }
652
-
653
- /* ------------ internals ------------ */
654
-
655
- /**
656
- * Nearest-wins boundary file from a chain projected outermost -> innermost
657
- * (the router builds these arrays via chainOf). The innermost (last) wins, the
658
- * same "nearest boundary" rule error.js uses. Returns null for an empty chain.
659
- * @param {string[] | undefined} files
660
- * @returns {string | null}
661
- */
662
- function nearest(files) {
663
- return files && files.length ? files[files.length - 1] : null;
664
- }
665
-
666
- /**
667
- * Render a simple boundary page (forbidden / unauthorized, #848) at the given
668
- * default heading. Loads the nearest boundary module when one exists (its
669
- * default export receives no props, like not-found), else emits the default
670
- * heading. Mirrors ssrNotFoundHtml.
671
- * @param {string | null} file
672
- * @param {string} heading e.g. '403: Forbidden'
673
- * @param {{ dev: boolean, appDir: string, req?: Request }} opts
674
- */
675
- async function ssrBoundaryHtml(file, heading, opts) {
676
- let body = `<h1>${heading}</h1>`;
677
- if (file) {
678
- try {
679
- const mod = await loadModule(file, opts.dev);
680
- if (mod.default) body = await renderToString(await mod.default({}), { ssr: true, dev: opts.dev });
681
- } catch (e) {
682
- body = `<h1>${heading}</h1><pre>${escapeHtml(String(e))}</pre>`;
683
- }
684
- }
685
- const nonce = opts.req ? getNonce(opts.req) : undefined;
686
- return wrapInDocument(body, {
687
- metadata: { title: heading.replace(/^\d+:\s*/, '') },
688
- moduleUrls: [],
689
- dev: opts.dev,
690
- nonce,
691
- });
692
- }
693
-
694
- async function ssrNotFoundHtml(notFoundFile, opts) {
695
- let body = '<h1>404: Not found</h1>';
696
- if (notFoundFile) {
697
- try {
698
- const mod = await loadModule(notFoundFile, opts.dev);
699
- if (mod.default) body = await renderToString(await mod.default({}), { ssr: true, dev: opts.dev });
700
- } catch (e) {
701
- body = `<h1>404: Not found</h1><pre>${escapeHtml(String(e))}</pre>`;
702
- }
703
- }
704
- const nonce = opts.req ? getNonce(opts.req) : undefined;
705
- return wrapInDocument(body, {
706
- metadata: { title: 'Not found' },
707
- moduleUrls: [],
708
- dev: opts.dev,
709
- nonce,
710
- });
711
- }
712
-
713
- async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
714
- // Reuse a caller-supplied page module when present. The only producer is the
715
- // HTML-cache path above, which loads the module to read `export const
716
- // revalidate` and threads it back so this does not load it a second time.
717
- // (The removed page `action` export used to be a second producer: it ran in
718
- // the page module, so the 422 re-render could share that evaluation. A form
719
- // action lives in a `.server.*` module instead, so the re-render loads the
720
- // page module itself, exactly as a plain render does.)
721
- const page = pageModule || await loadModule(route.file, dev);
722
- if (!page.default) throw new Error(`Page ${route.file} must have a default export`);
723
- let tree = await page.default(ctx);
724
-
725
- // If the route has a loading.ts file, wrap the page in a Suspense boundary
726
- // with the loading content as the fallback. This mirrors NextJs's automatic
727
- // Suspense wrapping when loading.tsx is present.
728
- if (route.loadings && route.loadings.length > 0) {
729
- // Use the innermost (closest) loading file
730
- const loadingFile = route.loadings[route.loadings.length - 1];
731
- try {
732
- const loadingMod = await loadModule(loadingFile, dev);
733
- if (loadingMod.default) {
734
- const { Suspense } = await import('@webjsdev/core');
735
- const fallback = await loadingMod.default(ctx);
736
- tree = Suspense({ fallback, children: Promise.resolve(tree) });
737
- }
738
- } catch { /* loading file failed: skip, render page directly */ }
739
- }
740
-
741
- // Resolved route params drive every boundary's route-key. ctx.params is
742
- // thenable (#848) but its `then` is non-enumerable, so a spread yields the
743
- // plain param map the route-key derivation expects.
744
- const params = { ...(/** @type {Record<string,string>} */ (ctx.params) || {}) };
745
-
746
- // Page-level boundary (#1015). The page gets its own keyed boundary pair
747
- // whose route-key is its full resolved path, so a dynamic-param change
748
- // (`/blog/a` -> `/blog/b`) REPLACES (remounts) the page on the client (Next
749
- // parity) while a shared parent layout (a shorter segment whose key did not
750
- // change) is preserved. Skipped when the page's segment equals the innermost
751
- // layout's: the layout's children-slot boundary already delimits that exact
752
- // range, and two boundaries with one segment id would break keyed pairing.
753
- const pageSeg = pageSegmentPath(route.file);
754
- const innermostLayoutSeg = route.layouts && route.layouts.length
755
- ? layoutSegmentPath(route.layouts[route.layouts.length - 1])
756
- : null;
757
- if (pageSeg !== innermostLayoutSeg) {
758
- tree = wrapWithChildrenMarker(tree, pageSeg, params);
759
- }
760
-
761
- // Wrap each layout's `${children}` interpolation in the KEYED boundary
762
- // comment pair (open `<!--wj:children:<segment>:<route-key>-->`, close
763
- // `<!--/wj:children:<segment>-->`, #1015). The client router scans both the
764
- // live and incoming DOM for these boundaries (strict id-matched pairing, no
765
- // LIFO guessing) and applies the two-tier swap: a changed route-key
766
- // REPLACES at the PARENT of the shallowest change, else the deepest shared
767
- // boundary MORPHS. Outer
768
- // layout DOM (and the scroll position of anything inside it: sidenavs,
769
- // fixed headers, inner scroll containers) is preserved. Auto-derived from
770
- // folder structure: no opt-in required from layout authors.
771
- // X-Webjs-Have optimization: iterate from innermost → outermost and
772
- // SHORT-CIRCUIT at the first layout whose segment path the client
773
- // already has rendered. Wrap the accumulated inner tree in that
774
- // layout's boundary pair (so the client can identify the splice
775
- // target) and return: outer layouts are not rendered at all,
776
- // saving CPU and wire bytes.
777
- for (let i = route.layouts.length - 1; i >= 0; i--) {
778
- const segmentPath = layoutSegmentPath(route.layouts[i]);
779
- // Short-circuit ONLY when the client's copy of this layout was rendered
780
- // for the SAME route-key: a param change at a dynamic layout must
781
- // re-render the layout's own markup (#1015).
782
- if (have && have.get(segmentPath) === regionRouteKey(segmentPath, params)) {
783
- tree = wrapWithChildrenMarker(tree, segmentPath, params);
784
- const body = await renderToString(tree, { ssr: true, suspenseCtx });
785
- // REDUCED response (#1009): the outer layouts were skipped, so these
786
- // bytes are only valid for a client that already HAS them. The caller
787
- // marks the response `private` so no shared cache can store it, and
788
- // additionally `Vary: X-Webjs-Have` for caches that honour it. The
789
- // `private` is the guarantee, not the Vary: Cloudflare and others honour
790
- // only `Accept-Encoding` (#1140).
791
- return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: true };
792
- }
793
- const mod = await loadModule(route.layouts[i], dev);
794
- if (!mod.default) continue;
795
- tree = await mod.default({
796
- ...ctx,
797
- children: wrapWithChildrenMarker(tree, segmentPath, params),
798
- });
799
- }
800
- const body = await renderToString(tree, { ssr: true, suspenseCtx });
801
- return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: false };
802
- }
803
-
804
- /**
805
- * Render each `loading.{js,ts}` in the route's chain into a hidden
806
- * `<template id="wj-loading:<segment-path>">`. The client router clones
807
- * the deepest matching template into the swap slot on nav-start, giving
808
- * users an instant per-segment skeleton instead of stale content.
809
- *
810
- * Each loading file's segment path is the URL prefix it serves: same
811
- * derivation as layoutSegmentPath but stripping `loading.ext` instead.
812
- *
813
- * Errors loading a single file are swallowed so a broken loading.ts in
814
- * one segment doesn't break the whole response.
815
- *
816
- * @param {{ loadings?: string[] }} route
817
- * @param {Record<string,unknown>} ctx
818
- * @param {boolean} dev
819
- * @returns {Promise<string>}
820
- */
821
- async function loadingTemplates(route, ctx, dev) {
822
- if (!route.loadings || route.loadings.length === 0) return '';
823
- /** @type {string[]} */
824
- const parts = [];
825
- for (const file of route.loadings) {
826
- try {
827
- const mod = await loadModule(file, dev);
828
- if (!mod.default) continue;
829
- const tree = await mod.default(ctx);
830
- const html = await renderToString(tree, { ssr: true, dev });
831
- const segmentPath = loadingSegmentPath(file);
832
- parts.push(`<template id="wj-loading:${segmentPath}">${html}</template>`);
833
- } catch { /* skip broken loading file */ }
834
- }
835
- return parts.join('');
836
- }
837
-
838
- /**
839
- * Like layoutSegmentPath but for `loading.{js,ts}` files. Strips the
840
- * `loading.ext` filename from the URL path under app/.
841
- *
842
- * @param {string} loadingFile
843
- * @returns {string}
844
- */
845
- function loadingSegmentPath(loadingFile) {
846
- const p = loadingFile
847
- .replace(/^.*\/app\//, '')
848
- .replace(/\/?loading\.[jt]sx?$/, '');
849
- return p === '' ? '/' : '/' + p;
850
- }
851
-
852
- /**
853
- * Derive a layout's segment path from its file path. The path identifies
854
- * the layout's slot in the layout chain for partial-nav marker matching.
855
- *
856
- * app/layout.ts → '/'
857
- * app/docs/layout.ts → '/docs'
858
- * app/docs/components/layout.ts → '/docs/components'
859
- * app/(marketing)/about/layout.ts → '/(marketing)/about'
860
- *
861
- * Route groups `(marketing)` are KEPT in the path. They don't appear in
862
- * URLs but DO scope distinct layouts: two routes at the same URL prefix
863
- * served by different `(group)` layouts must produce different markers
864
- * so the client doesn't falsely identify them as a shared layout.
865
- *
866
- * @param {string} layoutFile Absolute path to the layout source file.
867
- * @returns {string}
868
- */
869
- function layoutSegmentPath(layoutFile) {
870
- const p = layoutFile
871
- .replace(/^.*\/app\//, '')
872
- .replace(/\/?layout\.[jt]sx?$/, '');
873
- return p === '' ? '/' : '/' + p;
874
- }
875
-
876
- /**
877
- * Like layoutSegmentPath but for the PAGE file. Strips the `page.ext`
878
- * filename, yielding the page's own segment path (the full route pattern,
879
- * dynamic tokens included):
880
- *
881
- * app/page.ts -> '/'
882
- * app/blog/[slug]/page.ts -> '/blog/[slug]'
883
- * app/files/[...rest]/page.ts -> '/files/[...rest]'
884
- *
885
- * This is the segment for the PAGE-level region (Pillar 1 / #1013): the page
886
- * needs its own region keyed by the full resolved path so a dynamic-param
887
- * change remounts the page (Next parity) while a shared parent LAYOUT (a
888
- * shorter segment path whose route-key does not change) is preserved.
889
- *
890
- * @param {string} pageFile Absolute path to the page source file.
891
- * @returns {string}
892
- */
893
- function pageSegmentPath(pageFile) {
894
- const p = pageFile
895
- .replace(/^.*\/app\//, '')
896
- .replace(/\/?page\.[jt]sx?$/, '');
897
- return p === '' ? '/' : '/' + p;
898
- }
899
-
900
1
  /**
901
- * Derive a region's ROUTE-KEY from its segment path pattern and the render's
902
- * resolved params. The route-key is the CONCRETE resolved URL path for the
903
- * region: dynamic `[param]` / catch-all `[...param]` / optional-catch-all
904
- * `[[...param]]` tokens are substituted with their param values, and `(group)`
905
- * segments are dropped (they scope layouts but never appear in the URL).
906
- * searchParams are excluded by construction (params carries route params only).
907
- *
908
- * The client router compares a region's OLD vs NEW route-key to pick the swap
909
- * tier: route-key CHANGED -> wholesale replace (Next page-remount parity),
910
- * route-key SAME -> bounded same-route morph (hydrated component state kept, the
911
- * searchParams-only-nav case). A static segment (`/`, `/docs`) has a constant
912
- * route-key, so that layout's region never remounts and its chrome always
913
- * survives.
914
- *
915
- * PARAM VALUES ARE ENCODED (`encodeURIComponent`, per path piece). The
916
- * route-key rides inside the boundary COMMENT, and param values are
917
- * user-controlled, so an unencoded value could carry `-->` and terminate the
918
- * comment mid-boundary. Encoding removes `<`, `>`, `:`, `/` from every
919
- * substituted piece, which makes all three HTML-forbidden comment sequences
920
- * (`<!--`, `-->`, `--!>`) impossible (each needs `<` or `>`) and keeps `:`
921
- * unambiguous as the boundary-format delimiter. A catch-all's value is encoded
922
- * per PIECE (split on `/`), so its literal separators stay readable. Static
923
- * segments come from folder names and are emitted as-is. A bare `--` can
924
- * survive encoding (`-` is unreserved) and is legal inside an HTML5 comment.
925
- * The key is only ever compared for equality, so encoding does not affect the
926
- * swap-tier decision.
927
- *
928
- * regionRouteKey('/', {}) -> '/'
929
- * regionRouteKey('/docs', {}) -> '/docs'
930
- * regionRouteKey('/blog/[slug]', {slug:'a'}) -> '/blog/a'
931
- * regionRouteKey('/(marketing)/about', {}) -> '/about'
932
- * regionRouteKey('/files/[...rest]', {rest:'a/b'}) -> '/files/a/b'
933
- * regionRouteKey('/shop/[[...slug]]', {}) -> '/shop'
934
- * regionRouteKey('/blog/[slug]', {slug:'a-->b'}) -> '/blog/a--%3Eb'
935
- *
936
- * @param {string} segmentPath Region segment pattern, e.g. '/blog/[slug]'.
937
- * @param {Record<string,string>} params Resolved route params (values are
938
- * strings; a catch-all value is already slash-joined, e.g. 'a/b/c').
939
- * @returns {string}
2
+ * @module @webjsdev/server/ssr
3
+ * Thin barrel re-exporting SSR render, document assembly, and head/metadata sub-modules.
940
4
  */
941
- function regionRouteKey(segmentPath, params) {
942
- const p = params || {};
943
- /** Encode one substituted value, per slash-piece (catch-alls keep their
944
- * literal separators; each piece is comment-safe + delimiter-safe). */
945
- const enc = (v) => String(v).split('/').map((s) => encodeURIComponent(s)).join('/');
946
- /** A STATIC piece is a folder name emitted as-is EXCEPT the boundary/header
947
- * delimiter characters (':' would mis-split the `segment:route-key` parses,
948
- * ',' the have-entry list, '%' the decode); percent-encode just those so
949
- * the no-delimiter invariant holds for every emitted route-key while
950
- * normal folder names stay byte-identical. */
951
- const encStatic = (v) => v.replace(/[%:,]/g, (c) => encodeURIComponent(c));
952
- const out = [];
953
- for (const seg of segmentPath.split('/')) {
954
- if (!seg) continue;
955
- // Route group `(marketing)`: scopes layouts, absent from the URL.
956
- if (seg.startsWith('(') && seg.endsWith(')')) continue;
957
- // Optional catch-all `[[...name]]` / catch-all `[...name]`: the value is
958
- // the already-slash-joined tail (may be '' for an empty optional one).
959
- if (seg.startsWith('[[...') && seg.endsWith(']]')) {
960
- const v = p[seg.slice(5, -2)];
961
- if (v) out.push(enc(v));
962
- continue;
963
- }
964
- if (seg.startsWith('[...') && seg.endsWith(']')) {
965
- const v = p[seg.slice(4, -1)];
966
- if (v) out.push(enc(v));
967
- continue;
968
- }
969
- // Dynamic `[name]`.
970
- if (seg.startsWith('[') && seg.endsWith(']')) {
971
- out.push(enc(p[seg.slice(1, -1)] ?? ''));
972
- continue;
973
- }
974
- out.push(encStatic(seg));
975
- }
976
- return '/' + out.join('/');
977
- }
978
5
 
979
- /**
980
- * Wrap a TemplateResult-or-renderable child in a KEYED partial-nav boundary
981
- * comment pair (#1015). Returns a synthetic TemplateResult: server
982
- * `renderToString` walks `.strings` and `.values` exactly the same way as for
983
- * the `html` tag.
984
- *
985
- * Format:
986
- * open <!--wj:children:<segment>:<route-key>-->
987
- * close <!--/wj:children:<segment>-->
988
- *
989
- * The close carries the SEGMENT, so client-side pairing is deterministic
990
- * id-matching instead of the LIFO reconstruction that produced the #994 class
991
- * of silent mispair. The open additionally carries the resolved ROUTE-KEY
992
- * (param values encoded, see `regionRouteKey`), which drives the client's
993
- * two-tier swap decision: key changed -> wholesale replace (Next remount
994
- * parity), key same -> bounded morph (hydrated state preserved). A comment is
995
- * invisible to structural CSS (`>`, `:nth-child`, flex/grid item enumeration),
996
- * so unlike a wrapper element this boundary is layout-free by construction.
997
- *
998
- * The marker text lives in `strings` (static template parts), NOT in
999
- * `values`: `values` get HTML-escaped on render, comments wouldn't survive.
1000
- *
1001
- * @param {unknown} tree A TemplateResult, string, array, or Promise.
1002
- * @param {string} segmentPath The boundary's segment pattern (pairing id).
1003
- * @param {Record<string,string>} params Resolved route params for the key.
1004
- * @returns {{ _$webjs: 'template', strings: string[], values: unknown[] }}
1005
- */
1006
- function wrapWithChildrenMarker(tree, segmentPath, params) {
1007
- const routeKey = regionRouteKey(segmentPath, params);
1008
- return {
1009
- _$webjs: 'template',
1010
- strings: [
1011
- `<!--wj:children:${segmentPath}:${routeKey}-->`,
1012
- `<!--/wj:children:${segmentPath}-->`,
1013
- ],
1014
- values: [tree],
1015
- };
1016
- }
1017
-
1018
- // Re-export for unit testing.
1019
6
  export {
1020
- layoutSegmentPath as _layoutSegmentPath,
1021
- pageSegmentPath as _pageSegmentPath,
1022
- regionRouteKey as _regionRouteKey,
1023
- wrapWithChildrenMarker as _wrapWithChildrenMarker,
1024
- };
1025
-
1026
- /**
1027
- * @param {import('./router.js').PageRoute} route
1028
- * @param {Record<string,unknown>} ctx
1029
- * @param {boolean} dev
1030
- */
1031
- async function collectMetadata(route, ctx, dev) {
1032
- /** @type {Record<string, any>} */
1033
- let meta = {};
1034
- // Carry the title template forward across layers. Once an outer layout
1035
- // sets `title: { template, default }`, every deeper layer that supplies
1036
- // a plain string title gets it transformed via the template: matching
1037
- // Next.js App Router semantics.
1038
- /** @type {string | null} */
1039
- let titleTemplate = null;
1040
- for (const file of route.metadataFiles) {
1041
- try {
1042
- const mod = await loadModule(file, dev);
1043
- let m = null;
1044
- if (typeof mod.generateMetadata === 'function') {
1045
- m = await mod.generateMetadata(ctx);
1046
- } else if (mod.metadata) {
1047
- m = mod.metadata;
1048
- }
1049
- // Next.js 14+ split `viewport` out of metadata into its own export
1050
- // (with `themeColor`, `colorScheme`). We support both: the new
1051
- // `export const viewport = {…}` shape merges into metadata.viewport
1052
- // as a string at emit time, and existing `metadata.viewport` keeps
1053
- // working. Likewise for `themeColor` and `colorScheme`.
1054
- let vp = null;
1055
- if (typeof mod.generateViewport === 'function') {
1056
- vp = await mod.generateViewport(ctx);
1057
- } else if (mod.viewport) {
1058
- vp = mod.viewport;
1059
- }
1060
- if (vp && typeof vp === 'object') {
1061
- m = { ...(m || {}), _viewport: { ...(m && m._viewport), ...vp } };
1062
- // Allow `themeColor` / `colorScheme` to live on the viewport export.
1063
- if (typeof vp.themeColor === 'string' && !(m && m.themeColor)) {
1064
- m.themeColor = vp.themeColor;
1065
- }
1066
- if (typeof vp.colorScheme === 'string') m.colorScheme = vp.colorScheme;
1067
- }
1068
- if (!m || typeof m !== 'object') continue;
1069
- // Pre-resolve the title for this layer using the inherited template.
1070
- const resolved = { ...m };
1071
- if (m.title !== undefined) {
1072
- const t = m.title;
1073
- if (typeof t === 'string') {
1074
- resolved.title = titleTemplate ? titleTemplate.replace('%s', t) : t;
1075
- } else if (t && typeof t === 'object') {
1076
- // { template, default, absolute }: `absolute` overrides everything;
1077
- // `template` is captured for deeper layers; `default` is the value
1078
- // used when no deeper layer supplies a plain title string.
1079
- if (typeof t.template === 'string') titleTemplate = t.template;
1080
- if (typeof t.absolute === 'string') {
1081
- resolved.title = t.absolute;
1082
- // `absolute` does NOT clear the template: Next.js propagates
1083
- // it for deeper segments below, but the *current* segment is
1084
- // rendered absolute.
1085
- } else if (typeof t.default === 'string') {
1086
- resolved.title = t.default;
1087
- } else {
1088
- delete resolved.title;
1089
- }
1090
- }
1091
- }
1092
- meta = { ...meta, ...resolved };
1093
- } catch {
1094
- // ignore: metadata collection never fails the request
1095
- }
1096
- }
1097
- return meta;
1098
- }
1099
-
1100
- /**
1101
- * Extract leading `<script>`, `<style>`, and `<link>` tags from the body
1102
- * HTML and hoist them into `<head>`. Ensures blocking scripts (e.g.
1103
- * Tailwind runtime, theme bootstrap) run before any body content renders,
1104
- * and that `<link rel="icon">` / `<link rel="stylesheet">` land where
1105
- * browsers reliably honour them.
1106
- *
1107
- * @param {string} headHtml
1108
- * @param {string} bodyHtml
1109
- * @returns {{ head: string, body: string }}
1110
- */
1111
- function hoistHeadTags(headHtml, bodyHtml) {
1112
- // Shares the leading-run scanner (comment-skipping included) with the
1113
- // streaming path so both hoist identically. See collectHoistedHeadTags.
1114
- const { tags: hoisted, body: remaining } = collectHoistedHeadTags(bodyHtml);
1115
- if (!hoisted.length) return { head: headHtml, body: bodyHtml };
1116
- const newHead = headHtml.replace('</head>', hoisted.join('\n') + '\n</head>');
1117
- return { head: newHead, body: remaining };
1118
- }
1119
-
1120
- // Internal helper re-exported for unit testing.
1121
- export { hoistHeadTags as _hoistHeadTags };
7
+ ssrPage,
8
+ ssrNotFound,
9
+ ssrForbidden,
10
+ ssrUnauthorized,
11
+ privateFragment,
12
+ } from './ssr/render.js';
1122
13
 
1123
- /**
1124
- * Detect a user-supplied <!doctype><html>…</html> shell at the top of
1125
- * `body`. Returns the parsed parts when present; otherwise null.
1126
- *
1127
- * The framework owns the shell by default: it auto-emits
1128
- * `<!doctype><html lang="en"><head>…</head><body>` around every page.
1129
- * But the *root layout* (only) may write its own shell to set
1130
- * `<html lang>`, `<html dir>`, `<html data-*>`, `<body class>`, etc.
1131
- * When that happens we keep the user's shell verbatim and splice the
1132
- * framework's required `<head>` tags (importmap, modulepreload, title,
1133
- * meta, og/twitter) into the user's `<head>`. Non-root layouts that
1134
- * try this would produce nested-shell garbage; `webjs check` flags
1135
- * them via the `shell-in-non-root-layout` rule.
1136
- *
1137
- * @param {string} body
1138
- * @returns {{
1139
- * htmlAttrs: string,
1140
- * headAttrs: string,
1141
- * userHead: string,
1142
- * bodyAttrs: string,
1143
- * userBody: string,
1144
- * } | null}
1145
- */
1146
- function extractUserShell(body) {
1147
- // Tolerant: allow optional whitespace, optional <!doctype>, then <html ...>.
1148
- // Capture html attributes (anything between <html and >).
1149
- const htmlOpen = /^\s*(?:<!doctype[^>]*>\s*)?<html\b([^>]*)>\s*([\s\S]*)<\/html>\s*$/i;
1150
- const m = body.match(htmlOpen);
1151
- if (!m) return null;
1152
- const htmlAttrs = m[1] || '';
1153
- const shellInner = m[2];
1154
-
1155
- // <head> is optional inside the user's shell: if missing, the
1156
- // framework's head content stands alone. Same for <body>.
1157
- const headRe = /<head\b([^>]*)>([\s\S]*?)<\/head>/i;
1158
- const bodyRe = /<body\b([^>]*)>([\s\S]*?)<\/body>/i;
1159
- const headMatch = shellInner.match(headRe);
1160
- const bodyMatch = shellInner.match(bodyRe);
1161
-
1162
- return {
1163
- htmlAttrs,
1164
- headAttrs: headMatch ? (headMatch[1] || '') : '',
1165
- userHead: headMatch ? headMatch[2] : '',
1166
- bodyAttrs: bodyMatch ? (bodyMatch[1] || '') : '',
1167
- // If the user omitted <body>, treat everything outside <head>…</head>
1168
- // as their body content.
1169
- userBody: bodyMatch
1170
- ? bodyMatch[2]
1171
- : (headMatch ? shellInner.replace(headMatch[0], '') : shellInner).trim(),
1172
- };
1173
- }
1174
-
1175
- // Re-export for unit testing.
1176
- export { extractUserShell as _extractUserShell };
1177
-
1178
- /**
1179
- * Inner-only variant of wrapHead: returns just the meta/title/link/script
1180
- * tags that should live INSIDE <head>, without the surrounding
1181
- * <!doctype><html><head>…</head><body> shell. Used to splice into a
1182
- * user-provided shell from `extractUserShell()`.
1183
- *
1184
- * @param {Parameters<typeof wrapHead>[0]} opts
1185
- * @returns {string}
1186
- */
1187
- function buildHeadInner(opts) {
1188
- // Pull the full prefix and strip the <!doctype><html><head> opening + the
1189
- // closing </head><body> so we're left with the inner tags only. Keeps a
1190
- // single source of truth for what goes in <head>.
1191
- const full = wrapHead({ ...opts, streaming: false });
1192
- const start = full.indexOf('<head>');
1193
- const end = full.indexOf('</head>');
1194
- if (start === -1 || end === -1) return '';
1195
- // +'<head>'.length to skip past the opening tag itself.
1196
- return full.slice(start + '<head>'.length, end).trim();
1197
- }
1198
-
1199
- /**
1200
- * Build the prefix/body/closer triple for a rendered layout's body. Single
1201
- * source of truth used by both the buffered (`wrapInDocument`) and
1202
- * streaming (`streamingHtmlResponse`) paths.
1203
- *
1204
- * If `body` starts with a user-supplied <!doctype><html>…</html> shell:
1205
- * - `prefix` opens with the user's `<!doctype><html><head>` (with their
1206
- * attributes), splices the framework's required tags + the user's
1207
- * own head content + auto-hoisted body-positioned head-bound tags,
1208
- * then closes `</head>` and opens `<body>` (with user attributes).
1209
- * - `streamBody` is the user's body content (head-hoist already stripped).
1210
- * - `closer` is `</body></html>`.
1211
- *
1212
- * Otherwise (no user shell): use the framework's auto-emitted shell.
1213
- *
1214
- * @param {string} body
1215
- * @param {Parameters<typeof wrapHead>[0]} wrapOpts
1216
- * @returns {{ prefix: string, streamBody: string, closer: string }}
1217
- */
1218
- function buildDocumentParts(body, wrapOpts) {
1219
- const shell = extractUserShell(body);
1220
- if (shell) {
1221
- const headInner = buildHeadInner(wrapOpts);
1222
- const hoist = collectHoistedHeadTags(shell.userBody);
1223
- const composedHead = [headInner, shell.userHead.trim(), hoist.tags.join('\n')]
1224
- .filter(Boolean)
1225
- .join('\n');
1226
- const prefix =
1227
- `<!doctype html>\n<html${shell.htmlAttrs}>\n<head${shell.headAttrs}>\n` +
1228
- composedHead +
1229
- `\n</head>\n<body${shell.bodyAttrs}>\n`;
1230
- return { prefix, streamBody: hoist.body, closer: `\n</body>\n</html>` };
1231
- }
1232
- // No user shell: framework owns the wrapper.
1233
- const headHtml = wrapHead(wrapOpts);
1234
- const { head, body: bodyOut } = hoistHeadTags(headHtml, body);
1235
- return { prefix: head, streamBody: bodyOut, closer: `\n</body>\n</html>` };
1236
- }
14
+ export { setClientRouterEnabled } from './ssr/client-router-flag.js';
1237
15
 
1238
- // Re-export for unit testing.
1239
- export { buildDocumentParts as _buildDocumentParts };
16
+ export { publicEnvShim } from './ssr/env-shim.js';
1240
17
 
1241
- /**
1242
- * Buffered wrapper (error / not-found paths; no Suspense streaming).
1243
- *
1244
- * @param {string} body
1245
- * @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean }} opts
1246
- */
1247
- function wrapInDocument(body, opts) {
1248
- const { prefix, streamBody, closer } = buildDocumentParts(body, { ...opts, streaming: false });
1249
- return prefix + streamBody + closer;
1250
- }
1251
-
1252
- /**
1253
- * Strip leading head-bound tags (<script>, <style>, <link>) from a body
1254
- * string. Returns the collected tags + the remaining body. Mirrors what
1255
- * `hoistHeadTags` does but takes/returns plain strings (no head input)
1256
- * so it can be used with a user-provided <head>.
1257
- *
1258
- * @param {string} bodyHtml
1259
- * @returns {{ tags: string[], body: string }}
1260
- */
1261
- function collectHoistedHeadTags(bodyHtml) {
1262
- const tags = [];
1263
- // <script>…</script> and <style>…</style> are paired; <link …> and <meta …>
1264
- // are void. A plain HTML comment (<!-- … -->) is consumed but NOT hoisted, so
1265
- // a comment interleaved with head-bound tags (e.g. "<!-- Self-hosted fonts -->"
1266
- // between a favicon <link> and the stylesheet <link>) does not terminate
1267
- // the leading run and strand the stylesheet in <body>, which caused FOUC
1268
- // because a <link rel="stylesheet"> in <body> is not reliably
1269
- // render-blocking (#406). A <meta …> is treated the same way for the same
1270
- // reason: a <meta name="color-scheme"> between the theme <script> and the
1271
- // stylesheet <link> would otherwise terminate the run and strand the
1272
- // stylesheet AND a <link rel="icon"> in <body> (a favicon in <body> is
1273
- // ignored by browsers, so the icon silently never renders). The `(?!/?wj:)`
1274
- // guard exempts client-router markers (<!--wj:children:…-->,
1275
- // <!--/wj:children-->) so a layout that renders children directly after its
1276
- // head tags still terminates the run there rather than swallowing the marker.
1277
- // The void-tag matchers are QUOTE-AWARE ((?:[^>"']|"[^"]*"|'[^']*')*): a `>`
1278
- // inside a quoted attribute value (a description meta like
1279
- // content="Guides > API") must not terminate the tag early, which would hoist
1280
- // a truncated tag, leak the remainder as visible body text, and strand the
1281
- // stylesheet (the #406 FOUC this hoist exists to prevent).
1282
- const re =
1283
- /^\s*(<!--(?!\/?wj:)[\s\S]*?-->|<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b(?:[^>"']|"[^"]*"|'[^']*')*>|<meta\b(?:[^>"']|"[^"]*"|'[^']*')*>)/i;
1284
- let remaining = bodyHtml;
1285
- // `body` only advances to just-past the LAST hoisted head tag. Comments
1286
- // are scanned through (so they don't terminate the run) but a comment that
1287
- // trails the final head tag stays in the body rather than being dropped.
1288
- let body = bodyHtml;
1289
- let m;
1290
- while ((m = re.exec(remaining)) !== null) {
1291
- const token = m[1];
1292
- remaining = remaining.slice(m[0].length);
1293
- if (!token.startsWith('<!--')) {
1294
- tags.push(token);
1295
- body = remaining;
1296
- }
1297
- }
1298
- return { tags, body };
1299
- }
1300
-
1301
- /**
1302
- * Produce the `<!doctype…><body>` prefix. If `streaming` is true, injects
1303
- * the tiny client-side resolver that swaps Suspense fallback nodes for
1304
- * streamed-in real content.
1305
- *
1306
- * Also emits `<link rel="modulepreload">` for every component that rendered
1307
- * (breaks the ES-module waterfall without a bundler) and any user-declared
1308
- * `metadata.preload` entries.
1309
- *
1310
- * @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean, streaming: boolean, preloads?: string[], lazyComponents?: Record<string, string>, nonce?: string }} opts
1311
- */
1312
- /**
1313
- * Build an inline `<script>` that exposes server-side environment
1314
- * variables to the browser via `window.process.env`. Two purposes:
1315
- *
1316
- * 1. App code can read `process.env.WEBJS_PUBLIC_X` directly in
1317
- * components (counterpart of Next.js's `NEXT_PUBLIC_` prefix,
1318
- * but without a build step).
1319
- * 2. `process.env.NODE_ENV` is defined for vendor bundles that
1320
- * probe it (lit, react, etc.) so they do not throw
1321
- * ReferenceError in the browser.
1322
- *
1323
- * Only variables whose name starts with `WEBJS_PUBLIC_` are exposed.
1324
- * Other server env vars stay on the server.
1325
- *
1326
- * `</...` sequences in stringified values are escaped so an env value
1327
- * containing `</script>` cannot terminate the inline script tag.
1328
- *
1329
- * @param {{ dev: boolean, nonce?: string, env?: Record<string, string|undefined> }} opts
1330
- * `env` defaults to `process.env`. Override for tests.
1331
- * @returns {string}
1332
- */
1333
- export function publicEnvShim(opts) {
1334
- const source = opts.env || process.env;
1335
- /** @type {Record<string, string>} */
1336
- const env = {};
1337
- for (const [k, v] of Object.entries(source)) {
1338
- if (k.startsWith('WEBJS_PUBLIC_') && v !== undefined) {
1339
- env[k] = String(v);
1340
- }
1341
- }
1342
- env.NODE_ENV = opts.dev ? 'development' : 'production';
1343
- const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
1344
- return `<script${n}>`
1345
- + `window.process=window.process||{};`
1346
- + `window.process.env=Object.assign(window.process.env||{},${jsonForScriptTag(env)});`
1347
- + `</script>`;
1348
- }
1349
-
1350
- // Client-router opt-out (#629). Default ON (the framework's automatic-nav
1351
- // thesis): the router auto-enables in the browser when `@webjsdev/core` loads.
1352
- // `webjs.clientRouter: false` flips this off app-wide; `dev.js` reads the
1353
- // config at boot / each rebuild and calls `setClientRouterEnabled`, and
1354
- // `wrapHead` then emits a `window.__WEBJS_CLIENT_ROUTER__=false` flag BEFORE
1355
- // the deferred boot module so the bundle's module-end auto-enable skips. A
1356
- // module-level switch (mirrors setBasePath / setElisionFingerprint) so no opt
1357
- // has to thread through every render path; default true keeps every existing
1358
- // app and test byte-identical.
1359
- let _clientRouterEnabled = true;
1360
- export function setClientRouterEnabled(enabled) { _clientRouterEnabled = enabled !== false; }
1361
- function clientRouterEnabled() { return _clientRouterEnabled; }
1362
-
1363
- // Which icon metadata ROUTES the app has (`app/icon.*`, `app/apple-icon.*`).
1364
- // Set at boot and on each route rebuild from the route table, the same shape
1365
- // as setClientRouterEnabled above, so no opt has to thread through every
1366
- // render path. Empty by default, which keeps an app that declares its icons
1367
- // (or has neither route) byte-identical.
1368
- /** @type {{ icon: boolean, apple: boolean }} */
1369
- let _metadataIconRoutes = { icon: false, apple: false };
1370
-
1371
- /**
1372
- * Record the icon metadata routes the app defines.
1373
- *
1374
- * @param {Iterable<{ stem: string }> | null | undefined} metadataRoutes
1375
- * The route table's `metadataRoutes`, or nullish to clear.
1376
- */
1377
- export function setMetadataIconRoutes(metadataRoutes) {
1378
- const stems = new Set();
1379
- for (const r of metadataRoutes || []) if (r && r.stem) stems.add(r.stem);
1380
- _metadataIconRoutes = { icon: stems.has('icon'), apple: stems.has('apple-icon') };
1381
- }
1382
-
1383
- /**
1384
- * The implicit `metadata.icons` an app's icon routes stand for, or null when
1385
- * it has none. Base-path prefixed, because that is where the routes are
1386
- * SERVED: the listener strips the base path before matching, so under
1387
- * `webjs.basePath` the route answers at `<basePath>/icon`. A user-authored
1388
- * `icons` URL is deliberately left alone (it may be cross-origin, and the
1389
- * author writes the path they mean), so only these framework-emitted ones
1390
- * are prefixed.
1391
- *
1392
- * No `type` or `sizes` is emitted. A metadata route picks its own content
1393
- * type at request time, which is the reason to use one, so declaring a type
1394
- * here could contradict the bytes; and `sizes` is unknowable without reading
1395
- * the response. Both are optional in HTML, and a browser sniffs the served
1396
- * content type.
1397
- *
1398
- * @returns {{ icon?: string, apple?: string } | null}
1399
- */
1400
- function autoMetadataRouteIcons() {
1401
- const { icon, apple } = _metadataIconRoutes;
1402
- if (!icon && !apple) return null;
1403
- const bp = basePath();
1404
- /** @type {{ icon?: string, apple?: string }} */
1405
- const out = {};
1406
- if (icon) out.icon = withBasePath('/icon', bp);
1407
- if (apple) out.apple = withBasePath('/apple-icon', bp);
1408
- return out;
1409
- }
1410
-
1411
- function wrapHead(opts) {
1412
- // CSP nonce: if provided, all inline <script> tags get nonce="…" so they
1413
- // pass strict Content-Security-Policy headers. The nonce is extracted from
1414
- // the request's CSP header by the caller.
1415
- const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
1416
-
1417
- // Sub-path deployment (issue #256): the boot script's per-route module
1418
- // specifiers and the dev reload `src` are framework-emitted same-origin
1419
- // absolute URLs, so prefix them with the base path (a no-op when empty).
1420
- // The lazy-loader import is a BARE specifier resolved through the importmap
1421
- // (whose target is already base-path-prefixed in importmap.js), so it is
1422
- // NOT prefixed here. The base path is the one set at boot via setBasePath
1423
- // (read from importmap.js's module state), the same value the importmap
1424
- // targets were prefixed with, so the boot specifiers and the map agree.
1425
- const bp = basePath();
1426
- // Content-hash asset URLs (issue #243): after base-path-prefixing, append
1427
- // `?v=<hash>` to a same-origin module specifier for immutable caching. A
1428
- // no-op in dev (so the boot script is byte-identical) and for a bare/
1429
- // cross-origin specifier; same compose order as the importmap targets
1430
- // (basePath then `?v`).
1431
- const fp = (u) => withAssetHash(withBasePath(u, bp), bp);
1432
- const imports = opts.moduleUrls
1433
- .map((u) => `import ${jsonForScriptTag(fp(u))};`)
1434
- .join('\n');
1435
- const rawLazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
1436
- ? opts.lazyComponents
1437
- : null;
1438
- // The lazy map's values are same-origin module URLs `observeLazy` will
1439
- // dynamically import, so prefix them with the base path too (no-op when
1440
- // empty).
1441
- // The lazy map's values are same-origin module URLs `observeLazy` will
1442
- // dynamically import, so base-path-prefix AND content-hash them (#243), the
1443
- // same as the eager boot specifiers. `fp` is a pure no-op in dev and at the
1444
- // root mount with fingerprinting off, so the mapped map equals the raw one
1445
- // byte-for-byte there; only prod fingerprinting / a sub-path mount changes it.
1446
- const lazyEntries = rawLazyEntries
1447
- ? Object.fromEntries(
1448
- Object.entries(rawLazyEntries).map(([tag, u]) => [tag, fp(u)]),
1449
- )
1450
- : rawLazyEntries;
1451
- const lazyBoot = lazyEntries
1452
- ? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${jsonForScriptTag(lazyEntries)});`
1453
- : '';
1454
- const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
1455
- const reload = opts.dev
1456
- ? `<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
1457
- : '';
1458
- const suspenseBoot = opts.streaming
1459
- ? `<script${n}>(function(){` +
1460
- `function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
1461
- `var b=document.getElementById(id);if(t&&b){b.replaceWith(t.content.cloneNode(true));t.remove();}}` +
1462
- `window.__webjsResolve=r;` +
1463
- `if(typeof MutationObserver!=='undefined'){` +
1464
- `new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){` +
1465
- `if(n.nodeType===1&&n.tagName==='TEMPLATE'&&n.dataset.webjsResolve){r(n.dataset.webjsResolve);}` +
1466
- `});});}).observe(document.documentElement,{childList:true,subtree:true});}` +
1467
- `})()</script>`
1468
- : '';
1469
-
1470
- const m = opts.metadata || {};
1471
- const metaTags = [];
1472
- // linkTags is populated by both the metadata emission below (icons,
1473
- // alternates, archives, etc.) AND by the preload block further down.
1474
- // Hoist the declaration so the metadata block can push into it.
1475
- const linkTags = [];
1476
- // scriptTags collects JSON-LD structured-data blocks (see m.jsonLd below).
1477
- const scriptTags = [];
1478
-
1479
- // Tiny URL resolver against metadataBase. If metadataBase is set and a
1480
- // value looks like a relative URL (no scheme, no `//` prefix), resolve
1481
- // it. Otherwise return as-is. Used by og:image, twitter:image,
1482
- // alternates.canonical / languages / media.
1483
- const base = typeof m.metadataBase === 'string' ? m.metadataBase : '';
1484
- /** @param {unknown} v */
1485
- const absUrl = (v) => {
1486
- const s = String(v);
1487
- if (!base) return s;
1488
- if (/^https?:\/\//i.test(s) || s.startsWith('//') || s.startsWith('data:')) return s;
1489
- try {
1490
- return new URL(s, base).toString();
1491
- } catch {
1492
- return s;
1493
- }
1494
- };
1495
-
1496
- if (m.description) metaTags.push(`<meta name="description" content="${escapeAttr(m.description)}">`);
1497
-
1498
- // viewport: support string form (legacy), `metadata.viewport` object form,
1499
- // and the new Next.js 14+ `export const viewport = { … }` shape captured
1500
- // into `_viewport` by collectMetadata.
1501
- let viewportStr = '';
1502
- if (typeof m.viewport === 'string') {
1503
- viewportStr = m.viewport;
1504
- } else if (m.viewport && typeof m.viewport === 'object') {
1505
- viewportStr = serializeViewport(m.viewport);
1506
- } else if (m._viewport && typeof m._viewport === 'object') {
1507
- viewportStr = serializeViewport(m._viewport);
1508
- }
1509
- metaTags.push(`<meta name="viewport" content="${escapeAttr(viewportStr || 'width=device-width,initial-scale=1')}">`);
1510
-
1511
- if (m.themeColor) metaTags.push(`<meta name="theme-color" content="${escapeAttr(m.themeColor)}">`);
1512
- if (m.colorScheme) metaTags.push(`<meta name="color-scheme" content="${escapeAttr(m.colorScheme)}">`);
1513
-
1514
- // ---- i18n + SEO essentials ----
1515
-
1516
- // robots: { index, follow, googleBot, etc. }
1517
- if (m.robots) {
1518
- if (typeof m.robots === 'string') {
1519
- metaTags.push(`<meta name="robots" content="${escapeAttr(m.robots)}">`);
1520
- } else if (typeof m.robots === 'object') {
1521
- const parts = [];
1522
- if (m.robots.index === false) parts.push('noindex');
1523
- else if (m.robots.index === true) parts.push('index');
1524
- if (m.robots.follow === false) parts.push('nofollow');
1525
- else if (m.robots.follow === true) parts.push('follow');
1526
- if (m.robots.noarchive) parts.push('noarchive');
1527
- if (m.robots.nosnippet) parts.push('nosnippet');
1528
- if (m.robots.noimageindex) parts.push('noimageindex');
1529
- if (parts.length) {
1530
- metaTags.push(`<meta name="robots" content="${escapeAttr(parts.join(', '))}">`);
1531
- }
1532
- if (typeof m.robots.googleBot === 'string') {
1533
- metaTags.push(`<meta name="googlebot" content="${escapeAttr(m.robots.googleBot)}">`);
1534
- }
1535
- }
1536
- }
1537
-
1538
- // keywords: string | string[]
1539
- if (m.keywords) {
1540
- const kws = Array.isArray(m.keywords) ? m.keywords.join(', ') : String(m.keywords);
1541
- if (kws) metaTags.push(`<meta name="keywords" content="${escapeAttr(kws)}">`);
1542
- }
1543
-
1544
- // authors: Array<{ name, url? }> | { name, url? } | string
1545
- if (m.authors) {
1546
- const list = Array.isArray(m.authors) ? m.authors : [m.authors];
1547
- for (const a of list) {
1548
- if (!a) continue;
1549
- const name = typeof a === 'string' ? a : a.name;
1550
- if (!name) continue;
1551
- metaTags.push(`<meta name="author" content="${escapeAttr(name)}">`);
1552
- if (typeof a === 'object' && a.url) {
1553
- metaTags.push(`<link rel="author" href="${escapeAttr(absUrl(a.url))}">`);
1554
- }
1555
- }
1556
- }
1557
-
1558
- // Singletons that map 1:1 to <meta name="…">.
1559
- for (const [field, metaName] of [
1560
- ['creator', 'creator'],
1561
- ['publisher', 'publisher'],
1562
- ['applicationName', 'application-name'],
1563
- ['generator', 'generator'],
1564
- ['referrer', 'referrer'],
1565
- ]) {
1566
- if (m[field]) {
1567
- metaTags.push(`<meta name="${metaName}" content="${escapeAttr(String(m[field]))}">`);
1568
- }
1569
- }
1570
-
1571
- // ---- Long-tail metadata (the Next.js "everything else") ----
1572
-
1573
- // appleWebApp: { capable, title, statusBarStyle, startupImage }
1574
- if (m.appleWebApp && typeof m.appleWebApp === 'object') {
1575
- if (m.appleWebApp.capable !== undefined) {
1576
- metaTags.push(
1577
- `<meta name="apple-mobile-web-app-capable" content="${m.appleWebApp.capable ? 'yes' : 'no'}">`,
1578
- );
1579
- }
1580
- if (m.appleWebApp.title) {
1581
- metaTags.push(`<meta name="apple-mobile-web-app-title" content="${escapeAttr(m.appleWebApp.title)}">`);
1582
- }
1583
- if (m.appleWebApp.statusBarStyle) {
1584
- metaTags.push(
1585
- `<meta name="apple-mobile-web-app-status-bar-style" content="${escapeAttr(m.appleWebApp.statusBarStyle)}">`,
1586
- );
1587
- }
1588
- // startupImage maps to <link rel="apple-touch-startup-image">.
1589
- if (m.appleWebApp.startupImage) {
1590
- const list = Array.isArray(m.appleWebApp.startupImage)
1591
- ? m.appleWebApp.startupImage
1592
- : [m.appleWebApp.startupImage];
1593
- for (const it of list) {
1594
- if (typeof it === 'string') {
1595
- linkTags.push(`<link rel="apple-touch-startup-image" href="${escapeAttr(absUrl(it))}">`);
1596
- } else if (it && it.url) {
1597
- const parts = [`rel="apple-touch-startup-image"`, `href="${escapeAttr(absUrl(it.url))}"`];
1598
- if (it.media) parts.push(`media="${escapeAttr(it.media)}"`);
1599
- linkTags.push(`<link ${parts.join(' ')}>`);
1600
- }
1601
- }
1602
- }
1603
- } else if (m.appleWebApp === true) {
1604
- metaTags.push(`<meta name="apple-mobile-web-app-capable" content="yes">`);
1605
- }
1606
-
1607
- // formatDetection: { telephone, address, email, date, … }. All booleans.
1608
- // Disabled detection types append "type=no" to the content string.
1609
- if (m.formatDetection && typeof m.formatDetection === 'object') {
1610
- const parts = [];
1611
- for (const [k, v] of Object.entries(m.formatDetection)) {
1612
- if (v === false) parts.push(`${k}=no`);
1613
- else if (v === true) parts.push(`${k}=yes`);
1614
- }
1615
- if (parts.length) {
1616
- metaTags.push(`<meta name="format-detection" content="${escapeAttr(parts.join(', '))}">`);
1617
- }
1618
- }
1619
-
1620
- // itunes: { appId, appArgument? }
1621
- if (m.itunes && typeof m.itunes === 'object' && m.itunes.appId) {
1622
- let content = `app-id=${m.itunes.appId}`;
1623
- if (m.itunes.appArgument) content += `, app-argument=${m.itunes.appArgument}`;
1624
- metaTags.push(`<meta name="apple-itunes-app" content="${escapeAttr(content)}">`);
1625
- }
1626
-
1627
- // Plain singleton string fields.
1628
- for (const [field, metaName] of [
1629
- ['category', 'category'],
1630
- ['classification', 'classification'],
1631
- ['abstract', 'abstract'],
1632
- ]) {
1633
- if (m[field]) metaTags.push(`<meta name="${metaName}" content="${escapeAttr(String(m[field]))}">`);
1634
- }
1635
-
1636
- // archives / assets / bookmarks: each is string | string[].
1637
- // Standard registered link relations.
1638
- for (const [field, rel] of [
1639
- ['archives', 'archives'],
1640
- ['assets', 'assets'],
1641
- ['bookmarks', 'bookmark'],
1642
- ]) {
1643
- if (m[field]) {
1644
- const list = Array.isArray(m[field]) ? m[field] : [m[field]];
1645
- for (const href of list) {
1646
- linkTags.push(`<link rel="${rel}" href="${escapeAttr(absUrl(href))}">`);
1647
- }
1648
- }
1649
- }
1650
-
1651
- // `other` is the typed escape hatch for any arbitrary <meta name="…">
1652
- // entries Next.js (or future webjs) doesn't ship as a typed field.
1653
- // Values can be string, number, or string[] (emits multiple meta tags).
1654
- if (m.other && typeof m.other === 'object') {
1655
- for (const [name, v] of Object.entries(m.other)) {
1656
- const list = Array.isArray(v) ? v : [v];
1657
- for (const item of list) {
1658
- if (item == null) continue;
1659
- metaTags.push(`<meta name="${escapeAttr(name)}" content="${escapeAttr(String(item))}">`);
1660
- }
1661
- }
1662
- }
1663
-
1664
- // verification: { google, yandex, yahoo, me }. Each is string OR string[].
1665
- // - google → <meta name="google-site-verification">
1666
- // - yandex → <meta name="yandex-verification">
1667
- // - yahoo → <meta name="y_key"> (Yahoo's unusual canonical name)
1668
- // - me → <meta name="me"> (IndieAuth / personal verification)
1669
- if (m.verification && typeof m.verification === 'object') {
1670
- const verifyKeys = {
1671
- google: 'google-site-verification',
1672
- yandex: 'yandex-verification',
1673
- yahoo: 'y_key',
1674
- me: 'me',
1675
- };
1676
- for (const [field, metaName] of Object.entries(verifyKeys)) {
1677
- const v = m.verification[field];
1678
- if (!v) continue;
1679
- const list = Array.isArray(v) ? v : [v];
1680
- for (const item of list) {
1681
- metaTags.push(`<meta name="${metaName}" content="${escapeAttr(String(item))}">`);
1682
- }
1683
- }
1684
- // `verification.other` allows arbitrary <meta name="…"> entries.
1685
- if (m.verification.other && typeof m.verification.other === 'object') {
1686
- for (const [name, v] of Object.entries(m.verification.other)) {
1687
- const list = Array.isArray(v) ? v : [v];
1688
- for (const item of list) {
1689
- metaTags.push(`<meta name="${escapeAttr(name)}" content="${escapeAttr(String(item))}">`);
1690
- }
1691
- }
1692
- }
1693
- }
1694
-
1695
- if (m.openGraph && typeof m.openGraph === 'object') {
1696
- for (const [k, v] of Object.entries(m.openGraph)) {
1697
- const out = k === 'image' || k === 'url' ? absUrl(v) : String(v);
1698
- metaTags.push(`<meta property="og:${escapeAttr(k)}" content="${escapeAttr(out)}">`);
1699
- }
1700
- }
1701
- // Twitter card tags. Twitter falls back to og:* when these are absent
1702
- // but won't upgrade to summary_large_image without an explicit
1703
- // twitter:card entry.
1704
- if (m.twitter && typeof m.twitter === 'object') {
1705
- for (const [k, v] of Object.entries(m.twitter)) {
1706
- const out = k === 'image' ? absUrl(v) : String(v);
1707
- metaTags.push(`<meta name="twitter:${escapeAttr(k)}" content="${escapeAttr(out)}">`);
1708
- }
1709
- }
1710
-
1711
- // JSON-LD structured data (schema.org). `m.jsonLd` is a single object
1712
- // OR an array of objects. The author owns the schema.org shape; the
1713
- // framework only serializes and HTML-safe-escapes each object into a
1714
- // `<script type="application/ld+json">` block. A single object emits
1715
- // ONE script; an array emits one script PER element.
1716
- //
1717
- // The block is a NON-EXECUTABLE data island (type application/ld+json),
1718
- // so CSP script-src does not gate it and it carries NO nonce. Adding one
1719
- // would wrongly imply it is executable script.
1720
- if (m.jsonLd != null) {
1721
- const list = Array.isArray(m.jsonLd) ? m.jsonLd : [m.jsonLd];
1722
- for (const obj of list) {
1723
- const tag = jsonLdScript(obj);
1724
- if (tag) scriptTags.push(tag);
1725
- }
1726
- }
1727
-
1728
- // Preload hints: page modules themselves + every discovered component
1729
- // module, then any custom `metadata.preload` entries (fonts, images, etc.)
1730
- // (linkTags array was declared earlier so the metadata block above can
1731
- // push icons / canonical / hreflang / archives / etc. into it.)
1732
- //
1733
- // Cross-origin URLs (vendor packages served from jspm.io etc.) MUST
1734
- // carry `crossorigin="anonymous"` on the preload link. Without it
1735
- // the browser either ignores the preload entirely or double-fetches
1736
- // (once for the preload as a non-CORS request, once for the actual
1737
- // module as a CORS request, defeating the optimization). Same-origin
1738
- // URLs get no attribute; adding `crossorigin=""` there would also
1739
- // double-fetch in some browsers because the preload becomes CORS
1740
- // but the import doesn't.
1741
- // CSP nonce on the preload link: under strict CSP (script-src
1742
- // 'nonce-...') the browser also gates modulepreload by the same
1743
- // policy. Without the attribute the preload is blocked and the
1744
- // import either falls back to a cold fetch or fails. Rails (via
1745
- // importmap-rails) applies nonce on every modulepreload tag for
1746
- // the same reason.
1747
- const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
1748
- // Core runtime modulepreload (#1118). Every module the boot imports pulls
1749
- // `@webjsdev/core`, but the boot script names only page and component URLs,
1750
- // so without this hint the browser discovers core only after one of those is
1751
- // fetched AND parsed: one full round trip into the load, which is exactly
1752
- // where the pre-boot click window lives on a cold, throttled connection.
1753
- //
1754
- // The href comes STRAIGHT from the importmap target (no `fp()` rewrite: the
1755
- // map's targets are already base-path-prefixed and content-hashed), so it is
1756
- // byte-identical to what the import resolves to. A differing href makes the
1757
- // browser treat the preload and the import as two resources and fetch core
1758
- // twice. That is why this copies the vendor loop below, not the module loop
1759
- // above. `vendorPreloadTargets` still excludes core deliberately; this hint
1760
- // is emitted from the head builder, not the vendor path.
1761
- //
1762
- // Gated on the boot actually shipping something. A fully elided page ships no
1763
- // boot module and must not be handed a preload for a runtime it never loads
1764
- // (#780), which also keeps it off `global-error.{js,ts}`, whose document is
1765
- // returned verbatim with no importmap and no boot script.
1766
- if (opts.moduleUrls.length || lazyEntries) {
1767
- const coreMap = buildImportMap();
1768
- const coreHref = coreMap.imports['@webjsdev/core'];
1769
- if (coreHref) {
1770
- const raw = coreMap.integrity ? coreMap.integrity[coreHref] : undefined;
1771
- const coreIntegrity = raw ? ` integrity="${escapeAttr(raw)}"` : '';
1772
- linkTags.push(
1773
- `<link rel="modulepreload" href="${escapeAttr(coreHref)}"` +
1774
- `${preloadCrossOriginAttr(coreHref)}${coreIntegrity}${noncePreload}>`,
1775
- );
1776
- }
1777
- }
1778
- // Sub-path deployment (issue #256): the modulepreload href is prefixed with
1779
- // the base path (a no-op when empty), but `crossorigin` / `integrity` are
1780
- // decided on the ORIGINAL url, so the integrity lookup still keys on the
1781
- // unprefixed map url and a cross-origin CDN url (never prefixed) keeps its
1782
- // crossorigin attribute.
1783
- // Content-hash (#243): the href additionally gets a `?v=<hash>` after the
1784
- // base-path prefix (a no-op in dev / for a cross-origin url), but
1785
- // `crossorigin` / `integrity` are still decided on the ORIGINAL url, so the
1786
- // integrity lookup keys on the unprefixed/unhashed map url and a cross-origin
1787
- // CDN url (never prefixed, never hashed) keeps its crossorigin attribute.
1788
- for (const url of opts.moduleUrls) {
1789
- linkTags.push(
1790
- `<link rel="modulepreload" href="${escapeAttr(fp(url))}"` +
1791
- `${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
1792
- );
1793
- }
1794
- for (const url of opts.preloads || []) {
1795
- linkTags.push(
1796
- `<link rel="modulepreload" href="${escapeAttr(fp(url))}"` +
1797
- `${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
1798
- );
1799
- }
1800
- // Vendor modulepreload (#754): the npm CDN dependencies the page reaches,
1801
- // hinted up front to flatten the cross-origin waterfall. The href comes
1802
- // straight from the importmap target (byte-identical, no `fp()` rewrite, so
1803
- // the browser does not double-fetch) and carries the importmap's `integrity`.
1804
- // `preloadCrossOriginAttr` adds `crossorigin` for a cross-origin CDN url (a
1805
- // same-origin pinned `/__webjs/vendor/*` url gets none). Deduped against the
1806
- // app module/component preloads already emitted above.
1807
- const emittedPreloadHrefs = new Set([
1808
- ...opts.moduleUrls.map((u) => fp(u)),
1809
- ...(opts.preloads || []).map((u) => fp(u)),
1810
- ]);
1811
- for (const v of opts.vendorPreloads || []) {
1812
- if (emittedPreloadHrefs.has(v.href)) continue;
1813
- emittedPreloadHrefs.add(v.href);
1814
- const integrity = v.integrity ? ` integrity="${escapeAttr(v.integrity)}"` : '';
1815
- linkTags.push(
1816
- `<link rel="modulepreload" href="${escapeAttr(v.href)}"` +
1817
- `${preloadCrossOriginAttr(v.href)}${integrity}${noncePreload}>`,
1818
- );
1819
- }
1820
- if (Array.isArray(m.preload)) {
1821
- for (const p of m.preload) {
1822
- if (!p || !p.href) continue;
1823
- const attrs = Object.entries(p)
1824
- .map(([k, v]) => `${k}="${escapeAttr(String(v))}"`)
1825
- .join(' ');
1826
- linkTags.push(`<link rel="preload" ${attrs}>`);
1827
- }
1828
- }
1829
-
1830
- // preconnect / dns-prefetch hints (issue #243). `metadata.preconnect` and
1831
- // `metadata.dnsPrefetch` each take a URL string, `{ url, crossorigin? }`, or
1832
- // an array of those. A preconnect warms DNS + TLS + TCP; dns-prefetch only
1833
- // resolves DNS (no crossorigin). The framework ALSO auto-emits ONE preconnect
1834
- // to the resolved vendor CDN origin for an unpinned cross-origin app, so the
1835
- // browser warms that connection before the importmap resolves. The author's
1836
- // declared origins are tracked so the auto one is not a duplicate.
1837
- /** @type {Set<string>} the origins the author already declared a preconnect to */
1838
- const declaredPreconnectOrigins = new Set();
1839
- /** @param {unknown} hint @returns {{ url: string, crossorigin?: string|boolean } | null} */
1840
- const normalizeHint = (hint) => {
1841
- if (typeof hint === 'string') return hint ? { url: hint } : null;
1842
- if (hint && typeof hint === 'object' && typeof (/** @type {any} */ (hint).url) === 'string') {
1843
- return /** @type {any} */ (hint);
1844
- }
1845
- return null;
1846
- };
1847
- /** @param {unknown} value @returns {Array<{ url: string, crossorigin?: string|boolean }>} */
1848
- const toHints = (value) => {
1849
- if (value == null) return [];
1850
- const list = Array.isArray(value) ? value : [value];
1851
- const out = [];
1852
- for (const h of list) {
1853
- const n = normalizeHint(h);
1854
- if (n) out.push(n);
1855
- }
1856
- return out;
1857
- };
1858
- /** @param {string|boolean|undefined} co @returns {string} */
1859
- const crossoriginAttr = (co) => {
1860
- if (co === undefined || co === false) return '';
1861
- if (co === true || co === '') return ' crossorigin';
1862
- return ` crossorigin="${escapeAttr(String(co))}"`;
1863
- };
1864
- for (const h of toHints(m.preconnect)) {
1865
- try { declaredPreconnectOrigins.add(new URL(h.url).origin); } catch { /* relative / opaque; track by raw href instead */ declaredPreconnectOrigins.add(h.url); }
1866
- linkTags.push(`<link rel="preconnect" href="${escapeAttr(h.url)}"${crossoriginAttr(h.crossorigin)}>`);
1867
- }
1868
- for (const h of toHints(m.dnsPrefetch)) {
1869
- // dns-prefetch never carries crossorigin (it only resolves DNS).
1870
- linkTags.push(`<link rel="dns-prefetch" href="${escapeAttr(h.url)}">`);
1871
- }
1872
- // Auto vendor preconnect: warm the cross-origin vendor CDN connection for an
1873
- // unpinned app. Deduped against an author-declared preconnect to the same
1874
- // origin; emits none for a same-origin pinned app or one with no cross-origin
1875
- // vendors (vendorPreconnectOrigins returns []). crossorigin is required (the
1876
- // importmap fetches the module as a CORS request).
1877
- for (const origin of vendorPreconnectOrigins()) {
1878
- if (declaredPreconnectOrigins.has(origin)) continue;
1879
- linkTags.push(`<link rel="preconnect" href="${escapeAttr(origin)}" crossorigin>`);
1880
- }
1881
-
1882
- // icons: { icon, apple, shortcut, other }. Each entry can be a string
1883
- // (URL), an object { url, sizes?, type? }, or an array of those.
1884
- // - icon → <link rel="icon">
1885
- // - apple → <link rel="apple-touch-icon">
1886
- // - shortcut→ <link rel="shortcut icon">
1887
- // - other → <link rel="…" href="…"> using the entry's `rel` field
1888
- //
1889
- // With no `icons` declared, an `app/icon.*` / `app/apple-icon.*` metadata
1890
- // ROUTE is linked automatically (Next parity). Those routes served their
1891
- // bytes and nothing referenced them before, so writing the file that every
1892
- // other framework treats as "this is my favicon" produced a blank tab and no
1893
- // diagnostic. A declared `icons` SUPPRESSES the routes rather than merging
1894
- // with them, which is also what Next does: it merges static icon files only
1895
- // when the resolved metadata has no `icons` of its own. Suppressing matters
1896
- // here because the file is frequently a placeholder an app has outgrown, and
1897
- // an author who names their icons has said which ones they want.
1898
- const declaredOrRouteIcons = m.icons || autoMetadataRouteIcons();
1899
- if (declaredOrRouteIcons) {
1900
- const buckets = typeof declaredOrRouteIcons === 'string' || Array.isArray(declaredOrRouteIcons)
1901
- ? { icon: declaredOrRouteIcons }
1902
- : declaredOrRouteIcons;
1903
- /** @param {string} rel @param {unknown} entry */
1904
- const pushIcon = (rel, entry) => {
1905
- if (!entry) return;
1906
- const items = Array.isArray(entry) ? entry : [entry];
1907
- for (const it of items) {
1908
- if (!it) continue;
1909
- if (typeof it === 'string') {
1910
- linkTags.push(`<link rel="${rel}" href="${escapeAttr(absUrl(it))}">`);
1911
- } else if (typeof it === 'object' && it.url) {
1912
- const parts = [`rel="${rel}"`, `href="${escapeAttr(absUrl(it.url))}"`];
1913
- if (it.sizes) parts.push(`sizes="${escapeAttr(it.sizes)}"`);
1914
- if (it.type) parts.push(`type="${escapeAttr(it.type)}"`);
1915
- linkTags.push(`<link ${parts.join(' ')}>`);
1916
- }
1917
- }
1918
- };
1919
- pushIcon('icon', buckets.icon);
1920
- pushIcon('apple-touch-icon', buckets.apple);
1921
- pushIcon('shortcut icon', buckets.shortcut);
1922
- // `other` is the catch-all: array of { rel, url, ...attrs }.
1923
- if (buckets.other) {
1924
- const others = Array.isArray(buckets.other) ? buckets.other : [buckets.other];
1925
- for (const o of others) {
1926
- if (!o || !o.rel || !o.url) continue;
1927
- const parts = [`rel="${escapeAttr(o.rel)}"`, `href="${escapeAttr(absUrl(o.url))}"`];
1928
- if (o.sizes) parts.push(`sizes="${escapeAttr(o.sizes)}"`);
1929
- if (o.type) parts.push(`type="${escapeAttr(o.type)}"`);
1930
- linkTags.push(`<link ${parts.join(' ')}>`);
1931
- }
1932
- }
1933
- }
1934
-
1935
- // manifest: a string URL → <link rel="manifest">
1936
- if (typeof m.manifest === 'string') {
1937
- linkTags.push(`<link rel="manifest" href="${escapeAttr(absUrl(m.manifest))}">`);
1938
- }
1939
-
1940
- // alternates: { canonical, languages: { '<hreflang>': url }, media: { '<media>': url } }
1941
- // Mirrors Next.js's metadata.alternates surface. Relative values are resolved
1942
- // against metadataBase.
1943
- if (m.alternates && typeof m.alternates === 'object') {
1944
- if (m.alternates.canonical) {
1945
- linkTags.push(`<link rel="canonical" href="${escapeAttr(absUrl(m.alternates.canonical))}">`);
1946
- }
1947
- if (m.alternates.languages && typeof m.alternates.languages === 'object') {
1948
- for (const [hreflang, href] of Object.entries(m.alternates.languages)) {
1949
- linkTags.push(
1950
- `<link rel="alternate" hreflang="${escapeAttr(hreflang)}" href="${escapeAttr(absUrl(href))}">`,
1951
- );
1952
- }
1953
- }
1954
- if (m.alternates.media && typeof m.alternates.media === 'object') {
1955
- for (const [media, href] of Object.entries(m.alternates.media)) {
1956
- linkTags.push(
1957
- `<link rel="alternate" media="${escapeAttr(media)}" href="${escapeAttr(absUrl(href))}">`,
1958
- );
1959
- }
1960
- }
1961
- if (m.alternates.types && typeof m.alternates.types === 'object') {
1962
- // alternates.types: { 'application/rss+xml': '/rss.xml' }
1963
- for (const [type, href] of Object.entries(m.alternates.types)) {
1964
- linkTags.push(
1965
- `<link rel="alternate" type="${escapeAttr(type)}" href="${escapeAttr(absUrl(href))}">`,
1966
- );
1967
- }
1968
- }
1969
- }
1970
-
1971
- const title = m.title || 'webjs app';
1972
-
1973
- return `<!doctype html>
1974
- <html lang="en">
1975
- <head>
1976
- <meta charset="utf-8">
1977
- <style${n}>@layer webjs-host{:where([data-wj-host]){display:block}:where([data-wj-host][hidden]:not([hidden='until-found'])){display:none}}</style>
1978
- ${opts.nonce ? `<meta name="csp-nonce" content="${escapeAttr(opts.nonce)}">` : ''}
1979
- ${metaTags.join('\n')}
1980
- <title>${escapeHtml(title)}</title>
1981
- ${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}${clientRouterEnabled() ? '' : `\n<script${n}>window.__WEBJS_CLIENT_ROUTER__=false;</script>`}
1982
- ${importMapTag({ nonce: opts.nonce })}
1983
- ${linkTags.join('\n')}
1984
- ${scriptTags.length ? scriptTags.join('\n') + '\n' : ''}${boot}
1985
- ${reload}
1986
- ${suspenseBoot}
1987
- </head>
1988
- <body>
1989
- `;
1990
- }
1991
-
1992
- /**
1993
- * Translate a Set of custom element tag names used on the page into browser
1994
- * URLs for modulepreload. Components that didn't pass a module URL to
1995
- * `register()` are skipped silently (no harm, just no preload hint).
1996
- *
1997
- * Returns separate eager and lazy lists. Lazy components (static lazy = true)
1998
- * are NOT preloaded: they're loaded by the IntersectionObserver-based
1999
- * lazy-loader when the element enters the viewport.
2000
- *
2001
- * Elidable (display-only) components are skipped entirely: their imports
2002
- * are stripped from the served source, so preloading their module would
2003
- * fetch JS the browser never executes.
2004
- *
2005
- * @param {Set<string>} usedTags
2006
- * @param {string} appDir
2007
- * @param {Set<string>} [elidable] absolute paths of elidable component files
2008
- * @returns {{ eager: string[], lazy: Record<string, string> }}
2009
- */
2010
- function componentPreloads(usedTags, appDir, elidable) {
2011
- const eager = [];
2012
- /** @type {Record<string, string>} */
2013
- const lazy = {};
2014
- for (const tag of usedTags) {
2015
- const fileUrl = lookupModuleUrl(tag);
2016
- if (!fileUrl) continue;
2017
- try {
2018
- const abs = fileURLToPath(fileUrl);
2019
- if (!abs.startsWith(appDir)) continue;
2020
- if (elidable && elidable.has(abs)) continue;
2021
- const url = toUrlPath(abs, appDir);
2022
- if (isLazy(tag)) {
2023
- lazy[tag] = url;
2024
- } else {
2025
- eager.push(url);
2026
- }
2027
- } catch { /* ignore */ }
2028
- }
2029
- return { eager, lazy };
2030
- }
2031
-
2032
- /**
2033
- * Merge component preloads with transitive dependencies from the module
2034
- * graph, then deduplicate against the already-imported module URLs.
2035
- *
2036
- * The walk ROOTS (`entryFiles`) are the boot's actually-SHIPPED page/layout
2037
- * module set (the caller passes the absolute paths of `moduleUrls`, which
2038
- * already drops an inert page/layout and substitutes an import-only page with
2039
- * its components), NOT the raw `[route.file, ...route.layouts]` route entries.
2040
- * This matches `reachedVendorSpecifiers`' roots so the two walks stay
2041
- * consistent. Rooting at the shipped set means a module reached ONLY through a
2042
- * dropped page/layout (its SSR-only direct app import OR its SSR-only relative
2043
- * helper) is never a walk root's dep, so it gets no `modulepreload` hint (no
2044
- * over-fetch, #780). A module that also ships some other way (a component shared
2045
- * with a live route, or reached via an import-only page's substituted
2046
- * components) is still reached through a real shipped root, so its hint stays
2047
- * (no under-fetch). `seen = new Set(moduleUrls)` already excludes the shipped
2048
- * modules' own URLs; the shipped-roots change closes the TRANSITIVE gap.
2049
- *
2050
- * @param {string[]} componentUrls direct component module URLs
2051
- * @param {string[]} moduleUrls boot script imports (page + layouts)
2052
- * @param {import('./module-graph.js').ModuleGraph | undefined} graph
2053
- * @param {string[]} entryFiles absolute paths of the SHIPPED page/layout modules (from `moduleUrls`)
2054
- * @param {string} appDir
2055
- * @param {Set<string>} [elidableComponents] absolute paths to skip in the walk
2056
- * @returns {string[]}
2057
- */
2058
- function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appDir, serverFiles, elidableComponents) {
2059
- const seen = new Set(moduleUrls);
2060
- const result = [];
2061
-
2062
- // Server-only modules are never useful to preload: they're imported by
2063
- // pages/layouts on the server, or surfaced to client components as
2064
- // generated RPC stubs that load lazily on first call. Preloading them
2065
- // wastes a roundtrip and pollutes the network tab with server-named files.
2066
- //
2067
- // Detection is belt-and-suspenders: filename suffix catches `.server.*`;
2068
- // the `serverFiles` set (built from the action index) also catches files
2069
- // that opted in via `'use server'` directive without the suffix.
2070
- const byName = (url) => /\.server\.m?[jt]s$/.test(url);
2071
- const byIndex = serverFiles
2072
- ? (abs) => (serverFiles.has ? serverFiles.has(abs) : false)
2073
- : () => false;
2074
-
2075
- // Add direct component URLs
2076
- for (const url of componentUrls) {
2077
- if (seen.has(url) || byName(url)) continue;
2078
- seen.add(url);
2079
- result.push(url);
2080
- }
2081
-
2082
- // Add transitive deps from the module graph
2083
- if (graph) {
2084
- // Combine entry files + component files for graph lookup
2085
- const allEntries = [...entryFiles];
2086
- for (const url of componentUrls) {
2087
- // Convert URL back to absolute path for graph lookup
2088
- const abs = resolve(appDir, url.startsWith('/') ? url.slice(1) : url);
2089
- allEntries.push(abs);
2090
- }
2091
- // Skip elidable components and any subtree reachable only through
2092
- // them: their imports are stripped from served source, so the
2093
- // browser never fetches these modules.
2094
- const deps = transitiveDeps(graph, allEntries, appDir, elidableComponents);
2095
- for (const dep of deps) {
2096
- if (byIndex(dep)) continue;
2097
- const url = toUrlPath(dep, appDir);
2098
- if (seen.has(url) || byName(url)) continue;
2099
- seen.add(url);
2100
- result.push(url);
2101
- }
2102
- }
2103
-
2104
- return result;
2105
- }
2106
-
2107
- /**
2108
- * Collect the bare npm vendor specifiers the page's SHIPPED modules import
2109
- * (#754). The walk ROOTS are the boot's actually-shipped module set: the caller
2110
- * passes `entryFiles` = the absolute paths of `moduleUrls` (which already drops
2111
- * inert page/layout modules and substitutes an import-only page with its
2112
- * components), plus `componentUrls` = the rendered components. From those roots
2113
- * it walks the transitive app-graph closure (elidable components and the subtree
2114
- * reachable only through them excluded), and collects each reached file's bare
2115
- * imports, excluding server files. The specifiers are resolved to `modulepreload`
2116
- * targets via the vendor importmap; a specifier not in the map (unpinned /
2117
- * unreached) drops out there.
2118
- *
2119
- * Because the roots are the SHIPPED set, a vendor reached ONLY through a module
2120
- * dropped from the boot, whether a dropped page's SSR-only DIRECT vendor import
2121
- * or its SSR-only RELATIVE HELPER's vendor, is never collected: the dropped
2122
- * module is not a root, and nothing that ships imports it (pages/layouts are not
2123
- * importable). So the canonical SSR-only-dependency pattern (which elision keeps
2124
- * off the client) is never preloaded (no over-fetch).
2125
- *
2126
- * @param {import('./module-graph.js').ModuleGraph | undefined} graph
2127
- * @param {string[]} entryFiles absolute paths of the SHIPPED page/layout modules (from `moduleUrls`)
2128
- * @param {string[]} componentUrls rendered eager component URL paths
2129
- * @param {string} appDir
2130
- * @param {Set<string>} [elidableComponents]
2131
- * @param {Set<string>} [serverFiles] the action / server-file index (`'use server'`, incl. no-`.server.` files)
2132
- * @returns {Set<string>}
2133
- */
2134
- function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elidableComponents, serverFiles) {
2135
- /** @type {Set<string>} */
2136
- const specs = new Set();
2137
- if (!graph) return specs;
2138
- const bare = bareImports(graph);
2139
- if (!bare.size) return specs;
2140
- // Roots = the SHIPPED page/layout modules (already inert-dropped + import-only
2141
- // expanded by the caller) + the rendered components, keyed by the graph's own
2142
- // absolute paths. Walk their non-elided transitive closure.
2143
- const allEntries = [...entryFiles];
2144
- for (const url of componentUrls) {
2145
- allEntries.push(resolve(appDir, url.startsWith('/') ? url.slice(1) : url));
2146
- }
2147
- const files = new Set(allEntries);
2148
- for (const dep of transitiveDeps(graph, allEntries, appDir, elidableComponents)) files.add(dep);
2149
- for (const file of files) {
2150
- // A server file is never served to the browser (its source is an RPC /
2151
- // throw-at-load stub), so a vendor it imports never ships and must NOT be
2152
- // preloaded. `transitiveDeps` stops AT a server-file boundary but still
2153
- // returns the boundary file itself, so filter it: the `.server.*` suffix
2154
- // AND the action index (a `'use server'` file without the suffix), matching
2155
- // `deduplicatedPreloads`' `byIndex` filter.
2156
- if (/\.server\.m?[jt]s$/.test(file)) continue;
2157
- if (serverFiles && serverFiles.has && serverFiles.has(file)) continue;
2158
- const fileBare = bare.get(file);
2159
- if (fileBare) for (const spec of fileBare) specs.add(spec);
2160
- }
2161
- return specs;
2162
- }
2163
-
2164
- /**
2165
- * Build a streaming Response. Degrades to a single-flush response when
2166
- * there are no pending Suspense boundaries.
2167
- *
2168
- * @param {string} prefix
2169
- * @param {string} bodyHtml
2170
- * @param {string} closer
2171
- * @param {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} ctx
2172
- * @param {number} status
2173
- * @param {Request | undefined} req
2174
- * @param {URL | undefined} url
2175
- * @param {Record<string, any>} [metadata]
2176
- * @param {string} [nonce]
2177
- * @param {boolean} [dev] dev surfaces a streamed-boundary error message; prod stays silent
2178
- */
2179
- function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata, nonce, dev) {
2180
- const encoder = new TextEncoder();
2181
- const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
2182
- // Default: no caching. Pages are dynamic by default: the developer
2183
- // opts in to caching explicitly via metadata.cacheControl. A non-200 does
2184
- // NOT inherit it (#1140): the form-action re-render is a 422 carrying the
2185
- // submitter's own field values and errors, which must never be handed to a
2186
- // shared cache just because the page opted into public caching.
2187
- headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store');
2188
- // See htmlResponse: published build id on every response for the
2189
- // client router's importmap-mismatch detection on partial swaps.
2190
- headers.set('x-webjs-build', publishedBuildId());
2191
- headers.set('x-webjs-src', appSourceId());
2192
-
2193
- if (!ctx.pending.length) {
2194
- // No pending boundaries: this degrades to a single buffered (string)
2195
- // flush, so opt it into the conditional-GET funnel like htmlResponse.
2196
- headers.set(BUFFERED_MARKER, '1');
2197
- return new Response(prefix + bodyHtml + closer, { status, headers });
2198
- }
2199
-
2200
- // Flag a genuinely streamed body so the conditional-GET funnel skips it
2201
- // (an unflushed stream cannot be hashed without buffering, which would
2202
- // defeat streaming). The marker is internal and stripped at the funnel
2203
- // before the response reaches the client. See conditional-get.js.
2204
- headers.set(STREAM_MARKER, '1');
2205
-
2206
- const stream = new ReadableStream({
2207
- async start(controller) {
2208
- // Flush the shell (prefix + body with fallbacks) immediately, followed by
2209
- // a shell-ready sentinel comment IN THE SAME chunk. The resolved boundary
2210
- // templates and the `</body></html>` closer are emitted LATER (after the
2211
- // slow data settles), so without this sentinel a streaming soft-nav client
2212
- // could not tell "shell complete, awaiting the slow boundary" from "shell
2213
- // still arriving" and would block its progressive swap until the slow
2214
- // boundary (#473). The comment is inert for the native initial-load parse.
2215
- controller.enqueue(encoder.encode(prefix + bodyHtml + '<!--wj-stream-shell-->'));
2216
- try {
2217
- // Loop: resolve all currently-pending promises in parallel; nested
2218
- // Suspense inside resolved content adds more pending entries.
2219
- while (ctx.pending.length) {
2220
- const batch = ctx.pending.slice();
2221
- ctx.pending.length = 0;
2222
- const settled = await Promise.all(
2223
- batch.map(async (p) => {
2224
- try {
2225
- const resolved = await p.promise;
2226
- const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev };
2227
- // A fresh scan that cannot see the shell the boundary sits in.
2228
- // That used to require carrying the boundary's form scope
2229
- // (#1207), or a `<button formaction=${fn}>` inside a bound
2230
- // form's boundary read as form-less, was refused, and the catch
2231
- // below turned it into an empty boundary on a 200 in
2232
- // production. #1307 made a bound submitter self-sufficient, so
2233
- // there is nothing left to carry.
2234
- const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
2235
- ctx.nextId = sub.nextId;
2236
- for (const n of sub.pending) ctx.pending.push(n);
2237
- return { id: p.id, html };
2238
- } catch (e) {
2239
- // Match the SSR error-isolation policy (render-server.js's
2240
- // defaultSSRErrorTemplate): dev surfaces the message so the
2241
- // failure is obvious, prod stays SILENT so no internal detail
2242
- // (a DB error, a stack-derived path) leaks to the client (#478).
2243
- const msg = e instanceof Error ? e.message : String(e);
2244
- const html = dev ? `<p>error: ${escapeHtml(msg)}</p>` : '';
2245
- return { id: p.id, html };
2246
- }
2247
- })
2248
- );
2249
- for (const r of settled) {
2250
- // Emit just the <template>: the MutationObserver-based resolver
2251
- // in the boot script detects it and swaps it into the placeholder.
2252
- // Falls back to the __webjsResolve global for browsers without MO.
2253
- // The fallback <script> carries the request's CSP nonce so
2254
- // strict-CSP enforcement passes. Browsers that support
2255
- // MutationObserver (all evergreen) handle the swap via the
2256
- // boot script's observer and skip this fallback; the
2257
- // <script> is here for legacy / extremely-restrictive
2258
- // environments. Either way it must be nonce-signed.
2259
- const scriptNonce = nonce ? ` nonce="${escapeAttr(nonce)}"` : '';
2260
- const chunk =
2261
- `<template data-webjs-resolve="${r.id}">${r.html}</template>` +
2262
- `<script${scriptNonce}>window.__webjsResolve&&__webjsResolve("${r.id}")</script>`;
2263
- controller.enqueue(encoder.encode(chunk));
2264
- }
2265
- }
2266
- } finally {
2267
- controller.enqueue(encoder.encode(closer));
2268
- controller.close();
2269
- }
2270
- },
2271
- });
2272
- return new Response(stream, { status, headers });
2273
- }
2274
-
2275
- /**
2276
- * Import a route module. In prod the URL is stable so Node's module cache
2277
- * serves a single evaluation; in dev a cache-bust query forces a fresh
2278
- * evaluation so source edits take effect (which also re-runs the module's
2279
- * top-level side effects, the reason pages/layouts must keep their top level
2280
- * side-effect-free).
2281
- *
2282
- * @param {string} file
2283
- * @param {boolean} dev
2284
- */
2285
- async function loadModule(file, dev) {
2286
- const url = pathToFileURL(file).toString();
2287
- const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
2288
- return import(url + bust);
2289
- }
2290
-
2291
- /**
2292
- * @param {string} file
2293
- * @param {string} appDir
2294
- */
2295
- function toUrlPath(file, appDir) {
2296
- let rel = file.startsWith(appDir) ? file.slice(appDir.length) : file;
2297
- rel = rel.split('\\').join('/');
2298
- if (!rel.startsWith('/')) rel = '/' + rel;
2299
- return rel;
2300
- }
2301
-
2302
- /**
2303
- * The CSP nonce for the in-flight request, or undefined if none is in
2304
- * scope. Delegates to `cspNonce()`, which returns the per-request nonce
2305
- * the handler MINTED when CSP is enabled (issue #233), or, as a fallback,
2306
- * the nonce parsed from an inbound `Content-Security-Policy` request
2307
- * header (the legacy consume-only path). Using the same source as the
2308
- * `Content-Security-Policy` response header is what guarantees the inline
2309
- * boot script, the importmap, the modulepreload hints, and the header all
2310
- * carry the EXACT same nonce: one minted value, no drift.
2311
- *
2312
- * `req` is accepted (and ignored) so existing call sites stay unchanged;
2313
- * the value comes from the request-scoped AsyncLocalStorage store, not
2314
- * the argument.
2315
- *
2316
- * @param {Request} [_req]
2317
- * @returns {string | undefined}
2318
- */
2319
- function getNonce(_req) {
2320
- return cspNonce() || undefined;
2321
- }
2322
-
2323
- /** @param {string} s */
2324
- function escapeHtml(s) {
2325
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
2326
- }
2327
- /** @param {string} s */
2328
- function escapeAttr(s) {
2329
- return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
2330
- }
2331
-
2332
- /**
2333
- * HTML-safe-escape a JSON string for embedding inside a
2334
- * `<script type="application/ld+json">` element.
2335
- *
2336
- * This is NOT the HTML-entity escaper (escapeHtml / escapeAttr). A
2337
- * JSON parser reads the raw character, so turning `<` into `&lt;`
2338
- * would CORRUPT the JSON. Instead we emit the Unicode escape form
2339
- * (`<`), which a JSON parser decodes back to the original
2340
- * character while making the literal byte sequence `</script>`
2341
- * impossible to form in the served HTML. So the embedded data parses
2342
- * back to the author's exact object, AND a value containing
2343
- * `</script><img onerror=...>` can never break out of the script tag.
2344
- *
2345
- * U+2028 / U+2029 are escaped too: they are valid inside a JSON
2346
- * string but are line terminators in HTML/JS contexts, and some
2347
- * consumers choke on them. Escaping keeps the block robust.
2348
- *
2349
- * @param {string} json the `JSON.stringify` output
2350
- * @returns {string}
2351
- */
2352
- function escapeJsonLd(json) {
2353
- return json
2354
- .replace(/</g, '\\u003c')
2355
- .replace(/>/g, '\\u003e')
2356
- .replace(/&/g, '\\u0026')
2357
- .replace(/\u2028/g, '\\u2028')
2358
- .replace(/\u2029/g, '\\u2029');
2359
- }
2360
-
2361
- /**
2362
- * Serialize one schema.org object into a `<script type="application/ld+json">`
2363
- * block, HTML-safe-escaped via escapeJsonLd. Fails SAFE: a non-object
2364
- * input, or a circular reference that makes JSON.stringify throw, is
2365
- * skipped (returns the empty string) with a one-line warn, never breaking
2366
- * the whole render.
2367
- *
2368
- * @param {unknown} obj
2369
- * @returns {string} the script tag, or '' to skip this element
2370
- */
2371
- function jsonLdScript(obj) {
2372
- if (!obj || typeof obj !== 'object') return '';
2373
- try {
2374
- const json = JSON.stringify(obj);
2375
- if (typeof json !== 'string') return '';
2376
- return `<script type="application/ld+json">${escapeJsonLd(json)}</script>`;
2377
- } catch (err) {
2378
- console.warn('[webjs] metadata.jsonLd: skipped an entry that could not be serialized:', err && err.message);
2379
- return '';
2380
- }
2381
- }
2382
-
2383
- // Internal helpers re-exported for unit testing.
2384
- export { escapeJsonLd as _escapeJsonLd, jsonLdScript as _jsonLdScript };
2385
-
2386
- /**
2387
- * Decide whether a `<link rel="modulepreload">` href needs a
2388
- * `crossorigin="anonymous"` attribute. True for absolute URLs with
2389
- * an http(s) scheme (vendor packages from jspm.io etc.); false for
2390
- * same-origin paths like `/__webjs/core/index.js`. Browsers require
2391
- * crossorigin on cross-origin module preload, else the preload is
2392
- * wasted or double-fetched. Same-origin URLs must NOT have it for
2393
- * the same reason in reverse.
2394
- *
2395
- * Exported for tests; production callers use it via documentParts.
2396
- *
2397
- * @param {string} url
2398
- * @returns {string} either ` crossorigin="anonymous"` or empty
2399
- */
2400
- export function preloadCrossOriginAttr(url) {
2401
- return /^https?:\/\//i.test(url) ? ' crossorigin="anonymous"' : '';
2402
- }
2403
-
2404
- /**
2405
- * Look up the SRI integrity hash for a vendor URL and format it as a
2406
- * `integrity="sha384-..."` attribute. Empty string for URLs without a
2407
- * known hash (framework files, user code, vendor URLs in live-API
2408
- * mode without a pin file).
2409
- *
2410
- * @param {string} url
2411
- * @returns {string}
2412
- */
2413
- export function integrityAttr(url) {
2414
- const hash = vendorIntegrityFor(url);
2415
- // Belt and suspenders: readPinFile already validates the integrity
2416
- // value end-to-end against /^sha(256|384|512)-[A-Za-z0-9+/=]+$/, so
2417
- // a valid hash has no HTML-special chars and escapeAttr is a no-op.
2418
- // But emission goes through the same attribute-injection-safe path
2419
- // as everything else in the SSR pipeline so a future regression in
2420
- // the validator doesn't bypass it.
2421
- return hash ? ` integrity="${escapeAttr(hash)}"` : '';
2422
- }
18
+ export {
19
+ _layoutSegmentPath,
20
+ _pageSegmentPath,
21
+ _boundarySegmentPath,
22
+ _layoutsForBoundary,
23
+ _regionRouteKey,
24
+ _wrapWithChildrenMarker,
25
+ _extractUserShell,
26
+ _buildDocumentParts,
27
+ } from './ssr/document.js';
2423
28
 
2424
- /**
2425
- * Serialize a Next.js-shaped viewport object into the comma-separated
2426
- * `content` string the meta tag expects. Recognised fields:
2427
- * width, height, initialScale, minimumScale, maximumScale,
2428
- * userScalable, viewportFit, interactiveWidget.
2429
- * Other fields (themeColor, colorScheme) live on their own meta tags
2430
- * and are handled by the caller: skipped here.
2431
- *
2432
- * @param {Record<string, unknown>} v
2433
- * @returns {string}
2434
- */
2435
- function serializeViewport(v) {
2436
- const parts = [];
2437
- /** @param {string} key @param {string} prop */
2438
- const push = (key, prop) => {
2439
- if (v[prop] !== undefined && v[prop] !== null && v[prop] !== '') {
2440
- parts.push(`${key}=${v[prop]}`);
2441
- }
2442
- };
2443
- push('width', 'width');
2444
- push('height', 'height');
2445
- push('initial-scale', 'initialScale');
2446
- push('minimum-scale', 'minimumScale');
2447
- push('maximum-scale', 'maximumScale');
2448
- if (v.userScalable === false) parts.push('user-scalable=no');
2449
- else if (v.userScalable === true) parts.push('user-scalable=yes');
2450
- push('viewport-fit', 'viewportFit');
2451
- push('interactive-widget', 'interactiveWidget');
2452
- return parts.join(',');
2453
- }
29
+ export {
30
+ _hoistHeadTags,
31
+ _escapeJsonLd,
32
+ _jsonLdScript,
33
+ preloadCrossOriginAttr,
34
+ integrityAttr,
35
+ setMetadataIconRoutes,
36
+ } from './ssr/head.js';