@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.
- package/README.md +4 -1
- package/index.d.ts +461 -102
- package/index.js +9 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-input.js +208 -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 +112 -15
- 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/helpers.js +85 -0
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +133 -28
- 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 +109 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +24 -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/dev-status.js +122 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +632 -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 +191 -133
- package/src/dev/server.js +109 -37
- 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 +322 -78
- 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-styles.js +64 -6
- 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 +150 -69
- 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 +49 -17
- package/src/ssr/head.js +228 -25
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +243 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +52 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +85 -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 +3 -8
- package/src/component-elision.js +0 -1852
- 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,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 {
|
|
47
|
-
export {
|
|
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.
|
|
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,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
|
+
}
|
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
|
}
|