@webjsdev/server 0.8.91 → 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 +461 -102
- package/index.js +9 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-input.js +208 -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 +112 -15
- 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/helpers.js +85 -0
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +133 -28
- 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 +109 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +24 -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/dev-status.js +122 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +632 -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 +191 -133
- package/src/dev/server.js +109 -37
- 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 +322 -78
- 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-styles.js +64 -6
- 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 +150 -69
- 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 +49 -17
- package/src/ssr/head.js +228 -25
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +243 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +52 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +85 -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 +3 -8
- package/src/component-elision.js +0 -1852
- 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';
|
|
@@ -18,6 +19,7 @@ import { stripBasePath } from '../base-path.js';
|
|
|
18
19
|
import { basePath } from '../importmap.js';
|
|
19
20
|
import { attachWebSocket } from '../websocket.js';
|
|
20
21
|
import { toWebRequest, sendWebResponse } from './helpers.js';
|
|
22
|
+
import { earlyHintLinks } from '../ssr/shipped-modules.js';
|
|
21
23
|
import { isAddrInUse, portInUseError, reusePortRequested } from '../port-in-use.js';
|
|
22
24
|
|
|
23
25
|
/** This copy of `@webjsdev/server`'s version, so a hot re-run can tell the framework itself changed. */
|
|
@@ -32,6 +34,29 @@ const SERVER_VERSION = (() => {
|
|
|
32
34
|
*/
|
|
33
35
|
const POKE_FALLBACK_MS = 1500;
|
|
34
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
|
+
|
|
35
60
|
function debounce(fn, ms) {
|
|
36
61
|
let t;
|
|
37
62
|
return (...args) => {
|
|
@@ -58,14 +83,21 @@ function debounce(fn, ms) {
|
|
|
58
83
|
* (drizzle-kit output) churn during db:migrate. The `db/dev.db` branch is
|
|
59
84
|
* prefix-only (no trailing separator) so the `-journal` / `-wal` sidecars match
|
|
60
85
|
* too, while staying anchored to `db/dev.db` so a SOURCE file like
|
|
61
|
-
* `db/schema.server.ts` still triggers a reload.
|
|
86
|
+
* `db/schema.server.ts` still triggers a reload. Beyond that, under the app's
|
|
87
|
+
* root `db/` only JS / TS source counts (#1664): a database file anywhere
|
|
88
|
+
* below it, whatever its name or sidecar (a `DATABASE_URL` pointing at
|
|
89
|
+
* `db/data/app.db`, libSQL's `-info` / `-client_wal_index` files), changes on
|
|
90
|
+
* every write and is skipped. Rails' reloader takes the same approach,
|
|
91
|
+
* watching a directory for source extensions only, rather than listing data
|
|
92
|
+
* files. The CLI's `shouldIgnoreRestartPath` carries the same pattern, and a
|
|
93
|
+
* test asserts the two agree over a corpus. The others stay
|
|
62
94
|
* separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
|
|
63
95
|
*
|
|
64
96
|
* @param {string} filename relative path from an fs.watch `event.filename`
|
|
65
97
|
* @returns {boolean} true when the change should be ignored
|
|
66
98
|
*/
|
|
67
99
|
export function shouldIgnoreWatchPath(filename) {
|
|
68
|
-
return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(filename || '');
|
|
100
|
+
return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)|^db[\\/](?!.*\.[cm]?[jt]s$)/.test(filename || '');
|
|
69
101
|
}
|
|
70
102
|
|
|
71
103
|
/**
|
|
@@ -149,7 +181,10 @@ async function startNodeListener(ctx) {
|
|
|
149
181
|
// 103 Early Hints: before running SSR, send preload hints for the
|
|
150
182
|
// page's module URLs so the browser can begin fetching them while
|
|
151
183
|
// the server is still computing the body. Skipped in dev (file churn
|
|
152
|
-
// would send stale URLs after rebuilds) and for non-GET/HEAD.
|
|
184
|
+
// would send stale URLs after rebuilds) and for non-GET/HEAD. Plain
|
|
185
|
+
// preloads, never modulepreload: a module hint arrives before the
|
|
186
|
+
// page's import map, and Firefox then refuses the map (see
|
|
187
|
+
// earlyHintLinks in ../ssr/shipped-modules.js).
|
|
153
188
|
if (
|
|
154
189
|
!dev &&
|
|
155
190
|
(req.method === 'GET' || req.method === 'HEAD') &&
|
|
@@ -159,7 +194,7 @@ async function startNodeListener(ctx) {
|
|
|
159
194
|
if (match && match.moduleUrls.length) {
|
|
160
195
|
try {
|
|
161
196
|
res.writeEarlyHints({
|
|
162
|
-
link: match.moduleUrls
|
|
197
|
+
link: earlyHintLinks(match.moduleUrls),
|
|
163
198
|
});
|
|
164
199
|
} catch (e) {
|
|
165
200
|
logger.warn('writeEarlyHints failed', { err: String(e) });
|
|
@@ -275,7 +310,9 @@ export async function startServer(opts) {
|
|
|
275
310
|
// identically on both). Built before the handler so its onReload / onDevError
|
|
276
311
|
// callbacks can fan out through it.
|
|
277
312
|
// A Bun hot host, or a server outside the dev supervisor (`--no-hot`, an
|
|
278
|
-
// 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.
|
|
279
316
|
const hub = new SseHub({ inPlace: dev && (!!hotKey || process.env.__WEBJS_DEV_CHILD !== '1') });
|
|
280
317
|
// Pages render the stream's state into a meta tag (#1516), so the relay can
|
|
281
318
|
// re-check a page against the server on every reconnect.
|
|
@@ -295,13 +332,14 @@ export async function startServer(opts) {
|
|
|
295
332
|
hub.closeAll();
|
|
296
333
|
throw e;
|
|
297
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;
|
|
298
338
|
|
|
299
339
|
/** @type {AbortController | null} */
|
|
300
340
|
let watcherAbort = null;
|
|
301
|
-
/** The
|
|
302
|
-
|
|
303
|
-
let pokedVerdict = null;
|
|
304
|
-
/** @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 });
|
|
305
343
|
if (dev) {
|
|
306
344
|
// Watch the app tree with Node's built-in `fs.watch` (no external dep):
|
|
307
345
|
// `watchTree` (#1521) watches the root plus each non-ignored top-level dir,
|
|
@@ -312,34 +350,32 @@ export async function startServer(opts) {
|
|
|
312
350
|
// (module-level, exported for tests) skips node_modules, .git, .webjs/, and
|
|
313
351
|
// the SQLite dev DB (db/dev.db) + db/migrations so a file the dev server itself writes never loops.
|
|
314
352
|
// Live-reload classification (#1398). Several files can change inside one
|
|
315
|
-
// 50ms debounce window (80ms before #1575), so
|
|
316
|
-
// hand
|
|
317
|
-
// accumulation, for the same reason: a window mixing a
|
|
318
|
-
// component edit is a component edit, and taking the last
|
|
319
|
-
// fresh markup onto the old component class.
|
|
320
|
-
|
|
321
|
-
|
|
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() };
|
|
322
362
|
// Under `bun --hot`, a window that changed a module a `Bun.plugin` serves
|
|
323
363
|
// pokes the re-run sentinel instead of rebuilding (#1575): the rebuild has
|
|
324
364
|
// to wait for the registry reset, or the next request would still import
|
|
325
365
|
// the stale module. The re-run (`hotRerun` below) rebuilds with the verdict.
|
|
326
366
|
let pendingPoke = false;
|
|
327
367
|
const rebuild = debounce(() => {
|
|
328
|
-
const v =
|
|
329
|
-
|
|
368
|
+
const v = pending.verdict;
|
|
369
|
+
const files = [...pending.changed];
|
|
370
|
+
pending.verdict = null;
|
|
371
|
+
pending.changed.clear();
|
|
330
372
|
if (pendingPoke) {
|
|
331
373
|
pendingPoke = false;
|
|
332
|
-
|
|
374
|
+
poked.poke(v, files, (pv, pf) => { if (pv) app.rebuild(pv, { changed: pf }); });
|
|
333
375
|
pokeHotSentinel(app.appDir);
|
|
334
|
-
clearTimeout(pokeFallback);
|
|
335
|
-
pokeFallback = setTimeout(() => {
|
|
336
|
-
const pv = pokedVerdict;
|
|
337
|
-
pokedVerdict = null;
|
|
338
|
-
if (pv) app.rebuild(pv);
|
|
339
|
-
}, POKE_FALLBACK_MS);
|
|
340
376
|
return;
|
|
341
377
|
}
|
|
342
|
-
app.rebuild(v || undefined);
|
|
378
|
+
app.rebuild(v || undefined, { changed: files });
|
|
343
379
|
}, 50);
|
|
344
380
|
watcherAbort = new AbortController();
|
|
345
381
|
// One warning per unwatchable path (#1521): an EACCES / ENOENT on a single
|
|
@@ -352,25 +388,55 @@ export async function startServer(opts) {
|
|
|
352
388
|
warned.add(key);
|
|
353
389
|
logger.warn(`file watcher skipped ${err && err.path ? err.path : 'a path'} (${err && err.code ? err.code : String(err)})`);
|
|
354
390
|
};
|
|
391
|
+
let pollFallbackWarned = false;
|
|
392
|
+
const restartFile = supervisorRestartFile();
|
|
355
393
|
const watchRoot = (root) => {
|
|
356
394
|
// Output nothing serves (`*.log`, coverage/, ...) and the root's
|
|
357
395
|
// `.gitignore` never reload the page: `npm run dev > dev.log` made every
|
|
358
396
|
// log line a reload (watch-ignore.js).
|
|
359
397
|
const unserved = createWatchIgnore(root);
|
|
360
398
|
const close = watchTree(root, {
|
|
361
|
-
|
|
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)),
|
|
362
403
|
onEvent: (filename) => {
|
|
404
|
+
const target = editorTempTarget(filename);
|
|
405
|
+
if (target) {
|
|
406
|
+
if (unserved(target) || !existsSync(resolve(root, target))) return;
|
|
407
|
+
filename = target;
|
|
408
|
+
}
|
|
363
409
|
// A regenerate output (#967) is a build product the server itself
|
|
364
410
|
// writes on request; ignoring it stops a spurious rebuild + reload
|
|
365
411
|
// (and a reload -> refetch -> recompile -> reload cycle), same as the
|
|
366
412
|
// db/dev.db carve-out above. `app` exposes the check because `state`
|
|
367
413
|
// lives in createRequestHandler's scope, not here.
|
|
368
414
|
if (app.isRegenerateOutput(filename)) return;
|
|
369
|
-
|
|
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));
|
|
370
425
|
if (hotKey && needsHotReset(filename, (f) => !app.needsRegistryReset(f, root))) pendingPoke = true;
|
|
426
|
+
// A status wait (#1664) holds until the rebuild this event schedules.
|
|
427
|
+
app.noteChange?.();
|
|
371
428
|
rebuild();
|
|
372
429
|
},
|
|
373
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
|
+
},
|
|
374
440
|
});
|
|
375
441
|
watcherAbort.signal.addEventListener('abort', close, { once: true });
|
|
376
442
|
};
|
|
@@ -423,21 +489,27 @@ export async function startServer(opts) {
|
|
|
423
489
|
* then re-derive the analysis so it re-imports the app's modules (the
|
|
424
490
|
* middleware, the components it primes) from that registry. A re-run a
|
|
425
491
|
* poke asked for carries the verdict of the edits behind it and tells the
|
|
426
|
-
* browser
|
|
427
|
-
*
|
|
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.
|
|
428
496
|
*/
|
|
429
497
|
rerun: async () => {
|
|
430
498
|
await armHotSentinel(app.appDir);
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
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 });
|
|
435
502
|
return owned;
|
|
436
503
|
},
|
|
437
504
|
poke: () => pokeHotSentinel(app.appDir),
|
|
438
505
|
});
|
|
439
506
|
announceHotInPlace();
|
|
440
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();
|
|
441
513
|
return handle;
|
|
442
514
|
} catch (e) {
|
|
443
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
|
+
}
|