@webjsdev/server 0.8.11 → 0.8.12

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/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
- const imports = opts.moduleUrls.map((u) => `import ${jsonForScriptTag(u)};`).join('\n');
780
- const lazyEntries = opts.lazyComponents && Object.keys(opts.lazyComponents).length
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 ? `<script type="module"${n} src="/__webjs/reload.js"></script>` : '';
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
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 `&lt;`
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
@@ -0,0 +1,147 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://webjs.dev/schemas/webjs-config.schema.json",
4
+ "title": "webjs config block",
5
+ "description": "Schema for the `webjs` object in a webjs app's package.json. Each key maps to a documented server reader. An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
6
+ "$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the server reader functions (readElideEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "elide": {
11
+ "description": "Display-only and inert-route dead-JS elision switch. Default true. Set to false to ship every module's JS app-wide, as before the feature existed. Read by readElideEnabled in dev.js; the WEBJS_ELIDE env override wins over this.",
12
+ "type": "boolean",
13
+ "default": true
14
+ },
15
+ "headers": {
16
+ "description": "Per-path response-header rules, shaped like Next's. Each rule pairs a URLPattern source against header directives. Read by compileHeaderRules in headers.js.",
17
+ "type": "array",
18
+ "items": {
19
+ "type": "object",
20
+ "required": ["source", "headers"],
21
+ "properties": {
22
+ "source": {
23
+ "description": "Path pattern matched with the native URLPattern API (so :param and :rest* syntax works).",
24
+ "type": "string"
25
+ },
26
+ "headers": {
27
+ "description": "Header directives applied on a match.",
28
+ "type": "array",
29
+ "items": {
30
+ "type": "object",
31
+ "required": ["key"],
32
+ "properties": {
33
+ "key": {
34
+ "description": "Header name, e.g. X-Frame-Options.",
35
+ "type": "string"
36
+ },
37
+ "value": {
38
+ "description": "Header value. A null or false value REMOVES the header on a match, the escape hatch that drops a secure default on a path. true is intentionally not allowed (it would stringify to the literal 'true').",
39
+ "oneOf": [{ "type": ["string", "null"] }, { "const": false }]
40
+ }
41
+ },
42
+ "additionalProperties": false
43
+ }
44
+ }
45
+ },
46
+ "additionalProperties": false
47
+ }
48
+ },
49
+ "redirects": {
50
+ "description": "Declarative permanent / temporary redirects for moved URLs. Compiled once at boot by compileRedirectRules in redirects.js. A malformed entry is dropped with a warning.",
51
+ "type": "array",
52
+ "items": {
53
+ "type": "object",
54
+ "required": ["source", "destination"],
55
+ "properties": {
56
+ "source": {
57
+ "description": "Path pattern matched with the native URLPattern API (so :param and :rest* syntax works).",
58
+ "type": "string"
59
+ },
60
+ "destination": {
61
+ "description": "Target path, a path referencing named groups captured by source, or an absolute URL. The incoming query string is preserved and merged onto the destination.",
62
+ "type": "string"
63
+ },
64
+ "permanent": {
65
+ "description": "true (the default) is a 308 Permanent Redirect, false is a 307 Temporary Redirect. Both preserve the request method and body. statusCode wins over this when set.",
66
+ "type": "boolean",
67
+ "default": true
68
+ },
69
+ "statusCode": {
70
+ "description": "Explicit redirect status, for a tool needing a legacy code. Wins over permanent. One of 301, 302, 303, 307, 308.",
71
+ "type": "integer",
72
+ "enum": [301, 302, 303, 307, 308]
73
+ }
74
+ },
75
+ "additionalProperties": false
76
+ }
77
+ },
78
+ "trailingSlash": {
79
+ "description": "Trailing-slash canonicalization policy. 'never' strips a trailing slash, 'always' adds one (both via a 308 redirect), 'ignore' (the default) does nothing. An unrecognized value is treated as 'ignore'. Read by readTrailingSlashPolicy in redirects.js.",
80
+ "type": "string",
81
+ "enum": ["never", "always", "ignore"],
82
+ "default": "ignore"
83
+ },
84
+ "basePath": {
85
+ "description": "Sub-path deployment prefix for an app mounted under example.com/app/ behind a proxy that does NOT strip the prefix. 'app', '/app', and '/app/' all normalize to '/app'; an empty value (the default) is a root mount and a pure no-op. The prefix is stripped from the incoming path at ingress and prepended to every framework-emitted URL (importmap targets, modulepreload hints, boot module specifiers, the dev reload src). Read by readBasePath in base-path.js. Author-written <a href> links and client-router navigation are NOT auto-prefixed (a documented follow-up).",
86
+ "type": "string",
87
+ "default": ""
88
+ },
89
+ "csp": {
90
+ "description": "Content-Security-Policy config. Off by default. true enables a strict nonce-based default policy. An object customizes directives and report-only mode. Read by readCspConfig in csp.js.",
91
+ "oneOf": [
92
+ {
93
+ "description": "true enables the strict default policy, false (or absence) disables CSP.",
94
+ "type": "boolean"
95
+ },
96
+ {
97
+ "description": "Custom config. directives merges over the strict defaults (a null / false / '' value drops a default directive); reportOnly emits the Content-Security-Policy-Report-Only header. A bare directive map (without a directives key) is also accepted.",
98
+ "type": "object",
99
+ "properties": {
100
+ "directives": {
101
+ "description": "Directive map merged over the strict defaults, e.g. { \"connect-src\": \"'self' https://api.example.com\" }. A __NONCE__ token in a value is replaced with the per-request nonce.",
102
+ "type": "object",
103
+ "additionalProperties": {
104
+ "type": ["string", "null", "boolean"]
105
+ }
106
+ },
107
+ "reportOnly": {
108
+ "description": "true emits Content-Security-Policy-Report-Only instead of the enforcing header (the staged-rollout path).",
109
+ "type": "boolean",
110
+ "default": false
111
+ }
112
+ }
113
+ }
114
+ ]
115
+ },
116
+ "maxBodyBytes": {
117
+ "description": "JSON / RPC request body cap in bytes. Default 1048576 (1 MiB). A value of 0 disables the cap. The WEBJS_MAX_BODY_BYTES env override wins. Read by readBodyLimits in body-limit.js.",
118
+ "type": "integer",
119
+ "minimum": 0,
120
+ "default": 1048576
121
+ },
122
+ "maxMultipartBytes": {
123
+ "description": "Form / multipart request body cap in bytes. Default 10485760 (10 MiB). A value of 0 disables the cap. The WEBJS_MAX_MULTIPART_BYTES env override wins. Read by readBodyLimits in body-limit.js.",
124
+ "type": "integer",
125
+ "minimum": 0,
126
+ "default": 10485760
127
+ },
128
+ "requestTimeoutMs": {
129
+ "description": "Max time in ms to receive the ENTIRE request (headers plus body). Default 30000. A value of 0 disables the timeout. The WEBJS_REQUEST_TIMEOUT_MS env override wins. Read by computeServerTimeouts in body-limit.js.",
130
+ "type": "integer",
131
+ "minimum": 0,
132
+ "default": 30000
133
+ },
134
+ "headersTimeoutMs": {
135
+ "description": "Max time in ms to receive just the request headers. Default 20000. Clamped strictly under requestTimeoutMs per node semantics. A value of 0 disables the timeout. The WEBJS_HEADERS_TIMEOUT_MS env override wins. Read by computeServerTimeouts in body-limit.js.",
136
+ "type": "integer",
137
+ "minimum": 0,
138
+ "default": 20000
139
+ },
140
+ "keepAliveTimeoutMs": {
141
+ "description": "Idle time in ms before a kept-alive socket is closed. Default 5000. A value of 0 disables the timeout. The WEBJS_KEEP_ALIVE_TIMEOUT_MS env override wins. Read by computeServerTimeouts in body-limit.js.",
142
+ "type": "integer",
143
+ "minimum": 0,
144
+ "default": 5000
145
+ }
146
+ }
147
+ }