@webjsdev/server 0.8.91 → 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 (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. 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,8 +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';
47
- export { maskJsSet, staticPageRoutes } from './src/elision-differential.js';
46
+ export { analyzeRouteModules } from './src/route-report.js';
47
+ export { regenerateOutputs, regenerateStatus } from './src/dev-regenerate.js';
48
48
  export { headers, cookies, getRequest, withRequest, cspNonce, requestId } from './src/context.js';
49
49
  export { defaultLogger } from './src/logger.js';
50
50
  export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
@@ -64,8 +64,13 @@ export { stream, streamResponse, acceptsStream, STREAM_MIME } from './src/stream
64
64
  export { attachWebSocket } from './src/websocket.js';
65
65
  export { getSerializer, setSerializer, defaultSerializer } from './src/serializer.js';
66
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
+
67
72
  // Auth (NextAuth-style)
68
- export { createAuth, Credentials, Google, GitHub } from './src/auth.js';
73
+ export { createAuth, Credentials, Google, GitHub, AuthError, CredentialsSignin } from './src/auth.js';
69
74
 
70
75
  // Test harness helpers (issue #267): thin builders over handle()
71
76
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.91",
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.7.51"
58
+ "@webjsdev/core": "^0.9.0"
59
59
  },
60
60
  "devDependencies": {
61
- "@webjsdev/core": "^0.7.51"
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,208 @@
1
+ /**
2
+ * The input shape a server action receives from a form submission.
3
+ *
4
+ * A `'use server'` action is reachable three ways, and before this module each
5
+ * handed it a different first argument: the deserialized arguments over RPC, a
6
+ * merged plain object through the `route()` adapter, and a raw `FormData` from
7
+ * a bound `<form action=${fn}>`. A validator written for the RPC shape
8
+ * (`input.title`) read `undefined` off the `FormData` and answered every form
9
+ * submission with a 422, while `webjs check` and typecheck both passed.
10
+ *
11
+ * `formInput` returns a `FormInput`: a real `FormData` (so `instanceof
12
+ * FormData`, the readers, the writers and `fetch(url, { body: input })` all
13
+ * behave as they always did) that ALSO carries every submitted field as an own
14
+ * enumerable property, so `input.title`, destructuring, spreading and a schema
15
+ * library's `parse(input)` read it like the plain object RPC delivers. Next
16
+ * and TanStack Start hand an action a `FormData`; this keeps that and adds the
17
+ * property view the RPC shape needs, so neither set of habits breaks.
18
+ *
19
+ * - A property holds the field's LAST value (the `Object.fromEntries`
20
+ * semantics). A name ending in `[]` collects every value into an array
21
+ * under the name without the brackets, the convention Rails' parameter
22
+ * builder uses for a checkbox group. A `File` stays a real `File`.
23
+ * - `get`, `getAll` and `has` also accept the bracket-less name, so
24
+ * `input.getAll('tags')` reads a `tags[]` group and agrees with
25
+ * `input.tags`. Iteration (`entries`, `keys`, `values`, `forEach`,
26
+ * `Object.fromEntries(input)`) reports names as SUBMITTED, brackets
27
+ * included, like any `FormData`.
28
+ * - A name submitted both plain and with `[]` (`x=1&x[]=2`) is one array
29
+ * group, so nothing is dropped. Rails rejects that shape as a parameter
30
+ * type error, so dev warns once about it.
31
+ * - The writers (`append`, `set`, `delete`) keep the property in step.
32
+ *
33
+ * Fields are defined with `Object.defineProperty`, never assigned, so a field
34
+ * named `__proto__` becomes an own data property instead of reaching the
35
+ * prototype setter. A field that shares a method's name wins as data on the
36
+ * instance: the data is what the author named.
37
+ *
38
+ * @module
39
+ */
40
+
41
+ /** Names already warned about, so a repeated name warns once per process. */
42
+ const warned = new Set();
43
+
44
+ /**
45
+ * A `FormData` whose submitted fields are also own enumerable properties.
46
+ * Built only by `formInput`.
47
+ */
48
+ class FormInput extends FormData {
49
+ /**
50
+ * @param {string} name
51
+ * @returns {FormDataEntryValue | null}
52
+ */
53
+ get(name) {
54
+ if (super.has(name)) return super.get(name);
55
+ const alias = arrayName(name);
56
+ return alias && super.has(alias) ? super.get(alias) : null;
57
+ }
58
+
59
+ /**
60
+ * @param {string} name
61
+ * @returns {FormDataEntryValue[]}
62
+ */
63
+ getAll(name) {
64
+ const alias = arrayName(name);
65
+ return alias ? [...super.getAll(name), ...super.getAll(alias)] : super.getAll(name);
66
+ }
67
+
68
+ /** @param {string} name */
69
+ has(name) {
70
+ const alias = arrayName(name);
71
+ return super.has(name) || (!!alias && super.has(alias));
72
+ }
73
+
74
+ /**
75
+ * @param {string} name
76
+ * @param {string | Blob} value
77
+ * @param {string} [filename]
78
+ */
79
+ append(name, value, filename) {
80
+ if (filename === undefined) super.append(name, /** @type {any} */ (value));
81
+ else super.append(name, /** @type {Blob} */ (value), filename);
82
+ syncField(this, name);
83
+ }
84
+
85
+ /**
86
+ * @param {string} name
87
+ * @param {string | Blob} value
88
+ * @param {string} [filename]
89
+ */
90
+ set(name, value, filename) {
91
+ if (filename === undefined) super.set(name, /** @type {any} */ (value));
92
+ else super.set(name, /** @type {Blob} */ (value), filename);
93
+ syncField(this, name);
94
+ }
95
+
96
+ /** @param {string} name */
97
+ delete(name) {
98
+ super.delete(name);
99
+ syncField(this, name);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Convert a submitted `FormData` into the action input.
105
+ *
106
+ * @param {FormData} formData
107
+ * @param {{ warn?: (message: string) => void, label?: string, omit?: string }} [opts]
108
+ * `warn` is passed in dev only: it reports a plain name submitted more than
109
+ * once (its property keeps only the last value) and a name submitted under
110
+ * both spellings. `label` names the action in that message and keys the
111
+ * once-per-process dedupe. `omit` is a field to leave out (the framework's
112
+ * own `__webjs_action` identity).
113
+ * @returns {FormData & Record<string, FormDataEntryValue | FormDataEntryValue[]>}
114
+ */
115
+ export function formInput(formData, opts = {}) {
116
+ const input = new FormInput();
117
+ /** @type {Map<string, number>} */
118
+ const counts = new Map();
119
+ /** @type {Set<string>} */
120
+ const plain = new Set();
121
+ for (const [name, value] of formData.entries()) {
122
+ if (name === opts.omit) continue;
123
+ FormData.prototype.append.call(input, name, value);
124
+ const key = isArrayName(name) ? name.slice(0, -2) : name;
125
+ counts.set(key, (counts.get(key) || 0) + 1);
126
+ if (!isArrayName(name)) plain.add(name);
127
+ }
128
+ for (const key of counts.keys()) syncField(input, key);
129
+
130
+ if (typeof opts.warn === 'function') {
131
+ for (const [key, n] of counts) {
132
+ const grouped = FormData.prototype.has.call(input, key + '[]');
133
+ let message = null;
134
+ if (grouped && plain.has(key)) {
135
+ message = `[webjs] the form field "${key}" was submitted both as "${key}" and as "${key}[]"${opts.label ? ` to ${opts.label}` : ''}, `
136
+ + `so input.${key} holds every value as one array. Give the controls one spelling, "${key}[]" for a group.`;
137
+ } else if (!grouped && n > 1) {
138
+ message = `[webjs] the form field "${key}" was submitted ${n} times${opts.label ? ` to ${opts.label}` : ''}, `
139
+ + `so input.${key} holds only the last value. Name the controls "${key}[]" to receive an array `
140
+ + `as input.${key}, or read every value with input.getAll('${key}').`;
141
+ }
142
+ if (!message) continue;
143
+ const warnKey = `${opts.label || ''}\u0000${key}`;
144
+ if (warned.has(warnKey)) continue;
145
+ warned.add(warnKey);
146
+ opts.warn(message);
147
+ }
148
+ }
149
+
150
+ return /** @type {any} */ (input);
151
+ }
152
+
153
+ /**
154
+ * True for a real `FormData` that `formInput` has not converted yet.
155
+ * @param {unknown} value
156
+ */
157
+ export function isFormData(value) {
158
+ return typeof FormData !== 'undefined' && value instanceof FormData && !(value instanceof FormInput);
159
+ }
160
+
161
+ /**
162
+ * Recompute the property for the field a submitted name belongs to, from the
163
+ * entries currently in the form: an array when the name ever arrived as `[]`,
164
+ * else the last value, else no property at all.
165
+ * @param {FormInput} input
166
+ * @param {string} name a submitted name, with or without `[]`
167
+ */
168
+ function syncField(input, name) {
169
+ const key = isArrayName(name) ? name.slice(0, -2) : name;
170
+ const own = FormData.prototype.getAll.call(input, key);
171
+ if (FormData.prototype.has.call(input, key + '[]')) {
172
+ // Plain and bracketed entries in submission order, so a mixed form keeps
173
+ // every value in the order the browser sent it.
174
+ /** @type {FormDataEntryValue[]} */
175
+ const values = [];
176
+ for (const [n, v] of FormData.prototype.entries.call(input)) if (n === key || n === key + '[]') values.push(v);
177
+ define(input, key, values);
178
+ } else if (own.length) {
179
+ define(input, key, own[own.length - 1]);
180
+ } else if (Object.prototype.hasOwnProperty.call(input, key)) {
181
+ delete /** @type {any} */ (input)[key];
182
+ }
183
+ }
184
+
185
+ /**
186
+ * True for a submitted name in the array form (`tags[]`).
187
+ * @param {string} name
188
+ */
189
+ function isArrayName(name) {
190
+ return name.length > 2 && name.endsWith('[]');
191
+ }
192
+
193
+ /**
194
+ * The `[]` spelling of a bracket-less name, or null for a name already in it.
195
+ * @param {string} name
196
+ */
197
+ function arrayName(name) {
198
+ return isArrayName(name) ? null : name + '[]';
199
+ }
200
+
201
+ /**
202
+ * @param {object} target
203
+ * @param {string} key
204
+ * @param {unknown} value
205
+ */
206
+ function define(target, key, value) {
207
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
208
+ }
@@ -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
  }