@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 +17 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/action-error.js +2 -2
- package/src/action-route.js +57 -9
- package/src/actions.js +2 -2
- package/src/api.js +7 -2
- package/src/auth.js +1 -1
- package/src/body-limit.js +1 -1
- package/src/check.js +4 -4
- package/src/component-elision.js +3 -3
- package/src/component-scanner.js +2 -2
- package/src/cors.js +2 -2
- package/src/csp.js +1 -1
- package/src/csrf.js +1 -1
- package/src/dev-error.js +1 -1
- package/src/dev.js +51 -15
- package/src/elision-report.js +1 -1
- package/src/env-schema.js +1 -1
- package/src/file-storage.js +2 -2
- package/src/forwarded.js +1 -1
- package/src/headers.js +1 -1
- package/src/instrumentation.js +103 -0
- package/src/json.js +2 -2
- package/src/listener-bun.js +2 -2
- package/src/logger.js +1 -1
- package/src/node-version.js +1 -1
- package/src/page-action.js +19 -3
- package/src/redirects.js +1 -1
- package/src/router.js +27 -1
- package/src/serializer.js +2 -2
- package/src/ssr.js +106 -4
- package/src/testing.js +3 -3
- package/src/thenable-params.js +42 -0
- package/src/ts-strip.js +2 -2
- package/src/vendor.js +5 -5
- 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/action-route.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The optional `route(action, opts?)` convenience adapter (#488).
|
|
3
3
|
*
|
|
4
|
-
* REST endpoints in
|
|
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>}
|
|
73
|
-
* the server action to call
|
|
74
|
-
*
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
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 (
|
|
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
|
|
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
|
|
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') (
|
|
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
|
|
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;
|
package/src/component-elision.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Static analyser deciding whether a
|
|
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
|
|
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
|
|
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',
|
package/src/component-scanner.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
2
|
+
* Reusable CORS primitive for WebJs.
|
|
3
3
|
*
|
|
4
|
-
* `cors(options)` returns a
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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).
|
|
@@ -1445,7 +1464,7 @@ export async function createRequestHandler(opts) {
|
|
|
1445
1464
|
}
|
|
1446
1465
|
|
|
1447
1466
|
/**
|
|
1448
|
-
* Start a
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
package/src/elision-report.js
CHANGED
|
@@ -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 (
|
|
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
|
-
*
|
|
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
|
package/src/file-storage.js
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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.
|
package/src/listener-bun.js
CHANGED
|
@@ -73,7 +73,7 @@ export function startBunListener(ctx) {
|
|
|
73
73
|
const server = Bun.serve({
|
|
74
74
|
port,
|
|
75
75
|
idleTimeout: bunIdleTimeout(timeouts),
|
|
76
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
*
|
package/src/node-version.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Node-version preflight guard (issue #238).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
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/redirects.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Declarative permanent / temporary redirects (issue #254).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|
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
|
|
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
|
|
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
|
|
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
|
|
2
|
+
* Pluggable TypeScript stripper (#508), the one seam that lets WebJs run on both
|
|
3
3
|
* Node and Bun.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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),
|