@webjsdev/server 0.8.58 → 0.8.59
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/index.d.ts +26 -2
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +364 -64
- package/src/actions.js +10 -2
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +92 -27
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +500 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +55 -15
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
package/src/dev.js
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
15
15
|
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
16
16
|
import { generateRouteTypes } from './route-types.js';
|
|
17
17
|
import { ssrPage, ssrNotFound, setClientRouterEnabled } from './ssr.js';
|
|
18
|
-
import {
|
|
18
|
+
import { runFormAction } from './form-dispatch.js';
|
|
19
19
|
import { handleApi } from './api.js';
|
|
20
20
|
import {
|
|
21
21
|
buildActionIndex,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
hasUseServerDirective,
|
|
27
27
|
hashFile,
|
|
28
28
|
} from './actions.js';
|
|
29
|
-
import {
|
|
29
|
+
import { registerActionHooks } from './action-seed.js';
|
|
30
30
|
import { readRegenerateRules, maybeRegenerate, isRegenerateOutputPath } from './dev-regenerate.js';
|
|
31
31
|
import { stripTypeScript, ensureStripper } from './ts-strip.js';
|
|
32
32
|
import { defaultLogger } from './logger.js';
|
|
@@ -93,7 +93,8 @@ import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBa
|
|
|
93
93
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
94
94
|
import { propagateTrustedRemoteIp } from './rate-limit.js';
|
|
95
95
|
import { readAllowedOrigins } from './csrf.js';
|
|
96
|
-
import { setAssetUrlProvider } from '@webjsdev/core';
|
|
96
|
+
import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
|
|
97
|
+
import { resolveActionIdentity } from './form-action-identity.js';
|
|
97
98
|
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports, resolveAssetUrl } from './asset-hash.js';
|
|
98
99
|
import { urlFromRequest } from './forwarded.js';
|
|
99
100
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
@@ -640,13 +641,17 @@ export async function createRequestHandler(opts) {
|
|
|
640
641
|
// never touched the disk.
|
|
641
642
|
setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
|
|
642
643
|
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
|
|
644
|
+
// The `'use server'` load hook. Install it NOW, at boot, before any action
|
|
645
|
+
// module is imported (ESM caches by URL, so a module loaded before the hook
|
|
646
|
+
// would never be wrapped). Read once (not per-rebuild): the hook is global
|
|
647
|
+
// and cannot be cleanly un-installed, so toggling needs a restart.
|
|
648
|
+
//
|
|
649
|
+
// It installs UNCONDITIONALLY, and only seed collection (#472) follows the
|
|
650
|
+
// `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
|
|
651
|
+
// what a bound `<form action=${action}>` resolves through, so gating the hook
|
|
652
|
+
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
653
|
+
await registerActionHooks({ seed: await readSeedEnabled(appDir) });
|
|
654
|
+
|
|
650
655
|
|
|
651
656
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
652
657
|
// deterministic vendor map that is cheap to read (one file, no analysis, no
|
|
@@ -757,7 +762,7 @@ export async function createRequestHandler(opts) {
|
|
|
757
762
|
// package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
|
|
758
763
|
// overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
|
|
759
764
|
// unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
|
|
760
|
-
// (used inside route handlers) enforces the same cap the RPC and
|
|
765
|
+
// (used inside route handlers) enforces the same cap the RPC and form-dispatch
|
|
761
766
|
// paths do.
|
|
762
767
|
const bodyLimits = await readBodyLimitsFromApp(appDir);
|
|
763
768
|
|
|
@@ -797,14 +802,27 @@ export async function createRequestHandler(opts) {
|
|
|
797
802
|
regenerateRules: dev ? await readRegenerateRules(appDir) : [],
|
|
798
803
|
};
|
|
799
804
|
|
|
805
|
+
// Teach the renderers how to turn a real server-action function into the
|
|
806
|
+
// `<hash>/<fn>` identity a form submits (#1155). The hook path is
|
|
807
|
+
// process-global and needs no index; `state.actionIndex` is consulted only as
|
|
808
|
+
// a hash memo, and by the scan fallback for a runtime with no hook. In a
|
|
809
|
+
// process running several handlers the fallback therefore resolves against
|
|
810
|
+
// the most recently booted one, which is the same scope the asset-url
|
|
811
|
+
// provider already has.
|
|
812
|
+
setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn) : null));
|
|
813
|
+
|
|
800
814
|
/**
|
|
801
815
|
* Report a dev error (#264): build a frame and push it to the open tab via
|
|
802
816
|
* the SSE overlay channel. DEV-ONLY and best-effort, so it can never affect a
|
|
803
817
|
* response or crash the server (a frame-build failure is swallowed). No file
|
|
804
818
|
* path or source is ever built in prod (the early return), so nothing leaks.
|
|
805
819
|
*
|
|
820
|
+
* `info.url` stamps a `render` frame with the URL that produced it (#1047), so
|
|
821
|
+
* the browser overlay can refuse a frame for a page the tab is not viewing.
|
|
822
|
+
* A `ts-strip` / `rebuild` frame passes none and stays unscoped.
|
|
823
|
+
*
|
|
806
824
|
* @param {unknown} error
|
|
807
|
-
* @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string }} [info]
|
|
825
|
+
* @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string, url?: string }} [info]
|
|
808
826
|
*/
|
|
809
827
|
function reportDevError(error, info = {}) {
|
|
810
828
|
if (!dev) return;
|
|
@@ -1181,7 +1199,7 @@ export async function createRequestHandler(opts) {
|
|
|
1181
1199
|
|
|
1182
1200
|
// Make the resolved body-size limits (issue #237) readable from the
|
|
1183
1201
|
// request scope, so `readBody` inside a route handler enforces the same
|
|
1184
|
-
// cap the framework's own RPC /
|
|
1202
|
+
// cap the framework's own RPC / form-dispatch body reads do.
|
|
1185
1203
|
setBodyLimits(state.bodyLimits);
|
|
1186
1204
|
|
|
1187
1205
|
let pathname = '/';
|
|
@@ -1349,7 +1367,7 @@ export async function createRequestHandler(opts) {
|
|
|
1349
1367
|
// to the inbound `x-webjs-remote-ip` header that the copied
|
|
1350
1368
|
// `req.headers` still carries, which a client can spoof. So strip
|
|
1351
1369
|
// that header on the rebuild and propagate the framework-trusted IP
|
|
1352
|
-
// across the new object, the same pattern
|
|
1370
|
+
// across the new object, the same pattern form-dispatch.js uses.
|
|
1353
1371
|
const headers = new Headers(req.headers);
|
|
1354
1372
|
headers.delete('x-webjs-remote-ip');
|
|
1355
1373
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
@@ -1839,7 +1857,7 @@ function startNodeListener(ctx) {
|
|
|
1839
1857
|
// Catch-all process handlers: log, but don't tear the process down on a
|
|
1840
1858
|
// single mishandled promise. Uncaught exceptions are different: state may be
|
|
1841
1859
|
// corrupted, so log + start an orderly shutdown rather than continuing.
|
|
1842
|
-
installProcessHandlers(logger, () => shutdown('uncaughtException'));
|
|
1860
|
+
installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
|
|
1843
1861
|
|
|
1844
1862
|
return {
|
|
1845
1863
|
server,
|
|
@@ -2179,14 +2197,30 @@ async function handleCore(req, ctx) {
|
|
|
2179
2197
|
}
|
|
2180
2198
|
|
|
2181
2199
|
// Page route. GET/HEAD render the page. A NON-GET/HEAD method (POST/PUT/…)
|
|
2182
|
-
// is
|
|
2183
|
-
// action runs inside the page's segment middleware,
|
|
2184
|
-
// (303)
|
|
2185
|
-
//
|
|
2186
|
-
//
|
|
2200
|
+
// is a form submission (#1155): the `__webjs_action` hidden field names the
|
|
2201
|
+
// server action to run, it runs inside the page's segment middleware, and the
|
|
2202
|
+
// result either PRG-redirects (303), re-renders the same page (422) with
|
|
2203
|
+
// field errors, or honors a thrown redirect()/notFound(). A non-GET carrying
|
|
2204
|
+
// no action identity is a 405 on a path that exists but only renders.
|
|
2187
2205
|
{
|
|
2188
2206
|
const page = matchPage(state.routeTable, path);
|
|
2189
2207
|
if (page) {
|
|
2208
|
+
// The URL this render is FOR, in the form the browser sees it (#1047), so
|
|
2209
|
+
// the overlay's scope gate can compare it against `location.pathname +
|
|
2210
|
+
// location.search`. Two details are load-bearing: the RAW `url.pathname`
|
|
2211
|
+
// (not the decoded `path`) is used, because `location.pathname` is
|
|
2212
|
+
// percent-encoded too; and the base path is put back, because the ingress
|
|
2213
|
+
// strip already removed it from `url` while the browser's location still
|
|
2214
|
+
// carries it, so without this the gate would suppress every overlay on a
|
|
2215
|
+
// sub-path deploy.
|
|
2216
|
+
const devErrorUrl = withBasePath(url.pathname, basePath()) + url.search;
|
|
2217
|
+
// A speculative link prefetch must never raise an overlay (#1047): the
|
|
2218
|
+
// user is only hovering a link to a page that throws, and the page they
|
|
2219
|
+
// are actually looking at is fine. Dropping the hook also keeps the throw
|
|
2220
|
+
// out of `state.lastDevError`, so the SSE replay cannot hand it to a
|
|
2221
|
+
// freshly-connected tab either. The APM `onError` sink below still fires,
|
|
2222
|
+
// because the render really did throw.
|
|
2223
|
+
const isPrefetch = req.headers.get('x-webjs-prefetch') === '1';
|
|
2190
2224
|
const ssrOpts = {
|
|
2191
2225
|
dev, appDir, moduleGraph: state.moduleGraph,
|
|
2192
2226
|
serverFiles: state.actionIndex.fileToHash,
|
|
@@ -2213,17 +2247,43 @@ async function handleCore(req, ctx) {
|
|
|
2213
2247
|
// Dev error overlay (#264): a render crash pushes a frame to the open
|
|
2214
2248
|
// tab. Dev-only (reportDevError early-returns in prod), so no source
|
|
2215
2249
|
// leaks. Distinct from onError (the APM sink), which always fires.
|
|
2216
|
-
onDevError: dev
|
|
2250
|
+
onDevError: dev && !isPrefetch
|
|
2251
|
+
? (e) => reportDevError(e, { kind: 'render', url: devErrorUrl })
|
|
2252
|
+
: undefined,
|
|
2217
2253
|
};
|
|
2218
2254
|
if (method === 'GET' || method === 'HEAD') {
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2255
|
+
// A successful render of URL U supersedes a RETAINED render error for
|
|
2256
|
+
// that same URL (#1047), so a reconnecting tab is not handed a frame the
|
|
2257
|
+
// page has since recovered from. Keyed on BOTH frame identity and url:
|
|
2258
|
+
// identity so a frame this very render just reported is never wiped, url
|
|
2259
|
+
// so a good render of an unrelated page cannot erase an error that is
|
|
2260
|
+
// still current for the page the user is looking at (the #893 gap the
|
|
2261
|
+
// retention exists to close). Dev-only, and skipped for a prefetch,
|
|
2262
|
+
// which is not the user's view of anything.
|
|
2263
|
+
const handler = dev && !isPrefetch
|
|
2264
|
+
? async () => {
|
|
2265
|
+
const before = state.lastDevError;
|
|
2266
|
+
const resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
2267
|
+
if (
|
|
2268
|
+
before && state.lastDevError === before
|
|
2269
|
+
&& before.kind === 'render' && before.url === devErrorUrl
|
|
2270
|
+
) state.lastDevError = null;
|
|
2271
|
+
return resp;
|
|
2272
|
+
}
|
|
2273
|
+
: () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
2225
2274
|
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
2226
2275
|
}
|
|
2276
|
+
// Every non-GET/HEAD to a page runs the page's segment middleware, and
|
|
2277
|
+
// the dispatcher always answers with a Response (a 405 when there is no
|
|
2278
|
+
// action to run), so a middleware that post-processes `await next()`
|
|
2279
|
+
// never has to handle an absent one.
|
|
2280
|
+
const deps = {
|
|
2281
|
+
actionIndex: state.actionIndex,
|
|
2282
|
+
allowedOrigins,
|
|
2283
|
+
onError: reportError ? (e) => reportError(e, req, 'action') : undefined,
|
|
2284
|
+
};
|
|
2285
|
+
const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps);
|
|
2286
|
+
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
2227
2287
|
}
|
|
2228
2288
|
}
|
|
2229
2289
|
|
|
@@ -2845,6 +2905,11 @@ function __webjsApplyError(data) {
|
|
|
2845
2905
|
let f; try { f = JSON.parse(data); } catch (_) { return; }
|
|
2846
2906
|
renderDevOverlay(f);
|
|
2847
2907
|
}
|
|
2908
|
+
// Keep the overlay tracking the page actually on screen (#1047): a render frame
|
|
2909
|
+
// is scoped to the URL that produced it, so navigating away takes it down, and a
|
|
2910
|
+
// frame that arrived before the URL advanced goes up once it does. The gate
|
|
2911
|
+
// itself lives inside renderDevOverlay, so __webjsApplyError needs no change.
|
|
2912
|
+
installDevOverlayNavSync();
|
|
2848
2913
|
// Never reload INTO a server that is still restarting (Node's node --watch
|
|
2849
2914
|
// briefly kills the process on an edit), which would paint a style-less,
|
|
2850
2915
|
// half-rendered page (#893). Probe the lightweight /__webjs/version endpoint
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a bound `<form action=${action}>` back to `<hash>/<fn>` (#1155).
|
|
3
|
+
*
|
|
4
|
+
* The renderer holds a function and needs the identity the browser will submit.
|
|
5
|
+
* On the client that is trivial (the RPC stub stamps its own identity on
|
|
6
|
+
* itself), but at SSR the value is the REAL server function, so something has
|
|
7
|
+
* to have watched it load. The `'use server'` load hook did, which is why it
|
|
8
|
+
* installs unconditionally rather than only when seeding is on.
|
|
9
|
+
*
|
|
10
|
+
* The naming is the RPC endpoint's, deliberately: the same `hashFile(file)`
|
|
11
|
+
* over the absolute path, the same `<hash>/<fnName>` shape. One identity
|
|
12
|
+
* scheme means the form dispatcher and the RPC endpoint resolve the same
|
|
13
|
+
* string to the same function, so the two transports can never disagree about
|
|
14
|
+
* what an action IS.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { actionIdentitiesOf, identityHookInstalled } from './action-seed.js';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolved identities for functions the hook never registered. Only ever
|
|
22
|
+
* populated by the scan fallback below, so on a supported runtime it stays
|
|
23
|
+
* empty.
|
|
24
|
+
* @type {WeakMap<Function, string>}
|
|
25
|
+
*/
|
|
26
|
+
const _scanned = new WeakMap();
|
|
27
|
+
|
|
28
|
+
/** Emit the scan-fallback warning once per process rather than per render. */
|
|
29
|
+
let _warnedScan = false;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The `<hash>/<fn>` identity of an action function, or null.
|
|
33
|
+
*
|
|
34
|
+
* @param {import('./actions.js').ActionIndex} idx
|
|
35
|
+
* @param {Function} fn
|
|
36
|
+
* @returns {Promise<string | null>}
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveActionIdentity(idx, fn) {
|
|
39
|
+
const known = actionIdentitiesOf(fn);
|
|
40
|
+
if (known.length) {
|
|
41
|
+
// The FIRST registration the index knows. Order is defining-module-first,
|
|
42
|
+
// so a function re-exported through a barrel resolves to the module that
|
|
43
|
+
// also carries its `validate` / `middleware` / `method` / `invalidates`
|
|
44
|
+
// config exports; the barrel is used only when the defining module is not
|
|
45
|
+
// in the index at all (an action from a linked workspace package, or one
|
|
46
|
+
// reached under a path the app-tree walk never produced, such as through a
|
|
47
|
+
// symlink).
|
|
48
|
+
for (const entry of known) {
|
|
49
|
+
const hash = idx.fileToHash.get(entry.file);
|
|
50
|
+
if (!hash) continue;
|
|
51
|
+
// Falling back past the defining module can COST something, so say so.
|
|
52
|
+
// The dispatcher loads whatever module the identity names and reads
|
|
53
|
+
// `validate` / `middleware` / `method` / `invalidates` off THAT namespace
|
|
54
|
+
// BY NAME, which cuts both ways: config declared beside the action does
|
|
55
|
+
// not travel with a `export { fn } from '...'` re-export, and config the
|
|
56
|
+
// re-exporting module declares for its own exports applies to this action
|
|
57
|
+
// instead. Neither direction is knowable without loading the module,
|
|
58
|
+
// which is why the warning states the condition rather than the
|
|
59
|
+
// consequence (see `warnConfigExportsBypassed`). It warns
|
|
60
|
+
// rather than refuses because the RPC endpoint resolves the same barrel
|
|
61
|
+
// hash the same way: both transports agree, and refusing would break a
|
|
62
|
+
// page that works while leaving the RPC path open anyway.
|
|
63
|
+
if (entry !== known[0]) warnConfigExportsBypassed(known[0], entry);
|
|
64
|
+
return `${hash}/${entry.fnName}`;
|
|
65
|
+
}
|
|
66
|
+
// Registered, but under no name the index knows. Minting a hash from the
|
|
67
|
+
// path (which `actionFileHash` will happily do for any string) would
|
|
68
|
+
// produce an identity the dispatcher cannot resolve, and an unresolvable
|
|
69
|
+
// hash reads as a deploy skew: every submission would answer 422 "please
|
|
70
|
+
// submit again", forever, with nothing logged. Refusing at render is the
|
|
71
|
+
// better failure, but the renderer's message can only say "not a server
|
|
72
|
+
// action", which is wrong here and would send the author looking in the
|
|
73
|
+
// wrong place. So name the real cause once, on the server.
|
|
74
|
+
warnUnindexedAction(known[0]);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return await scanForIdentity(idx, fn);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Files already warned about, so a re-render does not repeat the line. */
|
|
81
|
+
const _warnedUnindexed = new Set();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* No name the action index knows.
|
|
85
|
+
*
|
|
86
|
+
* The message names the SYMPTOM and lists the causes rather than asserting one.
|
|
87
|
+
* Two produce this, and they need opposite fixes: the module genuinely lives
|
|
88
|
+
* outside the app tree, or it lives inside but under a different path than the
|
|
89
|
+
* one that was walked (an `appDir` reached through a symlink, since the index
|
|
90
|
+
* stores the walked path while the ESM loader realpaths the module). Asserting
|
|
91
|
+
* the first would confidently misdiagnose the second, whose author would then
|
|
92
|
+
* go looking for a package that does not exist.
|
|
93
|
+
*
|
|
94
|
+
* The remedy has to say NAMED, too: `export * from` cannot be enumerated, so
|
|
95
|
+
* the facade never wraps it and nothing registers, which leaves the page
|
|
96
|
+
* failing in exactly the same way it already was.
|
|
97
|
+
*
|
|
98
|
+
* @param {{ file: string, fnName: string }} entry
|
|
99
|
+
*/
|
|
100
|
+
function warnUnindexedAction(entry) {
|
|
101
|
+
if (_warnedUnindexed.has(entry.file)) return;
|
|
102
|
+
_warnedUnindexed.add(entry.file);
|
|
103
|
+
console.warn(
|
|
104
|
+
`[webjs] cannot bind ${entry.fnName} to a <form>: it is a 'use server' export, but the `
|
|
105
|
+
+ `action index has no entry for ${entry.file}, so the identity the browser submits `
|
|
106
|
+
+ 'could never be resolved back. Common causes: the module is outside the app '
|
|
107
|
+
+ 'directory, or the app directory was reached through a symlink so the walked path '
|
|
108
|
+
+ `and the loaded path differ. Fix the path, or add a NAMED re-export ("export { ${entry.fnName} } `
|
|
109
|
+
+ `from ...") in a 'use server' module inside the app; a star re-export cannot be `
|
|
110
|
+
+ 'enumerated and will not register it.',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Defining modules already warned about for a bypassed-config fallback. */
|
|
115
|
+
const _warnedBypass = new Set();
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Warn that the dispatched module is not the defining one.
|
|
119
|
+
*
|
|
120
|
+
* Deliberately CONDITIONAL about the consequence. What is known here is cheap
|
|
121
|
+
* and certain: the identity names a re-exporting module, so the dispatcher will
|
|
122
|
+
* read config off that namespace. What that costs is not knowable without
|
|
123
|
+
* loading the module, which this path refuses to do (it is a render-time
|
|
124
|
+
* resolve, and eager-loading every action module is exactly the cost
|
|
125
|
+
* `scanForIdentity` exists to avoid).
|
|
126
|
+
*
|
|
127
|
+
* Config is matched BY NAME off the dispatched namespace
|
|
128
|
+
* (`actionConfigFn(mod, 'validate')`), with no association back to a particular
|
|
129
|
+
* function, and that cuts BOTH ways. Three wordings have now been wrong by
|
|
130
|
+
* describing only one direction:
|
|
131
|
+
*
|
|
132
|
+
* - config declared beside the action does NOT travel with a plain
|
|
133
|
+
* `export { fn } from` re-export, so it stops applying
|
|
134
|
+
* - config the DISPATCHING module declares DOES apply to this action, even
|
|
135
|
+
* though it was written for one of that module's own exports
|
|
136
|
+
*
|
|
137
|
+
* The second is why "an action that declares no config loses nothing" was
|
|
138
|
+
* false: a config-less action re-exported through a module that has a
|
|
139
|
+
* `validate` for its own sibling inherits that validator, and its submissions
|
|
140
|
+
* start failing with a message about a different action.
|
|
141
|
+
*
|
|
142
|
+
* It follows that re-exporting the config into a SHARED barrel is bad advice
|
|
143
|
+
* (it attaches one action's validator to every sibling), while re-exporting it
|
|
144
|
+
* into a module holding only this action is safe and is the only option when
|
|
145
|
+
* the action lives in a workspace package several apps share, which is exactly
|
|
146
|
+
* the case that produces this warning. The remedy has to offer both, and the
|
|
147
|
+
* second needs its CONDITION stated: among indexed re-exporters the identity
|
|
148
|
+
* goes to the first one LOADED (registration order, and module load is lazy per
|
|
149
|
+
* route), so a stray second re-exporter can silently win over the remedy module
|
|
150
|
+
* depending on which page a visitor hits first.
|
|
151
|
+
*
|
|
152
|
+
* The second remedy also cannot silence this line. The defining module stays
|
|
153
|
+
* outside the index, so the fallback still runs and this check cannot see
|
|
154
|
+
* inside the dispatched module to know the config now travels with it. Round 4
|
|
155
|
+
* of this PR's review found a diagnostic that persisted in the state its own
|
|
156
|
+
* remedy produced while still describing it as broken; the honest fix is the
|
|
157
|
+
* message saying so itself.
|
|
158
|
+
*
|
|
159
|
+
* `webjs check` catches none of it: `one-action-per-configured-file` counts
|
|
160
|
+
* callables from `export function` / `export const … =>`, so a re-exported
|
|
161
|
+
* action is invisible to it (a pure barrel reads as zero callables).
|
|
162
|
+
*
|
|
163
|
+
* @param {{ file: string, fnName: string }} defining
|
|
164
|
+
* @param {{ file: string, fnName: string }} used
|
|
165
|
+
*/
|
|
166
|
+
function warnConfigExportsBypassed(defining, used) {
|
|
167
|
+
if (_warnedBypass.has(defining.file)) return;
|
|
168
|
+
_warnedBypass.add(defining.file);
|
|
169
|
+
console.warn(
|
|
170
|
+
`[webjs] ${defining.fnName} is bound to a <form>, but its module ${defining.file} is `
|
|
171
|
+
+ `not in the action index, so the submission is dispatched through ${used.file} `
|
|
172
|
+
+ 'instead. Config is matched by NAME off that module, so it flows both ways: a '
|
|
173
|
+
+ '`validate` / `middleware` / `method` / `invalidates` declared beside the action does '
|
|
174
|
+
+ 'not travel with a plain `export { fn } from` re-export, and any that module declares '
|
|
175
|
+
+ 'of its own applies to this action. Move the action inside the app directory. Or '
|
|
176
|
+
+ 're-export it from a module holding only this one action, put its config there, and '
|
|
177
|
+
+ 'make sure NO other in-app module re-exports it: among re-exporters the first one '
|
|
178
|
+
+ 'loaded wins the identity, so a shared barrel can silently override the dedicated '
|
|
179
|
+
+ 'module. Adding config to a re-export module that carries several actions applies it '
|
|
180
|
+
+ 'to all of them, and `webjs check` cannot see that. This notice keeps printing after '
|
|
181
|
+
+ 'the dedicated-module fix, because the check cannot see inside the dispatched module; '
|
|
182
|
+
+ 'if that module carries the config, it is working as intended. (The RPC endpoint '
|
|
183
|
+
+ 'resolves the same way, so this is not specific to forms.)',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Cold-path fallback: find the function by loading the indexed action modules
|
|
189
|
+
* and comparing exports.
|
|
190
|
+
*
|
|
191
|
+
* Reached in exactly one situation: a runtime with neither
|
|
192
|
+
* `module.registerHooks` nor `Bun.plugin`, which never installs the hook at all.
|
|
193
|
+
* A module imported BEFORE the hook installed is also unwrapped, but the gate
|
|
194
|
+
* below rules it out, and the server installs the hook at boot ahead of any app
|
|
195
|
+
* module, so that shape only exists in a unit test that imports one by hand.
|
|
196
|
+
*
|
|
197
|
+
* It is gated on the hook being ABSENT rather than merely on a registry miss.
|
|
198
|
+
* The scan imports every `'use server'` module in the app, which is exactly the
|
|
199
|
+
* eager loading (DB driver init, connection pools) that `buildActionIndex`
|
|
200
|
+
* avoids by only hashing paths. Paying that during a render because one lookup
|
|
201
|
+
* missed would turn a fast miss into a slow one on every page; with the hook
|
|
202
|
+
* installed, a miss means the function genuinely is not an action, and the
|
|
203
|
+
* renderer should say so immediately.
|
|
204
|
+
*
|
|
205
|
+
* @param {import('./actions.js').ActionIndex} idx
|
|
206
|
+
* @param {Function} fn
|
|
207
|
+
* @returns {Promise<string | null>}
|
|
208
|
+
*/
|
|
209
|
+
export async function scanForIdentity(idx, fn) {
|
|
210
|
+
if (identityHookInstalled()) return null;
|
|
211
|
+
const cached = _scanned.get(fn);
|
|
212
|
+
if (cached) return cached;
|
|
213
|
+
if (!_warnedScan) {
|
|
214
|
+
_warnedScan = true;
|
|
215
|
+
console.warn(
|
|
216
|
+
'[webjs] resolving a bound form action by scanning the action index, because '
|
|
217
|
+
+ "the 'use server' load hook is not installed on this runtime. Every action "
|
|
218
|
+
+ 'module is imported to do it. Node 24+ and Bun both install the hook.',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
for (const [hash, file] of idx.hashToFile) {
|
|
222
|
+
let mod;
|
|
223
|
+
try {
|
|
224
|
+
// Deliberately NOT cache-busted, unlike every other dev-time import here.
|
|
225
|
+
// The match is function-object identity against the instance the page
|
|
226
|
+
// already holds, and a busted specifier is a fresh module whose exports
|
|
227
|
+
// are new objects, so `value === fn` could never be true. In dev that
|
|
228
|
+
// made this fallback import every action module and always return null.
|
|
229
|
+
mod = await import(pathToFileURL(file).toString());
|
|
230
|
+
} catch {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
for (const [name, value] of Object.entries(mod)) {
|
|
234
|
+
if (value !== fn) continue;
|
|
235
|
+
const id = `${hash}/${name}`;
|
|
236
|
+
_scanned.set(fn, id);
|
|
237
|
+
return id;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve an identity string back to the function it names, for the form
|
|
245
|
+
* dispatcher.
|
|
246
|
+
*
|
|
247
|
+
* Returns a `reason` rather than a bare null on failure, because the failures
|
|
248
|
+
* need different responses. An unknown HASH is a deploy skew (a form rendered
|
|
249
|
+
* by an older build, submitted against a newer one), which re-renders the page
|
|
250
|
+
* with a resubmit message.
|
|
251
|
+
*
|
|
252
|
+
* An unknown FUNCTION inside a known file is NOT decided here: this resolves
|
|
253
|
+
* the file and hands back its module namespace, and the caller decides whether
|
|
254
|
+
* the named export is a callable action (it also has to reject a reserved
|
|
255
|
+
* config export, which only it knows about). That is why there is no
|
|
256
|
+
* `unknown-fn` reason.
|
|
257
|
+
*
|
|
258
|
+
* A module that THROWS at import is a third case, and folding it into skew was
|
|
259
|
+
* a real defect. The hash resolves (the index only hashes paths, it never
|
|
260
|
+
* imports), so a `.server.ts` whose module scope throws (a DB connection built
|
|
261
|
+
* at load with a missing env var) would answer every submission with "please
|
|
262
|
+
* submit again" forever, with the actual error discarded: no log line, no
|
|
263
|
+
* `onError`, nothing. The same broken module surfaces a logged 500 through a
|
|
264
|
+
* page render and through the RPC endpoint, which lets `loadModule` throw on
|
|
265
|
+
* purpose, so the form path was the one place it went silent. The error is
|
|
266
|
+
* carried out instead and the caller turns it into a sanitized, digest-logged
|
|
267
|
+
* 500 like any other action throw.
|
|
268
|
+
*
|
|
269
|
+
* @param {import('./actions.js').ActionIndex} idx
|
|
270
|
+
* @param {string} id the submitted `<hash>/<fn>` value
|
|
271
|
+
* @returns {Promise<{ ok: true, file: string, fnName: string, module: Record<string, unknown> }
|
|
272
|
+
* | { ok: false, reason: 'malformed' | 'skew' }
|
|
273
|
+
* | { ok: false, reason: 'load-failed', error: unknown }>}
|
|
274
|
+
*/
|
|
275
|
+
export async function lookupActionIdentity(idx, id) {
|
|
276
|
+
const raw = typeof id === 'string' ? id : '';
|
|
277
|
+
const slash = raw.indexOf('/');
|
|
278
|
+
if (slash <= 0 || slash === raw.length - 1) return { ok: false, reason: 'malformed' };
|
|
279
|
+
const hash = raw.slice(0, slash);
|
|
280
|
+
const fnName = raw.slice(slash + 1);
|
|
281
|
+
const file = idx.hashToFile.get(hash);
|
|
282
|
+
if (!file) return { ok: false, reason: 'skew' };
|
|
283
|
+
let module;
|
|
284
|
+
try {
|
|
285
|
+
module = await import(pathToFileURL(file).toString() + (idx.dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : ''));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return { ok: false, reason: 'load-failed', error };
|
|
288
|
+
}
|
|
289
|
+
return { ok: true, file, fnName, module };
|
|
290
|
+
}
|