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