@webjsdev/server 0.8.43 → 0.8.45

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.d.ts CHANGED
@@ -212,6 +212,23 @@ export declare function applyEnvValidation(
212
212
  opts?: { dev?: boolean; env?: Record<string, string | undefined> },
213
213
  ): Promise<void>;
214
214
 
215
+ // ---------------------------------------------------------------------------
216
+ // instrumentation.js (#848)
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /**
220
+ * Register an error sink from inside `instrumentation.register()`. Composes with
221
+ * the `createRequestHandler({ onError })` option (both fire).
222
+ */
223
+ export declare function setOnError(
224
+ fn: (error: unknown, ctx?: { request?: Request; requestId?: string; phase?: string }) => void,
225
+ ): void;
226
+ /** Run the app's boot-time `instrumentation.{js,ts}` hook once; returns any registered error sink. */
227
+ export declare function runInstrumentation(
228
+ appDir: string,
229
+ opts?: { dev?: boolean; logger?: { error?: (...a: unknown[]) => void } },
230
+ ): Promise<{ onError: ((error: unknown, ctx?: unknown) => void) | null }>;
231
+
215
232
  // ---------------------------------------------------------------------------
216
233
  // router.js
217
234
  // ---------------------------------------------------------------------------
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { startServer, createRequestHandler } from './src/dev.js';
2
2
  export { assertNodeVersion, checkNodeVersion, requiredNodeMajor, parseMajor, parseRequiredMajor } from './src/node-version.js';
3
3
  export { validateEnv, formatEnvErrors, loadEnvSchema, applyEnvValidation } from './src/env-schema.js';
4
+ export { setOnError, runInstrumentation } from './src/instrumentation.js';
4
5
  export { buildRouteTable, matchPage, matchApi } from './src/router.js';
5
6
  export { generateRouteTypes } from './src/route-types.js';
6
7
  export { ssrPage, ssrNotFound } from './src/ssr.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.43",
3
+ "version": "0.8.45",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -18,7 +18,7 @@
18
18
  * @module action-error
19
19
  */
20
20
 
21
- import { isRedirect, isNotFound } from '@webjsdev/core';
21
+ import { isRedirect, isNotFound, isForbidden, isUnauthorized } from '@webjsdev/core';
22
22
  import { digestHex } from './crypto-utils.js';
23
23
 
24
24
  /** The generic client-facing message for a sanitized prod action error. */
@@ -38,7 +38,7 @@ export const GENERIC_ERROR_MESSAGE = 'Internal server error';
38
38
  * @returns {boolean}
39
39
  */
40
40
  export function isControlFlowThrow(err) {
41
- return isRedirect(err) || isNotFound(err);
41
+ return isRedirect(err) || isNotFound(err) || isForbidden(err) || isUnauthorized(err);
42
42
  }
43
43
 
44
44
  /**
package/src/api.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { pathToFileURL } from 'node:url';
2
+ import { makeThenable } from './thenable-params.js';
2
3
 
3
4
  /**
4
5
  * Dispatch an incoming request to a matched API route.
@@ -24,10 +25,14 @@ export async function handleApi(route, params, webRequest, dev) {
24
25
  headers: { allow: allowedMethods(mod).join(', ') },
25
26
  });
26
27
  }
27
- /** @type any */ (webRequest).params = params;
28
+ // Route-handler params are awaitable AND sync-readable (#848, Next parity:
29
+ // `const { id } = await params`). Non-enumerable `then`, so a handler that
30
+ // spreads / JSON-stringifies params is unaffected.
31
+ const thenableParams = makeThenable(params);
32
+ /** @type any */ (webRequest).params = thenableParams;
28
33
  let result;
29
34
  try {
30
- result = await handler(webRequest, { params });
35
+ result = await handler(webRequest, { params: thenableParams });
31
36
  } catch (e) {
32
37
  // A route handler that read its body via `readBody` (json.js) over the
33
38
  // size limit (issue #237) throws a BodyLimitError; surface it as 413 rather
package/src/check.js CHANGED
@@ -139,6 +139,11 @@ export const RULES = [
139
139
  description:
140
140
  'API route handlers (`route.{js,ts}`) must NOT call `redirect()` from `@webjsdev/core`. That function throws a control-flow signal designed for the SSR page renderer; in a route handler it goes uncaught and produces a 500. Use `Response.redirect(url, 303)` for external redirects or return a 3xx Response directly. Page functions, layouts, and server actions may still use `redirect()` (caught by the SSR pipeline).',
141
141
  },
142
+ {
143
+ name: 'no-interpolation-in-raw-text-element',
144
+ description:
145
+ 'Flags a template interpolation (`${...}`) placed as a child of a `<style>` or `<script>` element inside a COMPONENT `html` template. Raw-text elements are an SSR/client asymmetry trap: the server renderer emits the interpolated content, but the client renderer drops it (a raw-text hole is a `noop`, since the compile cache is keyed on the static strings), so the element renders correctly on the server and then wipes to empty on hydration. Scoped to components (files with a `WebComponent` class), which hydrate; pages and layouts render server-only and never hydrate, so a page interpolating a `css` result into a `<style>` is a legitimate pattern and is not flagged. In a component, author scoped CSS with `static styles` (shadow DOM) or a `css` template. Found dogfooding a tic-tac-toe app (#845): a `<style>${STYLE}</style>` painted at SSR then vanished on hydrate.',
146
+ },
142
147
  ];
143
148
 
144
149
  /** Set of all known rule names for fast lookup. */
@@ -634,6 +639,50 @@ export async function checkConventions(appDir) {
634
639
  }
635
640
  }
636
641
 
642
+ // --- Rule: no-interpolation-in-raw-text-element ---
643
+ // A `${...}` hole inside a `<style>` or `<script>` element in an `html`
644
+ // template is an SSR/client asymmetry: `renderToString` emits it, but the
645
+ // client parser drops a raw-text hole as a `noop` (the compile cache is keyed
646
+ // on the static strings, so a per-render value cannot be baked in), so the
647
+ // element paints at SSR then wipes to empty on hydration.
648
+ //
649
+ // Scoped to COMPONENTS. The drop only happens on the CLIENT renderer, which
650
+ // runs for components (hydration + re-render). Pages and layouts render
651
+ // server-only (never hydrate), so a page's `<style>${STYLES.text}</style>` is
652
+ // a legitimate, taught pattern and must NOT be flagged. Scan raw source with
653
+ // comments stripped (the tag text lives in a template string, which the
654
+ // redacted `scan` view blanks). One violation per file.
655
+ {
656
+ for (const { rel, content } of files) {
657
+ // Only files that define a hydrating custom element. A page/layout that
658
+ // interpolates a `css` result into a `<style>` is server-only and fine.
659
+ if (!/class\s+\w+\s+extends\s+WebComponent/.test(content)) continue;
660
+ const stripped = content
661
+ .replace(/\/\/.*$/gm, '')
662
+ .replace(/\/\*[\s\S]*?\*\//g, '');
663
+ for (const tag of ['style', 'script']) {
664
+ // `<tag ...> ... ${ ... </tag>`, where the hole sits before the close
665
+ // tag. The negative lookahead keeps the match from crossing `</tag>`.
666
+ const re = new RegExp(
667
+ `<${tag}\\b[^>]*>(?:(?!<\\/${tag}>)[\\s\\S])*?\\$\\{(?:(?!<\\/${tag}>)[\\s\\S])*?<\\/${tag}>`,
668
+ 'i',
669
+ );
670
+ if (re.test(stripped)) {
671
+ violations.push({
672
+ rule: 'no-interpolation-in-raw-text-element',
673
+ file: rel,
674
+ message: `An interpolation (\`\${...}\`) sits inside a <${tag}> element in an html template. The server renderer emits it but the client renderer drops it, so it paints at SSR then wipes to empty on hydration.`,
675
+ fix:
676
+ tag === 'style'
677
+ ? `Move the CSS out of the raw-text hole: use \`static styles\` (shadow DOM) or a \`css\` template for a component, or put page CSS in the layout. Static \`<style>...</style>\` with no \`\${}\` is fine.`
678
+ : `Build the script body outside the raw-text element (set attributes/properties via bindings, or compute the value before the template). Static \`<script>...</script>\` with no \`\${}\` is fine.`,
679
+ });
680
+ break; // one violation per file is enough
681
+ }
682
+ }
683
+ }
684
+ }
685
+
637
686
  // --- Rule: no-server-env-in-components ---
638
687
  // Catches `process.env.X` reads in component files where X is not a
639
688
  // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
package/src/dev.js CHANGED
@@ -30,6 +30,7 @@ import { stripTypeScript, ensureStripper } from './ts-strip.js';
30
30
  import { defaultLogger } from './logger.js';
31
31
  import { assertNodeVersion } from './node-version.js';
32
32
  import { applyEnvValidation } from './env-schema.js';
33
+ import { runInstrumentation, findInstrumentationClient } from './instrumentation.js';
33
34
  import { withRequest, setCspNonce, setBodyLimits, setRequestId, requestId as getRequestId } from './context.js';
34
35
  import { buildInfoResponse } from './build-info.js';
35
36
  import { readCspConfig, mintNonce, buildCspHeader, cspHeaderName } from './csp.js';
@@ -482,6 +483,13 @@ export async function createRequestHandler(opts) {
482
483
  await applyEnvValidation(appDir, { dev: !!opts.dev });
483
484
  const dev = !!opts.dev;
484
485
  const logger = opts.logger || defaultLogger({ dev });
486
+ // Boot-time instrumentation hook (#848): run the optional app-root
487
+ // instrumentation.{js,ts} register() ONCE, after env validation and before
488
+ // the route table / action index are built, so observability plumbing starts
489
+ // before traffic. Any error sink it registers via setOnError composes with
490
+ // the programmatic opts.onError (both fire). Fail-open (a throwing hook is
491
+ // logged, not fatal).
492
+ const { onError: instrumentationOnError } = await runInstrumentation(appDir, { dev, logger });
485
493
  // APM / Sentry integration point (issue #239). Called whenever the request
486
494
  // pipeline catches an unhandled error: the top-level handle() catch (the
487
495
  // last-resort 500), an unexpected throw inside the produce() funnel, or a
@@ -490,20 +498,26 @@ export async function createRequestHandler(opts) {
490
498
  // sanitized 500 / existing error behavior is unchanged (the hook is purely
491
499
  // additive). The sink receives the error plus the correlation id so it can
492
500
  // tie the report to the same id the access log and X-Request-Id carry.
493
- const onError = typeof opts.onError === 'function' ? opts.onError : null;
501
+ // Both the programmatic opts.onError AND any sink an instrumentation.{js,ts}
502
+ // register() installed via setOnError fire (they compose, #848).
503
+ const onErrorSinks = [opts.onError, instrumentationOnError].filter((f) => typeof f === 'function');
504
+ const hasOnError = onErrorSinks.length > 0;
494
505
  /**
495
- * Invoke the app's onError sink defensively. The phase is a coarse label of
496
- * where the pipeline caught the error, for the sink's own grouping.
506
+ * Invoke every registered onError sink defensively. The phase is a coarse
507
+ * label of where the pipeline caught the error, for the sink's own grouping.
497
508
  * @param {unknown} error
498
509
  * @param {Request} request
499
510
  * @param {string} phase
500
511
  */
501
512
  function reportError(error, request, phase) {
502
- if (!onError) return;
503
- try {
504
- onError(error, { request, requestId: getRequestId(), phase });
505
- } catch (e) {
506
- logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
513
+ if (!hasOnError) return;
514
+ const ctx = { request, requestId: getRequestId(), phase };
515
+ for (const sink of onErrorSinks) {
516
+ try {
517
+ sink(error, ctx);
518
+ } catch (e) {
519
+ logger.error?.('[webjs] onError hook threw (ignored)', { err: String(e) });
520
+ }
507
521
  }
508
522
  }
509
523
  // Sub-path deployment base path (issue #256), read once from the app's
@@ -616,6 +630,10 @@ export async function createRequestHandler(opts) {
616
630
  // eagerly: it is a cheap directory scan (no code reads), and routing, Early
617
631
  // Hints, and WebSocket lookups need it available before the first request.
618
632
  const routeTable = await buildRouteTable(appDir);
633
+ // instrumentation-client.{js,ts} (#848) is an app-ROOT file (sibling of app/,
634
+ // like env.js / readiness.js), not a router stem. Resolve it once and stash it
635
+ // on the route table so ssrOpts + the browser-servable gate reach it uniformly.
636
+ routeTable.instrumentationClient = await findInstrumentationClient(appDir);
619
637
 
620
638
  // Emit `.webjs/routes.d.ts` (typed Route union + per-route params, #258) in
621
639
  // dev so an editor's tsserver always has up-to-date route types without the
@@ -1011,6 +1029,7 @@ export async function createRequestHandler(opts) {
1011
1029
  // The route table is the only eager artifact (cheap directory scan); rebuild
1012
1030
  // it so routing reflects added/removed route files immediately.
1013
1031
  state.routeTable = await buildRouteTable(appDir);
1032
+ state.routeTable.instrumentationClient = await findInstrumentationClient(appDir);
1014
1033
  // Refresh the generated route types (#258) so adding/removing a route file
1015
1034
  // updates `.webjs/routes.d.ts` without a manual `webjs types`. Dev only,
1016
1035
  // best-effort (see emitRouteTypes).
@@ -2010,6 +2029,14 @@ async function handleCore(req, ctx) {
2010
2029
  inertRouteModules: state.inertRouteModules,
2011
2030
  importOnlyRouteModules: state.importOnlyRouteModules,
2012
2031
  notFoundFile: state.routeTable.notFound,
2032
+ // Root-only boundaries (#848): the app-wide catch-all error page and the
2033
+ // unmatched-anywhere 404, rendered by ssr.js when a nested boundary is
2034
+ // absent.
2035
+ globalError: state.routeTable.globalError,
2036
+ globalNotFound: state.routeTable.globalNotFound,
2037
+ // instrumentation-client.{js,ts} (#848): imported first in the client
2038
+ // boot so it runs before app modules.
2039
+ instrumentationClient: state.routeTable.instrumentationClient,
2013
2040
  // Server HTML cache (#241): a CSP-enabled page emits a fresh
2014
2041
  // per-request nonce into its body, so its bytes vary per request and
2015
2042
  // it must never be HTML-cached. Pass the flag so the cache guard skips
@@ -2039,7 +2066,9 @@ async function handleCore(req, ctx) {
2039
2066
  if (wantsJson(req, path)) {
2040
2067
  return Response.json({ error: 'Not found', path }, { status: 404 });
2041
2068
  }
2042
- return ssrNotFound(state.routeTable.notFound, { dev, appDir, req, url });
2069
+ // Unmatched anywhere: prefer the root not-found.{js,ts}, then a
2070
+ // global-not-found.{js,ts} (#848), else the default 404 page.
2071
+ return ssrNotFound(state.routeTable.notFound || state.routeTable.globalNotFound, { dev, appDir, req, url });
2043
2072
  }
2044
2073
 
2045
2074
  /** @param {Request} req @param {string} path */
@@ -2487,11 +2516,18 @@ function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
2487
2516
  for (const f of page.layouts || []) entries.add(f);
2488
2517
  for (const f of page.errors || []) entries.add(f);
2489
2518
  for (const f of page.loadings || []) entries.add(f);
2519
+ for (const f of page.forbiddens || []) entries.add(f);
2520
+ for (const f of page.unauthorizeds || []) entries.add(f);
2490
2521
  }
2491
2522
  if (routeTable.notFound) entries.add(routeTable.notFound);
2492
2523
  if (routeTable.notFounds) {
2493
2524
  for (const f of routeTable.notFounds.values()) entries.add(f);
2494
2525
  }
2526
+ if (routeTable.globalError) entries.add(routeTable.globalError);
2527
+ if (routeTable.globalNotFound) entries.add(routeTable.globalNotFound);
2528
+ // instrumentation-client is browser-bound (imported first in the boot), so it
2529
+ // must be servable through the gate.
2530
+ if (routeTable.instrumentationClient) entries.add(routeTable.instrumentationClient);
2495
2531
  // Lazy components live in the registry but no page imports their
2496
2532
  // class directly; the lazy-loader fetches their module URLs on
2497
2533
  // viewport entry. Add every discovered component file as an entry so
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Boot-time instrumentation hook (#848, Next parity for instrumentation.ts).
3
+ *
4
+ * An optional app-root `instrumentation.{js,ts}` default-exports (or names) a
5
+ * `register()` function the framework runs ONCE at boot, before the route table
6
+ * is built and before any request is served. It is the place to wire
7
+ * observability plumbing (OpenTelemetry, an APM, a logger) that must start
8
+ * before the app handles traffic.
9
+ *
10
+ * Inside `register()`, the app may call `setOnError(fn)` (imported from
11
+ * `@webjsdev/server`) to register an error sink. It COMPOSES with the
12
+ * programmatic `createRequestHandler({ onError })` option (both fire), so the
13
+ * file-based hook and the option-based sink coexist.
14
+ *
15
+ * @module instrumentation
16
+ */
17
+
18
+ import { join } from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
20
+ import { stat } from 'node:fs/promises';
21
+ import { AsyncLocalStorage } from 'node:async_hooks';
22
+
23
+ /**
24
+ * Per-boot handoff channel for a `register()`-registered error sink. An
25
+ * AsyncLocalStorage (NOT a module global) so two `runInstrumentation` calls
26
+ * racing in one process (e.g. `Promise.all` of two `createRequestHandler`s in a
27
+ * test) each get their OWN store: `setOnError` writes to whichever run's context
28
+ * is active, so a concurrent boot can never capture the other's sink. The
29
+ * context propagates across the `await register()` awaits.
30
+ * @type {AsyncLocalStorage<{ onError: ((error: unknown, ctx?: any) => void) | null }>}
31
+ */
32
+ const _als = new AsyncLocalStorage();
33
+
34
+ /**
35
+ * Register an error sink from inside `instrumentation.register()`. Composes with
36
+ * the `createRequestHandler({ onError })` option. Passing a non-function clears
37
+ * it. A call made outside a `runInstrumentation` context (no active store) is a
38
+ * safe no-op.
39
+ * @param {(error: unknown, ctx?: any) => void} fn
40
+ */
41
+ export function setOnError(fn) {
42
+ const store = _als.getStore();
43
+ if (store) store.onError = typeof fn === 'function' ? fn : null;
44
+ }
45
+
46
+ async function exists(p) {
47
+ try { await stat(p); return true; } catch { return false; }
48
+ }
49
+
50
+ /**
51
+ * Locate the optional app-root `instrumentation-client.{js,ts,mts,mjs}` (#848),
52
+ * the browser boot hook imported first in the client boot script. Lives at the
53
+ * app root (sibling of `app/`), like the server `instrumentation.{js,ts}` and
54
+ * Next's instrumentation-client. Returns the absolute path or null.
55
+ * @param {string} appDir
56
+ * @returns {Promise<string | null>}
57
+ */
58
+ export async function findInstrumentationClient(appDir) {
59
+ for (const name of ['instrumentation-client.ts', 'instrumentation-client.js', 'instrumentation-client.mts', 'instrumentation-client.mjs']) {
60
+ const p = join(appDir, name);
61
+ if (await exists(p)) return p;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Run the app's boot-time instrumentation hook exactly once. Loads
68
+ * `instrumentation.{js,ts,mts,mjs}` at the app root (opt-in: absent is a no-op)
69
+ * and calls its `register()` (the default export if it is a function, else a
70
+ * named `register` export). Returns any error sink the hook registered via
71
+ * `setOnError`, so the caller can compose it with its own `onError`.
72
+ *
73
+ * @param {string} appDir
74
+ * @param {{ dev?: boolean, logger?: { error?: (...a: any[]) => void } }} [opts]
75
+ * @returns {Promise<{ onError: ((error: unknown, ctx?: any) => void) | null }>}
76
+ */
77
+ export async function runInstrumentation(appDir, opts = {}) {
78
+ const { dev = false, logger } = opts;
79
+ let file = null;
80
+ for (const name of ['instrumentation.ts', 'instrumentation.js', 'instrumentation.mts', 'instrumentation.mjs']) {
81
+ const p = join(appDir, name);
82
+ if (await exists(p)) { file = p; break; }
83
+ }
84
+ if (!file) return { onError: null };
85
+ // Per-boot store: setOnError writes here, scoped to THIS run's async context.
86
+ const store = { onError: null };
87
+ await _als.run(store, async () => {
88
+ try {
89
+ const url = pathToFileURL(file).toString();
90
+ const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
91
+ const mod = await import(url + bust);
92
+ const register = typeof mod.default === 'function'
93
+ ? mod.default
94
+ : (typeof mod.register === 'function' ? mod.register : null);
95
+ if (register) await register();
96
+ } catch (e) {
97
+ // A failing instrumentation hook must not crash boot: log and continue, the
98
+ // same fail-open posture as the readiness loader. The app still serves.
99
+ logger?.error?.(`[webjs] instrumentation.{js,ts} register() failed`, { err: String(e) });
100
+ }
101
+ });
102
+ return { onError: store.onError };
103
+ }
@@ -1,8 +1,9 @@
1
- import { isNotFound, isRedirect } from '@webjsdev/core';
2
- import { ssrPage, ssrNotFound, loadModule } from './ssr.js';
1
+ import { isNotFound, isRedirect, isForbidden, isUnauthorized } from '@webjsdev/core';
2
+ import { ssrPage, ssrNotFound, ssrForbidden, ssrUnauthorized, loadModule } from './ssr.js';
3
3
  import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
4
4
  import { getBodyLimits } from './context.js';
5
5
  import { propagateTrustedRemoteIp } from './rate-limit.js';
6
+ import { makeThenable } from './thenable-params.js';
6
7
 
7
8
  /**
8
9
  * Page server actions: a `page.{js,ts}` may export an `action` function that
@@ -201,7 +202,14 @@ export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
201
202
  /** @type {ActionResult | undefined} */
202
203
  let result;
203
204
  try {
204
- result = await action({ request: actionReq, params, searchParams, url, formData });
205
+ // params / searchParams are awaitable AND sync-readable here too (#848).
206
+ result = await action({
207
+ request: actionReq,
208
+ params: makeThenable(params),
209
+ searchParams: makeThenable(searchParams),
210
+ url,
211
+ formData,
212
+ });
205
213
  } catch (err) {
206
214
  if (isRedirect(err)) {
207
215
  const e = /** @type any */ (err);
@@ -215,6 +223,14 @@ export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
215
223
  if (isNotFound(err)) {
216
224
  return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
217
225
  }
226
+ // forbidden()/unauthorized() from a page action render the same 403/401
227
+ // boundary as the page-render path (#848), not a generic 500.
228
+ if (isForbidden(err)) {
229
+ return ssrForbidden(route, { ...ssrOpts, req, url });
230
+ }
231
+ if (isUnauthorized(err)) {
232
+ return ssrUnauthorized(route, { ...ssrOpts, req, url });
233
+ }
218
234
  throw err;
219
235
  }
220
236
 
package/src/router.js CHANGED
@@ -10,6 +10,9 @@ import { walk } from './fs-walk.js';
10
10
  * layouts: string[],
11
11
  * errors: string[],
12
12
  * loadings: string[],
13
+ * notFounds: string[],
14
+ * forbiddens: string[],
15
+ * unauthorizeds: string[],
13
16
  * metadataFiles: string[],
14
17
  * middlewares: string[],
15
18
  * isCatchAll: boolean
@@ -29,6 +32,8 @@ import { walk } from './fs-walk.js';
29
32
  * apis: ApiRoute[],
30
33
  * notFound: string | null,
31
34
  * notFounds: Map<string, string>,
35
+ * globalError: string | null,
36
+ * globalNotFound: string | null,
32
37
  * metadataRoutes: MetadataRoute[],
33
38
  * appDir: string
34
39
  * }} RouteTable
@@ -75,6 +80,14 @@ export async function buildRouteTable(appDir) {
75
80
  /** @type {Map<string, string>} */
76
81
  const notFounds = new Map();
77
82
  let notFound = null;
83
+ /** @type {Map<string,string>} */
84
+ const forbiddens = new Map();
85
+ /** @type {Map<string,string>} */
86
+ const unauthorizeds = new Map();
87
+ // Root-only boundaries (#848): the app-wide catch-all error and the
88
+ // unmatched-anywhere 404, set only at dir '.'.
89
+ let globalError = null;
90
+ let globalNotFound = null;
78
91
  /** @type {MetadataRoute[]} */
79
92
  const metadataRoutes = [];
80
93
 
@@ -114,6 +127,8 @@ export async function buildRouteTable(appDir) {
114
127
  layouts: [],
115
128
  errors: [],
116
129
  loadings: [],
130
+ forbiddens: [],
131
+ unauthorizeds: [],
117
132
  metadataFiles: [],
118
133
  middlewares: [],
119
134
  isCatchAll,
@@ -129,6 +144,14 @@ export async function buildRouteTable(appDir) {
129
144
  } else if (stem === 'not-found') {
130
145
  notFounds.set(dir, file);
131
146
  if (dir === '.') notFound = file;
147
+ } else if (stem === 'forbidden') {
148
+ forbiddens.set(dir, file);
149
+ } else if (stem === 'unauthorized') {
150
+ unauthorizeds.set(dir, file);
151
+ } else if (stem === 'global-error') {
152
+ if (dir === '.') globalError = file;
153
+ } else if (stem === 'global-not-found') {
154
+ if (dir === '.') globalNotFound = file;
132
155
  } else if (METADATA_STEMS.has(stem) && (dir === '.' || dir.split('/').every(s => !s.startsWith('[')))) {
133
156
  // Metadata route: sitemap.ts, robots.ts, icon.ts, etc.
134
157
  // Only at root or static segments (no dynamic params in metadata routes).
@@ -148,6 +171,9 @@ export async function buildRouteTable(appDir) {
148
171
  page.layouts = chainDirs.map((d) => layouts.get(d)).filter(Boolean);
149
172
  page.errors = chainDirs.map((d) => errors.get(d)).filter(Boolean);
150
173
  page.loadings = chainDirs.map((d) => loadings.get(d)).filter(Boolean);
174
+ page.notFounds = chainDirs.map((d) => notFounds.get(d)).filter(Boolean);
175
+ page.forbiddens = chainDirs.map((d) => forbiddens.get(d)).filter(Boolean);
176
+ page.unauthorizeds = chainDirs.map((d) => unauthorizeds.get(d)).filter(Boolean);
151
177
  page.middlewares = chainDirs.map((d) => middlewares.get(d)).filter(Boolean);
152
178
  page.metadataFiles = [...page.layouts, page.file];
153
179
  }
@@ -159,7 +185,7 @@ export async function buildRouteTable(appDir) {
159
185
  }
160
186
 
161
187
  pages.sort(compareSpecificity);
162
- return { pages, apis, notFound, notFounds, metadataRoutes, appDir };
188
+ return { pages, apis, notFound, notFounds, globalError, globalNotFound, metadataRoutes, appDir };
163
189
  }
164
190
 
165
191
  /**
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, cspNonce } from '@webjsdev/core';
3
+ import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
4
4
  import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js';
5
5
  import { withBasePath } from './base-path.js';
6
6
  import { withAssetHash } from './asset-hash.js';
@@ -14,6 +14,7 @@ import {
14
14
  HTML_CACHE_MARKER,
15
15
  } from './html-cache.js';
16
16
  import { requestedFrameId, extractFrameSubtree } from './frame-render.js';
17
+ import { makeThenable } from './thenable-params.js';
17
18
 
18
19
  /**
19
20
  * SSR a matched page route to a Response.
@@ -67,8 +68,11 @@ export async function ssrPage(route, params, url, opts) {
67
68
  }
68
69
 
69
70
  const ctx = {
70
- params,
71
- searchParams: Object.fromEntries(url.searchParams.entries()),
71
+ // params / searchParams are awaitable AND synchronously readable (#848):
72
+ // `params.id` still works, `await params` also works (Next 15/16 parity).
73
+ // The `then` is non-enumerable so spread / JSON / Object.keys are unchanged.
74
+ params: makeThenable(params),
75
+ searchParams: makeThenable(Object.fromEntries(url.searchParams.entries())),
72
76
  url: url.toString(),
73
77
  // Populated only when this render is the re-render after a failed page
74
78
  // `action` submission (#244). The page function and every layout receive
@@ -172,6 +176,16 @@ export async function ssrPage(route, params, url, opts) {
172
176
  else push(f);
173
177
  }
174
178
  }
179
+ // instrumentation-client.{js,ts} (#848): import it FIRST in the client boot
180
+ // so it runs before app modules (Next's instrumentation-client ordering).
181
+ // It ships even on an otherwise-static page (the app opted into client work),
182
+ // so it is prepended AFTER the component/page modules are collected.
183
+ if (opts.instrumentationClient) {
184
+ const u = toUrlPath(opts.instrumentationClient, opts.appDir);
185
+ const i = moduleUrls.indexOf(u);
186
+ if (i !== -1) moduleUrls.splice(i, 1);
187
+ moduleUrls.unshift(u);
188
+ }
175
189
  // Emit <link rel="modulepreload"> for every custom element that
176
190
  // actually rendered PLUS their transitive dependencies (from the
177
191
  // module graph). URLs are deduplicated so the browser never sees
@@ -274,9 +288,17 @@ export async function ssrPage(route, params, url, opts) {
274
288
  return new Response(null, { status: e.status || 302, headers: { location: e.url } });
275
289
  }
276
290
  if (isNotFound(err)) {
277
- const html = await ssrNotFoundHtml(null, opts);
291
+ // Nearest not-found.{js,ts} in the page's chain wins (#848 fix: previously
292
+ // this always rendered the bare default, ignoring even a root not-found);
293
+ // fall back to a root global-not-found.{js,ts}, else the default page.
294
+ const html = await ssrNotFoundHtml(nearest(route.notFounds) || opts.globalNotFound || null, opts);
278
295
  return htmlResponse(html, 404, opts.req, url);
279
296
  }
297
+ // forbidden() / unauthorized() sentinels (#848, Next 15/16 parity): render
298
+ // the nearest forbidden.{js,ts} / unauthorized.{js,ts} boundary (innermost
299
+ // wins), else a default page, at 403 / 401.
300
+ if (isForbidden(err)) return ssrForbidden(route, { ...opts, url });
301
+ if (isUnauthorized(err)) return ssrUnauthorized(route, { ...opts, url });
280
302
  // APM / Sentry sink (issue #239): a page render error that becomes a 500
281
303
  // (an error.js boundary OR the default 500 page) is an unhandled error the
282
304
  // app should see in its error tracker. Report it best-effort BEFORE
@@ -309,6 +331,23 @@ export async function ssrPage(route, params, url, opts) {
309
331
  // fall through to next error boundary
310
332
  }
311
333
  }
334
+ // Root global-error.{js,ts} (#848): the app-wide catch-all, tried after the
335
+ // nested error boundaries are exhausted. It renders the FULL document (its
336
+ // own <!doctype><html><body>, like the root layout), since a root-layout
337
+ // failure is exactly when it fires, so its rendered output is returned
338
+ // verbatim without wrapInDocument. Status 500.
339
+ if (opts.globalError) {
340
+ try {
341
+ const mod = await loadModule(opts.globalError, opts.dev);
342
+ if (mod.default) {
343
+ const tree = await mod.default({ error: err });
344
+ const body = await renderToString(tree, { ssr: true, dev: opts.dev });
345
+ return htmlResponse(body, 500, opts.req, url);
346
+ }
347
+ } catch (nested) {
348
+ // fall through to the default 500 page
349
+ }
350
+ }
312
351
  // Default: dev shows stack, prod shows a terse message (no stack trace leaks).
313
352
  console.error('[webjs] unhandled render error:', err);
314
353
  const body = opts.dev
@@ -335,6 +374,30 @@ export async function ssrNotFound(notFoundFile, opts) {
335
374
  return htmlResponse(html, 404, opts.req, opts.url);
336
375
  }
337
376
 
377
+ /**
378
+ * 403 response for a thrown forbidden() (#848). Renders the nearest
379
+ * forbidden.{js,ts} in the page's chain, else a default 403 page. Shared by the
380
+ * page-render catch (ssr.js) and the page-action write path (page-action.js) so
381
+ * both behave identically.
382
+ * @param {{ forbiddens?: string[] }} route
383
+ * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
384
+ */
385
+ export async function ssrForbidden(route, opts) {
386
+ const html = await ssrBoundaryHtml(nearest(route.forbiddens), '403: Forbidden', opts);
387
+ return htmlResponse(html, 403, opts.req, opts.url);
388
+ }
389
+
390
+ /**
391
+ * 401 response for a thrown unauthorized() (#848). Renders the nearest
392
+ * unauthorized.{js,ts} in the page's chain, else a default 401 page.
393
+ * @param {{ unauthorizeds?: string[] }} route
394
+ * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
395
+ */
396
+ export async function ssrUnauthorized(route, opts) {
397
+ const html = await ssrBoundaryHtml(nearest(route.unauthorizeds), '401: Unauthorized', opts);
398
+ return htmlResponse(html, 401, opts.req, opts.url);
399
+ }
400
+
338
401
  /**
339
402
  * Build an HTML Response. Sets no cookie: action CSRF is an Origin /
340
403
  * Sec-Fetch-Site check, so the page response is cookieless (CDN-cacheable).
@@ -389,6 +452,45 @@ function cachedHtmlResponse(rec, req, url) {
389
452
 
390
453
  /* ------------ internals ------------ */
391
454
 
455
+ /**
456
+ * Nearest-wins boundary file from a chain projected outermost -> innermost
457
+ * (the router builds these arrays via chainOf). The innermost (last) wins, the
458
+ * same "nearest boundary" rule error.js uses. Returns null for an empty chain.
459
+ * @param {string[] | undefined} files
460
+ * @returns {string | null}
461
+ */
462
+ function nearest(files) {
463
+ return files && files.length ? files[files.length - 1] : null;
464
+ }
465
+
466
+ /**
467
+ * Render a simple boundary page (forbidden / unauthorized, #848) at the given
468
+ * default heading. Loads the nearest boundary module when one exists (its
469
+ * default export receives no props, like not-found), else emits the default
470
+ * heading. Mirrors ssrNotFoundHtml.
471
+ * @param {string | null} file
472
+ * @param {string} heading e.g. '403: Forbidden'
473
+ * @param {{ dev: boolean, appDir: string, req?: Request }} opts
474
+ */
475
+ async function ssrBoundaryHtml(file, heading, opts) {
476
+ let body = `<h1>${heading}</h1>`;
477
+ if (file) {
478
+ try {
479
+ const mod = await loadModule(file, opts.dev);
480
+ if (mod.default) body = await renderToString(await mod.default({}), { ssr: true, dev: opts.dev });
481
+ } catch (e) {
482
+ body = `<h1>${heading}</h1><pre>${escapeHtml(String(e))}</pre>`;
483
+ }
484
+ }
485
+ const nonce = opts.req ? getNonce(opts.req) : undefined;
486
+ return wrapInDocument(body, {
487
+ metadata: { title: heading.replace(/^\d+:\s*/, '') },
488
+ moduleUrls: [],
489
+ dev: opts.dev,
490
+ nonce,
491
+ });
492
+ }
493
+
392
494
  async function ssrNotFoundHtml(notFoundFile, opts) {
393
495
  let body = '<h1>404: Not found</h1>';
394
496
  if (notFoundFile) {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Make a routing `params` / `searchParams` object awaitable WITHOUT losing
3
+ * synchronous access (#848, Next.js 15/16 parity where these are Promises).
4
+ *
5
+ * Both forms work after wrapping:
6
+ *
7
+ * const id = params.id; // sync, unchanged
8
+ * const { id } = await params; // Next-style, now also valid
9
+ *
10
+ * The `then` method is added NON-ENUMERABLE and NON-OWN-ENUMERABLE, so a
11
+ * `{ ...obj }` spread, `Object.keys`, `JSON.stringify`, and `for...in` never
12
+ * see it. That is the whole safety story: nothing that copies or serializes the
13
+ * object can accidentally turn a copy into a thenable and poison a downstream
14
+ * `Promise.resolve`. Only an explicit `await` / `.then` observes it.
15
+ *
16
+ * @template {object} T
17
+ * @param {T} obj the plain params/searchParams record
18
+ * @returns {T} the same object, now thenable (or `obj` unchanged when it is not
19
+ * a plain object or already carries a `then` data key)
20
+ */
21
+ export function makeThenable(obj) {
22
+ if (obj == null || typeof obj !== 'object') return obj;
23
+ // A real `then` data key (an app route literally named its param "then")
24
+ // must not be clobbered; leave such an object un-wrapped.
25
+ if (Object.prototype.hasOwnProperty.call(obj, 'then')) return obj;
26
+ Object.defineProperty(obj, 'then', {
27
+ value: /** @param {(v: any) => void} resolve @param {(e: any) => void} [reject] */ (
28
+ resolve,
29
+ reject
30
+ ) => {
31
+ // Resolve to a PLAIN shallow copy (the non-enumerable `then` is not
32
+ // copied), so the awaited value is a clean object and `await` cannot
33
+ // recursively re-await the same thenable.
34
+ const plain = { ...obj };
35
+ return Promise.resolve(plain).then(resolve, reject);
36
+ },
37
+ enumerable: false,
38
+ writable: true,
39
+ configurable: true,
40
+ });
41
+ return obj;
42
+ }
package/src/vendor.js CHANGED
@@ -726,7 +726,8 @@ function pinFilePath(appDir) {
726
726
  * The three-line `.gitignore` pattern that ignores the transient
727
727
  * `.webjs` caches at any depth while re-including the committed
728
728
  * `.webjs/vendor/` pin output. This mirrors the scaffold template
729
- * (`packages/cli/templates/.gitignore`) and the `vendor-gitignore`
729
+ * (`packages/cli/templates/gitignore`, shipped dotless so npm cannot
730
+ * strip it, renamed to `.gitignore` on copy) and the `vendor-gitignore`
730
731
  * check in `doctor.js` verbatim, so a self-healed `.gitignore`
731
732
  * ends up byte-identical to a freshly scaffolded one.
732
733
  */
package/src/websocket.js CHANGED
@@ -3,6 +3,7 @@ import { matchApi } from './router.js';
3
3
  import { urlFromRequest } from './forwarded.js';
4
4
  import { registerClient } from './broadcast.js';
5
5
  import { loadWsModule } from './listener-core.js';
6
+ import { makeThenable } from './thenable-params.js';
6
7
 
7
8
  /**
8
9
  * WebSocket support.
@@ -57,7 +58,7 @@ export function attachWebSocket(server, getRouteTable, opts) {
57
58
  try {
58
59
  registerClient(url.pathname, ws);
59
60
  const webReq = buildRequestFromUpgrade(req, url);
60
- mod.WS(ws, webReq, { params: match.params });
61
+ mod.WS(ws, webReq, { params: makeThenable(match.params) });
61
62
  } catch (e) {
62
63
  opts.logger.error('WebSocket handler threw', {
63
64
  err: e instanceof Error ? e.stack || e.message : String(e),