@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.
- package/index.d.ts +32 -3
- package/package.json +2 -2
- package/src/browser-entries.js +64 -0
- package/src/check/helpers.js +365 -0
- package/src/check/rules-actions.js +174 -0
- package/src/check/rules-components.js +275 -0
- package/src/check/rules-imports.js +166 -0
- package/src/check/rules-registry.js +128 -0
- package/src/check/rules-routing.js +224 -0
- package/src/check/rules-typescript.js +177 -0
- package/src/check/rules.js +127 -0
- package/src/check/runner-support.js +405 -0
- package/src/check/runner.js +113 -0
- package/src/check.js +4 -1774
- package/src/component-elision.js +1 -1
- package/src/dev/config.js +338 -0
- package/src/dev/handler.js +1372 -0
- package/src/dev/helpers.js +542 -0
- package/src/dev/serve.js +840 -0
- package/src/dev/server.js +322 -0
- package/src/dev-classify.js +174 -0
- package/src/dev-reload-worker.js +133 -7
- package/src/dev-styles.js +97 -0
- package/src/dev.js +22 -3077
- package/src/form-dispatch.js +12 -3
- package/src/importmap.js +6 -3
- package/src/listener-core.js +16 -2
- package/src/module-graph.js +112 -7
- package/src/rate-limit.js +50 -5
- package/src/router.js +11 -1
- package/src/ssr/client-router-flag.js +42 -0
- package/src/ssr/document.js +361 -0
- package/src/ssr/env-shim.js +51 -0
- package/src/ssr/escape.js +29 -0
- package/src/ssr/head.js +857 -0
- package/src/ssr/preloads.js +195 -0
- package/src/ssr/render.js +1481 -0
- package/src/ssr/responses.js +201 -0
- package/src/ssr.js +28 -2445
- package/src/vendor/audit.js +400 -0
- package/src/vendor/integrity.js +245 -0
- package/src/vendor/jspm.js +348 -0
- package/src/vendor/manifest.js +96 -0
- package/src/vendor/pins.js +892 -0
- package/src/vendor/providers.js +35 -0
- package/src/vendor/resolver.js +225 -0
- package/src/vendor/scanner.js +132 -0
- 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
|
-
*
|
|
902
|
-
*
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
-
|
|
1239
|
-
export { buildDocumentParts as _buildDocumentParts };
|
|
16
|
+
export { publicEnvShim } from './ssr/env-shim.js';
|
|
1240
17
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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, '&').replace(/</g, '<');
|
|
2326
|
-
}
|
|
2327
|
-
/** @param {string} s */
|
|
2328
|
-
function escapeAttr(s) {
|
|
2329
|
-
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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 `<`
|
|
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
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
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';
|