@webjsdev/server 0.8.82 → 0.8.84

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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * One dev server per process under `bun --hot` (#1575).
3
+ *
4
+ * `bun --hot` re-evaluates the entry module whenever a file it loaded changes,
5
+ * with a fresh module registry and `globalThis` preserved. For `webjs dev` the
6
+ * entry is the CLI, so every page, layout or component edit ran `startServer`
7
+ * again: a second request handler, a second set of file watchers, a second SSE
8
+ * hub, another `instrumentation.register()`, and both `Bun.plugin` hooks
9
+ * registered again. Nothing from the previous run was disposed, because nothing
10
+ * could reach it. Measured on the scaffold: about 25 MB more per edit, 5.8 GB
11
+ * after 225 edits, after which `bun --hot` stopped reloading at all and a
12
+ * component edit was never served again until a restart. A second instance also
13
+ * built its route table before its watchers existed, so a file added or renamed
14
+ * while it booted was invisible to the instance that ended up serving (a 404
15
+ * for a route that exists, an unknown action).
16
+ *
17
+ * So the first run owns the process: its listener, hub, watchers and request
18
+ * handler stay, and a later run only tells it the module registry was reset
19
+ * (`rerun`). The handler then re-derives its analysis on the next request,
20
+ * which re-imports the app's modules from the fresh registry. The framework's
21
+ * own code stays the first run's, so its caches stay warm. Mixing that code
22
+ * with app modules from a later registry is safe by design: core's type checks
23
+ * are structural (`_$webjs`) and its shared state is keyed by `Symbol.for`.
24
+ *
25
+ * The state lives on `globalThis` under a `Symbol.for` key because a re-run
26
+ * evaluates a NEW copy of this module, whose own module variables start empty.
27
+ *
28
+ * @module dev/hot-host
29
+ */
30
+ import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
31
+ import { join, dirname } from 'node:path';
32
+ import { tmpdir } from 'node:os';
33
+ import { createHash } from 'node:crypto';
34
+ import { pathToFileURL } from 'node:url';
35
+
36
+ const HOSTS = Symbol.for('webjs.dev.hotHosts');
37
+
38
+ /**
39
+ * Sent to the `webjs dev` supervisor over IPC once the hot host is up, so it
40
+ * stops restarting the process for plugin-served edits this server now reloads
41
+ * in place. Mirrors `HOT_IN_PLACE_MESSAGE` in `@webjsdev/cli`'s `dev-reload.js`.
42
+ */
43
+ export const HOT_IN_PLACE_MESSAGE = 'hot-reload-in-place';
44
+
45
+ /** Tell the supervisor (when there is one) that edits reload in place. */
46
+ export function announceHotInPlace() {
47
+ try {
48
+ if (typeof process.send === 'function' && process.connected) process.send({ webjs: HOT_IN_PLACE_MESSAGE });
49
+ } catch { /* no supervisor, or it is gone */ }
50
+ }
51
+
52
+ /**
53
+ * Whether this process re-runs its entry on a file change: Bun under `--hot`.
54
+ *
55
+ * @param {{ isBun?: boolean, execArgv?: string[] }} [env]
56
+ * @returns {boolean}
57
+ */
58
+ export function hotRerunCapable({ isBun = !!process.versions.bun, execArgv = process.execArgv } = {}) {
59
+ return isBun && Array.isArray(execArgv) && execArgv.includes('--hot');
60
+ }
61
+
62
+ /**
63
+ * @typedef {{
64
+ * version: string,
65
+ * rerun: () => Promise<unknown>,
66
+ * poke: () => void,
67
+ * }} HotHost
68
+ */
69
+
70
+ /** @returns {Map<string, HotHost>} */
71
+ function hosts() {
72
+ const g = /** @type {any} */ (globalThis);
73
+ if (!(g[HOSTS] instanceof Map)) g[HOSTS] = new Map();
74
+ return g[HOSTS];
75
+ }
76
+
77
+ /**
78
+ * The host key: one dev server per app directory and port.
79
+ * @param {string} appDir absolute
80
+ * @param {number} port
81
+ */
82
+ export function hotHostKey(appDir, port) {
83
+ return `${appDir}\0${port}`;
84
+ }
85
+
86
+ /** @param {string} key @returns {HotHost | undefined} */
87
+ export function getHotHost(key) {
88
+ return hosts().get(key);
89
+ }
90
+
91
+ /** @param {string} key @param {HotHost} host */
92
+ export function setHotHost(key, host) {
93
+ hosts().set(key, host);
94
+ }
95
+
96
+ /** @param {string} key */
97
+ export function deleteHotHost(key) {
98
+ hosts().delete(key);
99
+ }
100
+
101
+ /**
102
+ * The sentinel module that lets the dev server ask `bun --hot` for a re-run.
103
+ * `bun --hot` does not watch a module a `Bun.plugin` served (#1550), and in dev
104
+ * every app module is one (`*.server.*` through the seed plugin, the rest
105
+ * through `dev/bun-app-source.js`). A `*.server.*` edit used to restart the
106
+ * whole process, which dropped requests for 0.3 to 2 seconds. Writing this file
107
+ * instead re-runs the entry within milliseconds, which resets the registry.
108
+ *
109
+ * It lives in the OS temp dir, per process: inside the app it would match the
110
+ * app-source plugin's filter (and so not be watched) and the dev watcher.
111
+ *
112
+ * @param {string} appDir
113
+ * @returns {string} absolute path
114
+ */
115
+ export function hotSentinelPath(appDir) {
116
+ const tag = createHash('sha1').update(appDir).digest('hex').slice(0, 12);
117
+ return join(tmpdir(), 'webjs-dev-hot', `${process.pid}-${tag}.mjs`);
118
+ }
119
+
120
+ /**
121
+ * Create (if needed) and import the sentinel so `bun --hot` watches it. A
122
+ * re-run's fresh registry imports (and so watches) it again.
123
+ *
124
+ * @param {string} appDir
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function armHotSentinel(appDir) {
128
+ const file = hotSentinelPath(appDir);
129
+ try {
130
+ mkdirSync(dirname(file), { recursive: true });
131
+ writeFileSync(file, 'export default 0;\n', { flag: 'wx' });
132
+ } catch { /* already there */ }
133
+ try { await import(pathToFileURL(file).href); } catch { /* best effort: without it a poke is a no-op */ }
134
+ }
135
+
136
+ /** Remove the sentinel (the server is closing). @param {string} appDir */
137
+ export function removeHotSentinel(appDir) {
138
+ try { rmSync(hotSentinelPath(appDir), { force: true }); } catch { /* best effort */ }
139
+ }
140
+
141
+ /**
142
+ * Ask `bun --hot` for a re-run by changing the sentinel.
143
+ * @param {string} appDir
144
+ */
145
+ export function pokeHotSentinel(appDir) {
146
+ try { writeFileSync(hotSentinelPath(appDir), `export default ${Date.now()};\n`); } catch { /* best effort */ }
147
+ }
148
+
149
+ const APP_MODULE = /\.m?[jt]s$/;
150
+ /** Files no module imports at runtime: an edit to one never needs a registry reset. */
151
+ const INERT_ASSET = /\.(?:css|md|mdx|txt|log|map|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf)$/i;
152
+
153
+ /**
154
+ * Whether a changed path could leave a stale module in the registry, so the
155
+ * dev server has to ask `bun --hot` for a reset. In dev on Bun every JS/TS
156
+ * module of the app is served by a `Bun.plugin` (`*.server.*` through the seed
157
+ * plugin, the rest through `dev/bun-app-source.js` or the source-locations
158
+ * plugin), which `bun --hot` never watches.
159
+ *
160
+ * `isRouteOnly` answers whether a module path is one only the router loads
161
+ * (nothing imports it; see `needsRegistryReset` on the request handler): its
162
+ * dev re-import is keyed by content, so it needs no reset. Anything the
163
+ * dev server cannot place, such as the extensionless temp file `sed -i`
164
+ * renames over the real one, gets the reset, because the event may name the
165
+ * temp file rather than the module it replaced.
166
+ *
167
+ * @param {string} path relative to the watched root
168
+ * @param {(path: string) => boolean} isRouteOnly
169
+ * @returns {boolean}
170
+ */
171
+ export function needsHotReset(path, isRouteOnly) {
172
+ if (/(?:^|[\\/])node_modules[\\/]/.test(path)) return false;
173
+ if (INERT_ASSET.test(path)) return false;
174
+ if (APP_MODULE.test(path)) return !isRouteOnly(path);
175
+ return true;
176
+ }
package/src/dev/serve.js CHANGED
@@ -34,7 +34,7 @@ import { versionModuleImports, withAssetHash } from '../asset-hash.js';
34
34
  import { toUrlPath } from '../ssr/preloads.js';
35
35
  import { BUFFERED_MARKER } from '../conditional-get.js';
36
36
  import { MIME, TS_CACHE_MAX, exists, reloadClientJs, reloadWorkerJs } from './helpers.js';
37
- import { devImportSpecifier } from '../dev-import.js';
37
+ import { devImport } from '../dev-import.js';
38
38
  import { runMetadataRoute } from '../metadata-routes.js';
39
39
 
40
40
  /**
@@ -573,7 +573,7 @@ export async function runWithSegmentMiddleware(req, files, terminal, dev) {
573
573
  const handlers = [];
574
574
  for (const f of files) {
575
575
  try {
576
- const mod = await import(devImportSpecifier(f, dev));
576
+ const mod = await devImport(f, dev);
577
577
  if (typeof mod.default === 'function') handlers.push(mod.default);
578
578
  } catch {
579
579
  // Bad middleware file: skip; top-level error handler will catch real problems.
@@ -617,7 +617,7 @@ export async function loadMiddleware(appDir, dev, logger) {
617
617
  }
618
618
  if (!file) return null;
619
619
  try {
620
- const mod = await import(devImportSpecifier(file, dev));
620
+ const mod = await devImport(file, dev);
621
621
  return typeof mod.default === 'function' ? mod.default : null;
622
622
  } catch (e) {
623
623
  logger.error('failed to load root middleware', { file, err: String(e) });
package/src/dev/server.js CHANGED
@@ -1,7 +1,11 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { strongerVerdict } from '../dev-classify.js';
3
3
  import { watchTree } from './watch-tree.js';
4
- import { relative } from 'node:path';
4
+ import { relative, resolve } from 'node:path';
5
+ import {
6
+ hotRerunCapable, hotHostKey, getHotHost, setHotHost, deleteHotHost,
7
+ armHotSentinel, pokeHotSentinel, removeHotSentinel, needsHotReset, announceHotInPlace,
8
+ } from './hot-host.js';
5
9
  import { createServer as createHttp1Server } from 'node:http';
6
10
  import { createRequestHandler } from './handler.js';
7
11
  import { readServerTimeoutsFromApp, readDevWatchPathsFromApp } from './config.js';
@@ -15,6 +19,18 @@ import { attachWebSocket } from '../websocket.js';
15
19
  import { toWebRequest, sendWebResponse } from './helpers.js';
16
20
  import { isAddrInUse, portInUseError, reusePortRequested } from '../port-in-use.js';
17
21
 
22
+ /** This copy of `@webjsdev/server`'s version, so a hot re-run can tell the framework itself changed. */
23
+ const SERVER_VERSION = (() => {
24
+ try { return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || ''; } catch { return ''; }
25
+ })();
26
+
27
+ /**
28
+ * How long a poked re-run (#1575) may take before the dev server stops waiting
29
+ * for it and rebuilds anyway, so an edit is never lost to a sentinel `bun --hot`
30
+ * did not see.
31
+ */
32
+ const POKE_FALLBACK_MS = 1500;
33
+
18
34
  function debounce(fn, ms) {
19
35
  let t;
20
36
  return (...args) => {
@@ -237,6 +253,22 @@ export async function startServer(opts) {
237
253
  const compress = opts.compress ?? !dev;
238
254
  const logger = opts.logger || defaultLogger({ dev });
239
255
 
256
+ // `bun --hot` re-runs the CLI, and so this function, on every edit to a
257
+ // module it loaded (#1575). The first run owns the process; a later one only
258
+ // tells it the module registry was reset. See dev/hot-host.js.
259
+ const hotKey = dev && hotRerunCapable() ? hotHostKey(resolve(opts.appDir), port) : null;
260
+ const host = hotKey ? getHotHost(hotKey) : undefined;
261
+ if (host) {
262
+ if (host.version !== SERVER_VERSION) {
263
+ // The framework itself changed under a running dev server (an upgrade).
264
+ // Its first-run code cannot load the new copy in place, so exit and let
265
+ // the `webjs dev` supervisor start a fresh process on the new version.
266
+ logger.warn(`[webjs] @webjsdev/server changed (${host.version || '?'} -> ${SERVER_VERSION || '?'}), restarting the dev server`);
267
+ if (process.env.__WEBJS_DEV_CHILD === '1') process.exit(0);
268
+ }
269
+ return /** @type {any} */ (await host.rerun());
270
+ }
271
+
240
272
  // Runtime-neutral SSE registry + fanout (shared by the node:http and Bun.serve
241
273
  // shells via listener-core.js, so live-reload + the dev error overlay behave
242
274
  // identically on both). Built before the handler so its onReload / onDevError
@@ -263,6 +295,10 @@ export async function startServer(opts) {
263
295
 
264
296
  /** @type {AbortController | null} */
265
297
  let watcherAbort = null;
298
+ /** The strongest verdict of the edits a poked re-run has yet to apply. */
299
+ /** @type {import('../dev-classify.js').ReloadVerdict | null} */
300
+ let pokedVerdict = null;
301
+ /** @type {any} */ let pokeFallback = null;
266
302
  if (dev) {
267
303
  // Watch the app tree with Node's built-in `fs.watch` (no external dep):
268
304
  // `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
@@ -273,18 +309,35 @@ export async function startServer(opts) {
273
309
  // (module-level, exported for tests) skips node_modules, .git, .webjs/, and
274
310
  // the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
275
311
  // Live-reload classification (#1398). Several files can change inside one
276
- // 80ms debounce window, so hold the STRONGEST verdict of the window and
312
+ // 50ms debounce window (80ms before #1575), so hold the STRONGEST verdict of the window and
277
313
  // hand it to the rebuild. Same rule as the browser relay's cross-batch
278
314
  // accumulation, for the same reason: a window mixing a page edit and a
279
315
  // component edit is a component edit, and taking the last one would morph
280
316
  // fresh markup onto the old component class.
281
317
  /** @type {import('../dev-classify.js').ReloadVerdict | null} */
282
318
  let pendingVerdict = null;
319
+ // Under `bun --hot`, a window that changed a module a `Bun.plugin` serves
320
+ // pokes the re-run sentinel instead of rebuilding (#1575): the rebuild has
321
+ // to wait for the registry reset, or the next request would still import
322
+ // the stale module. The re-run (`hotRerun` below) rebuilds with the verdict.
323
+ let pendingPoke = false;
283
324
  const rebuild = debounce(() => {
284
325
  const v = pendingVerdict;
285
326
  pendingVerdict = null;
327
+ if (pendingPoke) {
328
+ pendingPoke = false;
329
+ pokedVerdict = strongerVerdict(pokedVerdict, v);
330
+ pokeHotSentinel(app.appDir);
331
+ clearTimeout(pokeFallback);
332
+ pokeFallback = setTimeout(() => {
333
+ const pv = pokedVerdict;
334
+ pokedVerdict = null;
335
+ if (pv) app.rebuild(pv);
336
+ }, POKE_FALLBACK_MS);
337
+ return;
338
+ }
286
339
  app.rebuild(v || undefined);
287
- }, 80);
340
+ }, 50);
288
341
  watcherAbort = new AbortController();
289
342
  // One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
290
343
  // file must never stop the dev server, and a burst of the same temp file
@@ -307,6 +360,7 @@ export async function startServer(opts) {
307
360
  // lives in createRequestHandler's scope, not here.
308
361
  if (app.isRegenerateOutput(filename)) return;
309
362
  pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
363
+ if (hotKey && needsHotReset(filename, (f) => !app.needsRegistryReset(f, root))) pendingPoke = true;
310
364
  rebuild();
311
365
  },
312
366
  onError: onWatchError,
@@ -341,11 +395,43 @@ export async function startServer(opts) {
341
395
  // req/s on the listening path (#511); the Bun shell is dynamically imported so
342
396
  // the `Bun.*` global is never referenced on Node.
343
397
  try {
398
+ /** @type {{ server: any, close: () => Promise<void> }} */
399
+ let handle;
344
400
  if (serverRuntime() === 'bun') {
345
401
  const { startBunListener } = await import('../listener-bun.js');
346
- return startBunListener(ctx);
402
+ handle = startBunListener(ctx);
403
+ } else {
404
+ handle = await startNodeListener(ctx);
405
+ }
406
+ if (hotKey) {
407
+ // Own the process for every later `bun --hot` re-run (#1575).
408
+ await armHotSentinel(app.appDir);
409
+ const close = handle.close;
410
+ handle = { ...handle, close: () => { deleteHotHost(hotKey); removeHotSentinel(app.appDir); return close(); } };
411
+ const owned = handle;
412
+ setHotHost(hotKey, {
413
+ version: SERVER_VERSION,
414
+ /**
415
+ * The registry was reset: re-watch the sentinel from the fresh registry,
416
+ * then re-derive the analysis so it re-imports the app's modules (the
417
+ * middleware, the components it primes) from that registry. A re-run a
418
+ * poke asked for carries the verdict of the edits behind it and tells the
419
+ * browser; any other is silent, since the watcher already sent that
420
+ * edit's own reload frame.
421
+ */
422
+ rerun: async () => {
423
+ await armHotSentinel(app.appDir);
424
+ const pv = pokedVerdict;
425
+ pokedVerdict = null;
426
+ clearTimeout(pokeFallback);
427
+ await app.rebuild(pv || undefined, { notify: !!pv });
428
+ return owned;
429
+ },
430
+ poke: () => pokeHotSentinel(app.appDir),
431
+ });
432
+ announceHotInPlace();
347
433
  }
348
- return await startNodeListener(ctx);
434
+ return handle;
349
435
  } catch (e) {
350
436
  // The listener never came up (a taken port, #1527): stop what was started
351
437
  // for it, so an embedding caller that catches this is not left with a
package/src/dev-import.js CHANGED
@@ -2,13 +2,21 @@
2
2
  * The import specifier for a module the dev server re-imports on every request
3
3
  * (#1550): a page, layout, route handler, action module, middleware, and so on.
4
4
  *
5
- * In dev the specifier carries a fresh `?t=` query, so each request gets a new
6
- * module instance and a just-edited top-level file is served without waiting
7
- * for a restart. Node keys its ESM cache by the full file URL, query included.
8
- * Bun keys its cache by path and DROPS the query of a `file://` specifier, so
9
- * the same URL-plus-query is a silent no-op there: on Bun the query must ride a
10
- * plain absolute path, which Bun loads fresh (and hands to a `Bun.plugin`
11
- * `onLoad` with the query intact).
5
+ * In dev the specifier carries a `?t=` query naming the file's CONTENT (#1575),
6
+ * so a just-edited top-level file is served without waiting for a restart. Node
7
+ * keys its ESM cache by the full file URL, query included. Bun keys its cache by
8
+ * path and DROPS the query of a `file://` specifier, so the same URL-plus-query
9
+ * is a silent no-op there: on Bun the query must ride a plain absolute path,
10
+ * which Bun loads fresh (and hands to a `Bun.plugin` `onLoad` with the query
11
+ * intact).
12
+ *
13
+ * The query used to be random per call, which made every request a NEW module
14
+ * instance that neither runtime ever frees: about 60 KB per page view, so a dev
15
+ * server left open on a page grew without bound. Keyed by content, an unchanged
16
+ * file maps to the module already loaded. Content rather than mtime, because
17
+ * Linux stamps mtime from a coarse clock, so two quick writes of the same
18
+ * length can share one and the second would never be seen. A file that cannot
19
+ * be read gets a unique query, the old behaviour.
12
20
  *
13
21
  * A path that already contains `?` or `#` cannot carry a query unambiguously as
14
22
  * a plain path, so on Bun it falls back to the file URL (no fresh import; the
@@ -17,18 +25,67 @@
17
25
  * Outside dev the specifier is the plain file URL, exactly as before.
18
26
  */
19
27
  import { pathToFileURL } from 'node:url';
28
+ import { readFileSync } from 'node:fs';
29
+ import { createHash } from 'node:crypto';
20
30
 
21
31
  /**
22
32
  * @param {string} file absolute file path
23
33
  * @param {boolean} dev whether to cache-bust
24
- * @param {{ bun?: boolean, now?: () => number }} [opts] test seams: the runtime (default: detected) and the clock
34
+ * @param {{ bun?: boolean, version?: (file: string) => string }} [opts] test
35
+ * seams: the runtime (default: detected) and the content version
25
36
  * @returns {string}
26
37
  */
27
38
  export function devImportSpecifier(file, dev, opts = {}) {
28
39
  const url = pathToFileURL(file).toString();
29
40
  if (!dev) return url;
30
41
  const bun = opts.bun ?? Boolean(typeof process !== 'undefined' && process.versions && process.versions.bun);
31
- const bust = `?t=${(opts.now ?? Date.now)()}-${Math.random().toString(36).slice(2)}`;
42
+ const bust = `?t=${(opts.version ?? contentVersion)(file)}`;
32
43
  if (bun && !/[?#]/.test(file)) return file + bust;
33
44
  return url + bust;
34
45
  }
46
+
47
+ /**
48
+ * A short hash of the file's bytes, or a unique token when it cannot be read.
49
+ * @param {string} file
50
+ * @returns {string}
51
+ */
52
+ export function contentVersion(file) {
53
+ try {
54
+ return createHash('sha1').update(readFileSync(file)).digest('hex').slice(0, 16);
55
+ } catch {
56
+ return `u${Date.now()}-${Math.random().toString(36).slice(2)}`;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Specifiers whose import rejected, mapped to the key to use instead (#1575).
62
+ * Under `bun --hot`, importing a module that failed to parse a SECOND time
63
+ * through the same specifier never settles, and a later reload can then crash
64
+ * the process (Bun 1.3.14). A content-keyed specifier repeats for as long as
65
+ * the bytes do, so a broken file would hang every request after the first. A
66
+ * failed key is therefore never imported again: the next attempt gets a fresh
67
+ * one, which sticks once it loads.
68
+ * @type {Map<string, string>}
69
+ */
70
+ const RETRY_KEYS = new Map();
71
+ let retrySeq = 0;
72
+
73
+ /**
74
+ * `import()` a module the dev server re-imports on every request, through
75
+ * `devImportSpecifier`. Use this rather than `import(devImportSpecifier(...))`.
76
+ *
77
+ * @param {string} file absolute file path
78
+ * @param {boolean} dev
79
+ * @returns {Promise<any>}
80
+ */
81
+ export async function devImport(file, dev) {
82
+ const base = devImportSpecifier(file, dev);
83
+ if (!dev) return import(base);
84
+ const spec = RETRY_KEYS.get(base) || base;
85
+ try {
86
+ return await import(spec);
87
+ } catch (e) {
88
+ RETRY_KEYS.set(base, `${base}-r${++retrySeq}`);
89
+ throw e;
90
+ }
91
+ }
@@ -105,6 +105,13 @@ export function registerSourceLocationHook(appDir) {
105
105
  if (_roots.has(appDir)) return _installed;
106
106
  _roots.add(appDir);
107
107
  if (serverRuntime() === 'bun') {
108
+ // Once per root per PROCESS (#1575): `bun --hot` re-runs this module with
109
+ // an empty `_roots`, and a `Bun.plugin` cannot be unregistered.
110
+ const g = /** @type {any} */ (globalThis);
111
+ const key = Symbol.for('webjs.sourceLocations.bunRoots');
112
+ if (!(g[key] instanceof Set)) g[key] = new Set();
113
+ if (g[key].has(appDir)) { _installed = true; return _installed; }
114
+ g[key].add(appDir);
108
115
  // One plugin per root, each with a filter that can never match a
109
116
  // `node_modules` or `*.server.*` path (see the module comment).
110
117
  const filter = new RegExp(
package/src/env-schema.js CHANGED
@@ -33,7 +33,7 @@
33
33
  */
34
34
  import { join } from 'node:path';
35
35
  import { stat } from 'node:fs/promises';
36
- import { devImportSpecifier } from './dev-import.js';
36
+ import { devImport } from './dev-import.js';
37
37
 
38
38
  /** Field type names a schema may declare. */
39
39
  const KNOWN_TYPES = new Set(['string', 'number', 'boolean', 'url', 'enum']);
@@ -223,7 +223,7 @@ export async function loadEnvSchema(appDir, opts = {}) {
223
223
  }
224
224
  }
225
225
  if (!file) return null;
226
- const mod = await import(devImportSpecifier(file, Boolean(opts.dev)));
226
+ const mod = await devImport(file, Boolean(opts.dev));
227
227
  return mod.default ?? null;
228
228
  }
229
229
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { actionIdentitiesOf, identityHookInstalled } from './action-seed.js';
18
18
  import { pathToFileURL } from 'node:url';
19
- import { devImportSpecifier } from './dev-import.js';
19
+ import { devImport } from './dev-import.js';
20
20
 
21
21
  /**
22
22
  * Resolved identities for functions the hook never registered. Only ever
@@ -283,7 +283,7 @@ export async function lookupActionIdentity(idx, id) {
283
283
  if (!file) return { ok: false, reason: 'skew' };
284
284
  let module;
285
285
  try {
286
- module = await import(devImportSpecifier(file, Boolean(idx.dev)));
286
+ module = await devImport(file, Boolean(idx.dev));
287
287
  } catch (error) {
288
288
  return { ok: false, reason: 'load-failed', error };
289
289
  }
@@ -385,6 +385,28 @@ function textValuesOf(formData) {
385
385
  return out;
386
386
  }
387
387
 
388
+ /**
389
+ * A failure result with `values` completed from the submission (#1581).
390
+ *
391
+ * Every submitted text field is there, so a page refills a field with
392
+ * `value=${actionData?.values?.x}` without the action having to echo it, which
393
+ * is the one step generated and hand-written actions both keep forgetting. The
394
+ * action's own `values` win, so an action that normalizes or deliberately
395
+ * blanks a field (a password) still decides. A repeated name (a checkbox group)
396
+ * keeps its last value, as `textValuesOf` does; read the FormData for the full
397
+ * list. Files are left out. The author's object is copied, never mutated.
398
+ *
399
+ * @param {unknown} result
400
+ * @param {FormData} formData
401
+ * @returns {unknown}
402
+ */
403
+ function withSubmittedValues(result, formData) {
404
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return result;
405
+ const own = /** @type any */ (result).values;
406
+ const ownValues = own && typeof own === 'object' && !Array.isArray(own) ? own : {};
407
+ return { ...result, values: { ...textValuesOf(formData), ...ownValues } };
408
+ }
409
+
388
410
  /**
389
411
  * A page path exists but only renders: the method is the problem, not the url.
390
412
  * @returns {Response}
@@ -523,8 +545,9 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
523
545
  return await formActionErrorResponse(v.thrown, ssrOpts.dev);
524
546
  }
525
547
  // A structured failure envelope is a NORMAL result the page renders, so
526
- // it takes the 422 re-render path exactly as a failing action would.
527
- return ssrPage(route, params, url, { ...ssrOpts, req, actionData: v.result, status: 422 });
548
+ // it takes the 422 re-render path exactly as a failing action would,
549
+ // submitted values included (#1581).
550
+ return ssrPage(route, params, url, { ...ssrOpts, req, actionData: withSubmittedValues(v.result, formData), status: 422 });
528
551
  }
529
552
  args = [v.value];
530
553
  }
@@ -634,10 +657,11 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
634
657
  }
635
658
 
636
659
  // FAILURE: re-render the SAME page with the action result available on
637
- // ctx.actionData, status 422. Repopulation is the page author's job (native
638
- // `value=${actionData.values?.field}`).
660
+ // ctx.actionData, status 422. `actionData.values` carries every submitted
661
+ // text field (#1581), so `value=${actionData?.values?.field}` refills a field
662
+ // whether or not the action echoed it.
639
663
  const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
640
- const page = await ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status });
664
+ const page = await ssrPage(route, params, url, { ...ssrOpts, req, actionData: withSubmittedValues(result, formData), status });
641
665
  // A failure result can still have mutated (a partial write, then a rejected
642
666
  // second step), so the eviction above applies here too and this response, not
643
667
  // being a redirect, is one the router can actually read the header off.
@@ -18,7 +18,7 @@
18
18
  import { join } from 'node:path';
19
19
  import { stat } from 'node:fs/promises';
20
20
  import { AsyncLocalStorage } from 'node:async_hooks';
21
- import { devImportSpecifier } from './dev-import.js';
21
+ import { devImport } from './dev-import.js';
22
22
 
23
23
  /**
24
24
  * Per-boot handoff channel for a `register()`-registered error sink. An
@@ -86,7 +86,7 @@ export async function runInstrumentation(appDir, opts = {}) {
86
86
  const store = { onError: null };
87
87
  await _als.run(store, async () => {
88
88
  try {
89
- const mod = await import(devImportSpecifier(file, dev));
89
+ const mod = await devImport(file, dev);
90
90
  const register = typeof mod.default === 'function'
91
91
  ? mod.default
92
92
  : (typeof mod.register === 'function' ? mod.register : null);
@@ -23,7 +23,7 @@ import {
23
23
  import { stripBasePath } from './base-path.js';
24
24
  import { randomUUID } from 'node:crypto';
25
25
  import { isWatchError } from './dev/watch-tree.js';
26
- import { devImportSpecifier } from './dev-import.js';
26
+ import { devImport } from './dev-import.js';
27
27
 
28
28
  /** The dev live-reload SSE path (matched after base-path stripping). */
29
29
  export const EVENTS_PATH = '/__webjs/events';
@@ -329,7 +329,7 @@ export async function readBufferedOrStream(web, maxBytes) {
329
329
  * @param {boolean} dev
330
330
  */
331
331
  export function loadWsModule(file, dev) {
332
- return import(devImportSpecifier(file, dev));
332
+ return devImport(file, dev);
333
333
  }
334
334
 
335
335
  /**
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { sitemap as sitemapXml, robots as robotsTxt } from './sitemap.js';
22
22
  import { isUrlSegment } from './router.js';
23
- import { devImportSpecifier } from './dev-import.js';
23
+ import { devImport } from './dev-import.js';
24
24
  import { siteUrlFromEnv } from './ssr/seo.js';
25
25
  import { basePath } from './importmap.js';
26
26
  import { withBasePath } from './base-path.js';
@@ -42,7 +42,7 @@ async function isNoindex(files, dev) {
42
42
  let robots;
43
43
  for (const file of files) {
44
44
  try {
45
- const mod = await import(devImportSpecifier(file, dev));
45
+ const mod = await devImport(file, dev);
46
46
  if (mod.metadata && typeof mod.metadata === 'object' && mod.metadata.robots !== undefined) {
47
47
  robots = mod.metadata.robots;
48
48
  }
@@ -120,7 +120,7 @@ export async function listPages(routeTable, { siteUrl, dev }) {
120
120
  }
121
121
  let mod;
122
122
  try {
123
- mod = await import(devImportSpecifier(page.file, dev));
123
+ mod = await devImport(page.file, dev);
124
124
  } catch {
125
125
  continue;
126
126
  }
@@ -167,7 +167,7 @@ function absoluteEntries(entries, siteUrl) {
167
167
  * @returns {Promise<Response | null>} null when the module has no default export
168
168
  */
169
169
  export async function runMetadataRoute(meta, { req, url, path, dev, routeTable }) {
170
- const mod = await import(devImportSpecifier(meta.file, dev));
170
+ const mod = await devImport(meta.file, dev);
171
171
  if (typeof mod.default !== 'function') return null;
172
172
  const siteUrl = siteUrlFromEnv() || url.origin;
173
173
  const ctx = {