@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.
Files changed (101) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +427 -109
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +96 -14
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +262 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/dev/bun-app-source.js +95 -5
  33. package/src/dev/config.js +7 -51
  34. package/src/dev/dev-service-worker.js +48 -0
  35. package/src/dev/esm-hot-hooks.js +373 -0
  36. package/src/dev/handler.js +502 -155
  37. package/src/dev/helpers.js +127 -96
  38. package/src/dev/hot-host.js +33 -6
  39. package/src/dev/poke-state.js +110 -0
  40. package/src/dev/serve.js +177 -129
  41. package/src/dev/server.js +92 -33
  42. package/src/dev/specifier-versions.js +125 -0
  43. package/src/dev/watch-ignore.js +49 -4
  44. package/src/dev/watch-poll.js +78 -0
  45. package/src/dev/watch-tree.js +59 -22
  46. package/src/dev-classify.js +59 -26
  47. package/src/dev-error.js +104 -3
  48. package/src/dev-hot-classify.js +291 -0
  49. package/src/dev-hot-client.js +245 -0
  50. package/src/dev-import.js +22 -5
  51. package/src/dev-overlay.js +26 -0
  52. package/src/dev-ready-probe.js +69 -0
  53. package/src/dev-regenerate.js +2 -2
  54. package/src/dev-reload-hold.js +49 -20
  55. package/src/dev-reload-reason.js +61 -0
  56. package/src/dev-reload-worker.js +62 -13
  57. package/src/dev-secret.js +73 -0
  58. package/src/dev-source-locations-hook.js +4 -1
  59. package/src/dev-source-locations.js +33 -2
  60. package/src/dev.js +0 -1
  61. package/src/env-files.js +124 -0
  62. package/src/file-storage.js +2 -2
  63. package/src/foreign-imports.js +228 -0
  64. package/src/form-action-identity.js +52 -25
  65. package/src/form-dispatch.js +140 -65
  66. package/src/html-cache.js +35 -4
  67. package/src/importmap.js +2 -2
  68. package/src/instrumentation.js +59 -3
  69. package/src/js-scan.js +5 -4
  70. package/src/jwe.js +155 -0
  71. package/src/lazy-components.js +166 -0
  72. package/src/listener-core.js +4 -2
  73. package/src/lit-hints.js +172 -0
  74. package/src/middleware-load.js +248 -0
  75. package/src/module-graph.js +73 -47
  76. package/src/module-side-effect.js +284 -0
  77. package/src/public-files.js +73 -0
  78. package/src/redirect-location.js +19 -0
  79. package/src/redirects.js +9 -1
  80. package/src/route-frontier.js +151 -0
  81. package/src/route-report.js +130 -0
  82. package/src/router.js +120 -13
  83. package/src/session.js +32 -10
  84. package/src/ssr/client-router-flag.js +1 -1
  85. package/src/ssr/document.js +37 -13
  86. package/src/ssr/head.js +78 -23
  87. package/src/ssr/preloads.js +31 -45
  88. package/src/ssr/render.js +241 -55
  89. package/src/ssr/responses.js +51 -4
  90. package/src/ssr/shipped-modules.js +22 -16
  91. package/src/testing.d.ts +31 -2
  92. package/src/testing.js +80 -17
  93. package/src/thenable-params.js +26 -0
  94. package/src/vendor/jspm.js +12 -1
  95. package/src/vendor/pins.js +9 -8
  96. package/src/vendor/scanner.js +14 -9
  97. package/src/vendor.js +5 -3
  98. package/webjs-config.schema.json +1 -6
  99. package/src/component-elision.js +0 -1828
  100. package/src/elision-differential.js +0 -101
  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 { analyzeAppElision } from './src/elision-report.js';
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.9.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.8.0"
58
+ "@webjsdev/core": "^0.9.0"
59
59
  },
60
60
  "devDependencies": {
61
- "@webjsdev/core": "^0.8.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
+ }
@@ -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; a throw stays a 400 (keeping a schema lib's `issues`); a
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
- const msg = v.result.error || 'Invalid input';
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
  }
@@ -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); the
667
- // rewrite reads the app roots dev registered, so it is a no-op in prod.
668
- const { rewriteAppAliases } = await import('./dev/bun-app-source.js');
669
- installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE, rewriteAliases: rewriteAppAliases });
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
  }