@webjsdev/server 0.8.11 → 0.8.13
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.js +19 -0
- package/package.json +5 -2
- package/src/actions.js +112 -11
- package/src/base-path.js +149 -0
- package/src/dev.js +262 -23
- package/src/importmap.js +54 -3
- package/src/redirects.js +389 -0
- package/src/route-types.js +176 -0
- package/src/sitemap.js +161 -0
- package/src/ssr.js +108 -7
- package/src/testing.js +400 -0
- package/src/vendor.js +143 -6
- package/webjs-config.schema.json +147 -0
package/src/sitemap.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap helpers (issue #276). Two pure functions that turn an array of
|
|
3
|
+
* entries into spec-valid XML, so an author of `app/sitemap.{js,ts}` does not
|
|
4
|
+
* hand-roll the `<urlset>` string (escaping URLs, formatting lastmod dates) and
|
|
5
|
+
* has a built-in path to shard a large site via a `<sitemapindex>`.
|
|
6
|
+
*
|
|
7
|
+
* Both are OPTIONAL and tiny. The dev server serves a STRING returned from a
|
|
8
|
+
* `sitemap.{js,ts}` default export as `application/xml`, so the author returns
|
|
9
|
+
* `sitemap([...])` from that function. They can still return a raw Response or a
|
|
10
|
+
* hand-built string, so this is thin XML serialization, not a sitemap framework.
|
|
11
|
+
*
|
|
12
|
+
* Reference: the sitemaps.org 0.9 protocol; Next.js `sitemap.ts` (a typed array
|
|
13
|
+
* serialized to XML) and `generateSitemaps()` (the index for sharding).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Per-URL entry for {@link sitemap}.
|
|
17
|
+
* @typedef {object} SitemapEntry
|
|
18
|
+
* @property {string} url Absolute URL (REQUIRED). XML-escaped on emit.
|
|
19
|
+
* @property {string|Date} [lastModified] Last-modified time, formatted as W3C
|
|
20
|
+
* datetime (ISO 8601). A Date is `toISOString()`'d; a string passes through.
|
|
21
|
+
* @property {'always'|'hourly'|'daily'|'weekly'|'monthly'|'yearly'|'never'} [changeFrequency]
|
|
22
|
+
* How often the page changes. An invalid value is dropped (not emitted).
|
|
23
|
+
* @property {number} [priority] Priority 0.0..1.0. Out-of-range / non-numeric
|
|
24
|
+
* is dropped.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Child-sitemap entry for {@link sitemapIndex}.
|
|
28
|
+
* @typedef {object} SitemapIndexEntry
|
|
29
|
+
* @property {string} url Absolute URL of a child sitemap (REQUIRED).
|
|
30
|
+
* @property {string|Date} [lastModified] Last-modified time (W3C datetime).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const VALID_CHANGEFREQ = new Set([
|
|
34
|
+
'always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const MAX_URLS = 50000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* XML-escape text destined for an element body. This is the security-critical
|
|
41
|
+
* part: a url derived from author data or a DB can contain `&` or `<`, which
|
|
42
|
+
* would break the XML document or allow injection if emitted raw. The five
|
|
43
|
+
* predefined XML entities are replaced (`&` first, so the entities the other
|
|
44
|
+
* replacements introduce are not double-escaped).
|
|
45
|
+
* @param {string} value
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function escapeXml(value) {
|
|
49
|
+
return String(value)
|
|
50
|
+
// Strip the C0 control characters XML 1.0 forbids (everything below 0x20
|
|
51
|
+
// except tab, newline, and carriage return). A stray one from a corrupt
|
|
52
|
+
// DB-derived URL would otherwise make the WHOLE sitemap document invalid
|
|
53
|
+
// and silently rejected by search engines, the exact failure this helper
|
|
54
|
+
// exists to prevent. Done before entity-escaping so it cannot interfere.
|
|
55
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
|
|
56
|
+
.replace(/&/g, '&')
|
|
57
|
+
.replace(/</g, '<')
|
|
58
|
+
.replace(/>/g, '>')
|
|
59
|
+
.replace(/"/g, '"')
|
|
60
|
+
.replace(/'/g, ''');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format a `lastModified` value as a W3C datetime (ISO 8601) string. A Date
|
|
65
|
+
* becomes its `toISOString()`; a non-empty string passes through verbatim (the
|
|
66
|
+
* spec accepts both `YYYY-MM-DD` and a full timestamp). Anything else yields
|
|
67
|
+
* null so the `<lastmod>` element is omitted rather than emitted broken.
|
|
68
|
+
* @param {string|Date|undefined} value
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
function formatLastmod(value) {
|
|
72
|
+
if (value instanceof Date) {
|
|
73
|
+
const ms = value.getTime();
|
|
74
|
+
if (Number.isNaN(ms)) return null;
|
|
75
|
+
return value.toISOString();
|
|
76
|
+
}
|
|
77
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim();
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate + format a `priority` to one decimal in the 0.0..1.0 range. Returns
|
|
83
|
+
* null (so the element is dropped) for a non-number or an out-of-range value.
|
|
84
|
+
* @param {number|undefined} value
|
|
85
|
+
* @returns {string|null}
|
|
86
|
+
*/
|
|
87
|
+
function formatPriority(value) {
|
|
88
|
+
if (typeof value !== 'number' || Number.isNaN(value)) return null;
|
|
89
|
+
if (value < 0 || value > 1) return null;
|
|
90
|
+
return value.toFixed(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Serialize an array of {@link SitemapEntry} into a complete `<urlset>` XML
|
|
95
|
+
* document. Element order per the protocol is loc, lastmod, changefreq,
|
|
96
|
+
* priority. A malformed entry (missing / non-string / empty `url`) is skipped so
|
|
97
|
+
* it cannot produce broken XML. Output is deterministic.
|
|
98
|
+
* @param {SitemapEntry[]} entries
|
|
99
|
+
* @returns {string} a `<?xml ?>` + `<urlset>` document
|
|
100
|
+
*/
|
|
101
|
+
export function sitemap(entries) {
|
|
102
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
103
|
+
if (list.length > MAX_URLS) {
|
|
104
|
+
console.warn(
|
|
105
|
+
`[webjs] sitemap() received ${list.length} URLs, over the ${MAX_URLS} per-file limit. ` +
|
|
106
|
+
'Shard the site with sitemapIndex() instead. Emitting all URLs anyway.'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const urls = [];
|
|
110
|
+
for (const entry of list) {
|
|
111
|
+
if (!entry || typeof entry.url !== 'string' || entry.url.trim() === '') {
|
|
112
|
+
console.warn('[webjs] sitemap(): skipping an entry with a missing or invalid url');
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const parts = [` <loc>${escapeXml(entry.url.trim())}</loc>`];
|
|
116
|
+
const lastmod = formatLastmod(entry.lastModified);
|
|
117
|
+
if (lastmod !== null) parts.push(` <lastmod>${escapeXml(lastmod)}</lastmod>`);
|
|
118
|
+
if (typeof entry.changeFrequency === 'string' && VALID_CHANGEFREQ.has(entry.changeFrequency)) {
|
|
119
|
+
parts.push(` <changefreq>${entry.changeFrequency}</changefreq>`);
|
|
120
|
+
}
|
|
121
|
+
const priority = formatPriority(entry.priority);
|
|
122
|
+
if (priority !== null) parts.push(` <priority>${priority}</priority>`);
|
|
123
|
+
urls.push(` <url>\n${parts.join('\n')}\n </url>`);
|
|
124
|
+
}
|
|
125
|
+
return (
|
|
126
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
127
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
|
|
128
|
+
(urls.length ? urls.join('\n') + '\n' : '') +
|
|
129
|
+
'</urlset>\n'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Serialize an array of {@link SitemapIndexEntry} into a `<sitemapindex>` XML
|
|
135
|
+
* document, one `<sitemap>` per child sitemap URL. Same escaping + lastmod rules
|
|
136
|
+
* and same skip-malformed-entry behavior as {@link sitemap}. Use it from the
|
|
137
|
+
* root `app/sitemap.{js,ts}` to point at sharded child sitemaps served by
|
|
138
|
+
* `route.{js,ts}` handlers.
|
|
139
|
+
* @param {SitemapIndexEntry[]} sitemaps
|
|
140
|
+
* @returns {string} a `<?xml ?>` + `<sitemapindex>` document
|
|
141
|
+
*/
|
|
142
|
+
export function sitemapIndex(sitemaps) {
|
|
143
|
+
const list = Array.isArray(sitemaps) ? sitemaps : [];
|
|
144
|
+
const items = [];
|
|
145
|
+
for (const entry of list) {
|
|
146
|
+
if (!entry || typeof entry.url !== 'string' || entry.url.trim() === '') {
|
|
147
|
+
console.warn('[webjs] sitemapIndex(): skipping an entry with a missing or invalid url');
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const parts = [` <loc>${escapeXml(entry.url.trim())}</loc>`];
|
|
151
|
+
const lastmod = formatLastmod(entry.lastModified);
|
|
152
|
+
if (lastmod !== null) parts.push(` <lastmod>${escapeXml(lastmod)}</lastmod>`);
|
|
153
|
+
items.push(` <sitemap>\n${parts.join('\n')}\n </sitemap>`);
|
|
154
|
+
}
|
|
155
|
+
return (
|
|
156
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
157
|
+
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
|
|
158
|
+
(items.length ? items.join('\n') + '\n' : '') +
|
|
159
|
+
'</sitemapindex>\n'
|
|
160
|
+
);
|
|
161
|
+
}
|
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, cspNonce } from '@webjsdev/core';
|
|
4
|
-
import { importMapTag, vendorIntegrityFor, publishedBuildId } from './importmap.js';
|
|
4
|
+
import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath } from './importmap.js';
|
|
5
|
+
import { withBasePath } from './base-path.js';
|
|
5
6
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
6
7
|
import { readToken, newToken, cookieHeader } from './csrf.js';
|
|
7
8
|
import { transitiveDeps } from './module-graph.js';
|
|
@@ -776,15 +777,36 @@ function wrapHead(opts) {
|
|
|
776
777
|
// the request's CSP header by the caller.
|
|
777
778
|
const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
778
779
|
|
|
779
|
-
|
|
780
|
-
|
|
780
|
+
// Sub-path deployment (issue #256): the boot script's per-route module
|
|
781
|
+
// specifiers and the dev reload `src` are framework-emitted same-origin
|
|
782
|
+
// absolute URLs, so prefix them with the base path (a no-op when empty).
|
|
783
|
+
// The lazy-loader import is a BARE specifier resolved through the importmap
|
|
784
|
+
// (whose target is already base-path-prefixed in importmap.js), so it is
|
|
785
|
+
// NOT prefixed here. The base path is the one set at boot via setBasePath
|
|
786
|
+
// (read from importmap.js's module state), the same value the importmap
|
|
787
|
+
// targets were prefixed with, so the boot specifiers and the map agree.
|
|
788
|
+
const bp = basePath();
|
|
789
|
+
const imports = opts.moduleUrls
|
|
790
|
+
.map((u) => `import ${jsonForScriptTag(withBasePath(u, bp))};`)
|
|
791
|
+
.join('\n');
|
|
792
|
+
const rawLazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
|
|
781
793
|
? opts.lazyComponents
|
|
782
794
|
: null;
|
|
795
|
+
// The lazy map's values are same-origin module URLs `observeLazy` will
|
|
796
|
+
// dynamically import, so prefix them with the base path too (no-op when
|
|
797
|
+
// empty).
|
|
798
|
+
const lazyEntries = rawLazyEntries && bp
|
|
799
|
+
? Object.fromEntries(
|
|
800
|
+
Object.entries(rawLazyEntries).map(([tag, u]) => [tag, withBasePath(u, bp)]),
|
|
801
|
+
)
|
|
802
|
+
: rawLazyEntries;
|
|
783
803
|
const lazyBoot = lazyEntries
|
|
784
804
|
? `\nimport { observeLazy } from '@webjsdev/core/lazy-loader';\nobserveLazy(${jsonForScriptTag(lazyEntries)});`
|
|
785
805
|
: '';
|
|
786
806
|
const boot = (imports || lazyBoot) ? `<script type="module"${n}>\n${imports}${lazyBoot}\n</script>` : '';
|
|
787
|
-
const reload = opts.dev
|
|
807
|
+
const reload = opts.dev
|
|
808
|
+
? `<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
|
|
809
|
+
: '';
|
|
788
810
|
const suspenseBoot = opts.streaming
|
|
789
811
|
? `<script${n}>(function(){` +
|
|
790
812
|
`function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
|
|
@@ -803,6 +825,8 @@ function wrapHead(opts) {
|
|
|
803
825
|
// alternates, archives, etc.) AND by the preload block further down.
|
|
804
826
|
// Hoist the declaration so the metadata block can push into it.
|
|
805
827
|
const linkTags = [];
|
|
828
|
+
// scriptTags collects JSON-LD structured-data blocks (see m.jsonLd below).
|
|
829
|
+
const scriptTags = [];
|
|
806
830
|
|
|
807
831
|
// Tiny URL resolver against metadataBase. If metadataBase is set and a
|
|
808
832
|
// value looks like a relative URL (no scheme, no `//` prefix), resolve
|
|
@@ -1036,6 +1060,23 @@ function wrapHead(opts) {
|
|
|
1036
1060
|
}
|
|
1037
1061
|
}
|
|
1038
1062
|
|
|
1063
|
+
// JSON-LD structured data (schema.org). `m.jsonLd` is a single object
|
|
1064
|
+
// OR an array of objects. The author owns the schema.org shape; the
|
|
1065
|
+
// framework only serializes and HTML-safe-escapes each object into a
|
|
1066
|
+
// `<script type="application/ld+json">` block. A single object emits
|
|
1067
|
+
// ONE script; an array emits one script PER element.
|
|
1068
|
+
//
|
|
1069
|
+
// The block is a NON-EXECUTABLE data island (type application/ld+json),
|
|
1070
|
+
// so CSP script-src does not gate it and it carries NO nonce. Adding one
|
|
1071
|
+
// would wrongly imply it is executable script.
|
|
1072
|
+
if (m.jsonLd != null) {
|
|
1073
|
+
const list = Array.isArray(m.jsonLd) ? m.jsonLd : [m.jsonLd];
|
|
1074
|
+
for (const obj of list) {
|
|
1075
|
+
const tag = jsonLdScript(obj);
|
|
1076
|
+
if (tag) scriptTags.push(tag);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1039
1080
|
// Preload hints: page modules themselves + every discovered component
|
|
1040
1081
|
// module, then any custom `metadata.preload` entries (fonts, images, etc.)
|
|
1041
1082
|
// (linkTags array was declared earlier so the metadata block above can
|
|
@@ -1056,15 +1097,21 @@ function wrapHead(opts) {
|
|
|
1056
1097
|
// importmap-rails) applies nonce on every modulepreload tag for
|
|
1057
1098
|
// the same reason.
|
|
1058
1099
|
const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
|
|
1100
|
+
// Sub-path deployment (issue #256): the modulepreload href is prefixed with
|
|
1101
|
+
// the base path (a no-op when empty), but `crossorigin` / `integrity` are
|
|
1102
|
+
// decided on the ORIGINAL url, so the integrity lookup still keys on the
|
|
1103
|
+
// unprefixed map url and a cross-origin CDN url (never prefixed) keeps its
|
|
1104
|
+
// crossorigin attribute.
|
|
1105
|
+
const bpPreload = basePath();
|
|
1059
1106
|
for (const url of opts.moduleUrls) {
|
|
1060
1107
|
linkTags.push(
|
|
1061
|
-
`<link rel="modulepreload" href="${escapeAttr(url)}"` +
|
|
1108
|
+
`<link rel="modulepreload" href="${escapeAttr(withBasePath(url, bpPreload))}"` +
|
|
1062
1109
|
`${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
|
|
1063
1110
|
);
|
|
1064
1111
|
}
|
|
1065
1112
|
for (const url of opts.preloads || []) {
|
|
1066
1113
|
linkTags.push(
|
|
1067
|
-
`<link rel="modulepreload" href="${escapeAttr(url)}"` +
|
|
1114
|
+
`<link rel="modulepreload" href="${escapeAttr(withBasePath(url, bpPreload))}"` +
|
|
1068
1115
|
`${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
|
|
1069
1116
|
);
|
|
1070
1117
|
}
|
|
@@ -1168,7 +1215,7 @@ ${metaTags.join('\n')}
|
|
|
1168
1215
|
${publicEnvShim({ dev: opts.dev, nonce: opts.nonce })}
|
|
1169
1216
|
${importMapTag({ nonce: opts.nonce })}
|
|
1170
1217
|
${linkTags.join('\n')}
|
|
1171
|
-
${boot}
|
|
1218
|
+
${scriptTags.length ? scriptTags.join('\n') + '\n' : ''}${boot}
|
|
1172
1219
|
${reload}
|
|
1173
1220
|
${suspenseBoot}
|
|
1174
1221
|
</head>
|
|
@@ -1424,6 +1471,60 @@ function escapeAttr(s) {
|
|
|
1424
1471
|
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
1425
1472
|
}
|
|
1426
1473
|
|
|
1474
|
+
/**
|
|
1475
|
+
* HTML-safe-escape a JSON string for embedding inside a
|
|
1476
|
+
* `<script type="application/ld+json">` element.
|
|
1477
|
+
*
|
|
1478
|
+
* This is NOT the HTML-entity escaper (escapeHtml / escapeAttr). A
|
|
1479
|
+
* JSON parser reads the raw character, so turning `<` into `<`
|
|
1480
|
+
* would CORRUPT the JSON. Instead we emit the Unicode escape form
|
|
1481
|
+
* (`<`), which a JSON parser decodes back to the original
|
|
1482
|
+
* character while making the literal byte sequence `</script>`
|
|
1483
|
+
* impossible to form in the served HTML. So the embedded data parses
|
|
1484
|
+
* back to the author's exact object, AND a value containing
|
|
1485
|
+
* `</script><img onerror=...>` can never break out of the script tag.
|
|
1486
|
+
*
|
|
1487
|
+
* U+2028 / U+2029 are escaped too: they are valid inside a JSON
|
|
1488
|
+
* string but are line terminators in HTML/JS contexts, and some
|
|
1489
|
+
* consumers choke on them. Escaping keeps the block robust.
|
|
1490
|
+
*
|
|
1491
|
+
* @param {string} json the `JSON.stringify` output
|
|
1492
|
+
* @returns {string}
|
|
1493
|
+
*/
|
|
1494
|
+
function escapeJsonLd(json) {
|
|
1495
|
+
return json
|
|
1496
|
+
.replace(/</g, '\\u003c')
|
|
1497
|
+
.replace(/>/g, '\\u003e')
|
|
1498
|
+
.replace(/&/g, '\\u0026')
|
|
1499
|
+
.replace(/\u2028/g, '\\u2028')
|
|
1500
|
+
.replace(/\u2029/g, '\\u2029');
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Serialize one schema.org object into a `<script type="application/ld+json">`
|
|
1505
|
+
* block, HTML-safe-escaped via escapeJsonLd. Fails SAFE: a non-object
|
|
1506
|
+
* input, or a circular reference that makes JSON.stringify throw, is
|
|
1507
|
+
* skipped (returns the empty string) with a one-line warn, never breaking
|
|
1508
|
+
* the whole render.
|
|
1509
|
+
*
|
|
1510
|
+
* @param {unknown} obj
|
|
1511
|
+
* @returns {string} the script tag, or '' to skip this element
|
|
1512
|
+
*/
|
|
1513
|
+
function jsonLdScript(obj) {
|
|
1514
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
1515
|
+
try {
|
|
1516
|
+
const json = JSON.stringify(obj);
|
|
1517
|
+
if (typeof json !== 'string') return '';
|
|
1518
|
+
return `<script type="application/ld+json">${escapeJsonLd(json)}</script>`;
|
|
1519
|
+
} catch (err) {
|
|
1520
|
+
console.warn('[webjs] metadata.jsonLd: skipped an entry that could not be serialized:', err && err.message);
|
|
1521
|
+
return '';
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Internal helpers re-exported for unit testing.
|
|
1526
|
+
export { escapeJsonLd as _escapeJsonLd, jsonLdScript as _jsonLdScript };
|
|
1527
|
+
|
|
1427
1528
|
/**
|
|
1428
1529
|
* Decide whether a `<link rel="modulepreload">` href needs a
|
|
1429
1530
|
* `crossorigin="anonymous"` attribute. True for absolute URLs with
|