@webjsdev/server 0.8.9 → 0.8.10

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,6 +1,6 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
- import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy } from '@webjsdev/core';
3
+ import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
4
4
  import { importMapTag, vendorIntegrityFor, publishedBuildId } from './importmap.js';
5
5
  import { jsonForScriptTag } from './script-tag-json.js';
6
6
  import { readToken, newToken, cookieHeader } from './csrf.js';
@@ -20,7 +20,7 @@ import { transitiveDeps } from './module-graph.js';
20
20
  * @param {import('./router.js').PageRoute} route
21
21
  * @param {Record<string,string>} params
22
22
  * @param {URL} url
23
- * @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('./module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string> }} opts
23
+ * @param {{ dev: boolean, appDir: string, req?: Request, moduleGraph?: import('./module-graph.js').ModuleGraph, serverFiles?: Map<string,string> | Set<string>, actionData?: unknown, status?: number, pageModule?: Record<string, unknown> }} opts
24
24
  * @returns {Promise<Response>}
25
25
  */
26
26
  export async function ssrPage(route, params, url, opts) {
@@ -28,6 +28,12 @@ export async function ssrPage(route, params, url, opts) {
28
28
  params,
29
29
  searchParams: Object.fromEntries(url.searchParams.entries()),
30
30
  url: url.toString(),
31
+ // Populated only when this render is the re-render after a failed page
32
+ // `action` submission (#244). The page function and every layout receive
33
+ // it so they can surface field errors and repopulate inputs from the
34
+ // user's submitted values. Undefined on a normal GET render, so GET output
35
+ // is byte-identical to before this feature.
36
+ actionData: opts.actionData,
31
37
  };
32
38
 
33
39
  // Collect metadata across layouts (outermost first) then page.
@@ -46,7 +52,7 @@ export async function ssrPage(route, params, url, opts) {
46
52
  const have = haveHeader
47
53
  ? new Set(haveHeader.split(',').map((s) => s.trim()).filter(Boolean))
48
54
  : null;
49
- const body = await renderChain(route, ctx, opts.dev, suspenseCtx, have);
55
+ const body = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
50
56
  // Module URLs for the page + every layout in its chain. These ride
51
57
  // the importmap; the browser fetches each file as it walks the
52
58
  // import graph. Combined with the modulepreload hints below, this
@@ -102,7 +108,10 @@ export async function ssrPage(route, params, url, opts) {
102
108
  streamBody,
103
109
  closer,
104
110
  suspenseCtx,
105
- 200,
111
+ // Normally 200. After a failed page `action` submission the caller passes
112
+ // 422 (or another 4xx) so the re-rendered page with field errors carries
113
+ // the right status for both the no-JS reload and the enhanced swap (#244).
114
+ opts.status || 200,
106
115
  opts.req,
107
116
  url,
108
117
  metadata,
@@ -209,8 +218,12 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
209
218
  });
210
219
  }
211
220
 
212
- async function renderChain(route, ctx, dev, suspenseCtx, have) {
213
- const page = await loadModule(route.file, dev);
221
+ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
222
+ // Reuse a caller-supplied page module when present (the page-action
223
+ // re-render passes the exact module whose `action` just ran, so the
224
+ // failure re-render shares that single evaluation instead of re-importing
225
+ // and re-running the module's top-level side effects).
226
+ const page = pageModule || await loadModule(route.file, dev);
214
227
  if (!page.default) throw new Error(`Page ${route.file} must have a default export`);
215
228
  let tree = await page.default(ctx);
216
229
 
@@ -1254,10 +1267,17 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1254
1267
  }
1255
1268
 
1256
1269
  /**
1270
+ * Import a route module. In prod the URL is stable so Node's module cache
1271
+ * serves a single evaluation; in dev a cache-bust query forces a fresh
1272
+ * evaluation so source edits take effect (which also re-runs the module's
1273
+ * top-level side effects, the reason pages/layouts must keep their top level
1274
+ * side-effect-free). Exported so page-action.js loads the page module the same
1275
+ * way the SSR re-render does.
1276
+ *
1257
1277
  * @param {string} file
1258
1278
  * @param {boolean} dev
1259
1279
  */
1260
- async function loadModule(file, dev) {
1280
+ export async function loadModule(file, dev) {
1261
1281
  const url = pathToFileURL(file).toString();
1262
1282
  const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
1263
1283
  return import(url + bust);
@@ -1275,26 +1295,24 @@ function toUrlPath(file, appDir) {
1275
1295
  }
1276
1296
 
1277
1297
  /**
1278
- * Extract a CSP nonce from the request's Content-Security-Policy header.
1279
- * Matches `'nonce-<base64>'` in the script-src directive.
1298
+ * The CSP nonce for the in-flight request, or undefined if none is in
1299
+ * scope. Delegates to `cspNonce()`, which returns the per-request nonce
1300
+ * the handler MINTED when CSP is enabled (issue #233), or, as a fallback,
1301
+ * the nonce parsed from an inbound `Content-Security-Policy` request
1302
+ * header (the legacy consume-only path). Using the same source as the
1303
+ * `Content-Security-Policy` response header is what guarantees the inline
1304
+ * boot script, the importmap, the modulepreload hints, and the header all
1305
+ * carry the EXACT same nonce: one minted value, no drift.
1280
1306
  *
1281
- * The regex matches the first `nonce-...` token anywhere in the
1282
- * header, regardless of which directive it sits under. This is
1283
- * intentional: in practice every reasonable CSP uses the same
1284
- * nonce across `script-src` and `style-src` (a per-request
1285
- * single-nonce model), and webjs only emits `<script>` /
1286
- * `<link rel="modulepreload">` tags, so reading the first match
1287
- * is the right behaviour. A future caller that emits styled
1288
- * inline content under a separate style nonce would need to
1289
- * extend this to be directive-scoped.
1307
+ * `req` is accepted (and ignored) so existing call sites stay unchanged;
1308
+ * the value comes from the request-scoped AsyncLocalStorage store, not
1309
+ * the argument.
1290
1310
  *
1291
- * @param {Request} req
1311
+ * @param {Request} [_req]
1292
1312
  * @returns {string | undefined}
1293
1313
  */
1294
- function getNonce(req) {
1295
- const csp = req.headers.get('content-security-policy') || '';
1296
- const match = /\bnonce-([A-Za-z0-9+/=]+)/.exec(csp);
1297
- return match ? match[1] : undefined;
1314
+ function getNonce(_req) {
1315
+ return cspNonce() || undefined;
1298
1316
  }
1299
1317
 
1300
1318
  /** @param {string} s */