@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/src/action-seed.js
CHANGED
|
@@ -64,7 +64,7 @@ import * as nodeModule from 'node:module';
|
|
|
64
64
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
65
65
|
import { readFileSync } from 'node:fs';
|
|
66
66
|
import { fileURLToPath } from 'node:url';
|
|
67
|
-
import { stringify } from '@webjsdev/core';
|
|
67
|
+
import { stringify, installActionBind } from '@webjsdev/core';
|
|
68
68
|
import { hashFile } from './actions.js';
|
|
69
69
|
import { isStreamable } from './action-stream.js';
|
|
70
70
|
import { serverRuntime } from './listener-core.js';
|
|
@@ -107,6 +107,28 @@ const _flags = shared('action-seed-flags', () => ({ seedEnabled: false, devMode:
|
|
|
107
107
|
* @type {WeakMap<Function, { file: string, fnName: string }[]>}
|
|
108
108
|
*/
|
|
109
109
|
const _identity = shared('action-identity', () => new WeakMap());
|
|
110
|
+
/** The function each seed Proxy wraps, so its source can still be read
|
|
111
|
+
* (`Function.prototype.toString` on a Proxy is native code). */
|
|
112
|
+
const _originals = shared('action-originals', () => new WeakMap());
|
|
113
|
+
|
|
114
|
+
/** The property a facade's exported wrapper reads its real action through. */
|
|
115
|
+
const ORIGINAL = Symbol.for('webjs.action.original');
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The action function itself, behind a facade's exported wrapper or a seed
|
|
119
|
+
* Proxy, or `fn` when it is neither. Used to read the action's own source,
|
|
120
|
+
* which neither wrapper carries (#1686).
|
|
121
|
+
* @param {Function} fn
|
|
122
|
+
* @returns {Function}
|
|
123
|
+
*/
|
|
124
|
+
export function originalAction(fn) {
|
|
125
|
+
/** @type {any} */
|
|
126
|
+
const f = fn;
|
|
127
|
+
let inner;
|
|
128
|
+
try { inner = f[ORIGINAL]; } catch { inner = undefined; }
|
|
129
|
+
if (typeof inner === 'function') return inner;
|
|
130
|
+
return _originals.get(fn) ?? fn;
|
|
131
|
+
}
|
|
110
132
|
|
|
111
133
|
/** Memoized `absPath -> hash` so a hot action call does not re-hash per call. */
|
|
112
134
|
const _hashCache = shared('action-hash-cache', () => new Map());
|
|
@@ -267,6 +289,10 @@ async function assertDeterministic(prev, next, hash, fnName) {
|
|
|
267
289
|
*/
|
|
268
290
|
export function __actionWrap(file, fnName, orig) {
|
|
269
291
|
if (typeof orig !== 'function') return orig;
|
|
292
|
+
// `action.bind(null, id)` keeps the identity, so the bound function can be
|
|
293
|
+
// bound to a form (Next.js's way to pass an extra argument). Installed on the
|
|
294
|
+
// target, so the seed Proxy below forwards it too.
|
|
295
|
+
installActionBind(orig);
|
|
270
296
|
// APPEND, defining module first. A barrel is faceted too and its body runs
|
|
271
297
|
// AFTER the module it re-exports from, so the first registration is the
|
|
272
298
|
// defining one: the module that also carries `validate` / `middleware` /
|
|
@@ -281,6 +307,7 @@ export function __actionWrap(file, fnName, orig) {
|
|
|
281
307
|
_identity.set(orig, list);
|
|
282
308
|
if (!_flags.seedEnabled) return orig;
|
|
283
309
|
const wrapped = seedProxy(file, fnName, orig);
|
|
310
|
+
_originals.set(wrapped, orig);
|
|
284
311
|
// The wrapper is a fresh object every time, so it shares the TARGET's list
|
|
285
312
|
// rather than starting one of its own.
|
|
286
313
|
_identity.set(wrapped, list);
|
|
@@ -552,6 +579,17 @@ function buildFacade(origUrl, absPath, exports) {
|
|
|
552
579
|
out += ` const fn = ${v} || (${v} = __w(${file}, ${k}, __orig[${k}]));\n`;
|
|
553
580
|
out += ` return typeof fn === 'function' ? fn.apply(this, args) : fn;\n`;
|
|
554
581
|
out += `}\n`;
|
|
582
|
+
// Keep the action's declared arity: a form-bound action declaring
|
|
583
|
+
// `(prevState, formData)` is told apart by `fn.length` (Next's
|
|
584
|
+
// useActionState contract), and the rest-args wrapper reports 0. It is a
|
|
585
|
+
// GETTER, read when the dispatcher asks: a circular import can run this
|
|
586
|
+
// facade before the real module, while a `const` action is still in TDZ,
|
|
587
|
+
// and a value captured then would freeze the arity at 0.
|
|
588
|
+
out += `try { Object.defineProperty(${n}, 'length', { configurable: true, get() { try { return (__orig[${k}] && __orig[${k}].length) || 0; } catch { return 0; } } }); } catch {}\n`;
|
|
589
|
+
// The real function, for reading its source (`originalAction`): the
|
|
590
|
+
// dispatcher counts DECLARED parameters, which `length` undercounts at a
|
|
591
|
+
// default (#1686). A getter for the same TDZ reason as `length`.
|
|
592
|
+
out += `try { Object.defineProperty(${n}, Symbol.for('webjs.action.original'), { configurable: true, get() { try { return __orig[${k}]; } catch { return undefined; } } }); } catch {}\n`;
|
|
555
593
|
out += `__w(${file}, ${k}, ${n});\n`;
|
|
556
594
|
}
|
|
557
595
|
for (const n of exports.valNames) {
|
|
@@ -663,10 +701,11 @@ export async function registerActionHooks(opts = {}) {
|
|
|
663
701
|
g[installed] = true;
|
|
664
702
|
// Bun has no module.registerHooks; install the same facade via Bun.plugin.
|
|
665
703
|
const { installBunSeedPlugin } = await import('./action-seed-bun.js');
|
|
666
|
-
// Dev rewrites the app's `#` imports to absolute paths (#1575)
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
704
|
+
// Dev rewrites the app's `#` imports to absolute paths (#1575) and stamps
|
|
705
|
+
// the module versions (#1689); both read the app roots dev registered, so
|
|
706
|
+
// the rewrite is a no-op in prod.
|
|
707
|
+
const { rewriteAppSource } = await import('./dev/bun-app-source.js');
|
|
708
|
+
installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE, rewriteAliases: rewriteAppSource });
|
|
670
709
|
_flags.hookInstalled = true;
|
|
671
710
|
return;
|
|
672
711
|
}
|
package/src/actions.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { digestHex } from './crypto-utils.js';
|
|
2
|
+
import { headerSafeLocation } from './redirect-location.js';
|
|
2
3
|
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import { join, sep } from 'node:path';
|
|
4
5
|
import { walk } from './fs-walk.js';
|
|
@@ -16,11 +17,12 @@ import { runActionChain } from './action-middleware.js';
|
|
|
16
17
|
import { isStreamable, streamActionResponse } from './action-stream.js';
|
|
17
18
|
import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
|
|
18
19
|
import { ifNoneMatchSatisfied } from './conditional-get.js';
|
|
19
|
-
import { getBodyLimits } from './context.js';
|
|
20
|
+
import { getBodyLimits, refreshHeaderValue } from './context.js';
|
|
20
21
|
import { basePath } from './importmap.js';
|
|
21
22
|
import { withBasePath } from './base-path.js';
|
|
22
|
-
import { FORM_ACTION_ID_KEY } from '@webjsdev/core';
|
|
23
|
+
import { FORM_ACTION_ID_KEY, FORM_ACTION_FIELD, isRedirect } from '@webjsdev/core';
|
|
23
24
|
import { devImport } from './dev-import.js';
|
|
25
|
+
import { formInput, isFormData } from './action-input.js';
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* The JSON / RPC body cap in effect for the current request: the per-request
|
|
@@ -43,6 +45,49 @@ function jsonBodyLimit() {
|
|
|
43
45
|
*/
|
|
44
46
|
export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
|
|
45
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The `issues` array a schema library's validation error carries (zod, valibot,
|
|
50
|
+
* Standard Schema), or null for any other throw.
|
|
51
|
+
* @param {unknown} e
|
|
52
|
+
* @returns {Array<{ path?: unknown[], message?: string }> | null}
|
|
53
|
+
*/
|
|
54
|
+
function schemaIssues(e) {
|
|
55
|
+
const issues = e && typeof e === 'object' ? /** @type {any} */ (e).issues : undefined;
|
|
56
|
+
return Array.isArray(issues) && issues.length > 0 ? issues : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A failure result from a schema error's issues: the first message per field
|
|
61
|
+
* as `fieldErrors` (a nested path joined with dots), and a form-level `error`
|
|
62
|
+
* for an issue with no path.
|
|
63
|
+
* @param {unknown} e
|
|
64
|
+
*/
|
|
65
|
+
function issuesResult(e) {
|
|
66
|
+
/** @type {Record<string, string>} */
|
|
67
|
+
const fieldErrors = {};
|
|
68
|
+
let error;
|
|
69
|
+
for (const issue of schemaIssues(e) || []) {
|
|
70
|
+
// A segment is a key, or Standard Schema's { key }; a symbol key is
|
|
71
|
+
// converted explicitly, since join's implicit conversion throws on one.
|
|
72
|
+
const path = Array.isArray(issue.path)
|
|
73
|
+
? issue.path.map((p) => {
|
|
74
|
+
const k = p && typeof p === 'object' && 'key' in p ? /** @type {any} */ (p).key : p;
|
|
75
|
+
return typeof k === 'symbol' ? (k.description ?? '') : String(k);
|
|
76
|
+
}).join('.')
|
|
77
|
+
: '';
|
|
78
|
+
const message = typeof issue.message === 'string' ? issue.message : 'Invalid value.';
|
|
79
|
+
if (path) {
|
|
80
|
+
// Own keys only (a field named `constructor` is not already present),
|
|
81
|
+
// and defined rather than assigned, so `__proto__` is a field, not the
|
|
82
|
+
// object's prototype.
|
|
83
|
+
if (!Object.hasOwn(fieldErrors, path)) {
|
|
84
|
+
Object.defineProperty(fieldErrors, path, { value: message, enumerable: true, writable: true, configurable: true });
|
|
85
|
+
}
|
|
86
|
+
} else if (!error) error = message;
|
|
87
|
+
}
|
|
88
|
+
return { success: false, fieldErrors, ...(error ? { error } : {}), status: 422 };
|
|
89
|
+
}
|
|
90
|
+
|
|
46
91
|
/**
|
|
47
92
|
* Run an attached input validator against an action's input, shared by the RPC
|
|
48
93
|
* path (`invokeAction`) and the `route()` REST adapter so the contract is
|
|
@@ -52,21 +97,27 @@ export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
|
|
|
52
97
|
* @param {(input: any) => any} validate the attached validator
|
|
53
98
|
* @param {any} input the value handed to the validator (the action's first arg
|
|
54
99
|
* on RPC, the merged query/params/body object on REST)
|
|
55
|
-
* @returns {{ ok: true, value: any } | { ok: false, result: { success: false, fieldErrors?: Record<string,string>, error?: string, status: number }, thrown?: unknown }}
|
|
100
|
+
* @returns {{ ok: true, value: any } | { ok: false, result: { success: false, fieldErrors?: Record<string,string>, error?: string, status: number }, thrown?: unknown, invalid?: boolean }}
|
|
56
101
|
* `ok: true` carries the value to pass to the action (the validated `data`,
|
|
57
102
|
* the transformed return, or the original input). `ok: false` carries the
|
|
58
103
|
* structured failure result the caller serializes back; on a THROWN validator
|
|
59
104
|
* it also carries the original error on `thrown` so a caller can salvage a
|
|
60
105
|
* schema lib's structured `issues` (the REST path does, for back-compat).
|
|
61
106
|
*/
|
|
62
|
-
export function runValidate(validate, input) {
|
|
107
|
+
export async function runValidate(validate, input) {
|
|
63
108
|
let out;
|
|
64
109
|
try {
|
|
65
|
-
|
|
110
|
+
// Awaited, so an async validator (an async schema check, a lookup) works
|
|
111
|
+
// like a sync one; a returned Promise must never read as a transformed input.
|
|
112
|
+
out = await validate(input);
|
|
66
113
|
} catch (e) {
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
114
|
+
// A schema library's validation error (a throw carrying `issues`: zod's
|
|
115
|
+
// `.parse`, valibot, Standard Schema) is invalid INPUT, mapped once here so
|
|
116
|
+
// every transport answers it alike: the issues become fieldErrors with a
|
|
117
|
+
// 422, as a structured envelope failure is (`invalid` marks it).
|
|
118
|
+
if (schemaIssues(e)) return { ok: false, result: issuesResult(e), thrown: e, invalid: true };
|
|
119
|
+
// Any other throw-to-reject: a sanitized failure result, a 400 over REST;
|
|
120
|
+
// `thrown` lets the RPC path treat it as the crash it may be.
|
|
70
121
|
const msg = e instanceof Error ? e.message : String(e);
|
|
71
122
|
return { ok: false, result: { success: false, error: msg, status: 400 }, thrown: e };
|
|
72
123
|
}
|
|
@@ -321,7 +372,7 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
321
372
|
// `method` is a default POST), so the first client call resolves from the
|
|
322
373
|
// seed with no hydration round-trip. The browser-cache staleness check is
|
|
323
374
|
// GET-only (only a GET is browser-cached).
|
|
324
|
-
const imports = ['stringify as __s', 'parse as __p', 'takeSeed as __seedTake', 'SEED_MISS as __MISS', 'markStale as __markStale', 'parseTagHeader as __tagHdr', 'activeActionSignal as __sig',
|
|
375
|
+
const imports = ['stringify as __s', 'parse as __p', 'takeSeed as __seedTake', 'SEED_MISS as __MISS', 'markStale as __markStale', 'parseTagHeader as __tagHdr', 'activeActionSignal as __sig', 'applyActionRefresh as __refresh', 'followActionRedirect as __redirect', 'installActionBind as __bindable',
|
|
325
376
|
// Streaming RPC (#489): an action returning a stream / async iterable sends
|
|
326
377
|
// back a framed body; the stub decodes it into an async iterable the caller
|
|
327
378
|
// `for await`s. The imports are small constants + one decoder factory.
|
|
@@ -342,6 +393,12 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
342
393
|
// caught on the next read).
|
|
343
394
|
lines.push(`async function __handle(res, fn, key, since) {`);
|
|
344
395
|
lines.push(` const inv = __tagHdr(res.headers.get('x-webjs-invalidate')); if (inv.length) __markStale(inv);`);
|
|
396
|
+
// redirect() in the action (Next parity): navigate there; the call's promise
|
|
397
|
+
// rejects with the redirect sentinel, which the page ignores unhandled.
|
|
398
|
+
lines.push(` const rd = res.headers.get('x-webjs-redirect'); if (rd) return __redirect(rd);`);
|
|
399
|
+
// revalidatePath() in the action: refresh the page on screen in place when
|
|
400
|
+
// it was revalidated, BEFORE the caller's await resolves (Next behaviour).
|
|
401
|
+
lines.push(` const rf = res.headers.get('x-webjs-refresh'); if (rf) await __refresh(rf);`);
|
|
345
402
|
lines.push(` if (key != null) { const t = __tagHdr(res.headers.get('x-webjs-tags')); if (t.length) __regTags(key, t, since); }`);
|
|
346
403
|
lines.push(` const ct = res.headers.get('content-type') || '';`);
|
|
347
404
|
// A streamed result (#489): the action returned a stream / async iterable.
|
|
@@ -424,7 +481,9 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
424
481
|
// load-hook registry instead; both produce the same string, which is what
|
|
425
482
|
// makes the SSR'd form and the re-rendered one submit the same value.
|
|
426
483
|
// Non-enumerable so `import * as actions` and a spread stay unchanged.
|
|
427
|
-
|
|
484
|
+
// `.bind(null, id)` keeps the identity too (installActionBind), so a bound
|
|
485
|
+
// stub re-renders to the same hidden field the server emitted.
|
|
486
|
+
lines.push(`const __id = (fn, name) => { try { Object.defineProperty(fn, ${J(FORM_ACTION_ID_KEY)}, { value: __HASH + '/' + name }); } catch {} return __bindable(fn); };`);
|
|
428
487
|
for (const name of fnNames) {
|
|
429
488
|
lines.push(
|
|
430
489
|
name === 'default'
|
|
@@ -447,8 +506,11 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
447
506
|
* it so a throwing sink can never affect the response.
|
|
448
507
|
* @param {string[]} [allowedOrigins] cross-site origins the CSRF check allows
|
|
449
508
|
* (the app's `webjs.allowedOrigins`), for reverse-proxy / multi-domain setups.
|
|
509
|
+
* @param {{ warn?: (msg: string) => void }} [logger] where a dev diagnostic
|
|
510
|
+
* goes (the repeated-field warning, #1664), so it lands with the form path's
|
|
511
|
+
* warnings instead of on the console. Falls back to `console.warn`.
|
|
450
512
|
*/
|
|
451
|
-
export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigins = []) {
|
|
513
|
+
export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigins = [], logger) {
|
|
452
514
|
const file = idx.hashToFile.get(hash);
|
|
453
515
|
if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
|
|
454
516
|
const mod = await loadModule(file, idx.dev);
|
|
@@ -496,6 +558,17 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
|
|
|
496
558
|
} catch {
|
|
497
559
|
return rpcResponse({ error: 'Invalid request body' }, { status: 400 });
|
|
498
560
|
}
|
|
561
|
+
// A FormData first argument (the serializer round-trips one) gets the same
|
|
562
|
+
// shape a bound form submission does, so an action's input never depends on
|
|
563
|
+
// the boundary it was reached through (#1664, `action-input.js`). It stays a
|
|
564
|
+
// real FormData, so a caller that built one keeps every FormData behaviour.
|
|
565
|
+
// A component that intercepts a bound form and passes `new FormData(form)`
|
|
566
|
+
// carries the framework's hidden identity field, which the form path removes
|
|
567
|
+
// before the action runs; it is removed here too.
|
|
568
|
+
if (isFormData(args[0])) {
|
|
569
|
+
const warn = logger && logger.warn ? (m) => logger.warn(m) : (m) => console.warn(m);
|
|
570
|
+
args = [formInput(args[0], { omit: FORM_ACTION_FIELD, ...(idx.dev ? { warn, label: fnName } : {}) }), ...args.slice(1)];
|
|
571
|
+
}
|
|
499
572
|
// Input validation (#245): a validator declared via the `validate` config
|
|
500
573
|
// export (#488) runs SERVER-SIDE before the body on this RPC path. The RPC
|
|
501
574
|
// stub sends an args array; an action conventionally takes one input object,
|
|
@@ -504,16 +577,17 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
|
|
|
504
577
|
// server-to-server call.
|
|
505
578
|
const validate = actionConfigFn(mod, 'validate');
|
|
506
579
|
if (typeof validate === 'function') {
|
|
507
|
-
const v = runValidate(validate, args[0]);
|
|
580
|
+
const v = await runValidate(validate, args[0]);
|
|
508
581
|
if (!v.ok) {
|
|
509
|
-
if (v.thrown !== undefined) {
|
|
582
|
+
if (v.thrown !== undefined && !v.invalid) {
|
|
510
583
|
// A THROWN validator behaves like a thrown action: a sanitized error
|
|
511
584
|
// response (non-200, so the client stub throws), with prod stack
|
|
512
585
|
// sanitization. The onError sink sees the original error too.
|
|
513
586
|
if (typeof onError === 'function') onError(v.thrown);
|
|
514
587
|
return actionErrorResponse(v.thrown, idx.dev);
|
|
515
588
|
}
|
|
516
|
-
// A structured `{ success: false, fieldErrors }` envelope
|
|
589
|
+
// A structured `{ success: false, fieldErrors }` envelope, or a schema
|
|
590
|
+
// error's issues mapped to one, is a NORMAL
|
|
517
591
|
// result the action author renders (`result.fieldErrors`): serialized as
|
|
518
592
|
// a 200 RPC payload so the client stub returns it as a real object rather
|
|
519
593
|
// than throwing, the failure status riding inside the envelope.
|
|
@@ -521,6 +595,7 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
|
|
|
521
595
|
}
|
|
522
596
|
args = [v.value, ...args.slice(1)];
|
|
523
597
|
}
|
|
598
|
+
let ranAction = false;
|
|
524
599
|
try {
|
|
525
600
|
// Run inside the request's AbortSignal scope (#492) and the per-action
|
|
526
601
|
// middleware chain (#490): the chain wraps the action, can short-circuit
|
|
@@ -528,7 +603,6 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
|
|
|
528
603
|
// the action reads via actionContext(). `ranAction` distinguishes a real
|
|
529
604
|
// action completion from a middleware short-circuit (the action never ran).
|
|
530
605
|
const middleware = actionMiddleware(mod);
|
|
531
|
-
let ranAction = false;
|
|
532
606
|
const result = await runWithActionSignal(req.signal, () =>
|
|
533
607
|
runActionChain(middleware, { request: req, args, signal: req.signal }, () => { ranAction = true; return fn(...args); }));
|
|
534
608
|
// Streaming result (#489): a COMPLETED action returning a ReadableStream /
|
|
@@ -542,6 +616,8 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
|
|
|
542
616
|
const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
|
|
543
617
|
if (inv.length) { await revalidateTags(inv); headers['x-webjs-invalidate'] = inv.join(','); }
|
|
544
618
|
}
|
|
619
|
+
const refresh = refreshHeaderValue();
|
|
620
|
+
if (refresh) headers['x-webjs-refresh'] = refresh;
|
|
545
621
|
return streamActionResponse(result, { signal: req.signal, onError, headers, dev: idx.dev });
|
|
546
622
|
}
|
|
547
623
|
if (method === 'GET') {
|
|
@@ -560,8 +636,29 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
|
|
|
560
636
|
headers['x-webjs-invalidate'] = inv.join(',');
|
|
561
637
|
}
|
|
562
638
|
}
|
|
639
|
+
// revalidatePath() during the action (Next parity): the browser refreshes
|
|
640
|
+
// those paths, and the page on screen in place when it is one of them.
|
|
641
|
+
const refresh = refreshHeaderValue();
|
|
642
|
+
if (refresh) headers['x-webjs-refresh'] = refresh;
|
|
563
643
|
return rpcResponse(result ?? null, { headers });
|
|
564
644
|
} catch (e) {
|
|
645
|
+
// redirect() / permanentRedirect() in an action called over RPC (Next.js
|
|
646
|
+
// parity): not an error. The response tells the generated stub where to
|
|
647
|
+
// go (`x-webjs-redirect: <url>;<push|replace>`, push by default as in a
|
|
648
|
+
// Next server action), and the client router navigates there. A mutation
|
|
649
|
+
// that ran still evicts its `invalidates` tags and reports revalidatePath.
|
|
650
|
+
if (isRedirect(e)) {
|
|
651
|
+
const r = /** @type {any} */ (e);
|
|
652
|
+
/** @type {Record<string, string>} */
|
|
653
|
+
const headers = { 'x-webjs-redirect': `${headerSafeLocation(r.url)};${r.redirectType === 'replace' ? 'replace' : 'push'}` };
|
|
654
|
+
if (ranAction && method !== 'GET') {
|
|
655
|
+
const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
|
|
656
|
+
if (inv.length) { await revalidateTags(inv); headers['x-webjs-invalidate'] = inv.join(','); }
|
|
657
|
+
}
|
|
658
|
+
const refresh = refreshHeaderValue();
|
|
659
|
+
if (refresh) headers['x-webjs-refresh'] = refresh;
|
|
660
|
+
return rpcResponse(null, { headers: { ...headers, 'cache-control': 'no-store' } });
|
|
661
|
+
}
|
|
565
662
|
if (typeof onError === 'function') onError(e);
|
|
566
663
|
return actionErrorResponse(e, idx.dev);
|
|
567
664
|
}
|
package/src/api.js
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
|
+
import { isRedirect, isNotFound, isForbidden, isUnauthorized } from '@webjsdev/core';
|
|
2
|
+
import { headerSafeLocation } from './redirect-location.js';
|
|
1
3
|
import { makeThenable } from './thenable-params.js';
|
|
2
4
|
import { devImport } from './dev-import.js';
|
|
5
|
+
import { decorateRequest } from './middleware-load.js';
|
|
6
|
+
|
|
7
|
+
/** Every method a route module may export (Next's HTTP_METHODS). */
|
|
8
|
+
const HTTP_METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* Dispatch an incoming request to a matched API route.
|
|
6
|
-
* API modules export methods as named async functions: GET,
|
|
12
|
+
* API modules export methods as named async functions: GET, HEAD, OPTIONS,
|
|
13
|
+
* POST, PUT, PATCH, DELETE.
|
|
7
14
|
*
|
|
8
15
|
* Handlers receive a standard `Request` and return a standard `Response`.
|
|
9
16
|
*
|
|
17
|
+
* Next.js parity:
|
|
18
|
+
* - HEAD and OPTIONS are implemented automatically when the module does not
|
|
19
|
+
* export them (a port of Next's `autoImplementMethods`): HEAD runs GET and
|
|
20
|
+
* drops the body; OPTIONS answers 204 with a sorted `Allow`.
|
|
21
|
+
* - `redirect()` thrown from a handler answers 307 (308 from
|
|
22
|
+
* `permanentRedirect()`; an explicit status wins), and `notFound()` /
|
|
23
|
+
* `forbidden()` / `unauthorized()` answer 404 / 403 / 401 with no body.
|
|
24
|
+
* They are answered HERE, so no caller ever sees them as errors.
|
|
25
|
+
*
|
|
10
26
|
* @param {import('./router.js').ApiRoute} route
|
|
11
|
-
* @param {Record<string,string>} params
|
|
27
|
+
* @param {Record<string, string | string[]>} params
|
|
12
28
|
* @param {Request} webRequest
|
|
13
29
|
* @param {boolean} dev
|
|
14
30
|
* @returns {Promise<Response>}
|
|
@@ -16,11 +32,20 @@ import { devImport } from './dev-import.js';
|
|
|
16
32
|
export async function handleApi(route, params, webRequest, dev) {
|
|
17
33
|
const mod = await devImport(route.file, dev);
|
|
18
34
|
const method = webRequest.method.toUpperCase();
|
|
19
|
-
const
|
|
35
|
+
const implemented = HTTP_METHODS.filter((m) => typeof mod[m] === 'function');
|
|
36
|
+
let handler = typeof mod[method] === 'function' ? mod[method] : null;
|
|
37
|
+
let headOnly = false;
|
|
38
|
+
if (!handler && method === 'HEAD' && typeof mod.GET === 'function') {
|
|
39
|
+
handler = mod.GET;
|
|
40
|
+
headOnly = true;
|
|
41
|
+
}
|
|
42
|
+
if (!handler && method === 'OPTIONS') {
|
|
43
|
+
return new Response(null, { status: 204, headers: { allow: allowHeader(implemented) } });
|
|
44
|
+
}
|
|
20
45
|
if (!handler) {
|
|
21
46
|
return new Response(`Method ${method} not allowed`, {
|
|
22
47
|
status: 405,
|
|
23
|
-
headers: { allow:
|
|
48
|
+
headers: { allow: allowHeader(implemented) },
|
|
24
49
|
});
|
|
25
50
|
}
|
|
26
51
|
// Route-handler params are awaitable AND sync-readable (#848, Next parity:
|
|
@@ -28,6 +53,9 @@ export async function handleApi(route, params, webRequest, dev) {
|
|
|
28
53
|
// spreads / JSON-stringifies params is unaffected.
|
|
29
54
|
const thenableParams = makeThenable(params);
|
|
30
55
|
/** @type any */ (webRequest).params = thenableParams;
|
|
56
|
+
// A Next.js route handler receives a NextRequest, so `request.cookies.get()`
|
|
57
|
+
// is how agents read a cookie there; it is the request's cookies() store.
|
|
58
|
+
decorateRequest(webRequest, dev);
|
|
31
59
|
let result;
|
|
32
60
|
try {
|
|
33
61
|
result = await handler(webRequest, { params: thenableParams });
|
|
@@ -42,14 +70,43 @@ export async function handleApi(route, params, webRequest, dev) {
|
|
|
42
70
|
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
43
71
|
});
|
|
44
72
|
}
|
|
73
|
+
const control = controlFlowResponse(e);
|
|
74
|
+
if (control) return control;
|
|
45
75
|
throw e;
|
|
46
76
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
77
|
+
const res = result instanceof Response ? result : Response.json(result);
|
|
78
|
+
if (!headOnly) return res;
|
|
79
|
+
// A synthesized HEAD answers GET's status and headers with no body. GET's own
|
|
80
|
+
// body is never read, so it is cancelled: a streamed one (SSE, a DB cursor)
|
|
81
|
+
// would otherwise keep producing for no reader until it is collected.
|
|
82
|
+
if (res.body && !res.bodyUsed) res.body.cancel().catch(() => {});
|
|
83
|
+
return new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
50
84
|
}
|
|
51
85
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
86
|
+
/**
|
|
87
|
+
* The response Next.js gives a control-flow throw inside a route handler, or
|
|
88
|
+
* null when `e` is a real error.
|
|
89
|
+
* @param {unknown} e
|
|
90
|
+
* @returns {Response | null}
|
|
91
|
+
*/
|
|
92
|
+
export function controlFlowResponse(e) {
|
|
93
|
+
if (isRedirect(e)) {
|
|
94
|
+
const r = /** @type any */ (e);
|
|
95
|
+
return new Response(null, { status: r.status || 307, headers: { location: headerSafeLocation(r.url) } });
|
|
96
|
+
}
|
|
97
|
+
if (isNotFound(e)) return new Response(null, { status: 404 });
|
|
98
|
+
if (isForbidden(e)) return new Response(null, { status: 403 });
|
|
99
|
+
if (isUnauthorized(e)) return new Response(null, { status: 401 });
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The `Allow` header Next sends: the implemented methods plus OPTIONS (always
|
|
105
|
+
* answered) and HEAD (when GET exists), sorted.
|
|
106
|
+
* @param {string[]} implemented
|
|
107
|
+
*/
|
|
108
|
+
function allowHeader(implemented) {
|
|
109
|
+
const allow = new Set(['OPTIONS', ...implemented]);
|
|
110
|
+
if (allow.has('GET')) allow.add('HEAD');
|
|
111
|
+
return [...allow].sort().join(', ');
|
|
55
112
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App layout validation: file arrangements that can never serve correctly,
|
|
3
|
+
* found from the route table alone (no code is loaded).
|
|
4
|
+
*
|
|
5
|
+
* Next.js refuses these at build time. WebJs has no build, so the same
|
|
6
|
+
* refusal happens in three places, all fed by `validateAppLayout`:
|
|
7
|
+
* - `webjs check` reports each problem as a violation (its `rule` id);
|
|
8
|
+
* - production refuses to boot (`createRequestHandler` throws);
|
|
9
|
+
* - dev answers every app request with a 500 naming the problem and pushes
|
|
10
|
+
* it to the error overlay, until an edit fixes it.
|
|
11
|
+
*
|
|
12
|
+
* @module app-layout
|
|
13
|
+
*/
|
|
14
|
+
import { relative, sep } from 'node:path';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{ rule: string, file: string, message: string, fix: string }} LayoutProblem
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The thrown form of one or more layout problems. `code` lets the request
|
|
22
|
+
* handler tell it apart from an ordinary analysis failure.
|
|
23
|
+
*/
|
|
24
|
+
export class AppLayoutError extends Error {
|
|
25
|
+
/** @param {LayoutProblem[]} problems */
|
|
26
|
+
constructor(problems) {
|
|
27
|
+
super(problems.map((p) => `[${p.rule}] ${p.file}: ${p.message} Fix: ${p.fix}`).join('\n'));
|
|
28
|
+
this.name = 'AppLayoutError';
|
|
29
|
+
this.code = 'WEBJS_APP_LAYOUT';
|
|
30
|
+
this.problems = problems;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} appDir
|
|
36
|
+
* @param {string} file
|
|
37
|
+
*/
|
|
38
|
+
function rel(appDir, file) {
|
|
39
|
+
return relative(appDir, file).split(sep).join('/');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every layout problem in the app, in a stable order.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} appDir
|
|
46
|
+
* @param {import('./router.js').RouteTable} table
|
|
47
|
+
* @param {Set<string>} [publicFiles] url paths of the files under `public/`
|
|
48
|
+
* @returns {LayoutProblem[]}
|
|
49
|
+
*/
|
|
50
|
+
export function validateAppLayout(appDir, table, publicFiles) {
|
|
51
|
+
/** @type {LayoutProblem[]} */
|
|
52
|
+
const problems = [];
|
|
53
|
+
|
|
54
|
+
// A public file at the url of a static page, a route handler or a metadata
|
|
55
|
+
// route: one would silently shadow the other (public files are served
|
|
56
|
+
// before routes, as in Next, which refuses the same conflict). A DYNAMIC
|
|
57
|
+
// route is not a conflict: the public file simply wins for its one url.
|
|
58
|
+
if (publicFiles && publicFiles.size) {
|
|
59
|
+
const staticOwner = new Map();
|
|
60
|
+
for (const r of [...(table.pages || []), ...(table.apis || [])]) {
|
|
61
|
+
if (r.paramNames && r.paramNames.length) continue;
|
|
62
|
+
const dir = /** @type {any} */ (r).routeDir;
|
|
63
|
+
const url = '/' + (dir === '.' ? '' : dir.split('/').filter((s) => !(s.startsWith('(') && s.endsWith(')'))).join('/'));
|
|
64
|
+
staticOwner.set(url, r.file);
|
|
65
|
+
}
|
|
66
|
+
for (const m of table.metadataRoutes || []) staticOwner.set(m.urlPath, m.file);
|
|
67
|
+
for (const url of publicFiles) {
|
|
68
|
+
const owner = staticOwner.get(url);
|
|
69
|
+
if (!owner) continue;
|
|
70
|
+
problems.push({
|
|
71
|
+
rule: 'no-public-route-conflict',
|
|
72
|
+
file: `public${url}`,
|
|
73
|
+
message: `public${url} is served at ${url}, the same url as ${rel(appDir, owner)}, so one of them can never be reached.`,
|
|
74
|
+
fix: `Rename or move one of them: public files are served at the url root (public/logo.png is /logo.png), before every route.`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A page and a route handler answering the same URL. Next refuses this
|
|
80
|
+
// ("Conflicting route and page"); WebJs used to let the route handler win
|
|
81
|
+
// silently, so the page never rendered.
|
|
82
|
+
for (const c of table.conflicts || []) {
|
|
83
|
+
problems.push({
|
|
84
|
+
rule: 'no-page-route-conflict',
|
|
85
|
+
file: rel(appDir, c.route),
|
|
86
|
+
message: `${rel(appDir, c.page)} and ${rel(appDir, c.route)} both answer ${c.url}, so the page can never render.`,
|
|
87
|
+
fix: 'Keep one: move the route handler to its own folder (for example app/api/...), or delete the page.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// A routing file written as `.tsx` / `.jsx`: JSX needs a build step, so the
|
|
91
|
+
// router never sees it and the route silently does not exist.
|
|
92
|
+
for (const file of table.ignoredJsx || []) {
|
|
93
|
+
const r = rel(appDir, file);
|
|
94
|
+
const ts = r.replace(/\.(t|j)sx$/, (_, t) => `.${t}s`);
|
|
95
|
+
problems.push({
|
|
96
|
+
rule: 'no-jsx-route-file',
|
|
97
|
+
file: r,
|
|
98
|
+
message: `${r} is a routing file written as JSX, which WebJs never loads (there is no build step), so this route does not exist.`,
|
|
99
|
+
fix: `Rename it to ${ts} and return an html\`...\` template from @webjsdev/core instead of JSX (interactive parts become a WebComponent).`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return problems;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The fix every CSS-import refusal names. */
|
|
106
|
+
export const CSS_IMPORT_FIX = 'Load a stylesheet with <link rel="stylesheet" href="..."> in the root layout (Tailwind\'s output included), or scope CSS to a component with `static styles = css`...`` and `static shadow = true`.';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every `import './x.css'` in the app's module graph, as layout problems. A
|
|
110
|
+
* CSS import is a bundler feature: Node refuses the extension, Bun silently
|
|
111
|
+
* turns it into a no-op on the server, and the browser refuses a stylesheet as
|
|
112
|
+
* a module script, so the importing module never loads there.
|
|
113
|
+
* @param {Map<string, Set<string>>} graph
|
|
114
|
+
* @param {(abs: string) => string} rel
|
|
115
|
+
* @returns {LayoutProblem[]}
|
|
116
|
+
*/
|
|
117
|
+
export function cssImportProblems(graph, rel) {
|
|
118
|
+
/** @type {LayoutProblem[]} */
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const [file, deps] of graph) {
|
|
121
|
+
if (/[\\/]node_modules[\\/]/.test(file)) continue;
|
|
122
|
+
for (const dep of deps) {
|
|
123
|
+
if (!/\.css$/i.test(dep)) continue;
|
|
124
|
+
out.push({
|
|
125
|
+
rule: 'no-css-import',
|
|
126
|
+
file: rel(file),
|
|
127
|
+
message: `Imports ${rel(dep)}, a stylesheet. WebJs has no bundler: the import fails on Node, is silently dropped on Bun, and stops the module from loading in the browser.`,
|
|
128
|
+
fix: CSS_IMPORT_FIX,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|