@webjsdev/server 0.8.44 → 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 +17 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/action-error.js +2 -2
- package/src/api.js +7 -2
- package/src/dev.js +45 -9
- package/src/instrumentation.js +103 -0
- package/src/page-action.js +19 -3
- package/src/router.js +27 -1
- package/src/ssr.js +106 -4
- package/src/thenable-params.js +42 -0
- package/src/websocket.js +2 -1
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
package/src/action-error.js
CHANGED
|
@@ -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
|
-
|
|
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/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
|
-
|
|
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
|
|
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 (!
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/page-action.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/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),
|