@webjsdev/server 0.8.44 → 0.8.46

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.44",
3
+ "version": "0.8.46",
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
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The optional `route(action, opts?)` convenience adapter (#488).
3
3
  *
4
- * REST endpoints in webjs go through `route.ts` (the framework's first-class
4
+ * REST endpoints in WebJs go through `route.ts` (the framework's first-class
5
5
  * HTTP handler). The ALWAYS-WORKS baseline is a hand-written `route.ts` that
6
6
  * imports a `'use server'` action and calls it:
7
7
  *
@@ -28,6 +28,22 @@
28
28
  * export const POST = route(createPost, { validate: createPostSchema });
29
29
  * ```
30
30
  *
31
+ * Passing the action's MODULE NAMESPACE instead of the function auto-applies the
32
+ * action's declared `export const middleware` and `export const validate`, so a
33
+ * guard declared once next to the action protects the REST boundary the same way
34
+ * it protects the RPC boundary (#876). This is the way to keep "declare once,
35
+ * protected on every entry point" true without re-listing the config:
36
+ *
37
+ * ```js
38
+ * // app/api/posts/route.ts
39
+ * import { route } from '@webjsdev/server';
40
+ * import * as postActions from '../../../modules/posts/actions/create-post.server.ts';
41
+ * export const POST = route(postActions); // its declared middleware + validate apply
42
+ * ```
43
+ *
44
+ * The function form still takes explicit `opts`, and an explicit `opts.middleware`
45
+ * / `opts.validate` overrides the module's declared config when both are given.
46
+ *
31
47
  * Adapter rules when invoked over HTTP:
32
48
  * - URL query params, route params (`ctx.params`), and the parsed JSON body
33
49
  * (for a method with a body) are merged into a single object argument
@@ -51,6 +67,7 @@
51
67
  import { runValidate } from './actions.js';
52
68
  import { runWithActionSignal } from './action-signal.js';
53
69
  import { runActionChain } from './action-middleware.js';
70
+ import { actionFunctionNames, actionMiddleware } from './action-config.js';
54
71
  import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
55
72
  import { getBodyLimits } from './context.js';
56
73
 
@@ -69,16 +86,48 @@ function jsonBodyLimit() {
69
86
  * Expose a plain `'use server'` action over REST as a `route.ts`-style handler.
70
87
  *
71
88
  * @template A, R
72
- * @param {(input: A, ctx: { req: Request, params: Record<string,string> }) => R | Promise<R>} action
73
- * the server action to call (its source is unchanged; this only wraps the
74
- * HTTP-to-action adapter around it).
89
+ * @param {((input: A, ctx: { req: Request, params: Record<string,string> }) => R | Promise<R>) | Record<string, unknown>} actionOrModule
90
+ * the server action to call, OR the action's module namespace (`import * as m`)
91
+ * so its declared `middleware` / `validate` config is auto-applied (#876). Its
92
+ * source is unchanged; this only wraps the HTTP-to-action adapter around it.
75
93
  * @param {{
76
94
  * validate?: (input: any) => any,
77
95
  * middleware?: Function[],
78
96
  * }} [opts]
79
97
  * @returns {(req: Request, ctx?: { params?: Record<string,string> }) => Promise<Response>}
80
98
  */
81
- export function route(action, opts = {}) {
99
+ export function route(actionOrModule, opts = {}) {
100
+ // Resolve the callable action plus any config the caller did not pass
101
+ // explicitly. A function is used verbatim (the original form). A module
102
+ // namespace exposes the single action function and its declared config, so
103
+ // `route(mod)` inherits the same `middleware` / `validate` the RPC boundary
104
+ // reads (#876). An explicit `opts` value still wins over the declared one.
105
+ let action;
106
+ /** @type {{ middleware?: Function[], validate?: Function }} */
107
+ let declared = {};
108
+ if (typeof actionOrModule === 'function') {
109
+ action = actionOrModule;
110
+ } else if (actionOrModule && typeof actionOrModule === 'object') {
111
+ const names = actionFunctionNames(actionOrModule);
112
+ if (names.length !== 1) {
113
+ throw new Error(
114
+ `route(module) expects exactly one action function in the module, found ${names.length}` +
115
+ (names.length ? ` (${names.join(', ')})` : ''),
116
+ );
117
+ }
118
+ action = /** @type {Function} */ (actionOrModule[names[0]]);
119
+ declared = {
120
+ middleware: actionMiddleware(actionOrModule),
121
+ validate: typeof actionOrModule.validate === 'function'
122
+ ? /** @type {Function} */ (actionOrModule.validate)
123
+ : undefined,
124
+ };
125
+ } else {
126
+ throw new Error('route() expects an action function or a module namespace');
127
+ }
128
+ const validate = opts.validate ?? declared.validate;
129
+ const middlewareChain = opts.middleware ?? declared.middleware ?? [];
130
+
82
131
  return async function handler(req, ctx) {
83
132
  const params = (ctx && ctx.params) || {};
84
133
  const url = new URL(req.url);
@@ -102,13 +151,13 @@ export function route(action, opts = {}) {
102
151
  }
103
152
 
104
153
  let arg = { ...query, ...params, ...body };
105
- if (typeof opts.validate === 'function') {
154
+ if (typeof validate === 'function') {
106
155
  // Run the validator through the SHARED contract (#245) so the REST adapter
107
156
  // and the RPC path interpret a `{ success, fieldErrors }` envelope, a
108
157
  // throw, and a transform-return identically. A structured failure becomes
109
158
  // a 422 JSON; a throw stays a 400 (keeping a schema lib's `issues`); a
110
159
  // transform-return replaces the input.
111
- const v = runValidate(opts.validate, arg);
160
+ const v = runValidate(validate, arg);
112
161
  if (!v.ok) {
113
162
  if (v.thrown !== undefined) {
114
163
  const msg = v.result.error || 'Invalid input';
@@ -126,10 +175,9 @@ export function route(action, opts = {}) {
126
175
  // The action runs inside the request signal scope (#492) and the per-action
127
176
  // middleware chain (#490). `ranAction` distinguishes a real completion from
128
177
  // a middleware short-circuit (the action never ran).
129
- const middleware = opts.middleware || [];
130
178
  let ranAction = false;
131
179
  const result = await runWithActionSignal(req.signal, () =>
132
- runActionChain(middleware, { request: req, args: [arg], signal: req.signal }, () => {
180
+ runActionChain(middlewareChain, { request: req, args: [arg], signal: req.signal }, () => {
133
181
  ranAction = true;
134
182
  return action(/** @type any */ (arg), { req, params });
135
183
  }));
package/src/actions.js CHANGED
@@ -33,12 +33,12 @@ function jsonBodyLimit() {
33
33
  }
34
34
 
35
35
  /**
36
- * Internal RPC wire-format content type. Distinguishes webjs action
36
+ * Internal RPC wire-format content type. Distinguishes WebJs action
37
37
  * responses from plain `application/json` so the stub can pick the right
38
38
  * parser and external JSON consumers aren't confused.
39
39
  *
40
40
  * Uses the content type from the active serializer (defaults to
41
- * `application/vnd.webjs+json` with the built-in webjs serializer).
41
+ * `application/vnd.webjs+json` with the built-in WebJs serializer).
42
42
  */
43
43
  export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
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/auth.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * NextAuth/Auth.js-style authentication for webjs.
2
+ * NextAuth/Auth.js-style authentication for WebJs.
3
3
  *
4
4
  * JWT or database sessions, Credentials + OAuth (Google, GitHub) providers.
5
5
  * Uses Web Crypto HMAC-SHA256: no external dependencies.
package/src/body-limit.js CHANGED
@@ -252,7 +252,7 @@ export async function readBytesBounded(req, limit) {
252
252
  /**
253
253
  * Read a request body as text, bounded by `limit`. Used by the RPC endpoint,
254
254
  * `readBody`, and the exposed-action REST path, all of which then parse the
255
- * text (webjs wire or JSON).
255
+ * text (WebJs wire or JSON).
256
256
  *
257
257
  * @param {Request} req
258
258
  * @param {number} limit max bytes (0 / negative = unlimited)
package/src/check.js CHANGED
@@ -14,7 +14,7 @@ import { analyzeElision } from './component-elision.js';
14
14
  import { RESERVED_CONFIG } from './action-config.js';
15
15
 
16
16
  /**
17
- * Convention validator for webjs apps.
17
+ * Convention validator for WebJs apps.
18
18
  *
19
19
  * Scans an app directory and reports correctness violations: things that
20
20
  * crash the app, leak a secret, or fail the build / type-strip. Designed to be
@@ -380,7 +380,7 @@ function findBrowserMemberUses(code) {
380
380
  }
381
381
 
382
382
  /**
383
- * Scan a webjs app directory and report convention violations.
383
+ * Scan a WebJs app directory and report convention violations.
384
384
  *
385
385
  * Every rule is a correctness check (a crash, a security leak, or a
386
386
  * build/type-strip failure), so they all run unconditionally. There is no
@@ -436,7 +436,7 @@ export async function checkConventions(appDir) {
436
436
  // redactor leaves them alone.
437
437
  if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
438
438
  // Accept either registration pattern:
439
- // Counter.register('tag') (webjs idiom)
439
+ // Counter.register('tag') (WebJs idiom)
440
440
  // customElements.define('tag', Counter) (native)
441
441
  if (/\b[A-Z][A-Za-z0-9_$]*\.register\s*\(\s*['"`]/.test(scan)) continue;
442
442
  if (/\bcustomElements\.define\s*\(/.test(scan)) continue;
@@ -1208,7 +1208,7 @@ function findImportChain(graph, from, to) {
1208
1208
  * @param {Violation[]} violations appended to in place
1209
1209
  */
1210
1210
  async function checkServerImportInBrowserModule(appDir, violations) {
1211
- // No `app/` directory means this is not a routable webjs app (e.g. a bare
1211
+ // No `app/` directory means this is not a routable WebJs app (e.g. a bare
1212
1212
  // component library, or a fixture with only `lib/`); nothing ships, so the
1213
1213
  // rule has nothing to police. Skip rather than do the heavy analysis.
1214
1214
  if (!(await pathExists(join(appDir, 'app')))) return;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Static analyser deciding whether a webjs component module can be
2
+ * Static analyser deciding whether a WebJs component module can be
3
3
  * ELIDED from the browser, that is, never downloaded as JS because its
4
4
  * SSR'd HTML is the complete, final output and the component does no
5
5
  * client-side work.
@@ -34,7 +34,7 @@
34
34
  * packages/core/AGENTS.md and the MAINTAINER NOTE on the
35
35
  * WebComponent lifecycle in packages/core/src/component.js both
36
36
  * direct anyone adding an interactivity surface to update these
37
- * lists, since webjs development is largely AI-agent driven.
37
+ * lists, since WebJs development is largely AI-agent driven.
38
38
  */
39
39
 
40
40
  import {
@@ -101,7 +101,7 @@ export const CLIENT_LIFECYCLE_HOOKS = [
101
101
  'connectedCallback',
102
102
  'disconnectedCallback',
103
103
  'attributeChangedCallback',
104
- // Standard custom-element callback webjs does not itself define (so it
104
+ // Standard custom-element callback WebJs does not itself define (so it
105
105
  // is absent from the prototype guard's CLASSIFICATION), but an author
106
106
  // can still override it to do client work. Kept for conservatism.
107
107
  'adoptedCallback',
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Server-side scanner that walks the app tree and records the
3
- * browser-visible URL for every webjs component module.
3
+ * browser-visible URL for every WebJs component module.
4
4
  *
5
5
  * Called once on the first request (lazily, via `ensureReady`), then memoized. Results are used to prime the core
6
6
  * registry (`primeModuleUrl`) BEFORE any SSR render: so when a page
7
7
  * renders a component tag, `lookupModuleUrl(tag)` already has the URL
8
8
  * ready for `<link rel="modulepreload">` hints.
9
9
  *
10
- * The convention webjs uses is the web-standard one:
10
+ * The convention WebJs uses is the web-standard one:
11
11
  *
12
12
  * class Counter extends WebComponent { … }
13
13
  * customElements.define('my-counter', Counter);
package/src/cors.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Reusable CORS primitive for webjs.
2
+ * Reusable CORS primitive for WebJs.
3
3
  *
4
- * `cors(options)` returns a webjs MIDDLEWARE `(req, next) => Response`,
4
+ * `cors(options)` returns a WebJs MIDDLEWARE `(req, next) => Response`,
5
5
  * usable in `middleware.js` (root or per-segment) or wrapped around a
6
6
  * `route.js` handler. This is the public app-facing API. The shared
7
7
  * origin-resolution + header-building core (`resolveOrigin` /
package/src/csp.js CHANGED
@@ -181,7 +181,7 @@ export function readCspConfig(pkg) {
181
181
  export function mintNonce() {
182
182
  const bytes = new Uint8Array(16);
183
183
  crypto.getRandomValues(bytes);
184
- // Buffer is available in every webjs server runtime (Node 24+). Avoids a
184
+ // Buffer is available in every WebJs server runtime (Node 24+). Avoids a
185
185
  // hand-rolled base64 of a binary string.
186
186
  return Buffer.from(bytes).toString('base64');
187
187
  }
package/src/csrf.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * `cop-middleware` and Go 1.25's `http.CrossOriginProtection` use, and the
5
5
  * spiritual sibling of Next.js / Astro's Origin-vs-Host check.
6
6
  *
7
- * Why not a token cookie: webjs previously issued a per-request `webjs_csrf`
7
+ * Why not a token cookie: WebJs previously issued a per-request `webjs_csrf`
8
8
  * double-submit cookie on every SSR response. That made SSR HTML
9
9
  * un-cacheable at a CDN (a CDN skips a response with `Set-Cookie`, and a
10
10
  * cached one would share / poison the token across visitors). A header check
package/src/dev-error.js CHANGED
@@ -19,7 +19,7 @@ import { readFileSync } from 'node:fs';
19
19
  /**
20
20
  * Pull the first useful `file:line:column` out of an error stack. Prefers a
21
21
  * frame under `appDir` (the user's own code) over a framework / node_modules
22
- * frame, so the overlay points at the edit that broke, not webjs internals.
22
+ * frame, so the overlay points at the edit that broke, not WebJs internals.
23
23
  *
24
24
  * Handles the V8 stack forms `at fn (/abs/file.ts:12:5)`, `at /abs/file.ts:12:5`,
25
25
  * and `at file:///abs/file.ts:12:5`.
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';
@@ -135,7 +136,7 @@ const MIME = {
135
136
  * `namespace` with values, parameter properties, legacy decorators
136
137
  * with `emitDecoratorMetadata`, `import = require`) throws at strip
137
138
  * time. The `erasable-typescript-only` and `no-non-erasable-typescript`
138
- * lint rules catch these at edit time. webjs is buildless end-to-end:
139
+ * lint rules catch these at edit time. WebJs is buildless end-to-end:
139
140
  * there is no bundler fallback.
140
141
  *
141
142
  * The transformed bytes are cached per request handler in `state.tsCache`
@@ -440,7 +441,7 @@ export async function readServerTimeoutsFromApp(appDir) {
440
441
  }
441
442
 
442
443
  /**
443
- * Create a reusable, framework-agnostic request handler for a webjs app.
444
+ * Create a reusable, framework-agnostic request handler for a WebJs app.
444
445
  * The returned `handle(req)` takes a standard `Request` and resolves to a
445
446
  * standard `Response`: suitable for Node http, Deno, Bun, Cloudflare Workers,
446
447
  * or embedding inside an Express/Fastify app.
@@ -455,7 +456,7 @@ export async function readServerTimeoutsFromApp(appDir) {
455
456
  * }} opts
456
457
  */
457
458
  export async function createRequestHandler(opts) {
458
- // Preflight: webjs needs Node 24+ (built-in TS strip + recursive fs.watch).
459
+ // Preflight: WebJs needs Node 24+ (built-in TS strip + recursive fs.watch).
459
460
  // Throw a clear Error here so an embedded host (Express/Fastify/Bun/Deno)
460
461
  // gets the actionable message at boot, not a cryptic API failure mid-request.
461
462
  assertNodeVersion({ onFail: 'throw' });
@@ -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).
@@ -1445,7 +1464,7 @@ export async function createRequestHandler(opts) {
1445
1464
  }
1446
1465
 
1447
1466
  /**
1448
- * Start a webjs HTTP server. Thin wrapper around `createRequestHandler`.
1467
+ * Start a WebJs HTTP server. Thin wrapper around `createRequestHandler`.
1449
1468
  *
1450
1469
  * Speaks plain HTTP/1.1. TLS termination + HTTP/2 to the browser is
1451
1470
  * expected to be handled by a reverse proxy (PaaS edge, nginx, Caddy,
@@ -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 */
@@ -2109,7 +2138,7 @@ async function loadMiddleware(appDir, dev, logger) {
2109
2138
  * @param {import('./logger.js').Logger} logger
2110
2139
  */
2111
2140
  /**
2112
- * Create a plain HTTP/1.1 server. webjs deploys are expected to sit
2141
+ * Create a plain HTTP/1.1 server. WebJs deploys are expected to sit
2113
2142
  * behind a reverse proxy (PaaS edge, nginx, Caddy, etc.) that handles
2114
2143
  * TLS termination and speaks HTTP/2 to clients: Node's http2 module
2115
2144
  * doesn't need to be involved on the framework side.
@@ -2298,7 +2327,7 @@ async function exists(p) {
2298
2327
  * dev server returns the error to the caller. The
2299
2328
  * `erasable-typescript-only` and `no-non-erasable-typescript` lint
2300
2329
  * rules catch these at edit time. There is no bundler fallback;
2301
- * webjs is buildless end-to-end.
2330
+ * WebJs is buildless end-to-end.
2302
2331
  *
2303
2332
  * The backend is the runtime-appropriate stripper (#508): Node 24+'s built-in
2304
2333
  * `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, equally
@@ -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
@@ -2,7 +2,7 @@
2
2
  * App-level elision report (#646).
3
3
  *
4
4
  * A reporting layer over the `analyzeElision` verdict, NOT a second analysis
5
- * and NOT a build (webjs is no-build; elision is the server's analysis pass,
5
+ * and NOT a build (WebJs is no-build; elision is the server's analysis pass,
6
6
  * run at dev-server start and re-derived after each fs.watch rebuild). It
7
7
  * builds the module graph, runs `analyzeElision`, and returns the page / layout
8
8
  * route modules that SHIP WHOLE to the browser, each with the first
package/src/env-schema.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Startup env-var validation with a typed schema hook (issue #236).
3
3
  *
4
- * webjs auto-loads `<appDir>/.env` into `process.env` at boot, but does no
4
+ * WebJs auto-loads `<appDir>/.env` into `process.env` at boot, but does no
5
5
  * validation, so a missing or misconfigured required var (DATABASE_URL,
6
6
  * AUTH_SECRET, ...) fails late and cryptically (a database connect error
7
7
  * mid-request, an undefined secret signing a token). This module adds an
@@ -2,7 +2,7 @@
2
2
  * File storage primitive: a pluggable `FileStore` with a streaming local-disk
3
3
  * default adapter (issue #247).
4
4
  *
5
- * webjs already round-trips a native `File` / `Blob` / `FormData` over the RPC
5
+ * WebJs already round-trips a native `File` / `Blob` / `FormData` over the RPC
6
6
  * wire (the serializer), but nothing decided WHERE the bytes land. This module
7
7
  * is that answer. The model mirrors `cache.js` exactly: a documented interface,
8
8
  * a default adapter (`diskStore`), and a module singleton (`setFileStore` /
@@ -35,7 +35,7 @@
35
35
  * (`File` / `Blob` / `ReadableStream` / `Uint8Array` in, a `{ body, size,
36
36
  * contentType }` handle out), so an S3 / R2 / GCS adapter is a drop-in: it
37
37
  * implements the same `put` / `get` / `delete` / `url` and the call sites do
38
- * not change. webjs ships no S3 SDK; see the JSDoc on `FileStore` below.
38
+ * not change. WebJs ships no S3 SDK; see the JSDoc on `FileStore` below.
39
39
  *
40
40
  * @module file-storage
41
41
  */
package/src/forwarded.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Build a full URL from a Node IncomingMessage, respecting standard
3
3
  * reverse-proxy headers (`X-Forwarded-Proto`, `X-Forwarded-Host`).
4
4
  *
5
- * Why: webjs apps are almost always deployed behind a reverse proxy
5
+ * Why: WebJs apps are almost always deployed behind a reverse proxy
6
6
  * (Railway, Fly, Render, Vercel, Cloudflare, nginx, Caddy, Traefik -
7
7
  * see the no-build architecture docs). The proxy terminates TLS and
8
8
  * speaks plain HTTP/1.1 to the container, so `req.url` inside the
package/src/headers.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Secure-by-default response headers plus a small declarative per-path
3
3
  * header config (issue #232).
4
4
  *
5
- * webjs sets a baseline of standard security headers on every document
5
+ * WebJs sets a baseline of standard security headers on every document
6
6
  * and asset response, so a scaffolded app is not clickjackable or
7
7
  * MIME-sniffable out of the box (no reverse proxy required for the
8
8
  * baseline). The defaults are LITERAL HTTP headers, no abstraction.
@@ -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
+ }
package/src/json.js CHANGED
@@ -15,7 +15,7 @@ import { readTextBounded, BodyLimitError, DEFAULT_MAX_BODY_BYTES } from './body-
15
15
  * The helper reads the in-flight Request from the AsyncLocalStorage
16
16
  * request context. If the caller sent `Accept: application/vnd.webjs+json`
17
17
  * (e.g. via the `richFetch` client helper), the response body is
18
- * encoded with the webjs serializer so rich types (Date, Map, Set,
18
+ * encoded with the WebJs serializer so rich types (Date, Map, Set,
19
19
  * BigInt, TypedArrays, Blob, File, FormData, cycles) survive. Otherwise
20
20
  * the response is plain `application/json`, unchanged behaviour for
21
21
  * curl / external consumers.
@@ -47,7 +47,7 @@ export async function json(data, init = {}) {
47
47
  }
48
48
 
49
49
  /**
50
- * Parse a request body using the webjs serializer when the client sent
50
+ * Parse a request body using the WebJs serializer when the client sent
51
51
  * our content type, otherwise as plain JSON. Handy for route handlers
52
52
  * that accept rich bodies from the `richFetch` helper but plain JSON
53
53
  * from everyone else.
@@ -73,7 +73,7 @@ export function startBunListener(ctx) {
73
73
  const server = Bun.serve({
74
74
  port,
75
75
  idleTimeout: bunIdleTimeout(timeouts),
76
- // webjs owns its dev error overlay (over SSE) and handles thrown errors in
76
+ // WebJs owns its dev error overlay (over SSE) and handles thrown errors in
77
77
  // `fetch`, so Bun's own development error page must never interfere; keep it
78
78
  // deterministic (off) rather than tied to NODE_ENV.
79
79
  development: false,
@@ -172,7 +172,7 @@ export function startBunListener(ctx) {
172
172
  * the `open` handler; messages/close are forwarded to its emitter from the
173
173
  * `websocket.message` / `websocket.close` handlers.
174
174
  *
175
- * Only `'message'` and `'close'` are forwarded (the events webjs WS handlers and
175
+ * Only `'message'` and `'close'` are forwarded (the events WebJs WS handlers and
176
176
  * `broadcast()` use). Bun exposes no per-socket `'error'` / `'ping'` / `'pong'`
177
177
  * handler to bridge; a socket failure on Bun surfaces as a `'close'`, so
178
178
  * `'close'`-keyed cleanup still runs. Documented in the WebSockets doc.
package/src/logger.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Minimal pluggable logger.
3
3
  *
4
- * webjs doesn't pick pino/winston/bunyan for you. The default in prod emits
4
+ * WebJs doesn't pick pino/winston/bunyan for you. The default in prod emits
5
5
  * one JSON object per line to stdout: trivially ingestable by any log
6
6
  * aggregator. In dev, lines are plain text for readability.
7
7
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Node-version preflight guard (issue #238).
3
3
  *
4
- * webjs depends on Node 24+ built-ins: `module.stripTypeScriptTypes` (the
4
+ * WebJs depends on Node 24+ built-ins: `module.stripTypeScriptTypes` (the
5
5
  * no-build TypeScript strip) and recursive `fs.watch` (dev live-reload). On an
6
6
  * older Node the failure surfaces late and cryptically (a strip error or a
7
7
  * missing API deep inside a request), not as a clear "you need Node 24+". This
@@ -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/redirects.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Declarative permanent / temporary redirects (issue #254).
3
3
  *
4
- * webjs already ships `redirect(url)` (a per-request throw sentinel) for
4
+ * WebJs already ships `redirect(url)` (a per-request throw sentinel) for
5
5
  * imperative, request-time redirects. This module adds the missing
6
6
  * DECLARATIVE surface: a config of old-path -> new-path rules an app
7
7
  * declares once and the framework applies at the very start of request
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/serializer.js CHANGED
@@ -2,7 +2,7 @@ import { stringify, parse } from '@webjsdev/core';
2
2
 
3
3
  /**
4
4
  * @typedef {Object} Serializer
5
- * A pluggable serializer that controls how webjs server actions encode and
5
+ * A pluggable serializer that controls how WebJs server actions encode and
6
6
  * decode values on the RPC wire.
7
7
  *
8
8
  * **AI hint:** The default serializer uses webjs's built-in
@@ -50,7 +50,7 @@ let current = defaultSerializer;
50
50
  *
51
51
  * **AI hint:** Use this in server-side code that needs to encode or decode
52
52
  * RPC payloads. It returns whatever serializer was set via `setSerializer`,
53
- * or the default webjs serializer if none was set.
53
+ * or the default WebJs serializer if none was set.
54
54
  *
55
55
  * @returns {Serializer}
56
56
  */
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) {
package/src/testing.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Server-side test harness helpers (issue #267).
3
3
  *
4
- * `createRequestHandler(...).handle(request)` drives the FULL webjs pipeline
4
+ * `createRequestHandler(...).handle(request)` drives the FULL WebJs pipeline
5
5
  * (middleware, routing, SSR, page actions, server-action RPC, auth + CSRF), and
6
6
  * the framework's own suite already drives it. These helpers are THIN builders
7
7
  * over the native `Request` / `Response` around that `handle()`, so an app test
@@ -350,7 +350,7 @@ export async function rawActionRequest(app, serverFilePath, fnName, args = [], o
350
350
  }
351
351
 
352
352
  /**
353
- * Browser-test harness (#806): build a webjs handler that a `web-test-runner`
353
+ * Browser-test harness (#806): build a WebJs handler that a `web-test-runner`
354
354
  * config can proxy module requests to, so a `test/**​/browser/*.test.js` file
355
355
  * can import a real `.ts` component that imports a `'use server'` action and it
356
356
  * loads + hydrates in a real browser. Plain WTR serves raw TS with no webjs
@@ -369,7 +369,7 @@ export async function rawActionRequest(app, serverFilePath, fnName, args = [], o
369
369
  * of `@webjsdev/server/testing` stays light (they pull the full server + the
370
370
  * `ws` WebSocket subsystem).
371
371
  *
372
- * @param {string} appDir the webjs app root (usually `process.cwd()`)
372
+ * @param {string} appDir the WebJs app root (usually `process.cwd()`)
373
373
  * @returns {Promise<{ handle: Handle, warmup: () => Promise<void>, importmapHtml: () => string }>}
374
374
  */
375
375
  export async function createBrowserTestHandler(appDir) {
@@ -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/ts-strip.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Pluggable TypeScript stripper (#508), the one seam that lets webjs run on both
2
+ * Pluggable TypeScript stripper (#508), the one seam that lets WebJs run on both
3
3
  * Node and Bun.
4
4
  *
5
- * webjs serves `.ts` / `.mts` source to the browser as JavaScript by ERASING the
5
+ * WebJs serves `.ts` / `.mts` source to the browser as JavaScript by ERASING the
6
6
  * type syntax in place (position-preserving whitespace replacement, so no
7
7
  * sourcemap is shipped and stack traces stay byte-exact). On Node 24+ that is the
8
8
  * built-in `module.stripTypeScriptTypes`. Bun has no such built-in, so this
package/src/vendor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Resolve bare npm imports to browser-loadable URLs via jspm.io.
3
3
  *
4
- * webjs follows the Rails 7 + importmap-rails posture exactly. When user
4
+ * WebJs follows the Rails 7 + importmap-rails posture exactly. When user
5
5
  * code imports a bare specifier (e.g. `import dayjs from 'dayjs'`), the
6
6
  * browser can't resolve it natively. The framework's job is to emit an
7
7
  * importmap that translates each bare specifier to a real URL.
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * importmap: { "dayjs": "https://ga.jspm.io/npm:dayjs@1.11.13/index.js" }
12
12
  *
13
- * The browser fetches the bundle directly from jspm.io. The webjs server
13
+ * The browser fetches the bundle directly from jspm.io. The WebJs server
14
14
  * does not proxy, cache, or bundle anything. jspm.io has done the work
15
15
  * server-side (CJS-to-ESM conversion, transitive bundling, browser
16
16
  * polyfills).
@@ -85,7 +85,7 @@ const FRAMEWORK_SERVER_ONLY = new Set(['@webjsdev/cli', '@webjsdev/server', '@we
85
85
  * Excludes:
86
86
  * - `node_modules`, `.webjs`, `public` directories
87
87
  * - Any directory starting with `_` (webjs `_private/` convention)
88
- * - `test/` and `tests/` (server-only by webjs convention)
88
+ * - `test/` and `tests/` (server-only by WebJs convention)
89
89
  * - Files whose name marks them as server-only:
90
90
  * * `*.server.{js,ts,mjs,mts}` (path-level boundary)
91
91
  * * `route.{js,ts,mjs,mts}` (file-router HTTP handler)
@@ -1030,7 +1030,7 @@ export async function readPinFile(appDir) {
1030
1030
  * When `integrity` is provided and non-empty, it's included alongside
1031
1031
  * `imports` as a sibling key (matching the browser importmap-integrity
1032
1032
  * spec: a flat `{url: 'sha384-...'}` map). Omitted entirely when empty
1033
- * so older webjs versions read the file as before.
1033
+ * so older WebJs versions read the file as before.
1034
1034
  *
1035
1035
  * `provider` is persisted alongside imports when non-default. It lets
1036
1036
  * `webjs vendor update` know which CDN to re-resolve against, and
@@ -1343,7 +1343,7 @@ export async function pinAll(appDir, opts = {}) {
1343
1343
  }
1344
1344
 
1345
1345
  // The app legitimately has zero bare-specifier imports (or the
1346
- // scanner is running outside a webjs project). Don't create an
1346
+ // scanner is running outside a WebJs project). Don't create an
1347
1347
  // empty `.webjs/vendor/importmap.json`. Without this guard the file
1348
1348
  // gets written as `{ imports: {} }` in whatever cwd the CLI was
1349
1349
  // invoked from, then immediately rejected by readPinFile's empty
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),