@webjsdev/server 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/index.js +21 -3
- package/package.json +1 -3
- package/src/actions.js +25 -9
- package/src/cache.js +19 -2
- package/src/check.js +227 -96
- package/src/component-elision.js +851 -0
- package/src/component-scanner.js +44 -7
- package/src/context.js +36 -0
- package/src/crypto-utils.js +65 -0
- package/src/dev.js +660 -134
- package/src/importmap.js +283 -20
- package/src/js-scan.js +288 -0
- package/src/module-graph.js +194 -20
- package/src/rate-limit.js +100 -12
- package/src/script-tag-json.js +63 -0
- package/src/session.js +60 -14
- package/src/ssr.js +133 -17
- package/src/vendor.js +1261 -103
- package/src/websocket.js +3 -1
package/src/ssr.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy } from '@webjsdev/core';
|
|
4
|
-
import { importMapTag } from './importmap.js';
|
|
4
|
+
import { importMapTag, vendorIntegrityFor, importMapHash } from './importmap.js';
|
|
5
|
+
import { jsonForScriptTag } from './script-tag-json.js';
|
|
5
6
|
import { readToken, newToken, cookieHeader } from './csrf.js';
|
|
6
7
|
import { transitiveDeps } from './module-graph.js';
|
|
7
8
|
|
|
@@ -51,7 +52,18 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
51
52
|
// import graph. Combined with the modulepreload hints below, this
|
|
52
53
|
// is the Rails 7+ / Hotwire pattern: per-file ESM, no bundling,
|
|
53
54
|
// HTTP/2 multiplex on the wire.
|
|
54
|
-
|
|
55
|
+
//
|
|
56
|
+
// Inert route modules (a page or layout that does no client work, even
|
|
57
|
+
// transitively) are dropped from the boot script: the browser never
|
|
58
|
+
// downloads them. The SSR'd HTML is the complete output, and
|
|
59
|
+
// progressive enhancement is unaffected, so a fully-static route ships
|
|
60
|
+
// zero application JS. The analysis is conservative (anything that
|
|
61
|
+
// touches the client router, a signal, an event, an npm import, or a
|
|
62
|
+
// shipping component keeps shipping).
|
|
63
|
+
const inert = opts.inertRouteModules;
|
|
64
|
+
const moduleUrls = [route.file, ...route.layouts]
|
|
65
|
+
.filter((f) => !(inert && inert.has(f)))
|
|
66
|
+
.map((f) => toUrlPath(f, opts.appDir));
|
|
55
67
|
// Emit <link rel="modulepreload"> for every custom element that
|
|
56
68
|
// actually rendered PLUS their transitive dependencies (from the
|
|
57
69
|
// module graph). URLs are deduplicated so the browser never sees
|
|
@@ -59,7 +71,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
59
71
|
// preloads and instead loaded via IntersectionObserver when they
|
|
60
72
|
// enter the viewport.
|
|
61
73
|
const { eager: eagerComponents, lazy: lazyComponents } =
|
|
62
|
-
componentPreloads(suspenseCtx.usedComponents, opts.appDir);
|
|
74
|
+
componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents);
|
|
63
75
|
const preloads = deduplicatedPreloads(
|
|
64
76
|
eagerComponents,
|
|
65
77
|
moduleUrls,
|
|
@@ -67,6 +79,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
67
79
|
[route.file, ...route.layouts],
|
|
68
80
|
opts.appDir,
|
|
69
81
|
opts.serverFiles,
|
|
82
|
+
opts.elidableComponents,
|
|
70
83
|
);
|
|
71
84
|
// Extract CSP nonce from request headers (if present).
|
|
72
85
|
const nonce = opts.req ? getNonce(opts.req) : undefined;
|
|
@@ -93,6 +106,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
93
106
|
opts.req,
|
|
94
107
|
url,
|
|
95
108
|
metadata,
|
|
109
|
+
nonce,
|
|
96
110
|
);
|
|
97
111
|
} catch (err) {
|
|
98
112
|
if (isRedirect(err)) {
|
|
@@ -103,6 +117,10 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
103
117
|
const html = await ssrNotFoundHtml(null, opts);
|
|
104
118
|
return htmlResponse(html, 404, opts.req, url);
|
|
105
119
|
}
|
|
120
|
+
// Error paths still need to honor the request's CSP nonce so the
|
|
121
|
+
// error page's boot scripts (when moduleUrls is non-empty) and
|
|
122
|
+
// the meta csp-nonce tag both pass strict-CSP enforcement.
|
|
123
|
+
const errNonce = opts.req ? getNonce(opts.req) : undefined;
|
|
106
124
|
// Try nearest error.js (innermost → outermost).
|
|
107
125
|
for (let i = route.errors.length - 1; i >= 0; i--) {
|
|
108
126
|
try {
|
|
@@ -111,7 +129,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
111
129
|
const tree = await mod.default({ ...ctx, error: err });
|
|
112
130
|
const body = await renderToString(tree);
|
|
113
131
|
const moduleUrls = [route.file, ...route.layouts].map((f) => toUrlPath(f, opts.appDir));
|
|
114
|
-
const html = wrapInDocument(body, { metadata, moduleUrls, dev: opts.dev });
|
|
132
|
+
const html = wrapInDocument(body, { metadata, moduleUrls, dev: opts.dev, nonce: errNonce });
|
|
115
133
|
return htmlResponse(html, 500, opts.req, url);
|
|
116
134
|
} catch (nested) {
|
|
117
135
|
// fall through to next error boundary
|
|
@@ -125,7 +143,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
125
143
|
)}</pre>`
|
|
126
144
|
: `<h1>Server error</h1><p>Something went wrong. Please try again.</p>`;
|
|
127
145
|
return htmlResponse(
|
|
128
|
-
wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev }),
|
|
146
|
+
wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev, nonce: errNonce }),
|
|
129
147
|
500,
|
|
130
148
|
opts.req,
|
|
131
149
|
url
|
|
@@ -156,6 +174,11 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
156
174
|
// Default: no caching. Pages are dynamic by default: the developer
|
|
157
175
|
// opts in to caching explicitly via metadata.cacheControl.
|
|
158
176
|
headers.set('cache-control', metadata?.cacheControl || 'no-store');
|
|
177
|
+
// X-Webjs-Build carries the current importmap hash so the client
|
|
178
|
+
// router can detect post-deploy importmap changes on EVERY
|
|
179
|
+
// response, including the X-Webjs-Have partial responses that
|
|
180
|
+
// omit the head entirely. See router-client.js applySwap.
|
|
181
|
+
headers.set('x-webjs-build', importMapHash());
|
|
159
182
|
if (req && !readToken(req)) {
|
|
160
183
|
const secure = url ? url.protocol === 'https:' : false;
|
|
161
184
|
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
@@ -175,10 +198,12 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
|
175
198
|
body = `<h1>404: Not found</h1><pre>${escapeHtml(String(e))}</pre>`;
|
|
176
199
|
}
|
|
177
200
|
}
|
|
201
|
+
const nonce = opts.req ? getNonce(opts.req) : undefined;
|
|
178
202
|
return wrapInDocument(body, {
|
|
179
203
|
metadata: { title: 'Not found' },
|
|
180
204
|
moduleUrls: [],
|
|
181
205
|
dev: opts.dev,
|
|
206
|
+
nonce,
|
|
182
207
|
});
|
|
183
208
|
}
|
|
184
209
|
|
|
@@ -632,11 +657,10 @@ export function publicEnvShim(opts) {
|
|
|
632
657
|
}
|
|
633
658
|
}
|
|
634
659
|
env.NODE_ENV = opts.dev ? 'development' : 'production';
|
|
635
|
-
const json = JSON.stringify(env).replace(/<\//g, '<\\/');
|
|
636
660
|
const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
637
661
|
return `<script${n}>`
|
|
638
662
|
+ `window.process=window.process||{};`
|
|
639
|
-
+ `window.process.env=Object.assign(window.process.env||{},${
|
|
663
|
+
+ `window.process.env=Object.assign(window.process.env||{},${jsonForScriptTag(env)});`
|
|
640
664
|
+ `</script>`;
|
|
641
665
|
}
|
|
642
666
|
|
|
@@ -646,12 +670,12 @@ function wrapHead(opts) {
|
|
|
646
670
|
// the request's CSP header by the caller.
|
|
647
671
|
const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
648
672
|
|
|
649
|
-
const imports = opts.moduleUrls.map((u) => `import ${
|
|
673
|
+
const imports = opts.moduleUrls.map((u) => `import ${jsonForScriptTag(u)};`).join('\n');
|
|
650
674
|
const lazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
|
|
651
675
|
? opts.lazyComponents
|
|
652
676
|
: null;
|
|
653
677
|
const lazyBoot = lazyEntries
|
|
654
|
-
? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${
|
|
678
|
+
? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${jsonForScriptTag(lazyEntries)});`
|
|
655
679
|
: '';
|
|
656
680
|
const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
|
|
657
681
|
const reload = opts.dev ? `<script type="module"${n} src="/__webjs/reload.js"></script>` : '';
|
|
@@ -910,11 +934,33 @@ function wrapHead(opts) {
|
|
|
910
934
|
// module, then any custom `metadata.preload` entries (fonts, images, etc.)
|
|
911
935
|
// (linkTags array was declared earlier so the metadata block above can
|
|
912
936
|
// push icons / canonical / hreflang / archives / etc. into it.)
|
|
937
|
+
//
|
|
938
|
+
// Cross-origin URLs (vendor packages served from jspm.io etc.) MUST
|
|
939
|
+
// carry `crossorigin="anonymous"` on the preload link. Without it
|
|
940
|
+
// the browser either ignores the preload entirely or double-fetches
|
|
941
|
+
// (once for the preload as a non-CORS request, once for the actual
|
|
942
|
+
// module as a CORS request, defeating the optimization). Same-origin
|
|
943
|
+
// URLs get no attribute; adding `crossorigin=""` there would also
|
|
944
|
+
// double-fetch in some browsers because the preload becomes CORS
|
|
945
|
+
// but the import doesn't.
|
|
946
|
+
// CSP nonce on the preload link: under strict CSP (script-src
|
|
947
|
+
// 'nonce-...') the browser also gates modulepreload by the same
|
|
948
|
+
// policy. Without the attribute the preload is blocked and the
|
|
949
|
+
// import either falls back to a cold fetch or fails. Rails (via
|
|
950
|
+
// importmap-rails) applies nonce on every modulepreload tag for
|
|
951
|
+
// the same reason.
|
|
952
|
+
const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
913
953
|
for (const url of opts.moduleUrls) {
|
|
914
|
-
linkTags.push(
|
|
954
|
+
linkTags.push(
|
|
955
|
+
`<link rel="modulepreload" href="${escapeAttr(url)}"` +
|
|
956
|
+
`${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
|
|
957
|
+
);
|
|
915
958
|
}
|
|
916
959
|
for (const url of opts.preloads || []) {
|
|
917
|
-
linkTags.push(
|
|
960
|
+
linkTags.push(
|
|
961
|
+
`<link rel="modulepreload" href="${escapeAttr(url)}"` +
|
|
962
|
+
`${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
|
|
963
|
+
);
|
|
918
964
|
}
|
|
919
965
|
if (Array.isArray(m.preload)) {
|
|
920
966
|
for (const p of m.preload) {
|
|
@@ -1010,10 +1056,11 @@ function wrapHead(opts) {
|
|
|
1010
1056
|
<html lang="en">
|
|
1011
1057
|
<head>
|
|
1012
1058
|
<meta charset="utf-8">
|
|
1059
|
+
${opts.nonce ? `<meta name="csp-nonce" content="${escapeAttr(opts.nonce)}">` : ''}
|
|
1013
1060
|
${metaTags.join('\n')}
|
|
1014
1061
|
<title>${escapeHtml(title)}</title>
|
|
1015
1062
|
${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}
|
|
1016
|
-
${importMapTag()}
|
|
1063
|
+
${importMapTag({ nonce: opts.nonce })}
|
|
1017
1064
|
${linkTags.join('\n')}
|
|
1018
1065
|
${boot}
|
|
1019
1066
|
${reload}
|
|
@@ -1032,11 +1079,16 @@ ${suspenseBoot}
|
|
|
1032
1079
|
* are NOT preloaded: they're loaded by the IntersectionObserver-based
|
|
1033
1080
|
* lazy-loader when the element enters the viewport.
|
|
1034
1081
|
*
|
|
1082
|
+
* Elidable (display-only) components are skipped entirely: their imports
|
|
1083
|
+
* are stripped from the served source, so preloading their module would
|
|
1084
|
+
* fetch JS the browser never executes.
|
|
1085
|
+
*
|
|
1035
1086
|
* @param {Set<string>} usedTags
|
|
1036
1087
|
* @param {string} appDir
|
|
1088
|
+
* @param {Set<string>} [elidable] absolute paths of elidable component files
|
|
1037
1089
|
* @returns {{ eager: string[], lazy: Record<string, string> }}
|
|
1038
1090
|
*/
|
|
1039
|
-
function componentPreloads(usedTags, appDir) {
|
|
1091
|
+
function componentPreloads(usedTags, appDir, elidable) {
|
|
1040
1092
|
const eager = [];
|
|
1041
1093
|
/** @type {Record<string, string>} */
|
|
1042
1094
|
const lazy = {};
|
|
@@ -1046,6 +1098,7 @@ function componentPreloads(usedTags, appDir) {
|
|
|
1046
1098
|
try {
|
|
1047
1099
|
const abs = fileURLToPath(fileUrl);
|
|
1048
1100
|
if (!abs.startsWith(appDir)) continue;
|
|
1101
|
+
if (elidable && elidable.has(abs)) continue;
|
|
1049
1102
|
const url = toUrlPath(abs, appDir);
|
|
1050
1103
|
if (isLazy(tag)) {
|
|
1051
1104
|
lazy[tag] = url;
|
|
@@ -1066,9 +1119,10 @@ function componentPreloads(usedTags, appDir) {
|
|
|
1066
1119
|
* @param {import('./module-graph.js').ModuleGraph | undefined} graph
|
|
1067
1120
|
* @param {string[]} entryFiles absolute paths of page + layout files
|
|
1068
1121
|
* @param {string} appDir
|
|
1122
|
+
* @param {Set<string>} [elidableComponents] absolute paths to skip in the walk
|
|
1069
1123
|
* @returns {string[]}
|
|
1070
1124
|
*/
|
|
1071
|
-
function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appDir, serverFiles) {
|
|
1125
|
+
function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appDir, serverFiles, elidableComponents) {
|
|
1072
1126
|
const seen = new Set(moduleUrls);
|
|
1073
1127
|
const result = [];
|
|
1074
1128
|
|
|
@@ -1101,7 +1155,10 @@ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appD
|
|
|
1101
1155
|
const abs = resolve(appDir, url.startsWith('/') ? url.slice(1) : url);
|
|
1102
1156
|
allEntries.push(abs);
|
|
1103
1157
|
}
|
|
1104
|
-
|
|
1158
|
+
// Skip elidable components and any subtree reachable only through
|
|
1159
|
+
// them: their imports are stripped from served source, so the
|
|
1160
|
+
// browser never fetches these modules.
|
|
1161
|
+
const deps = transitiveDeps(graph, allEntries, appDir, elidableComponents);
|
|
1105
1162
|
for (const dep of deps) {
|
|
1106
1163
|
if (byIndex(dep)) continue;
|
|
1107
1164
|
const url = toUrlPath(dep, appDir);
|
|
@@ -1126,12 +1183,15 @@ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appD
|
|
|
1126
1183
|
* @param {URL | undefined} url
|
|
1127
1184
|
* @param {Record<string, any>} [metadata]
|
|
1128
1185
|
*/
|
|
1129
|
-
function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata) {
|
|
1186
|
+
function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata, nonce) {
|
|
1130
1187
|
const encoder = new TextEncoder();
|
|
1131
1188
|
const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
|
|
1132
1189
|
// Default: no caching. Pages are dynamic by default: the developer
|
|
1133
1190
|
// opts in to caching explicitly via metadata.cacheControl.
|
|
1134
1191
|
headers.set('cache-control', metadata?.cacheControl || 'no-store');
|
|
1192
|
+
// See htmlResponse: build hash on every response for the client
|
|
1193
|
+
// router's importmap-mismatch detection on partial swaps.
|
|
1194
|
+
headers.set('x-webjs-build', importMapHash());
|
|
1135
1195
|
if (req && !readToken(req)) {
|
|
1136
1196
|
const secure = url ? url.protocol === 'https:' : false;
|
|
1137
1197
|
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
@@ -1169,9 +1229,16 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1169
1229
|
// Emit just the <template>: the MutationObserver-based resolver
|
|
1170
1230
|
// in the boot script detects it and swaps it into the placeholder.
|
|
1171
1231
|
// Falls back to the __webjsResolve global for browsers without MO.
|
|
1232
|
+
// The fallback <script> carries the request's CSP nonce so
|
|
1233
|
+
// strict-CSP enforcement passes. Browsers that support
|
|
1234
|
+
// MutationObserver (all evergreen) handle the swap via the
|
|
1235
|
+
// boot script's observer and skip this fallback; the
|
|
1236
|
+
// <script> is here for legacy / extremely-restrictive
|
|
1237
|
+
// environments. Either way it must be nonce-signed.
|
|
1238
|
+
const scriptNonce = nonce ? ` nonce="${escapeAttr(nonce)}"` : '';
|
|
1172
1239
|
const chunk =
|
|
1173
1240
|
`<template data-webjs-resolve="${r.id}">${r.html}</template>` +
|
|
1174
|
-
`<script>window.__webjsResolve&&__webjsResolve("${r.id}")</script>`;
|
|
1241
|
+
`<script${scriptNonce}>window.__webjsResolve&&__webjsResolve("${r.id}")</script>`;
|
|
1175
1242
|
controller.enqueue(encoder.encode(chunk));
|
|
1176
1243
|
}
|
|
1177
1244
|
}
|
|
@@ -1208,6 +1275,17 @@ function toUrlPath(file, appDir) {
|
|
|
1208
1275
|
/**
|
|
1209
1276
|
* Extract a CSP nonce from the request's Content-Security-Policy header.
|
|
1210
1277
|
* Matches `'nonce-<base64>'` in the script-src directive.
|
|
1278
|
+
*
|
|
1279
|
+
* The regex matches the first `nonce-...` token anywhere in the
|
|
1280
|
+
* header, regardless of which directive it sits under. This is
|
|
1281
|
+
* intentional: in practice every reasonable CSP uses the same
|
|
1282
|
+
* nonce across `script-src` and `style-src` (a per-request
|
|
1283
|
+
* single-nonce model), and webjs only emits `<script>` /
|
|
1284
|
+
* `<link rel="modulepreload">` tags, so reading the first match
|
|
1285
|
+
* is the right behaviour. A future caller that emits styled
|
|
1286
|
+
* inline content under a separate style nonce would need to
|
|
1287
|
+
* extend this to be directive-scoped.
|
|
1288
|
+
*
|
|
1211
1289
|
* @param {Request} req
|
|
1212
1290
|
* @returns {string | undefined}
|
|
1213
1291
|
*/
|
|
@@ -1226,6 +1304,44 @@ function escapeAttr(s) {
|
|
|
1226
1304
|
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
1227
1305
|
}
|
|
1228
1306
|
|
|
1307
|
+
/**
|
|
1308
|
+
* Decide whether a `<link rel="modulepreload">` href needs a
|
|
1309
|
+
* `crossorigin="anonymous"` attribute. True for absolute URLs with
|
|
1310
|
+
* an http(s) scheme (vendor packages from jspm.io etc.); false for
|
|
1311
|
+
* same-origin paths like `/__webjs/core/index.js`. Browsers require
|
|
1312
|
+
* crossorigin on cross-origin module preload, else the preload is
|
|
1313
|
+
* wasted or double-fetched. Same-origin URLs must NOT have it for
|
|
1314
|
+
* the same reason in reverse.
|
|
1315
|
+
*
|
|
1316
|
+
* Exported for tests; production callers use it via documentParts.
|
|
1317
|
+
*
|
|
1318
|
+
* @param {string} url
|
|
1319
|
+
* @returns {string} either ` crossorigin="anonymous"` or empty
|
|
1320
|
+
*/
|
|
1321
|
+
export function preloadCrossOriginAttr(url) {
|
|
1322
|
+
return /^https?:\/\//i.test(url) ? ' crossorigin="anonymous"' : '';
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Look up the SRI integrity hash for a vendor URL and format it as a
|
|
1327
|
+
* `integrity="sha384-..."` attribute. Empty string for URLs without a
|
|
1328
|
+
* known hash (framework files, user code, vendor URLs in live-API
|
|
1329
|
+
* mode without a pin file).
|
|
1330
|
+
*
|
|
1331
|
+
* @param {string} url
|
|
1332
|
+
* @returns {string}
|
|
1333
|
+
*/
|
|
1334
|
+
export function integrityAttr(url) {
|
|
1335
|
+
const hash = vendorIntegrityFor(url);
|
|
1336
|
+
// Belt and suspenders: readPinFile already validates the integrity
|
|
1337
|
+
// value end-to-end against /^sha(256|384|512)-[A-Za-z0-9+/=]+$/, so
|
|
1338
|
+
// a valid hash has no HTML-special chars and escapeAttr is a no-op.
|
|
1339
|
+
// But emission goes through the same attribute-injection-safe path
|
|
1340
|
+
// as everything else in the SSR pipeline so a future regression in
|
|
1341
|
+
// the validator doesn't bypass it.
|
|
1342
|
+
return hash ? ` integrity="${escapeAttr(hash)}"` : '';
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1229
1345
|
/**
|
|
1230
1346
|
* Serialize a Next.js-shaped viewport object into the comma-separated
|
|
1231
1347
|
* `content` string the meta tag expects. Recognised fields:
|