@webjsdev/server 0.9.0 → 0.10.0
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/README.md +4 -1
- package/index.d.ts +427 -109
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +96 -14
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +140 -65
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +80 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/index.js
CHANGED
|
@@ -2,7 +2,7 @@ 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
4
|
export { setOnError, runInstrumentation } from './src/instrumentation.js';
|
|
5
|
-
export { buildRouteTable, matchPage, matchApi } from './src/router.js';
|
|
5
|
+
export { buildRouteTable, matchPage, matchApi, staticPageRoutes } from './src/router.js';
|
|
6
6
|
export { generateRouteTypes } from './src/route-types.js';
|
|
7
7
|
export { ssrPage, ssrNotFound } from './src/ssr.js';
|
|
8
8
|
export { handleApi } from './src/api.js';
|
|
@@ -43,9 +43,8 @@ export {
|
|
|
43
43
|
} from './src/vendor.js';
|
|
44
44
|
export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
|
|
45
45
|
export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
|
|
46
|
-
export {
|
|
46
|
+
export { analyzeRouteModules } from './src/route-report.js';
|
|
47
47
|
export { regenerateOutputs, regenerateStatus } from './src/dev-regenerate.js';
|
|
48
|
-
export { maskJsSet, staticPageRoutes } from './src/elision-differential.js';
|
|
49
48
|
export { headers, cookies, getRequest, withRequest, cspNonce, requestId } from './src/context.js';
|
|
50
49
|
export { defaultLogger } from './src/logger.js';
|
|
51
50
|
export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
|
|
@@ -65,8 +64,13 @@ export { stream, streamResponse, acceptsStream, STREAM_MIME } from './src/stream
|
|
|
65
64
|
export { attachWebSocket } from './src/websocket.js';
|
|
66
65
|
export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
|
|
67
66
|
|
|
67
|
+
// Next.js's navigation helpers (`next/navigation`), the same functions
|
|
68
|
+
// `@webjsdev/core` exports, so a server action or route handler can import
|
|
69
|
+
// them from the server package as well. (#1686)
|
|
70
|
+
export { notFound, forbidden, unauthorized, redirect, permanentRedirect } from '@webjsdev/core';
|
|
71
|
+
|
|
68
72
|
// Auth (NextAuth-style)
|
|
69
|
-
export { createAuth, Credentials, Google, GitHub } from './src/auth.js';
|
|
73
|
+
export { createAuth, Credentials, Google, GitHub, AuthError, CredentialsSignin } from './src/auth.js';
|
|
70
74
|
|
|
71
75
|
// Test harness helpers (issue #267): thin builders over handle()
|
|
72
76
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
"amaro": "^1.1.10"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@webjsdev/core": "^0.
|
|
58
|
+
"@webjsdev/core": "^0.9.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@webjsdev/core": "^0.
|
|
61
|
+
"@webjsdev/core": "^0.9.0"
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form-action flash: what a form-bound server action returned, carried
|
|
3
|
+
* across the 303 that answers a successful submission, so the page it lands
|
|
4
|
+
* on still sees the result (`actionData`), and the NEXT submission of the same
|
|
5
|
+
* action receives it as `prevState` (Next.js's `useActionState(action,
|
|
6
|
+
* initial)` contract, `(prevState, formData) => newState`).
|
|
7
|
+
*
|
|
8
|
+
* The carrier is one cookie, `__webjs_flash`: HttpOnly, SameSite=Lax, 60
|
|
9
|
+
* seconds, HMAC-signed (so a visitor cannot forge a `prevState`), capped at
|
|
10
|
+
* 2 KB (a larger result is simply not carried; the action's own redirect
|
|
11
|
+
* still happens). The payload is the rich-serialized result (minus its
|
|
12
|
+
* `redirect`), the action's `<hash>/<fn>` identity, and whether a page has
|
|
13
|
+
* already displayed it: a result is shown ONCE as `actionData`, then kept
|
|
14
|
+
* only as the next `prevState`.
|
|
15
|
+
*
|
|
16
|
+
* Key: `WEBJS_SECRET`, else `AUTH_SECRET`, else `SESSION_SECRET`, else (in
|
|
17
|
+
* development and tests) the persisted dev secret, else a random per-process key (so a
|
|
18
|
+
* flash still works on one instance; a multi-instance deploy sets a secret).
|
|
19
|
+
*
|
|
20
|
+
* @module action-flash
|
|
21
|
+
*/
|
|
22
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
23
|
+
import { stringify, parse } from '@webjsdev/core';
|
|
24
|
+
import { shared } from './process-shared.js';
|
|
25
|
+
import { devSecret, devSecretApplies } from './dev-secret.js';
|
|
26
|
+
import { stripBasePath } from './base-path.js';
|
|
27
|
+
|
|
28
|
+
export const FLASH_COOKIE = '__webjs_flash';
|
|
29
|
+
const MAX_BYTES = 2048;
|
|
30
|
+
const MAX_AGE_SECONDS = 60;
|
|
31
|
+
|
|
32
|
+
const _key = shared('action-flash-key', () => ({ random: randomBytes(32).toString('hex'), warned: false, dev: /** @type {string | undefined | null} */ (null) }));
|
|
33
|
+
|
|
34
|
+
function key() {
|
|
35
|
+
// Any secret the app already set signs it, and in development and tests the
|
|
36
|
+
// persisted dev secret does, so a flash survives a dev server restart too.
|
|
37
|
+
const set = process.env.WEBJS_SECRET || process.env.AUTH_SECRET || process.env.SESSION_SECRET;
|
|
38
|
+
if (set) return set;
|
|
39
|
+
// Whether the dev secret applies is decided on every call, as devSecret
|
|
40
|
+
// decides it, so a flash signed before NODE_ENV was set does not pin the
|
|
41
|
+
// random key for the rest of the process. Only a secret it RETURNS is kept:
|
|
42
|
+
// it is a file, and a sign and its verify must use one key even when .webjs
|
|
43
|
+
// cannot be written (devSecret then makes a new one per call).
|
|
44
|
+
if (devSecretApplies()) {
|
|
45
|
+
if (!_key.dev) _key.dev = devSecret('WEBJS_SECRET') || null;
|
|
46
|
+
if (_key.dev) return _key.dev;
|
|
47
|
+
}
|
|
48
|
+
// A per-process key verifies only on the instance that signed it, so behind
|
|
49
|
+
// a load balancer a flash whose 303 lands elsewhere is silently dropped. Say
|
|
50
|
+
// so once in production rather than let it look like a missing feature.
|
|
51
|
+
if (process.env.NODE_ENV === 'production' && !_key.warned) {
|
|
52
|
+
_key.warned = true;
|
|
53
|
+
console.warn('[webjs] none of WEBJS_SECRET, AUTH_SECRET or SESSION_SECRET is set, so form action results (actionData after a redirect, prevState) are signed with a per-process key and are lost when the redirect lands on another instance. Set any one of them.');
|
|
54
|
+
}
|
|
55
|
+
return _key.random;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* HMAC of a flash body. The input is prefixed so the signature is unrelated by
|
|
60
|
+
* construction to any other HMAC made with the same secret (`AUTH_SECRET` also
|
|
61
|
+
* signs the session), whatever either format becomes.
|
|
62
|
+
* @param {string} s
|
|
63
|
+
*/
|
|
64
|
+
function sign(s) {
|
|
65
|
+
return createHmac('sha256', key()).update(`webjs-flash:${s}`).digest('base64url');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `path` is the page the success redirect lands on: only that page shows it.
|
|
70
|
+
* @typedef {{ id: string, result: unknown, shown: boolean, path?: string }} Flash
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Encode a flash as a cookie value, or null when it does not fit the cap.
|
|
75
|
+
* @param {Flash} flash
|
|
76
|
+
* @returns {Promise<string | null>}
|
|
77
|
+
*/
|
|
78
|
+
export async function encodeFlash(flash) {
|
|
79
|
+
let result = flash.result;
|
|
80
|
+
if (result && typeof result === 'object' && !Array.isArray(result) && 'redirect' in result) {
|
|
81
|
+
const { redirect: _drop, ...rest } = /** @type {Record<string, unknown>} */ (result);
|
|
82
|
+
result = rest;
|
|
83
|
+
}
|
|
84
|
+
let body;
|
|
85
|
+
try {
|
|
86
|
+
body = Buffer.from(JSON.stringify({ a: flash.id, s: flash.shown ? 1 : 0, ...(flash.path ? { p: flash.path } : {}), r: await stringify(result ?? null) })).toString('base64url');
|
|
87
|
+
} catch {
|
|
88
|
+
return null; // unserializable result: nothing to carry
|
|
89
|
+
}
|
|
90
|
+
const value = `${body}.${sign(body)}`;
|
|
91
|
+
return value.length > MAX_BYTES ? null : value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decode and verify a flash cookie value; null when absent, forged, or broken.
|
|
96
|
+
* @param {string | undefined | null} value
|
|
97
|
+
* @returns {Promise<Flash | null>}
|
|
98
|
+
*/
|
|
99
|
+
export async function decodeFlash(value) {
|
|
100
|
+
if (!value || typeof value !== 'string') return null;
|
|
101
|
+
const dot = value.lastIndexOf('.');
|
|
102
|
+
if (dot <= 0) return null;
|
|
103
|
+
const body = value.slice(0, dot);
|
|
104
|
+
const mac = Buffer.from(value.slice(dot + 1));
|
|
105
|
+
const want = Buffer.from(sign(body));
|
|
106
|
+
if (mac.length !== want.length || !timingSafeEqual(mac, want)) return null;
|
|
107
|
+
try {
|
|
108
|
+
const o = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
109
|
+
if (!o || typeof o.a !== 'string' || typeof o.r !== 'string') return null;
|
|
110
|
+
return { id: o.a, shown: o.s === 1, ...(typeof o.p === 'string' ? { path: o.p } : {}), result: parse(o.r) };
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `Set-Cookie` value that stores a flash (or clears it with null).
|
|
118
|
+
* @param {string | null} value
|
|
119
|
+
*/
|
|
120
|
+
export function flashSetCookie(value) {
|
|
121
|
+
return value
|
|
122
|
+
? `${FLASH_COOKIE}=${value}; Path=/; Max-Age=${MAX_AGE_SECONDS}; HttpOnly; SameSite=Lax`
|
|
123
|
+
: `${FLASH_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read the flash cookie off a request.
|
|
128
|
+
* @param {Request | undefined | null} req
|
|
129
|
+
* @returns {Promise<Flash | null>}
|
|
130
|
+
*/
|
|
131
|
+
export async function readFlash(req) {
|
|
132
|
+
const header = req && req.headers.get('cookie');
|
|
133
|
+
if (!header || !header.includes(FLASH_COOKIE)) return null;
|
|
134
|
+
const m = new RegExp(`(?:^|;\\s*)${FLASH_COOKIE}=([^;]*)`).exec(header);
|
|
135
|
+
return m ? decodeFlash(m[1]) : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A path without its trailing slash (the root stays "/"). @param {string} p */
|
|
139
|
+
const trimSlash = (p) => (p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The app-root-relative form of a path: the `webjs.basePath` mount prefix
|
|
143
|
+
* removed when the path is under it (a redirect target may be written with or
|
|
144
|
+
* without it), else the path as is.
|
|
145
|
+
* @param {string} pathname
|
|
146
|
+
* @param {string} mount
|
|
147
|
+
*/
|
|
148
|
+
export function appRelativePath(pathname, mount) {
|
|
149
|
+
return (mount && stripBasePath(pathname, mount)) || pathname;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Retire an unshown flash when the page it is bound to answers a redirect
|
|
154
|
+
* instead of rendering (#1686). The result belongs to the page the success
|
|
155
|
+
* redirect named; when that page sends the visitor on to ANOTHER page (a
|
|
156
|
+
* sign-in check, a redirects rule, middleware), nothing shows it, as a
|
|
157
|
+
* redirect after an action
|
|
158
|
+
* discards its state in Next.js. Following the redirect instead would put the
|
|
159
|
+
* result on whatever page the chain ends on, a sign-in page included. The
|
|
160
|
+
* flash is marked shown, so it stays the next submission's `prevState` (as a
|
|
161
|
+
* 422's does) and is never displayed later. A redirect to the same page (the
|
|
162
|
+
* trailing-slash policy answering /done/ with /done) is not one away, so it
|
|
163
|
+
* leaves the flash for the page; a speculative prefetch leaves it too.
|
|
164
|
+
* Mutates `res.headers`; a no-op for anything else.
|
|
165
|
+
* @param {Request} req
|
|
166
|
+
* @param {Response} res
|
|
167
|
+
* @param {string} mount the app's `webjs.basePath` ('' when none)
|
|
168
|
+
*/
|
|
169
|
+
export async function retireFlashOnRedirect(req, res, mount) {
|
|
170
|
+
if (res.status < 300 || res.status >= 400) return;
|
|
171
|
+
if (req.headers.get('x-webjs-prefetch') === '1') return;
|
|
172
|
+
const flash = await readFlash(req);
|
|
173
|
+
if (!flash || flash.shown || !flash.path) return;
|
|
174
|
+
const from = new URL(req.url);
|
|
175
|
+
const bound = trimSlash(flash.path);
|
|
176
|
+
if (trimSlash(appRelativePath(from.pathname, mount)) !== bound) return;
|
|
177
|
+
const location = res.headers.get('location');
|
|
178
|
+
if (location) {
|
|
179
|
+
let to = null;
|
|
180
|
+
try { to = new URL(location, from); } catch { /* unparsable: treated as away */ }
|
|
181
|
+
if (to && to.origin === from.origin && trimSlash(appRelativePath(to.pathname, mount)) === bound) return;
|
|
182
|
+
}
|
|
183
|
+
const value = await encodeFlash({ ...flash, shown: true });
|
|
184
|
+
if (value) res.headers.append('set-cookie', flashSetCookie(value));
|
|
185
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many parameters a server action DECLARES (#1686), which is how a form
|
|
3
|
+
* submission tells a `(prevState, formData)` action from a `(formData)` one.
|
|
4
|
+
*
|
|
5
|
+
* `fn.length` cannot answer it: it stops counting at the first parameter with
|
|
6
|
+
* a default, so `async function save(prevState = initial, formData)` reports 0
|
|
7
|
+
* and never received its previous result. That default is exactly how an
|
|
8
|
+
* action supplies the first submission's state, the role `useActionState`'s
|
|
9
|
+
* second argument plays in Next.js, so the count comes from the function's
|
|
10
|
+
* source instead. A rest parameter is not counted, and anything that is not
|
|
11
|
+
* plain source (a native or bound function) falls back to `fn.length`.
|
|
12
|
+
*
|
|
13
|
+
* @param {Function} fn
|
|
14
|
+
* @returns {number}
|
|
15
|
+
*/
|
|
16
|
+
export function declaredParamCount(fn) {
|
|
17
|
+
let src;
|
|
18
|
+
try {
|
|
19
|
+
src = Function.prototype.toString.call(fn);
|
|
20
|
+
} catch {
|
|
21
|
+
return fn.length;
|
|
22
|
+
}
|
|
23
|
+
if (/\{\s*\[native code\]\s*\}\s*$/.test(src)) return fn.length;
|
|
24
|
+
// A single bare parameter arrow: `x => ...` or `async x => ...`.
|
|
25
|
+
if (/^\s*(?:async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(src)) return 1;
|
|
26
|
+
const open = src.indexOf('(');
|
|
27
|
+
if (open < 0) return fn.length;
|
|
28
|
+
const list = paramListAt(src, open);
|
|
29
|
+
if (list === null) return fn.length;
|
|
30
|
+
return Math.max(fn.length, countParams(list));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The text between the `(` at `open` and its matching `)`, skipping strings,
|
|
35
|
+
* template literals, comments and nested brackets, or null if unbalanced.
|
|
36
|
+
* @param {string} src
|
|
37
|
+
* @param {number} open
|
|
38
|
+
* @returns {string | null}
|
|
39
|
+
*/
|
|
40
|
+
function paramListAt(src, open) {
|
|
41
|
+
let depth = 0;
|
|
42
|
+
for (let i = open; i < src.length; i++) {
|
|
43
|
+
const c = src[i];
|
|
44
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
45
|
+
i = skipQuoted(src, i);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (c === '/' && src[i + 1] === '/') { const nl = src.indexOf('\n', i); i = nl < 0 ? src.length : nl; continue; }
|
|
49
|
+
if (c === '/' && src[i + 1] === '*') { const end = src.indexOf('*/', i + 2); i = end < 0 ? src.length : end + 1; continue; }
|
|
50
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
51
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
52
|
+
depth--;
|
|
53
|
+
if (depth === 0) return src.slice(open + 1, i);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} src
|
|
61
|
+
* @param {number} start index of the opening quote
|
|
62
|
+
* @returns {number} index of the closing quote
|
|
63
|
+
*/
|
|
64
|
+
function skipQuoted(src, start) {
|
|
65
|
+
const q = src[start];
|
|
66
|
+
for (let i = start + 1; i < src.length; i++) {
|
|
67
|
+
if (src[i] === '\\') { i++; continue; }
|
|
68
|
+
if (q === '`' && src[i] === '$' && src[i + 1] === '{') {
|
|
69
|
+
const inner = paramListAt(src, i + 1);
|
|
70
|
+
i += 2 + (inner === null ? 0 : inner.length);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (src[i] === q) return i;
|
|
74
|
+
}
|
|
75
|
+
return src.length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether the function's declared parameters each carry a default, in order
|
|
80
|
+
* (rest parameter excluded), or null when the source cannot be read (a native
|
|
81
|
+
* or bound function). `(input, opts = {})` is `[false, true]`.
|
|
82
|
+
* @param {Function} fn
|
|
83
|
+
* @returns {boolean[] | null}
|
|
84
|
+
*/
|
|
85
|
+
export function declaredParamDefaults(fn) {
|
|
86
|
+
let src;
|
|
87
|
+
try {
|
|
88
|
+
src = Function.prototype.toString.call(fn);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (/\{\s*\[native code\]\s*\}\s*$/.test(src)) return null;
|
|
93
|
+
if (/^\s*(?:async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(src)) return [false];
|
|
94
|
+
const open = src.indexOf('(');
|
|
95
|
+
if (open < 0) return null;
|
|
96
|
+
const list = paramListAt(src, open);
|
|
97
|
+
if (list === null) return null;
|
|
98
|
+
return splitParams(list).map(hasTopLevelDefault);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a parameter's source has a default: an `=` at depth 0 that is not
|
|
103
|
+
* part of `==`, `=>`, `<=`, `>=` or `!=` (a destructuring pattern's own
|
|
104
|
+
* defaults sit inside its brackets).
|
|
105
|
+
* @param {string} param
|
|
106
|
+
*/
|
|
107
|
+
function hasTopLevelDefault(param) {
|
|
108
|
+
let depth = 0;
|
|
109
|
+
for (let i = 0; i < param.length; i++) {
|
|
110
|
+
const c = param[i];
|
|
111
|
+
if (c === '"' || c === "'" || c === '`') { i = skipQuoted(param, i); continue; }
|
|
112
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
113
|
+
else if (c === ')' || c === ']' || c === '}') depth--;
|
|
114
|
+
else if (c === '=' && depth === 0 && param[i + 1] !== '=' && param[i + 1] !== '>' && !'=!<>'.includes(param[i - 1] || '')) return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Top-level parameters in a parameter list, a rest parameter not counted.
|
|
121
|
+
* @param {string} list
|
|
122
|
+
* @returns {number}
|
|
123
|
+
*/
|
|
124
|
+
function countParams(list) {
|
|
125
|
+
return splitParams(list).length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The declared parameters' source texts, top-level commas only, the rest
|
|
130
|
+
* parameter dropped.
|
|
131
|
+
* @param {string} list
|
|
132
|
+
* @returns {string[]}
|
|
133
|
+
*/
|
|
134
|
+
function splitParams(list) {
|
|
135
|
+
/** @type {string[]} */
|
|
136
|
+
const parts = [];
|
|
137
|
+
let depth = 0;
|
|
138
|
+
let cur = '';
|
|
139
|
+
for (let i = 0; i < list.length; i++) {
|
|
140
|
+
const c = list[i];
|
|
141
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
142
|
+
const end = skipQuoted(list, i);
|
|
143
|
+
cur += list.slice(i, end + 1);
|
|
144
|
+
i = end;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (c === '/' && list[i + 1] === '/') { const nl = list.indexOf('\n', i); i = nl < 0 ? list.length : nl; continue; }
|
|
148
|
+
if (c === '/' && list[i + 1] === '*') { const end = list.indexOf('*/', i + 2); i = end < 0 ? list.length : end + 1; continue; }
|
|
149
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
150
|
+
else if (c === ')' || c === ']' || c === '}') depth--;
|
|
151
|
+
if (c === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
|
|
152
|
+
cur += c;
|
|
153
|
+
}
|
|
154
|
+
parts.push(cur);
|
|
155
|
+
return parts.map((p) => p.trim()).filter((p) => p && !p.startsWith('...'));
|
|
156
|
+
}
|
package/src/action-route.js
CHANGED
|
@@ -155,19 +155,18 @@ export function route(actionOrModule, opts = {}) {
|
|
|
155
155
|
// Run the validator through the SHARED contract (#245) so the REST adapter
|
|
156
156
|
// and the RPC path interpret a `{ success, fieldErrors }` envelope, a
|
|
157
157
|
// throw, and a transform-return identically. A structured failure becomes
|
|
158
|
-
// a 422 JSON
|
|
158
|
+
// a 422 JSON, and so does a schema library's throw (its issues mapped to
|
|
159
|
+
// fieldErrors, the raw `issues` kept); any other throw is a 400; a
|
|
159
160
|
// transform-return replaces the input.
|
|
160
|
-
const v = runValidate(validate, arg);
|
|
161
|
+
const v = await runValidate(validate, arg);
|
|
161
162
|
if (!v.ok) {
|
|
162
|
-
if (v.thrown !== undefined) {
|
|
163
|
-
|
|
164
|
-
const issues = v.thrown && typeof v.thrown === 'object' && 'issues' in v.thrown
|
|
165
|
-
? /** @type any */ (v.thrown).issues
|
|
166
|
-
: undefined;
|
|
167
|
-
return Response.json({ error: msg, issues }, { status: 400 });
|
|
163
|
+
if (v.thrown !== undefined && !v.invalid) {
|
|
164
|
+
return Response.json({ error: v.result.error || 'Invalid input' }, { status: 400 });
|
|
168
165
|
}
|
|
166
|
+
// A structured envelope, or a schema library's throw mapped to one
|
|
167
|
+
// (which also keeps its raw `issues` for a REST client).
|
|
169
168
|
const { status, ...payload } = v.result;
|
|
170
|
-
return Response.json(payload, { status });
|
|
169
|
+
return Response.json(v.invalid ? { ...payload, issues: /** @type any */ (v.thrown).issues } : payload, { status });
|
|
171
170
|
}
|
|
172
171
|
arg = v.value;
|
|
173
172
|
}
|
package/src/action-seed.js
CHANGED
|
@@ -64,7 +64,7 @@ import * as nodeModule from 'node:module';
|
|
|
64
64
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
65
65
|
import { readFileSync } from 'node:fs';
|
|
66
66
|
import { fileURLToPath } from 'node:url';
|
|
67
|
-
import { stringify } from '@webjsdev/core';
|
|
67
|
+
import { stringify, installActionBind } from '@webjsdev/core';
|
|
68
68
|
import { hashFile } from './actions.js';
|
|
69
69
|
import { isStreamable } from './action-stream.js';
|
|
70
70
|
import { serverRuntime } from './listener-core.js';
|
|
@@ -107,6 +107,28 @@ const _flags = shared('action-seed-flags', () => ({ seedEnabled: false, devMode:
|
|
|
107
107
|
* @type {WeakMap<Function, { file: string, fnName: string }[]>}
|
|
108
108
|
*/
|
|
109
109
|
const _identity = shared('action-identity', () => new WeakMap());
|
|
110
|
+
/** The function each seed Proxy wraps, so its source can still be read
|
|
111
|
+
* (`Function.prototype.toString` on a Proxy is native code). */
|
|
112
|
+
const _originals = shared('action-originals', () => new WeakMap());
|
|
113
|
+
|
|
114
|
+
/** The property a facade's exported wrapper reads its real action through. */
|
|
115
|
+
const ORIGINAL = Symbol.for('webjs.action.original');
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The action function itself, behind a facade's exported wrapper or a seed
|
|
119
|
+
* Proxy, or `fn` when it is neither. Used to read the action's own source,
|
|
120
|
+
* which neither wrapper carries (#1686).
|
|
121
|
+
* @param {Function} fn
|
|
122
|
+
* @returns {Function}
|
|
123
|
+
*/
|
|
124
|
+
export function originalAction(fn) {
|
|
125
|
+
/** @type {any} */
|
|
126
|
+
const f = fn;
|
|
127
|
+
let inner;
|
|
128
|
+
try { inner = f[ORIGINAL]; } catch { inner = undefined; }
|
|
129
|
+
if (typeof inner === 'function') return inner;
|
|
130
|
+
return _originals.get(fn) ?? fn;
|
|
131
|
+
}
|
|
110
132
|
|
|
111
133
|
/** Memoized `absPath -> hash` so a hot action call does not re-hash per call. */
|
|
112
134
|
const _hashCache = shared('action-hash-cache', () => new Map());
|
|
@@ -267,6 +289,10 @@ async function assertDeterministic(prev, next, hash, fnName) {
|
|
|
267
289
|
*/
|
|
268
290
|
export function __actionWrap(file, fnName, orig) {
|
|
269
291
|
if (typeof orig !== 'function') return orig;
|
|
292
|
+
// `action.bind(null, id)` keeps the identity, so the bound function can be
|
|
293
|
+
// bound to a form (Next.js's way to pass an extra argument). Installed on the
|
|
294
|
+
// target, so the seed Proxy below forwards it too.
|
|
295
|
+
installActionBind(orig);
|
|
270
296
|
// APPEND, defining module first. A barrel is faceted too and its body runs
|
|
271
297
|
// AFTER the module it re-exports from, so the first registration is the
|
|
272
298
|
// defining one: the module that also carries `validate` / `middleware` /
|
|
@@ -281,6 +307,7 @@ export function __actionWrap(file, fnName, orig) {
|
|
|
281
307
|
_identity.set(orig, list);
|
|
282
308
|
if (!_flags.seedEnabled) return orig;
|
|
283
309
|
const wrapped = seedProxy(file, fnName, orig);
|
|
310
|
+
_originals.set(wrapped, orig);
|
|
284
311
|
// The wrapper is a fresh object every time, so it shares the TARGET's list
|
|
285
312
|
// rather than starting one of its own.
|
|
286
313
|
_identity.set(wrapped, list);
|
|
@@ -552,6 +579,17 @@ function buildFacade(origUrl, absPath, exports) {
|
|
|
552
579
|
out += ` const fn = ${v} || (${v} = __w(${file}, ${k}, __orig[${k}]));\n`;
|
|
553
580
|
out += ` return typeof fn === 'function' ? fn.apply(this, args) : fn;\n`;
|
|
554
581
|
out += `}\n`;
|
|
582
|
+
// Keep the action's declared arity: a form-bound action declaring
|
|
583
|
+
// `(prevState, formData)` is told apart by `fn.length` (Next's
|
|
584
|
+
// useActionState contract), and the rest-args wrapper reports 0. It is a
|
|
585
|
+
// GETTER, read when the dispatcher asks: a circular import can run this
|
|
586
|
+
// facade before the real module, while a `const` action is still in TDZ,
|
|
587
|
+
// and a value captured then would freeze the arity at 0.
|
|
588
|
+
out += `try { Object.defineProperty(${n}, 'length', { configurable: true, get() { try { return (__orig[${k}] && __orig[${k}].length) || 0; } catch { return 0; } } }); } catch {}\n`;
|
|
589
|
+
// The real function, for reading its source (`originalAction`): the
|
|
590
|
+
// dispatcher counts DECLARED parameters, which `length` undercounts at a
|
|
591
|
+
// default (#1686). A getter for the same TDZ reason as `length`.
|
|
592
|
+
out += `try { Object.defineProperty(${n}, Symbol.for('webjs.action.original'), { configurable: true, get() { try { return __orig[${k}]; } catch { return undefined; } } }); } catch {}\n`;
|
|
555
593
|
out += `__w(${file}, ${k}, ${n});\n`;
|
|
556
594
|
}
|
|
557
595
|
for (const n of exports.valNames) {
|
|
@@ -663,10 +701,11 @@ export async function registerActionHooks(opts = {}) {
|
|
|
663
701
|
g[installed] = true;
|
|
664
702
|
// Bun has no module.registerHooks; install the same facade via Bun.plugin.
|
|
665
703
|
const { installBunSeedPlugin } = await import('./action-seed-bun.js');
|
|
666
|
-
// Dev rewrites the app's `#` imports to absolute paths (#1575)
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
704
|
+
// Dev rewrites the app's `#` imports to absolute paths (#1575) and stamps
|
|
705
|
+
// the module versions (#1689); both read the app roots dev registered, so
|
|
706
|
+
// the rewrite is a no-op in prod.
|
|
707
|
+
const { rewriteAppSource } = await import('./dev/bun-app-source.js');
|
|
708
|
+
installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE, rewriteAliases: rewriteAppSource });
|
|
670
709
|
_flags.hookInstalled = true;
|
|
671
710
|
return;
|
|
672
711
|
}
|