@webjsdev/server 0.9.0 → 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 +427 -109
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -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 +96 -14
- 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/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- 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 +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -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/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -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 +177 -129
- package/src/dev/server.js +92 -33
- 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 +2 -2
- 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.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 +140 -65
- 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 +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +80 -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 +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- 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
|
|
193
|
-
* `
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|