@webjsdev/server 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +97 -15
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. package/src/elision-report.js +0 -193
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Foreign-framework imports: `next/*`, `react`, `lit` and its family. An agent
3
+ * trained on those frameworks writes them from habit, and each one fails
4
+ * somewhere unhelpful: a bare 404 in the browser, `Cannot find package 'next'`
5
+ * on the server, or (for `lit`, which jspm resolves) a second component
6
+ * runtime silently loaded from the CDN beside WebJs's own.
7
+ *
8
+ * WebJs never aliases or accepts these packages. This table is the ONE place
9
+ * that names each and the exact WebJs replacement, and every refusal reads it:
10
+ * - `webjs check` (`no-foreign-framework-import`, in check/rules-imports.js);
11
+ * - the dev / prod analysis (`foreignImportProblems`, a load-time refusal
12
+ * before anything renders, dev overlay or prod readiness 503);
13
+ * - the server import path: `explainImportError` rewrites Node's
14
+ * `ERR_MODULE_NOT_FOUND` for a foreign package, and the Node resolve hook
15
+ * (`registerForeignImportGuard`) refuses one an app file imports even when
16
+ * the package IS installed (a transitive dependency);
17
+ * - the vendor scanner, which never sends one to jspm.
18
+ *
19
+ * `react` / `react-dom` stay allowed in a `*.server.*` file, where a library
20
+ * such as an email renderer may legitimately use them; they never reach the
21
+ * browser from there.
22
+ *
23
+ * @module foreign-imports
24
+ */
25
+ import * as nodeModule from 'node:module';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { sep } from 'node:path';
28
+ import { readFileSync } from 'node:fs';
29
+ import { shared } from './process-shared.js';
30
+
31
+ /** The stylesheet fix (kept in step with app-layout.js CSS_IMPORT_FIX). */
32
+ const CSS_FIX = 'Load it with <link rel="stylesheet" href="..."> in the root layout, or scope CSS to a component with `static styles = css`...`` and `static shadow = true`.';
33
+
34
+ /** Where every refusal points for more. */
35
+ export const FOREIGN_IMPORT_DOCS = 'https://webjs.dev/docs/troubleshooting#foreign-framework-import';
36
+
37
+ /**
38
+ * @typedef {{ framework: 'Next.js' | 'React' | 'Lit', fix: string }} ForeignImport
39
+ */
40
+
41
+ /** A cheap gate before the table, since the resolve guard sees every import. */
42
+ const FOREIGN_PREFIX = /^(?:next|react|lit|@lit)/;
43
+
44
+ /** @type {Array<[RegExp, ForeignImport]>} */
45
+ const TABLE = [
46
+ [/^next\/navigation$/, { framework: 'Next.js', fix: "import { redirect, permanentRedirect, notFound, forbidden, unauthorized, navigate } from '@webjsdev/core'. A page's params and searchParams arrive as props; a component reads location for the current URL." }],
47
+ [/^next\/headers$/, { framework: 'Next.js', fix: "import { cookies, headers } from '@webjsdev/server' (same API: await cookies() then .get(name)?.value)." }],
48
+ [/^next\/server$/, { framework: 'Next.js', fix: "middleware and route handlers use the platform Request and Response: return a Response to answer, call next() to continue (returning undefined also continues). Type a middleware's request as MiddlewareRequest from '@webjsdev/server' (it carries request.cookies, as NextRequest does). Use Response.json() for NextResponse.json() and new URL(request.url) for request.nextUrl. after() has no equivalent: finish the work before returning, or hand it to a queue." }],
49
+ [/^next\/cache$/, { framework: 'Next.js', fix: "import { revalidatePath, revalidateTag, cache } from '@webjsdev/server'. updateTag(tag) is revalidateTag(tag) here." }],
50
+ [/^next\/link$/, { framework: 'Next.js', fix: 'write a plain <a href="/path">. The client router intercepts it automatically, with prefetch on hover.' }],
51
+ [/^next\/image$/, { framework: 'Next.js', fix: 'write a plain <img src="/photo.png" width height loading="lazy"> (public/photo.png is served at /photo.png).' }],
52
+ [/^next\/font(\/.*)?$/, { framework: 'Next.js', fix: 'load the font with a <link rel="stylesheet"> in the root layout, or @font-face in your CSS with a file from public/.' }],
53
+ [/^next\/script$/, { framework: 'Next.js', fix: 'write a <script type="module" src="..."> in the root layout, or move the behaviour into a component.' }],
54
+ [/^next\/dynamic$/, { framework: 'Next.js', fix: "use a native dynamic import('./x.ts') with a string literal, or `static lazy = true` on a component to load it when it scrolls into view." }],
55
+ [/^next-auth(\/.*)?$/, { framework: 'Next.js', fix: "auth is Auth.js's API from '@webjsdev/server': export const { handlers, auth, signIn, signOut } = createAuth({ providers: [Credentials({ authorize })], secret }); mount it with export const { GET, POST } = handlers in app/api/auth/[...path]/route.ts. signIn, signOut and AuthError behave as in Auth.js v5 inside a server action." }],
56
+ [/^next(\/.*)?$/, { framework: 'Next.js', fix: "types such as Metadata, PageProps and LayoutProps are `import type { ... } from '@webjsdev/core'`; next.config settings live in the \"webjs\" block of package.json." }],
57
+ [/^react-dom(\/.*)?$/, { framework: 'React', fix: "write a component: class X extends WebComponent({ ... }) { render() { return html`...`; } } X.register('x-tag'); from '@webjsdev/core'. State is a reactive property or signal() from '@webjsdev/core'." }],
58
+ [/^react(\/.*)?$/, { framework: 'React', fix: "write a component: class X extends WebComponent({ ... }) { render() { return html`...`; } } X.register('x-tag'); from '@webjsdev/core'. useState becomes a reactive property or signal(), useEffect becomes connectedCallback / updated()." }],
59
+ [/^lit\/directives\/.+$/, { framework: 'Lit', fix: "import the directive from '@webjsdev/core/directives' (repeat, classMap, styleMap, ifDefined, when, ref, live, until, ...)." }],
60
+ [/^lit\/decorators(\.js)?$/, { framework: 'Lit', fix: "decorators are not erasable TypeScript; declare reactive properties with `static properties = { ... }` or the WebComponent({ ... }) factory from '@webjsdev/core'." }],
61
+ [/^lit\/static-html(\.js)?$/, { framework: 'Lit', fix: "import { literal, unsafeStatic, html } from '@webjsdev/core/static-html'." }],
62
+ [/^lit\/directive(\.js)?$/, { framework: 'Lit', fix: "import { directive, Directive, PartType } from '@webjsdev/core/directive'." }],
63
+ [/^lit\/async-directive(\.js)?$/, { framework: 'Lit', fix: "import { AsyncDirective } from '@webjsdev/core/async-directive'." }],
64
+ [/^(lit|lit-html|lit-element)(\/.*)?$/, { framework: 'Lit', fix: "import { WebComponent, html, css, svg, nothing, noChange } from '@webjsdev/core'. LitElement is WebComponent; register with X.register('x-tag')." }],
65
+ [/^@lit\/context(\/.*)?$/, { framework: 'Lit', fix: "import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context'." }],
66
+ [/^@lit\/task(\/.*)?$/, { framework: 'Lit', fix: "import { Task, TaskStatus } from '@webjsdev/core/task'." }],
67
+ [/^@lit\/reactive-element(\/.*)?$/, { framework: 'Lit', fix: "import { WebComponent } from '@webjsdev/core' (it is the reactive element base class)." }],
68
+ [/^@lit-labs\/.+$/, { framework: 'Lit', fix: "the @lit-labs packages are built into WebJs: SSR is automatic, signals are signal() / computed() from '@webjsdev/core', and context and task live at '@webjsdev/core/context' and '@webjsdev/core/task'." }],
69
+ ];
70
+
71
+ /**
72
+ * The foreign framework a bare specifier names, with its WebJs replacement,
73
+ * or null when the specifier is not foreign.
74
+ * @param {string} spec
75
+ * @returns {ForeignImport | null}
76
+ */
77
+ export function foreignImport(spec) {
78
+ if (typeof spec !== 'string' || !spec || !FOREIGN_PREFIX.test(spec)) return null;
79
+ for (const [re, entry] of TABLE) if (re.test(spec)) return entry;
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Whether `react` / `react-dom` may be imported from `file`: only from a
85
+ * server-only module, which never reaches the browser.
86
+ * @param {string} spec
87
+ * @param {string} [file]
88
+ */
89
+ export function foreignImportAllowed(spec, file) {
90
+ const f = foreignImport(spec);
91
+ if (!f) return true;
92
+ return f.framework === 'React' && !!file && /\.server\.m?[jt]s$/.test(file);
93
+ }
94
+
95
+ /**
96
+ * The one-line refusal every surface prints.
97
+ * @param {string} spec
98
+ * @param {string} [file] the importing file, as the reader should see it
99
+ * @returns {string}
100
+ */
101
+ export function foreignImportMessage(spec, file) {
102
+ const f = foreignImport(spec);
103
+ if (!f) return '';
104
+ const where = file ? `${file} imports '${spec}'` : `'${spec}' is imported`;
105
+ return `${where}, a ${f.framework} package. WebJs does not load ${f.framework}: ${f.fix} See ${FOREIGN_IMPORT_DOCS}`;
106
+ }
107
+
108
+ /**
109
+ * Every foreign import in an app's module graph, as layout-style problems
110
+ * (rule id, file, message, fix). Fed by `bareImports(graph)`, so it covers
111
+ * every file the graph walked, server modules included.
112
+ * @param {Map<string, Set<string>>} bareByFile absolute file -> bare specifiers
113
+ * @param {(abs: string) => string} rel
114
+ * @returns {{ rule: string, file: string, message: string, fix: string }[]}
115
+ */
116
+ export function foreignImportProblems(bareByFile, rel) {
117
+ const out = [];
118
+ for (const [abs, specs] of bareByFile) {
119
+ if (/[\\/]node_modules[\\/]/.test(abs)) continue;
120
+ for (const spec of specs) {
121
+ const f = foreignImport(spec);
122
+ if (!f || foreignImportAllowed(spec, abs)) continue;
123
+ out.push({
124
+ rule: 'no-foreign-framework-import',
125
+ file: rel(abs),
126
+ message: `Imports '${spec}', a ${f.framework} package. WebJs does not load ${f.framework}.`,
127
+ fix: f.fix,
128
+ });
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * Rewrite a module-load failure into the WebJs fix when its cause is a
136
+ * foreign import (Node's `ERR_MODULE_NOT_FOUND` / Bun's "Cannot find package"
137
+ * for `next`, `react`, `lit`, ...). Any other error is returned unchanged.
138
+ * @param {unknown} err
139
+ * @param {string} [file] the module that was being imported
140
+ * @returns {unknown}
141
+ */
142
+ export function explainImportError(err, file) {
143
+ if (!err || typeof err !== 'object') return err;
144
+ const e = /** @type {any} */ (err);
145
+ if (e.webjsForeignImport) return err;
146
+ const msg = String(e.message || '');
147
+ // `import './x.css'` on Node: a bundler feature WebJs does not have.
148
+ if (e.code === 'ERR_UNKNOWN_FILE_EXTENSION' && /\.css\b/.test(msg)) {
149
+ const out = new Error(`${file ? file + ' imports' : 'An import of'} a stylesheet (${/for (\S+)/.exec(msg)?.[1] || '.css'}). WebJs has no bundler, so CSS cannot be imported as a module. ${CSS_FIX} See https://webjs.dev/docs/troubleshooting#css-import`, { cause: err });
150
+ /** @type {any} */ (out).code = e.code;
151
+ /** @type {any} */ (out).webjsForeignImport = '.css';
152
+ return out;
153
+ }
154
+ const m = /Cannot find (?:package|module) '([^']+)'/.exec(msg) || /Cannot find package "([^"]+)"/.exec(msg);
155
+ if (!m) return err;
156
+ let spec = m[1];
157
+ // Node names the package directory for a missing package
158
+ // ("Cannot find package '/app/node_modules/next/'"); keep the name.
159
+ const nm = /node_modules[\\/](@[^\\/]+[\\/][^\\/]+|[^\\/]+)/.exec(spec);
160
+ if (nm) spec = nm[1].replace(/\\/g, '/');
161
+ // The importing file's own foreign import is the truth: Node names only the
162
+ // package ('next' for 'next/headers'), and Bun can name a package deep
163
+ // inside a dependency it auto-installed ('react' for an import of
164
+ // 'next/headers'). Read the source and name what the author wrote.
165
+ const own = file ? firstForeignSpecifier(file) : null;
166
+ if (own) spec = own;
167
+ if (!foreignImport(spec)) return err;
168
+ const out = new Error(foreignImportMessage(spec, file), { cause: err });
169
+ /** @type {any} */ (out).code = e.code || 'ERR_MODULE_NOT_FOUND';
170
+ /** @type {any} */ (out).webjsForeignImport = spec;
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * The first foreign import specifier written in `file`, or null.
176
+ * @param {string} file
177
+ * @returns {string | null}
178
+ */
179
+ function firstForeignSpecifier(file) {
180
+ let src = '';
181
+ try { src = readFileSync(file, 'utf8'); } catch { return null; }
182
+ const re = /(?:\bfrom\s*|\bimport\s*\(?\s*)(['"])([^'"\n]+)\1/g;
183
+ let m;
184
+ while ((m = re.exec(src))) if (foreignImport(m[2])) return m[2];
185
+ return null;
186
+ }
187
+
188
+ /** App roots the resolve guard watches (process-wide, one hook per process). */
189
+ const _guard = shared('foreign-import-guard', () => ({ roots: new Set(), registered: false }));
190
+
191
+ /**
192
+ * Refuse a foreign import from an app's own source at RESOLVE time on Node,
193
+ * even when the package is installed (a transitive dependency of something
194
+ * else), so `import { LitElement } from 'lit'` in a page never quietly loads a
195
+ * second runtime. Scoped to files under a registered app root and outside
196
+ * `node_modules`, so a dependency that uses React internally is untouched.
197
+ *
198
+ * Node only: Bun has no `module.registerHooks`, and its `Bun.plugin`
199
+ * `onResolve` cannot see the importer reliably enough to scope the refusal
200
+ * to app files; there the check rule and the analysis scan carry it.
201
+ * Coexists with the seed load hook (`action-seed.js`): this registers a
202
+ * `resolve` hook only, which chains independently of `load` hooks.
203
+ *
204
+ * @param {string} appDir
205
+ */
206
+ export function registerForeignImportGuard(appDir) {
207
+ if (process.versions.bun) return;
208
+ _guard.roots.add(appDir.endsWith(sep) ? appDir : appDir + sep);
209
+ if (_guard.registered || typeof nodeModule.registerHooks !== 'function') return;
210
+ _guard.registered = true;
211
+ nodeModule.registerHooks({
212
+ resolve(specifier, context, nextResolve) {
213
+ const parent = context.parentURL;
214
+ if (parent && parent.startsWith('file:') && foreignImport(specifier)) {
215
+ let file = '';
216
+ try { file = fileURLToPath(parent.split('?')[0]); } catch { /* not a path */ }
217
+ if (file && !/[\\/]node_modules[\\/]/.test(file) && [..._guard.roots].some((r) => file.startsWith(r))
218
+ && !foreignImportAllowed(specifier, file)) {
219
+ const err = new Error(foreignImportMessage(specifier, file));
220
+ /** @type {any} */ (err).code = 'ERR_WEBJS_FOREIGN_IMPORT';
221
+ /** @type {any} */ (err).webjsForeignImport = specifier;
222
+ throw err;
223
+ }
224
+ }
225
+ return nextResolve(specifier, context);
226
+ },
227
+ });
228
+ }
@@ -26,6 +26,13 @@ import { devImport } from './dev-import.js';
26
26
  */
27
27
  const _scanned = new WeakMap();
28
28
 
29
+ /**
30
+ * Functions the scan already looked for and did not find, so a genuine
31
+ * non-action bound to a form is scanned once rather than on every render.
32
+ * @type {WeakSet<Function>}
33
+ */
34
+ const _scanMissed = new WeakSet();
35
+
29
36
  /** Emit the scan-fallback warning once per process rather than per render. */
30
37
  let _warnedScan = false;
31
38
 
@@ -34,9 +41,10 @@ let _warnedScan = false;
34
41
  *
35
42
  * @param {import('./actions.js').ActionIndex} idx
36
43
  * @param {Function} fn
44
+ * @param {{ dev?: boolean }} [opts]
37
45
  * @returns {Promise<string | null>}
38
46
  */
39
- export async function resolveActionIdentity(idx, fn) {
47
+ export async function resolveActionIdentity(idx, fn, opts = {}) {
40
48
  const known = actionIdentitiesOf(fn);
41
49
  if (known.length) {
42
50
  // The FIRST registration the index knows. Order is defining-module-first,
@@ -75,7 +83,7 @@ export async function resolveActionIdentity(idx, fn) {
75
83
  warnUnindexedAction(known[0]);
76
84
  return null;
77
85
  }
78
- return await scanForIdentity(idx, fn);
86
+ return await scanForIdentity(idx, fn, opts);
79
87
  }
80
88
 
81
89
  /** Files already warned about, so a re-render does not repeat the line. */
@@ -189,36 +197,33 @@ function warnConfigExportsBypassed(defining, used) {
189
197
  * Cold-path fallback: find the function by loading the indexed action modules
190
198
  * and comparing exports.
191
199
  *
192
- * Reached in exactly one situation: a runtime with neither
193
- * `module.registerHooks` nor `Bun.plugin`, which never installs the hook at all.
194
- * A module imported BEFORE the hook installed is also unwrapped, but the gate
195
- * below rules it out, and the server installs the hook at boot ahead of any app
196
- * module, so that shape only exists in a unit test that imports one by hand.
200
+ * Reached in two situations. A runtime with neither `module.registerHooks` nor
201
+ * `Bun.plugin` never installs the hook at all. And a module imported BEFORE the
202
+ * hook installed is loaded unwrapped and stays that way in the module cache:
203
+ * the server installs the hook when the handler is created, so this is a test
204
+ * that statically imports an action module (to call it directly) and only then
205
+ * calls `createRequestHandler`. Static imports load before any code runs, so
206
+ * the test cannot order it away, and the page then threw "not a server
207
+ * action" for a function that is one.
197
208
  *
198
- * It is gated on the hook being ABSENT rather than merely on a registry miss.
199
- * The scan imports every `'use server'` module in the app, which is exactly the
200
- * eager loading (DB driver init, connection pools) that `buildActionIndex`
201
- * avoids by only hashing paths. Paying that during a render because one lookup
202
- * missed would turn a fast miss into a slow one on every page; with the hook
203
- * installed, a miss means the function genuinely is not an action, and the
204
- * renderer should say so immediately.
209
+ * With the hook installed it runs in DEV only. The scan imports every
210
+ * `'use server'` module in the app, which is exactly the eager loading (DB
211
+ * driver init, connection pools) that `buildActionIndex` avoids by only
212
+ * hashing paths, and in production a miss means the function genuinely is not
213
+ * an action and the renderer should say so at once. In dev and tests the cost
214
+ * is paid once per function: a miss is remembered.
205
215
  *
206
216
  * @param {import('./actions.js').ActionIndex} idx
207
217
  * @param {Function} fn
218
+ * @param {{ dev?: boolean }} [opts]
208
219
  * @returns {Promise<string | null>}
209
220
  */
210
- export async function scanForIdentity(idx, fn) {
211
- if (identityHookInstalled()) return null;
221
+ export async function scanForIdentity(idx, fn, opts = {}) {
222
+ const hooked = identityHookInstalled();
223
+ if (hooked && !opts.dev) return null;
212
224
  const cached = _scanned.get(fn);
213
225
  if (cached) return cached;
214
- if (!_warnedScan) {
215
- _warnedScan = true;
216
- console.warn(
217
- '[webjs] resolving a bound form action by scanning the action index, because '
218
- + "the 'use server' load hook is not installed on this runtime. Every action "
219
- + 'module is imported to do it. Node 24+ and Bun both install the hook.',
220
- );
221
- }
226
+ if (_scanMissed.has(fn)) return null;
222
227
  for (const [hash, file] of idx.hashToFile) {
223
228
  let mod;
224
229
  try {
@@ -227,7 +232,9 @@ export async function scanForIdentity(idx, fn) {
227
232
  // already holds, and a busted specifier is a fresh module whose exports
228
233
  // are new objects, so `value === fn` could never be true. In dev that
229
234
  // made this fallback import every action module and always return null.
230
- mod = await import(pathToFileURL(file).toString());
235
+ // Bun keys a module by its plain path, Node by its file URL; each is the
236
+ // form a static import of the file produced.
237
+ mod = await import(process.versions.bun ? file : pathToFileURL(file).toString());
231
238
  } catch {
232
239
  continue;
233
240
  }
@@ -235,12 +242,32 @@ export async function scanForIdentity(idx, fn) {
235
242
  if (value !== fn) continue;
236
243
  const id = `${hash}/${name}`;
237
244
  _scanned.set(fn, id);
245
+ warnScanned(hooked, file);
238
246
  return id;
239
247
  }
240
248
  }
249
+ _scanMissed.add(fn);
241
250
  return null;
242
251
  }
243
252
 
253
+ /**
254
+ * Say once why a form action was found by scanning, since the cause differs.
255
+ * @param {boolean} hooked
256
+ * @param {string} file
257
+ */
258
+ function warnScanned(hooked, file) {
259
+ if (_warnedScan) return;
260
+ _warnedScan = true;
261
+ console.warn(hooked
262
+ ? `[webjs] ${file} was imported before createRequestHandler() installed the 'use server' `
263
+ + 'load hook, so its actions were found by scanning the action index instead. This is '
264
+ + 'harmless in a test; create the handler first (or import the action with a dynamic '
265
+ + 'import() after it) to skip the scan.'
266
+ : '[webjs] resolving a bound form action by scanning the action index, because '
267
+ + "the 'use server' load hook is not installed on this runtime. Every action "
268
+ + 'module is imported to do it. Node 24+ and Bun both install the hook.');
269
+ }
270
+
244
271
  /**
245
272
  * Resolve an identity string back to the function it names, for the form
246
273
  * dispatcher.