@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
package/src/dev/server.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import { createWatchIgnore } from './watch-ignore.js';
|
|
2
|
+
import { mergeVerdicts } from '../dev-classify.js';
|
|
3
|
+
import { createWatchIgnore, editorTempTarget } from './watch-ignore.js';
|
|
4
4
|
import { watchTree } from './watch-tree.js';
|
|
5
|
+
import { createPokeState } from './poke-state.js';
|
|
5
6
|
import { relative, resolve } from 'node:path';
|
|
6
7
|
import {
|
|
7
8
|
hotRerunCapable, hotHostKey, getHotHost, setHotHost, deleteHotHost,
|
|
8
|
-
armHotSentinel, pokeHotSentinel, removeHotSentinel, needsHotReset, announceHotInPlace,
|
|
9
|
+
armHotSentinel, pokeHotSentinel, removeHotSentinel, needsHotReset, announceHotInPlace, requestSupervisorRestart,
|
|
9
10
|
} from './hot-host.js';
|
|
10
11
|
import { createServer as createHttp1Server } from 'node:http';
|
|
11
12
|
import { createRequestHandler } from './handler.js';
|
|
@@ -33,6 +34,29 @@ const SERVER_VERSION = (() => {
|
|
|
33
34
|
*/
|
|
34
35
|
const POKE_FALLBACK_MS = 1500;
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The root files the `webjs dev` supervisor restarts this child for (#1689),
|
|
39
|
+
* as the pattern the supervisor hands over in `__WEBJS_DEV_RESTART_FILES`, or
|
|
40
|
+
* null when no supervisor will (`--no-hot`, an embedder, an older CLI). This
|
|
41
|
+
* watcher sees the same files, and a reload frame it sent for one would reload
|
|
42
|
+
* the tab a second time on top of the restart, which already reloads it.
|
|
43
|
+
* @returns {RegExp | null}
|
|
44
|
+
*/
|
|
45
|
+
export function supervisorRestartFile(env = process.env) {
|
|
46
|
+
if (env.__WEBJS_DEV_CHILD !== '1' || !env.__WEBJS_DEV_RESTART_FILES) return null;
|
|
47
|
+
try { return new RegExp(env.__WEBJS_DEV_RESTART_FILES); } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An internal test seam (like `__WEBJS_DEV_CHILD`): how long a `bun --hot`
|
|
52
|
+
* re-run waits before taking its batch, so `test/bun/dev-late-rerun.mjs` can
|
|
53
|
+
* land it after the poke fallback every time, the race #1689 fixed. Unset is 0.
|
|
54
|
+
*/
|
|
55
|
+
function rerunDelayMs() {
|
|
56
|
+
const n = Number(process.env.__WEBJS_RERUN_DELAY_MS || 0);
|
|
57
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
36
60
|
function debounce(fn, ms) {
|
|
37
61
|
let t;
|
|
38
62
|
return (...args) => {
|
|
@@ -286,7 +310,9 @@ export async function startServer(opts) {
|
|
|
286
310
|
// identically on both). Built before the handler so its onReload / onDevError
|
|
287
311
|
// callbacks can fan out through it.
|
|
288
312
|
// A Bun hot host, or a server outside the dev supervisor (`--no-hot`, an
|
|
289
|
-
// embedder), applies an edit in place
|
|
313
|
+
// embedder), applies an edit in place. A supervised Node child does too once
|
|
314
|
+
// its handler has the module resolve hook (#1689), which is set below; only a
|
|
315
|
+
// Node without `module.registerHooks` is still restarted.
|
|
290
316
|
const hub = new SseHub({ inPlace: dev && (!!hotKey || process.env.__WEBJS_DEV_CHILD !== '1') });
|
|
291
317
|
// Pages render the stream's state into a meta tag (#1516), so the relay can
|
|
292
318
|
// re-check a page against the server on every reconnect.
|
|
@@ -306,13 +332,14 @@ export async function startServer(opts) {
|
|
|
306
332
|
hub.closeAll();
|
|
307
333
|
throw e;
|
|
308
334
|
}
|
|
335
|
+
// Node re-evaluates edited app modules in place (#1689), so its frames say so
|
|
336
|
+
// and the relay waits the short in-place window rather than a restart's.
|
|
337
|
+
if (app.hotModules) hub.inPlace = true;
|
|
309
338
|
|
|
310
339
|
/** @type {AbortController | null} */
|
|
311
340
|
let watcherAbort = null;
|
|
312
|
-
/** The
|
|
313
|
-
|
|
314
|
-
let pokedVerdict = null;
|
|
315
|
-
/** @type {any} */ let pokeFallback = null;
|
|
341
|
+
/** The edits a poked re-run has yet to apply, and the fallback's late re-run (#1689). */
|
|
342
|
+
const poked = createPokeState({ fallbackMs: POKE_FALLBACK_MS });
|
|
316
343
|
if (dev) {
|
|
317
344
|
// Watch the app tree with Node's built-in `fs.watch` (no external dep):
|
|
318
345
|
// `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
|
|
@@ -323,34 +350,32 @@ export async function startServer(opts) {
|
|
|
323
350
|
// (module-level, exported for tests) skips node_modules, .git, .webjs/, and
|
|
324
351
|
// the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
|
|
325
352
|
// Live-reload classification (#1398). Several files can change inside one
|
|
326
|
-
// 50ms debounce window (80ms before #1575), so
|
|
327
|
-
// hand
|
|
328
|
-
// accumulation, for the same reason: a window mixing a
|
|
329
|
-
// component edit is a component edit, and taking the last
|
|
330
|
-
// fresh markup onto the old component class.
|
|
331
|
-
|
|
332
|
-
|
|
353
|
+
// 50ms debounce window (80ms before #1575), so merge the verdicts of the
|
|
354
|
+
// window and hand the result to the rebuild. Same rule as the browser
|
|
355
|
+
// relay's cross-batch accumulation, for the same reason: a window mixing a
|
|
356
|
+
// page edit and a component edit is a component edit, and taking the last
|
|
357
|
+
// one would morph fresh markup onto the old component class. The window's
|
|
358
|
+
// changed files ride along (#1689), so the rebuild can version exactly the
|
|
359
|
+
// modules the edit touched.
|
|
360
|
+
/** @type {{ verdict: import('../dev-classify.js').ReloadVerdict | null, changed: Set<string> }} */
|
|
361
|
+
const pending = { verdict: null, changed: new Set() };
|
|
333
362
|
// Under `bun --hot`, a window that changed a module a `Bun.plugin` serves
|
|
334
363
|
// pokes the re-run sentinel instead of rebuilding (#1575): the rebuild has
|
|
335
364
|
// to wait for the registry reset, or the next request would still import
|
|
336
365
|
// the stale module. The re-run (`hotRerun` below) rebuilds with the verdict.
|
|
337
366
|
let pendingPoke = false;
|
|
338
367
|
const rebuild = debounce(() => {
|
|
339
|
-
const v =
|
|
340
|
-
|
|
368
|
+
const v = pending.verdict;
|
|
369
|
+
const files = [...pending.changed];
|
|
370
|
+
pending.verdict = null;
|
|
371
|
+
pending.changed.clear();
|
|
341
372
|
if (pendingPoke) {
|
|
342
373
|
pendingPoke = false;
|
|
343
|
-
|
|
374
|
+
poked.poke(v, files, (pv, pf) => { if (pv) app.rebuild(pv, { changed: pf }); });
|
|
344
375
|
pokeHotSentinel(app.appDir);
|
|
345
|
-
clearTimeout(pokeFallback);
|
|
346
|
-
pokeFallback = setTimeout(() => {
|
|
347
|
-
const pv = pokedVerdict;
|
|
348
|
-
pokedVerdict = null;
|
|
349
|
-
if (pv) app.rebuild(pv);
|
|
350
|
-
}, POKE_FALLBACK_MS);
|
|
351
376
|
return;
|
|
352
377
|
}
|
|
353
|
-
app.rebuild(v || undefined);
|
|
378
|
+
app.rebuild(v || undefined, { changed: files });
|
|
354
379
|
}, 50);
|
|
355
380
|
watcherAbort = new AbortController();
|
|
356
381
|
// One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
|
|
@@ -363,27 +388,55 @@ export async function startServer(opts) {
|
|
|
363
388
|
warned.add(key);
|
|
364
389
|
logger.warn(`file watcher skipped ${err && err.path ? err.path : 'a path'} (${err && err.code ? err.code : String(err)})`);
|
|
365
390
|
};
|
|
391
|
+
let pollFallbackWarned = false;
|
|
392
|
+
const restartFile = supervisorRestartFile();
|
|
366
393
|
const watchRoot = (root) => {
|
|
367
394
|
// Output nothing serves (`*.log`, coverage/, ...) and the root's
|
|
368
395
|
// `.gitignore` never reload the page: `npm run dev > dev.log` made every
|
|
369
396
|
// log line a reload (watch-ignore.js).
|
|
370
397
|
const unserved = createWatchIgnore(root);
|
|
371
398
|
const close = watchTree(root, {
|
|
372
|
-
|
|
399
|
+
// An editor temp file renamed over a real file is let through, and
|
|
400
|
+
// reported below as the file it saved (#1689): Bun's fs.watch names
|
|
401
|
+
// only the temp file for such a save.
|
|
402
|
+
ignore: (rel) => shouldIgnoreWatchPath(rel) || (unserved(rel) && !editorTempTarget(rel)),
|
|
373
403
|
onEvent: (filename) => {
|
|
404
|
+
const target = editorTempTarget(filename);
|
|
405
|
+
if (target) {
|
|
406
|
+
if (unserved(target) || !existsSync(resolve(root, target))) return;
|
|
407
|
+
filename = target;
|
|
408
|
+
}
|
|
374
409
|
// A regenerate output (#967) is a build product the server itself
|
|
375
410
|
// writes on request; ignoring it stops a spurious rebuild + reload
|
|
376
411
|
// (and a reload -> refetch -> recompile -> reload cycle), same as the
|
|
377
412
|
// db/dev.db carve-out above. `app` exposes the check because `state`
|
|
378
413
|
// lives in createRequestHandler's scope, not here.
|
|
379
414
|
if (app.isRegenerateOutput(filename)) return;
|
|
380
|
-
|
|
415
|
+
// The supervisor restarts the process for this one, and the restart
|
|
416
|
+
// is the reload; a frame from here would be a second one (#1689).
|
|
417
|
+
if (restartFile && root === app.appDir && restartFile.test(filename)) return;
|
|
418
|
+
// A module the CommonJS loader holds cannot be versioned, so the
|
|
419
|
+
// edit needs a fresh process (#1689): the supervisor restarts, and
|
|
420
|
+
// that restart is the reload, so no frame goes out from here.
|
|
421
|
+
if (restartFile && app.isCommonJsModule(resolve(root, filename))
|
|
422
|
+
&& requestSupervisorRestart(relative(app.appDir, resolve(root, filename)))) return;
|
|
423
|
+
pending.verdict = mergeVerdicts(pending.verdict, app.classifyWatchPath(filename, root));
|
|
424
|
+
pending.changed.add(resolve(root, filename));
|
|
381
425
|
if (hotKey && needsHotReset(filename, (f) => !app.needsRegistryReset(f, root))) pendingPoke = true;
|
|
382
426
|
// A status wait (#1664) holds until the rebuild this event schedules.
|
|
383
427
|
app.noteChange?.();
|
|
384
428
|
rebuild();
|
|
385
429
|
},
|
|
386
430
|
onError: onWatchError,
|
|
431
|
+
// Out of inotify watches (#1689): the directory is polled instead, and
|
|
432
|
+
// the fix is printed once per process rather than per directory.
|
|
433
|
+
onFallback: (name, err) => {
|
|
434
|
+
if (pollFallbackWarned) return;
|
|
435
|
+
pollFallbackWarned = true;
|
|
436
|
+
const code = (err && err.code) || 'ENOSPC';
|
|
437
|
+
const what = name ? `${name}/` : 'the app root';
|
|
438
|
+
logger.warn(`[webjs] the file watch limit is reached (${code}), so ${what} is watched by polling every 1s. Raise it: sudo sysctl fs.inotify.max_user_watches=524288 (persist it in /etc/sysctl.d/).`);
|
|
439
|
+
},
|
|
387
440
|
});
|
|
388
441
|
watcherAbort.signal.addEventListener('abort', close, { once: true });
|
|
389
442
|
};
|
|
@@ -436,21 +489,27 @@ export async function startServer(opts) {
|
|
|
436
489
|
* then re-derive the analysis so it re-imports the app's modules (the
|
|
437
490
|
* middleware, the components it primes) from that registry. A re-run a
|
|
438
491
|
* poke asked for carries the verdict of the edits behind it and tells the
|
|
439
|
-
* browser
|
|
440
|
-
*
|
|
492
|
+
* browser, and so does one that arrives after the poke's fallback
|
|
493
|
+
* already rebuilt against the old registry (#1689), so the tab picks up
|
|
494
|
+
* the fresh modules; any other is silent, since the watcher already
|
|
495
|
+
* sent that edit's own reload frame.
|
|
441
496
|
*/
|
|
442
497
|
rerun: async () => {
|
|
443
498
|
await armHotSentinel(app.appDir);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
await app.rebuild(pv || undefined, { notify: !!pv });
|
|
499
|
+
if (rerunDelayMs()) await new Promise((r) => setTimeout(r, rerunDelayMs()));
|
|
500
|
+
const { verdict: pv, changed: pf, notify } = poked.takeForRerun();
|
|
501
|
+
await app.rebuild(pv || undefined, { notify, changed: pf });
|
|
448
502
|
return owned;
|
|
449
503
|
},
|
|
450
504
|
poke: () => pokeHotSentinel(app.appDir),
|
|
451
505
|
});
|
|
452
506
|
announceHotInPlace();
|
|
453
507
|
}
|
|
508
|
+
// A Node child with the module resolve hook (#1689) tells the supervisor
|
|
509
|
+
// the same thing, so an app edit no longer restarts it; only the restart
|
|
510
|
+
// files (`instrumentation.*`, `env.*`, `.env`, `package.json`, a lockfile)
|
|
511
|
+
// still do.
|
|
512
|
+
if (app.hotModules) announceHotInPlace();
|
|
454
513
|
return handle;
|
|
455
514
|
} catch (e) {
|
|
456
515
|
// The listener never came up (a taken port, #1527): stop what was started
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser module versions for `webjs dev` (#1689), runtime-neutral.
|
|
3
|
+
*
|
|
4
|
+
* A browser keeps one module instance per URL for the life of the page, so a
|
|
5
|
+
* re-imported module whose own URL is unchanged comes back as the old instance,
|
|
6
|
+
* and so does every module it imports. To hand a tab the edited code without a
|
|
7
|
+
* reload, a rebuild bumps each changed file the browser holds AND every module
|
|
8
|
+
* that imports it (transitively, through the shipped graph), and the server
|
|
9
|
+
* rewrites the import specifiers of every served app module so an import of a
|
|
10
|
+
* bumped file carries `?t=<n>` (`&t=<n>` when the specifier already has a
|
|
11
|
+
* query), the technique the Remix asset server uses. A re-import of a bumped
|
|
12
|
+
* boundary then walks a fresh URL chain down to the edited file, while every
|
|
13
|
+
* module nothing changed under keeps its bare URL and therefore its original
|
|
14
|
+
* instance, which is what keeps `@webjsdev/core` (a bare specifier, never
|
|
15
|
+
* versioned) and every module-scope signal one instance.
|
|
16
|
+
*
|
|
17
|
+
* The map is separate from the server's (`esm-hot-hooks.js`) because Bun has no
|
|
18
|
+
* resolve hook and the browser's module map is separate anyway. It lives on the
|
|
19
|
+
* handler's state and is never cleared on a rebuild.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { dynamicEdges, expandImportAlias, importSpecifierRanges, resolveImport } from '../module-graph.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{
|
|
26
|
+
* bump: (changed: Iterable<string>, importers: Map<string, Set<string>> | null, shipped: Set<string>) => Set<string>,
|
|
27
|
+
* versionOf: (abs: string) => number,
|
|
28
|
+
* clear: () => void,
|
|
29
|
+
* }} BrowserVersions
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Who imports whom among the modules the browser holds: for each module, the
|
|
34
|
+
* shipped modules that import it statically or through a string-literal
|
|
35
|
+
* dynamic import. One walk of the graph per rebuild, shared by the version
|
|
36
|
+
* bump and the hot-swap boundary walk (`hotVerdict` in `handler.js`).
|
|
37
|
+
* @param {Map<string, Set<string>>} graph
|
|
38
|
+
* @param {Set<string>} shipped
|
|
39
|
+
* @returns {Map<string, Set<string>>}
|
|
40
|
+
*/
|
|
41
|
+
export function shippedImporters(graph, shipped) {
|
|
42
|
+
/** @type {Map<string, Set<string>>} */
|
|
43
|
+
const importers = new Map();
|
|
44
|
+
for (const edges of [graph, dynamicEdges(graph)]) {
|
|
45
|
+
for (const [from, tos] of edges) {
|
|
46
|
+
if (!shipped.has(from)) continue;
|
|
47
|
+
for (const to of tos) {
|
|
48
|
+
let set = importers.get(to);
|
|
49
|
+
if (!set) importers.set(to, (set = new Set()));
|
|
50
|
+
set.add(from);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return importers;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns {BrowserVersions} */
|
|
58
|
+
export function createBrowserVersions() {
|
|
59
|
+
/** @type {Map<string, number>} */
|
|
60
|
+
const versions = new Map();
|
|
61
|
+
let next = 0;
|
|
62
|
+
return {
|
|
63
|
+
// Give each changed file the browser holds and every shipped module that
|
|
64
|
+
// imports it (`shippedImporters`) ONE new version. A changed file outside
|
|
65
|
+
// `shipped` is skipped, since the browser holds no instance of it. Returns
|
|
66
|
+
// the files that moved.
|
|
67
|
+
bump(changed, importers, shipped) {
|
|
68
|
+
/** @type {Set<string>} */
|
|
69
|
+
const reached = new Set();
|
|
70
|
+
if (!importers) return reached;
|
|
71
|
+
const stack = [];
|
|
72
|
+
for (const f of changed) if (shipped.has(f)) stack.push(f);
|
|
73
|
+
while (stack.length) {
|
|
74
|
+
const f = /** @type {string} */ (stack.pop());
|
|
75
|
+
if (reached.has(f)) continue;
|
|
76
|
+
reached.add(f);
|
|
77
|
+
for (const imp of importers.get(f) || []) if (!reached.has(imp)) stack.push(imp);
|
|
78
|
+
}
|
|
79
|
+
if (reached.size) {
|
|
80
|
+
next++;
|
|
81
|
+
for (const f of reached) versions.set(f, next);
|
|
82
|
+
}
|
|
83
|
+
return reached;
|
|
84
|
+
},
|
|
85
|
+
versionOf(abs) {
|
|
86
|
+
return versions.get(abs) || 0;
|
|
87
|
+
},
|
|
88
|
+
clear() {
|
|
89
|
+
versions.clear();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Append `t=<version>` to every import specifier in `code` that resolves to an
|
|
96
|
+
* app file with a version above 0. Static imports, `export … from` and
|
|
97
|
+
* string-literal dynamic imports are rewritten; a type-only clause, a
|
|
98
|
+
* specifier written inside a string, template or comment, and a bare specifier
|
|
99
|
+
* are left alone. Returns `code` itself when nothing applies.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} code a served module's source (after type stripping)
|
|
102
|
+
* @param {string} importerAbs the module's absolute path
|
|
103
|
+
* @param {string} appDir
|
|
104
|
+
* @param {(abs: string) => number} versionOf
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function versionImportSpecifiers(code, importerAbs, appDir, versionOf) {
|
|
108
|
+
/** @type {Array<{ end: number, add: string }>} */
|
|
109
|
+
const edits = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const { start, end, spec } of importSpecifierRanges(code)) {
|
|
112
|
+
if (seen.has(start)) continue;
|
|
113
|
+
seen.add(start);
|
|
114
|
+
if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
|
|
115
|
+
const resolved = resolveImport(spec, importerAbs, appDir);
|
|
116
|
+
if (!resolved) continue;
|
|
117
|
+
const n = versionOf(resolved);
|
|
118
|
+
if (n > 0) edits.push({ end, add: (spec.includes('?') ? '&' : '?') + 't=' + n });
|
|
119
|
+
}
|
|
120
|
+
if (!edits.length) return code;
|
|
121
|
+
edits.sort((a, b) => b.end - a.end);
|
|
122
|
+
let out = code;
|
|
123
|
+
for (const { end, add } of edits) out = out.slice(0, end) + add + out.slice(end);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
package/src/dev/watch-ignore.js
CHANGED
|
@@ -24,15 +24,60 @@
|
|
|
24
24
|
import { readFileSync, statSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
|
|
27
|
-
const OUTPUT = /(?:^|[\\/])(?:coverage|\.cache|\.nyc_output|test-results|playwright-report|\.turbo)(?:[\\/]|$)|\.log$|(?:^|[\\/])\.DS_Store$|\.swp$|~$/;
|
|
27
|
+
const OUTPUT = /(?:^|[\\/])(?:coverage|\.cache|\.nyc_output|test-results|playwright-report|\.turbo)(?:[\\/]|$)|\.log$|(?:^|[\\/])\.DS_Store$|(?:^|[\\/])(?:\.#[^\\/]*|#[^\\/]*#|\.goutputstream-[^\\/]*|~\$[^\\/]*)$|\.(?:sw[a-p]|tmp|crswap|kate-swp)$|___jb_(?:tmp|old)___$|~$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Vim's write probe, an all-digit FILE (`4913`, then `5036` and up when that
|
|
31
|
+
* one exists) it creates and deletes in the directory it saves into. Only a
|
|
32
|
+
* file is a probe: a directory named by digits (`app/blog/2024/`, `app/404/`)
|
|
33
|
+
* is app source, and the walkers ask about directories by the same path test.
|
|
34
|
+
*/
|
|
35
|
+
const DIGIT_LEAF = /(?:^|[\\/])\d+$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether `rel` is vim's write probe: an all-digit name that is not a
|
|
39
|
+
* directory under `root` (a probe is usually already deleted when its event
|
|
40
|
+
* arrives, and a missing path is not a directory either).
|
|
41
|
+
* @param {string} rel
|
|
42
|
+
* @param {string} [root] the app root; without one an all-digit name is taken as a file
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isVimProbe(rel, root) {
|
|
46
|
+
if (!DIGIT_LEAF.test(rel || '')) return false;
|
|
47
|
+
if (!root) return true;
|
|
48
|
+
try { return !statSync(join(root, rel)).isDirectory(); } catch { return true; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Editor temp files that are RENAMED over the file they save (#1689): `x.tmp`
|
|
53
|
+
* (`sed`-style and many atomic writers), JetBrains' `x___jb_tmp___` /
|
|
54
|
+
* `x___jb_old___`, and Chrome's `x.crswap`. Bun's `fs.watch` reports such a
|
|
55
|
+
* save ONLY under the temp name, so ignoring it outright would lose the edit
|
|
56
|
+
* there; the watcher reports the target instead.
|
|
57
|
+
*/
|
|
58
|
+
const RENAMED_TEMP = /(?:\.tmp|\.crswap|___jb_(?:tmp|old)___)$/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The file an editor temp file is renamed over, or null when the name says
|
|
62
|
+
* nothing about it (vim's `4913`, an emacs lock, a gedit stream).
|
|
63
|
+
* @param {string} rel
|
|
64
|
+
* @returns {string | null}
|
|
65
|
+
*/
|
|
66
|
+
export function editorTempTarget(rel) {
|
|
67
|
+
const p = String(rel || '');
|
|
68
|
+
const m = RENAMED_TEMP.exec(p);
|
|
69
|
+
if (!m || m.index === 0 || /[\\/]$/.test(p.slice(0, m.index))) return null;
|
|
70
|
+
return p.slice(0, m.index);
|
|
71
|
+
}
|
|
28
72
|
|
|
29
73
|
/**
|
|
30
74
|
* Tool output that is never served, whatever `.gitignore` says.
|
|
31
75
|
* @param {string} rel path relative to the app root
|
|
76
|
+
* @param {string} [root] the app root, which tells vim's all-digit probe file from a directory
|
|
32
77
|
* @returns {boolean}
|
|
33
78
|
*/
|
|
34
|
-
export function isUnservedOutput(rel) {
|
|
35
|
-
return OUTPUT.test(rel || '');
|
|
79
|
+
export function isUnservedOutput(rel, root) {
|
|
80
|
+
return OUTPUT.test(rel || '') || isVimProbe(rel, root);
|
|
36
81
|
}
|
|
37
82
|
|
|
38
83
|
/**
|
|
@@ -115,7 +160,7 @@ export function createWatchIgnore(root) {
|
|
|
115
160
|
try { rules = parseGitignore(readFileSync(join(root, '.gitignore'), 'utf8')); } catch { /* no .gitignore */ }
|
|
116
161
|
return (rel) => {
|
|
117
162
|
const p = String(rel || '').replace(/\\/g, '/');
|
|
118
|
-
if (isUnservedOutput(p)) return true;
|
|
163
|
+
if (isUnservedOutput(p, root)) return true;
|
|
119
164
|
if (/(?:^|\/)\.env(?:\.[^/]*)?$/.test(p)) return false;
|
|
120
165
|
let dir = false;
|
|
121
166
|
if (rules.length) { try { dir = statSync(join(root, p)).isDirectory(); } catch { /* gone or unreadable */ } }
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Poll a directory for changes, the dev watcher's fallback when the OS has no
|
|
6
|
+
* watch left to give (#1689). Linux caps inotify watches per user
|
|
7
|
+
* (`fs.inotify.max_user_watches`, often 8192 on older distributions and lower
|
|
8
|
+
* in containers), and a large repo or a second dev server reaches it: `fs.watch`
|
|
9
|
+
* then fails with ENOSPC (EMFILE for open files), and the directory used to be
|
|
10
|
+
* dropped for the rest of the session, so its edits never reloaded anything.
|
|
11
|
+
* Polling is slower and costs a directory walk per tick, but it never stops.
|
|
12
|
+
*
|
|
13
|
+
* Each tick takes a snapshot `Map<rel, 'mtimeMs:size'>` of every file under
|
|
14
|
+
* `dir` that `ignore` admits (directories are walked, not reported) and diffs it
|
|
15
|
+
* against the previous one, so an added, a changed and a removed file are each
|
|
16
|
+
* reported once. The first snapshot is the baseline and reports nothing. The
|
|
17
|
+
* timer is `unref()`ed, so a forgotten poller never holds the process open.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} dir absolute directory to poll
|
|
20
|
+
* @param {{
|
|
21
|
+
* ignore?: (relPath: string) => boolean,
|
|
22
|
+
* onEvent: (relPath: string) => void,
|
|
23
|
+
* intervalMs?: number,
|
|
24
|
+
* recursive?: boolean,
|
|
25
|
+
* }} opts `relPath` is relative to `dir`, with OS separators. `recursive:
|
|
26
|
+
* false` reports the direct entries only (directories included), the shape
|
|
27
|
+
* the root of a watched tree needs.
|
|
28
|
+
* @returns {() => void} stops polling
|
|
29
|
+
*/
|
|
30
|
+
export function pollTree(dir, { ignore = () => false, onEvent, intervalMs = 1000, recursive = true }) {
|
|
31
|
+
/** @returns {Map<string, string>} */
|
|
32
|
+
const snapshot = () => {
|
|
33
|
+
/** @type {Map<string, string>} */
|
|
34
|
+
const out = new Map();
|
|
35
|
+
/** @param {string} rel */
|
|
36
|
+
const walk = (rel) => {
|
|
37
|
+
let entries;
|
|
38
|
+
try { entries = readdirSync(rel ? join(dir, rel) : dir, { withFileTypes: true }); } catch { return; }
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
const child = rel ? join(rel, e.name) : e.name;
|
|
41
|
+
if (ignore(child)) continue;
|
|
42
|
+
let st;
|
|
43
|
+
try { st = statSync(join(dir, child)); } catch { continue; }
|
|
44
|
+
if (st.isDirectory()) {
|
|
45
|
+
if (recursive) walk(child);
|
|
46
|
+
else out.set(child, 'dir');
|
|
47
|
+
} else {
|
|
48
|
+
out.set(child, `${st.mtimeMs}:${st.size}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk('');
|
|
53
|
+
return out;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let prev = snapshot();
|
|
57
|
+
const timer = setInterval(() => {
|
|
58
|
+
const next = snapshot();
|
|
59
|
+
for (const [rel, sig] of next) if (prev.get(rel) !== sig) onEvent(rel);
|
|
60
|
+
for (const rel of prev.keys()) if (!next.has(rel)) onEvent(rel);
|
|
61
|
+
prev = next;
|
|
62
|
+
}, intervalMs);
|
|
63
|
+
timer.unref?.();
|
|
64
|
+
return () => clearInterval(timer);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether a watch failure means the OS is out of watches (or file
|
|
69
|
+
* descriptors), the two failures polling can stand in for. Any other watch
|
|
70
|
+
* error (EACCES, ENOENT on a vanished path) is about one path and stays a
|
|
71
|
+
* warning.
|
|
72
|
+
* @param {unknown} err
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function isWatchLimitError(err) {
|
|
76
|
+
const code = err && typeof err === 'object' ? /** @type {any} */ (err).code : undefined;
|
|
77
|
+
return code === 'ENOSPC' || code === 'EMFILE';
|
|
78
|
+
}
|
package/src/dev/watch-tree.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { watch, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { watchRecursive } from './watch-recursive.js';
|
|
4
|
+
import { pollTree, isWatchLimitError } from './watch-poll.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The dev live-reload watcher over one root (#1521).
|
|
@@ -25,58 +26,94 @@ import { watchRecursive } from './watch-recursive.js';
|
|
|
25
26
|
* root watcher's events, so `mkdir lib` mid-session is watched like it was
|
|
26
27
|
* there at boot.
|
|
27
28
|
*
|
|
29
|
+
* When the OS runs out of watches (ENOSPC once the inotify limit is reached,
|
|
30
|
+
* EMFILE for file descriptors, #1689), the directory that failed is polled
|
|
31
|
+
* instead (`watch-poll.js`) and `onFallback` is told once per directory, so its
|
|
32
|
+
* edits keep arriving, a second slower, instead of never. A root watcher that
|
|
33
|
+
* fails that way polls the root's direct entries.
|
|
34
|
+
*
|
|
28
35
|
* @param {string} root absolute directory to watch
|
|
29
36
|
* @param {{
|
|
30
37
|
* ignore: (relPath: string) => boolean,
|
|
31
38
|
* onEvent: (relPath: string) => void,
|
|
32
39
|
* onError: (err: NodeJS.ErrnoException) => void,
|
|
33
|
-
*
|
|
40
|
+
* onFallback?: (name: string, err: NodeJS.ErrnoException) => void,
|
|
41
|
+
* watchFn?: typeof watch,
|
|
42
|
+
* pollMs?: number,
|
|
43
|
+
* }} opts `relPath` is relative to `root`, with OS separators. `onFallback`
|
|
44
|
+
* gets the top-level directory name (`''` for the root). `watchFn` and
|
|
45
|
+
* `pollMs` are test seams.
|
|
34
46
|
* @returns {() => void} closes every watcher
|
|
35
47
|
*/
|
|
36
|
-
export function watchTree(root, { ignore, onEvent, onError }) {
|
|
37
|
-
/** @type {Map<string,
|
|
48
|
+
export function watchTree(root, { ignore, onEvent, onError, onFallback, watchFn = watch, pollMs = 1000 }) {
|
|
49
|
+
/** @type {Map<string, { close: () => void, polled?: boolean }>} */
|
|
38
50
|
const dirs = new Map();
|
|
39
51
|
let closed = false;
|
|
40
52
|
|
|
41
|
-
const guard = (/** @type {import('node:fs').FSWatcher} */ w) => {
|
|
42
|
-
w.on('error', (err) => onError(/** @type {NodeJS.ErrnoException} */ (err)));
|
|
43
|
-
return w;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
53
|
const isDir = (name) => {
|
|
47
54
|
try { return statSync(join(root, name)).isDirectory(); } catch { return false; }
|
|
48
55
|
};
|
|
49
56
|
|
|
57
|
+
/** Replace a top-level directory's watcher with a poller. */
|
|
58
|
+
const pollDir = (name, /** @type {NodeJS.ErrnoException} */ err) => {
|
|
59
|
+
if (closed || dirs.get(name)?.polled) return;
|
|
60
|
+
try { dirs.get(name)?.close(); } catch {}
|
|
61
|
+
const stop = pollTree(join(root, name), {
|
|
62
|
+
ignore: (rel) => ignore(join(name, rel)),
|
|
63
|
+
onEvent: (rel) => onEvent(join(name, rel)),
|
|
64
|
+
intervalMs: pollMs,
|
|
65
|
+
});
|
|
66
|
+
dirs.set(name, { close: stop, polled: true });
|
|
67
|
+
onFallback?.(name, err);
|
|
68
|
+
};
|
|
69
|
+
|
|
50
70
|
const watchDir = (name) => {
|
|
51
71
|
if (closed || dirs.has(name) || ignore(name)) return;
|
|
72
|
+
const failed = (/** @type {NodeJS.ErrnoException} */ err) => {
|
|
73
|
+
if (isWatchLimitError(err)) pollDir(name, err);
|
|
74
|
+
else onError(err);
|
|
75
|
+
};
|
|
52
76
|
try {
|
|
53
77
|
// `watchRecursive` (#1529): on Linux under Node a per-directory walker,
|
|
54
78
|
// since Node 24's recursive watcher goes deaf to a file once it is
|
|
55
79
|
// replaced (`sed -i`, an editor's atomic save).
|
|
56
|
-
const w =
|
|
80
|
+
const w = watchRecursive(join(root, name), (_type, filename) => {
|
|
57
81
|
const rel = filename ? join(name, String(filename)) : name;
|
|
58
82
|
if (!ignore(rel)) onEvent(rel);
|
|
59
|
-
}, { ignore: (rel) => ignore(join(name, rel)) })
|
|
83
|
+
}, { ignore: (rel) => ignore(join(name, rel)), watchFn });
|
|
84
|
+
w.on('error', (err) => failed(/** @type {NodeJS.ErrnoException} */ (err)));
|
|
60
85
|
dirs.set(name, w);
|
|
61
86
|
} catch (err) {
|
|
62
|
-
|
|
87
|
+
failed(/** @type {NodeJS.ErrnoException} */ (err));
|
|
63
88
|
}
|
|
64
89
|
};
|
|
65
90
|
|
|
66
|
-
/**
|
|
91
|
+
/** A top-level entry changed: start or stop following it as a directory. */
|
|
92
|
+
const onTop = (/** @type {string} */ name) => {
|
|
93
|
+
if (ignore(name)) return;
|
|
94
|
+
if (isDir(name)) watchDir(name);
|
|
95
|
+
else if (dirs.has(name)) { try { dirs.get(name)?.close(); } catch {} dirs.delete(name); }
|
|
96
|
+
onEvent(name);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** @type {{ close: () => void } | null} */
|
|
67
100
|
let top = null;
|
|
101
|
+
const pollRoot = (/** @type {NodeJS.ErrnoException} */ err) => {
|
|
102
|
+
if (closed) return;
|
|
103
|
+
try { top?.close(); } catch {}
|
|
104
|
+
top = { close: pollTree(root, { ignore, onEvent: onTop, intervalMs: pollMs, recursive: false }) };
|
|
105
|
+
onFallback?.('', err);
|
|
106
|
+
};
|
|
107
|
+
const rootFailed = (/** @type {NodeJS.ErrnoException} */ err) => {
|
|
108
|
+
if (isWatchLimitError(err)) pollRoot(err);
|
|
109
|
+
else onError(err);
|
|
110
|
+
};
|
|
68
111
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (ignore(name)) return;
|
|
73
|
-
// A top-level entry changed: start or stop following it as a directory.
|
|
74
|
-
if (isDir(name)) watchDir(name);
|
|
75
|
-
else if (dirs.has(name)) { try { dirs.get(name)?.close(); } catch {} dirs.delete(name); }
|
|
76
|
-
onEvent(name);
|
|
77
|
-
}));
|
|
112
|
+
const w = watchFn(root, (_type, filename) => { if (filename) onTop(String(filename)); });
|
|
113
|
+
w.on('error', (err) => rootFailed(/** @type {NodeJS.ErrnoException} */ (err)));
|
|
114
|
+
top = w;
|
|
78
115
|
} catch (err) {
|
|
79
|
-
|
|
116
|
+
rootFailed(/** @type {NodeJS.ErrnoException} */ (err));
|
|
80
117
|
}
|
|
81
118
|
|
|
82
119
|
let entries = [];
|