@webjsdev/server 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +97 -15
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. package/src/elision-report.js +0 -193
package/src/dev/config.js CHANGED
@@ -9,52 +9,8 @@ import { readCspConfig } from '../csp.js';
9
9
  import { readBodyLimits, computeServerTimeouts } from '../body-limit.js';
10
10
 
11
11
  /**
12
- * Read the `WEBJS_ELIDE` environment override, if set.
13
- * `0` / `false` / `off` / `no` (case-insensitive) force elision OFF;
14
- * `1` / `true` / `on` / `yes` force it ON. Any other value, or an unset
15
- * variable, returns `undefined` so the caller falls through to the
16
- * `package.json` switch. The env override is the deploy-time / ops escape
17
- * hatch: force-disable elision to rule it out while debugging a wrong-strip
18
- * without editing committed code, or force-enable it regardless of an
19
- * app's `package.json`. It is also the seam the differential elision test
20
- * uses to render the same app on and off in one process.
21
- * @returns {boolean | undefined}
22
- */
23
- function elideEnvOverride() {
24
- const raw = process.env.WEBJS_ELIDE;
25
- if (raw == null || raw === '') return undefined;
26
- const v = String(raw).trim().toLowerCase();
27
- if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
28
- if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
29
- return undefined;
30
- }
31
-
32
- /**
33
- * Read the project-level elision switch.
34
- * Precedence: the `WEBJS_ELIDE` env override wins when set, otherwise the
35
- * `package.json` `{ "webjs": { "elide": false } }` switch disables
36
- * display-only and inert-route elision app-wide (everything ships, like
37
- * before the feature existed). Any other value, or an absent key, leaves
38
- * elision enabled (the default). Re-read on every rebuild so toggling
39
- * either control takes effect without a server restart.
40
- * @param {string} appDir
41
- * @returns {Promise<boolean>}
42
- */
43
- export async function readElideEnabled(appDir) {
44
- const override = elideEnvOverride();
45
- if (override !== undefined) return override;
46
- try {
47
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
48
- if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
49
- } catch {
50
- // No package.json, malformed JSON, or unreadable. Keep the default.
51
- }
52
- return true;
53
- }
54
-
55
- /**
56
- * Read the `WEBJS_SEED` environment override, if set. Same grammar as
57
- * `WEBJS_ELIDE`: `0`/`false`/`off`/`no` force seeding OFF, `1`/`true`/`on`/`yes`
12
+ * Read the `WEBJS_SEED` environment override, if set. `0`/`false`/`off`/`no`
13
+ * force seeding OFF, `1`/`true`/`on`/`yes`
58
14
  * force it ON, anything else (or unset) falls through to the package.json
59
15
  * switch. The deploy-time / ops escape hatch and the seam tests use to render
60
16
  * an app with seeding on and off in one process.
@@ -73,11 +29,11 @@ function seedEnvOverride() {
73
29
  * Read the project-level SSR action-seeding switch (#472). Precedence: the
74
30
  * `WEBJS_SEED` env override wins when set, otherwise the package.json
75
31
  * `{ "webjs": { "seed": false } }` switch disables it (the client re-fetches on
76
- * hydration as it did before the feature). Default ON (opt-out), mirroring
77
- * elision: any value other than the literal `false` keeps seeding enabled.
32
+ * hydration as it did before the feature). Default ON (opt-out): any value
33
+ * other than the literal `false` keeps seeding enabled.
78
34
  *
79
- * Seeding installs a process-global `module.registerHooks` load hook, so unlike
80
- * elision it is read ONCE at boot (not re-read per rebuild): toggling it needs a
35
+ * Seeding installs a process-global `module.registerHooks` load hook, so it is
36
+ * read ONCE at boot (not re-read per rebuild): toggling it needs a
81
37
  * restart, like a deploy.
82
38
  * @param {string} appDir
83
39
  * @returns {Promise<boolean>}
@@ -101,7 +57,7 @@ export async function readSeedEnabled(appDir) {
101
57
  * `{ "webjs": { "clientRouter": false } }` opts the WHOLE app out (pure MPA,
102
58
  * full-page navigation), which the render path signals to the browser so the
103
59
  * core bundle's module-end auto-enable is skipped. Any other value, or an
104
- * absent key, keeps the router on. Re-read on every rebuild (alongside elide)
60
+ * absent key, keeps the router on. Re-read on every rebuild
105
61
  * so toggling it takes effect without a server restart.
106
62
  * @param {string} appDir
107
63
  * @returns {Promise<boolean>}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The service worker `webjs dev` serves at `/sw.js` (#1689): a kill switch,
3
+ * the default of next-pwa and vite-plugin-pwa in development.
4
+ *
5
+ * A caching worker on a dev origin makes every edit look like it did nothing.
6
+ * The scaffold's `public/sw.js` serves `.js` and `.css` stale-while-revalidate,
7
+ * so a tab that once registered it (a `webjs start` on the same port, an app
8
+ * that registers on every load) is one edit behind forever, and the reload
9
+ * client's own probes go through it too. So under `webjs dev` the `/sw.js` URL
10
+ * answers with this worker whether or not the app has one. It takes over at
11
+ * once, deletes every cache it can see, unregisters itself, and claims the open
12
+ * tabs so the next request goes to the network. It has no `fetch` listener, so
13
+ * it never answers a request even in the moment before it unregisters.
14
+ *
15
+ * Production serves the app's own `public/sw.js` unchanged. Offline behaviour
16
+ * is tested with `npm run start`, which the log line says.
17
+ */
18
+ export const DEV_SERVICE_WORKER_SOURCE = `// webjs dev: the service worker is switched off (#1689)
19
+ self.addEventListener('install', function () { self.skipWaiting(); });
20
+ self.addEventListener('activate', function (event) {
21
+ event.waitUntil((async function () {
22
+ try {
23
+ var keys = await caches.keys();
24
+ await Promise.all(keys.map(function (k) { return caches.delete(k); }));
25
+ } catch (_) { /* no CacheStorage: nothing to clear */ }
26
+ try { await self.registration.unregister(); } catch (_) { /* already gone */ }
27
+ try { await self.clients.claim(); } catch (_) { /* no clients */ }
28
+ console.info('[webjs] the service worker is off under webjs dev; test offline behaviour with npm run start');
29
+ })());
30
+ });
31
+ `;
32
+
33
+ /**
34
+ * The `/sw.js` response under `webjs dev`. `no-store`, so a browser never runs
35
+ * a cached copy, and `Service-Worker-Allowed: /` so it can replace a worker
36
+ * registered at the root scope.
37
+ * @returns {Response}
38
+ */
39
+ export function devServiceWorkerResponse() {
40
+ return new Response(DEV_SERVICE_WORKER_SOURCE, {
41
+ status: 200,
42
+ headers: {
43
+ 'content-type': 'text/javascript; charset=utf-8',
44
+ 'cache-control': 'no-store',
45
+ 'service-worker-allowed': '/',
46
+ },
47
+ });
48
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * In-place server module freshness for `webjs dev` on Node (#1689).
3
+ *
4
+ * Node keys its ESM cache by the full resolved URL and has no API to evict a
5
+ * module. The dev re-import (`dev-import.js`) gives the module the server
6
+ * imports directly a content `?t=` query, so an edited page loads fresh, but
7
+ * every module that page imports comes back from the cache unchanged. That is
8
+ * why the dev supervisor used to restart the whole process on any edit.
9
+ *
10
+ * This module makes the restart unnecessary with a `module.registerHooks`
11
+ * RESOLVE hook and no source rewriting. The hook calls `nextResolve` first,
12
+ * then, for an app ESM module, records the edge `importer -> module` and, when
13
+ * the module has been bumped, returns its URL with `webjs-v=<n>` set. A rebuild
14
+ * calls `bumpModuleVersions(changed)`, which gives the edited files and every
15
+ * transitive importer the hook has seen ONE new number. The next request's
16
+ * import of the entry then resolves its whole static chain again: edited
17
+ * modules and their importers evaluate fresh, and every other module keeps its
18
+ * instance and its module-scope state (a session map, a `cache()` store, a
19
+ * counter, an open connection).
20
+ *
21
+ * Why a resolve hook rather than a load hook that rewrites import specifiers
22
+ * (the Remix `node-hmr` shape): the resolve hook sees every edge Node actually
23
+ * links, static, `export from`, string and computed dynamic `import()`, `#`
24
+ * aliases and `.js`-to-`.ts`, and nothing touches source bytes, so stack
25
+ * positions and source maps stay exact. Edges come from the hook, never from
26
+ * the analysis graph, because the hook's edges are exactly the modules that
27
+ * have instances to invalidate, including `db/`, `route.ts`, middleware and
28
+ * `*.server.*` files the browser graph never walks.
29
+ *
30
+ * Every other query on the URL is kept (`?t=` from `devImport`, the seed
31
+ * facade's `webjs-seed-orig`), so the seed facade and the original it imports
32
+ * carry the same `webjs-v` and move together.
33
+ *
34
+ * State lives on `globalThis` under a registered symbol, so a second handler in
35
+ * one process (tests) shares it with the hook. Old module instances stay in
36
+ * Node's cache for the life of the dev process, the trade Remix accepts too;
37
+ * `devImport` stays content-keyed, so an idle tab adds nothing.
38
+ *
39
+ * Bun has no resolve hook, so there the same state is filled and read by the
40
+ * dev source plugins instead (`installBunModuleVersions`): every app module
41
+ * Bun loads in dev goes through a `Bun.plugin` `onLoad`, which records the
42
+ * module's import edges from its source and rewrites the specifier of a bumped
43
+ * target to its absolute path plus `?webjs-v=<n>` (`bun-app-source.js`), and
44
+ * `devImportSpecifier` adds the version to an entry. Bun keys a plain path's
45
+ * module by the query too, so the result is the same as on Node: the edited
46
+ * chain evaluates fresh and an untouched module keeps its instance, with no
47
+ * `bun --hot` registry reset. A runtime with neither keeps the dev supervisor's
48
+ * restart.
49
+ */
50
+ import * as nodeModule from 'node:module';
51
+ import { realpathSync, statSync } from 'node:fs';
52
+ import { fileURLToPath } from 'node:url';
53
+ import { basename, dirname, join, relative, isAbsolute } from 'node:path';
54
+
55
+ /** What the hook may version: an app ESM module. */
56
+ const MODULE_FILE = /\.(?:m?[jt]s|json)$/;
57
+
58
+ /**
59
+ * @typedef {{
60
+ * roots: Set<string>,
61
+ * versions: Map<string, number>,
62
+ * importers: Map<string, Set<string>>,
63
+ * next: number,
64
+ * installed: boolean,
65
+ * loaded?: Map<string, string>,
66
+ * commonjs?: Set<string>,
67
+ * }} EsmHotState
68
+ */
69
+
70
+ /** @returns {EsmHotState} */
71
+ function state() {
72
+ const g = /** @type {any} */ (globalThis);
73
+ const key = Symbol.for('webjs.dev.esmHot');
74
+ if (!g[key]) {
75
+ g[key] = { roots: new Set(), versions: new Map(), importers: new Map(), next: 1, installed: false };
76
+ }
77
+ return g[key];
78
+ }
79
+
80
+ /**
81
+ * Whether an absolute path is an app module the hook versions: under a
82
+ * registered app root, outside its `node_modules` and `.webjs`, with a module
83
+ * extension.
84
+ * @param {string} abs
85
+ * @param {EsmHotState} st
86
+ */
87
+ function isAppModule(abs, st) {
88
+ if (!MODULE_FILE.test(abs)) return false;
89
+ for (const root of st.roots) {
90
+ const rel = relative(root, abs);
91
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
92
+ if (/(?:^|[\\/])(?:node_modules|\.webjs)(?:[\\/]|$)/.test(rel)) return false;
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Whether an absolute path is an app file of ANY extension (under a registered
100
+ * root, outside its `node_modules` and `.webjs`): what a CommonJS load is
101
+ * recorded for, `.cjs` and a `require`d `.json` included.
102
+ * @param {string} abs
103
+ * @param {EsmHotState} st
104
+ */
105
+ function isAppFile(abs, st) {
106
+ for (const root of st.roots) {
107
+ const rel = relative(root, abs);
108
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
109
+ return !/(?:^|[\\/])(?:node_modules|\.webjs)(?:[\\/]|$)/.test(rel);
110
+ }
111
+ return false;
112
+ }
113
+
114
+ /**
115
+ * Note an app file the CommonJS loader holds (#1689). No version can reach
116
+ * it: `require` caches by filename, so a query changes nothing, and an ESM
117
+ * import of a CommonJS module resolves to a format the hook leaves alone. An
118
+ * edit to one needs a fresh process, so the dev server asks the `webjs dev`
119
+ * supervisor for a restart (`isCommonJsModule`).
120
+ * @param {string} abs
121
+ * @param {EsmHotState} st
122
+ */
123
+ function recordCommonJs(abs, st) {
124
+ if (!st.commonjs) st.commonjs = new Set();
125
+ if (st.commonjs.has(abs)) return;
126
+ st.commonjs.add(abs);
127
+ }
128
+
129
+ /**
130
+ * Whether the CommonJS loader holds `abs` in this process, so an edit to it
131
+ * cannot apply in place and needs a restart. Always false on Bun, where a
132
+ * `bun --hot` registry reset reloads it.
133
+ * @param {string} abs
134
+ * @returns {boolean}
135
+ */
136
+ export function isCommonJsModule(abs) {
137
+ const st = state();
138
+ return !!st.commonjs && st.commonjs.has(abs);
139
+ }
140
+
141
+ /** The path of a `file:` URL with its query dropped, or null. @param {string | undefined} url */
142
+ function pathOf(url) {
143
+ if (typeof url !== 'string' || !url.startsWith('file:')) return null;
144
+ return fileURLToPath(new URL(url));
145
+ }
146
+
147
+ /**
148
+ * The resolve hook. Fail-open: anything it cannot handle returns
149
+ * `nextResolve`'s result unchanged, and an error from `nextResolve` itself (a
150
+ * file that does not exist yet) propagates as is. Node does not keep a link
151
+ * that failed on resolution, so once the file exists the unchanged importer
152
+ * links again with no version of its own.
153
+ * @param {string} specifier
154
+ * @param {{ parentURL?: string, conditions?: string[] }} context
155
+ * @param {(s: string, c: any) => { url: string, format?: string | null }} nextResolve
156
+ */
157
+ function resolveHook(specifier, context, nextResolve) {
158
+ const result = nextResolve(specifier, context);
159
+ try {
160
+ if (!result || typeof result.url !== 'string' || !result.url.startsWith('file:')) return result;
161
+ const st = state();
162
+ const url = new URL(result.url);
163
+ const abs = fileURLToPath(url);
164
+ // A module the CommonJS loader holds (a `.cjs` file, a `require`d one) is
165
+ // recorded and left alone: no version reaches it.
166
+ const conditions = context && Array.isArray(/** @type {any} */ (context).conditions) ? /** @type {any} */ (context).conditions : [];
167
+ if ((typeof result.format === 'string' && result.format.startsWith('commonjs')) || conditions.includes('require')) {
168
+ if (isAppFile(abs, st)) recordCommonJs(abs, st);
169
+ return result;
170
+ }
171
+ if (!isAppModule(abs, st)) return result;
172
+ const parent = pathOf(context && context.parentURL);
173
+ if (parent && parent !== abs && isAppModule(parent, st)) {
174
+ let set = st.importers.get(abs);
175
+ if (!set) st.importers.set(abs, (set = new Set()));
176
+ set.add(parent);
177
+ }
178
+ const n = st.versions.get(abs) || 0;
179
+ if (!n) return result;
180
+ url.searchParams.set('webjs-v', String(n));
181
+ return { ...result, url: url.href };
182
+ } catch {
183
+ return result;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Install the resolve hook for an app root. Idempotent: the hook registers once
189
+ * per process and each call adds a root. Returns false, installing nothing, on
190
+ * Bun and on a Node with no `module.registerHooks`.
191
+ * @param {string} appDir absolute app root
192
+ * @returns {boolean}
193
+ */
194
+ export function installEsmHotHooks(appDir) {
195
+ if (process.versions.bun) return false;
196
+ if (typeof nodeModule.registerHooks !== 'function') return false;
197
+ const st = state();
198
+ st.roots.add(appDir);
199
+ if (!st.installed) {
200
+ nodeModule.registerHooks({ resolve: resolveHook });
201
+ st.installed = true;
202
+ }
203
+ return true;
204
+ }
205
+
206
+ /**
207
+ * Turn on module versions for an app root on Bun, where the dev source plugins
208
+ * record the edges and stamp the versions (`bun-app-source.js`) because there
209
+ * is no resolve hook. Idempotent. Returns false, doing nothing, off Bun.
210
+ * @param {string} appDir absolute app root
211
+ * @returns {boolean}
212
+ */
213
+ export function installBunModuleVersions(appDir) {
214
+ if (!process.versions.bun) return false;
215
+ const st = state();
216
+ st.roots.add(appDir);
217
+ st.installed = true;
218
+ if (!st.loaded) st.loaded = new Map();
219
+ return true;
220
+ }
221
+
222
+ /** A file's identity on disk, or '' when it is gone. @param {string} abs */
223
+ export function diskStamp(abs) {
224
+ try {
225
+ const s = statSync(abs);
226
+ return `${s.ino}:${s.size}:${s.mtimeMs}`;
227
+ } catch {
228
+ return '';
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Note that the Bun source plugins just served `abs` as it is on disk now.
234
+ * A rebuild compares every noted file against the disk (`movedServedModules`,
235
+ * called from the dev handler's rebuild), because Bun's `fs.watch` (1.3.14) drops the event for a second file written
236
+ * a few milliseconds after another in the same directory, so an agent saving
237
+ * two files at once would otherwise leave the second one stale for good. The
238
+ * `bun --hot` registry reset used to hide that, since it re-evaluated
239
+ * everything. A no-op where module versions are off or not on Bun.
240
+ * @param {string} abs
241
+ */
242
+ export function recordModuleLoad(abs) {
243
+ const st = state();
244
+ if (st.loaded && isAppModule(abs, st)) st.loaded.set(abs, diskStamp(abs));
245
+ }
246
+
247
+ /**
248
+ * Record that `importer` imports `target`, the edge a bump walks up. Only an
249
+ * edge between two app modules is kept. The Bun source plugins call this for
250
+ * each import specifier of a module they serve.
251
+ * @param {string} importer absolute path
252
+ * @param {string} target absolute path
253
+ */
254
+ export function recordModuleImport(importer, target) {
255
+ const st = state();
256
+ if (!st.installed || importer === target || !isAppModule(importer, st) || !isAppModule(target, st)) return;
257
+ let set = st.importers.get(target);
258
+ if (!set) st.importers.set(target, (set = new Set()));
259
+ set.add(importer);
260
+ }
261
+
262
+ /**
263
+ * Whether an absolute path is a module this process versions in place: module
264
+ * versions are on and the path is an app module under a registered root.
265
+ * @param {string} abs
266
+ * @returns {boolean}
267
+ */
268
+ export function isVersionedModule(abs) {
269
+ const st = state();
270
+ return st.installed && isAppModule(abs, st);
271
+ }
272
+
273
+ /**
274
+ * Every module the Bun source plugins served whose disk stamp moved since, the
275
+ * ones Bun's `fs.watch` may have reported no event for (see
276
+ * `recordModuleLoad`). Each returned file's stamp is updated, so a later
277
+ * rebuild does not report the same change again. Empty off Bun.
278
+ *
279
+ * `dirs` limits the scan to modules directly in those directories. The event
280
+ * Bun drops is always a sibling of one it reported, written in the same
281
+ * directory, so the rebuild passes the directories of its changed files and
282
+ * stats a handful of modules instead of every one the process ever served.
283
+ * @param {Set<string>} [dirs]
284
+ * @returns {string[]}
285
+ */
286
+ export function movedServedModules(dirs) {
287
+ const st = state();
288
+ /** @type {string[]} */
289
+ const moved = [];
290
+ if (!st.loaded) return moved;
291
+ for (const [f, stamp] of st.loaded) {
292
+ if (dirs && !dirs.has(dirname(f))) continue;
293
+ const now = diskStamp(f);
294
+ if (now !== stamp) { st.loaded.set(f, now); moved.push(f); }
295
+ }
296
+ return moved;
297
+ }
298
+
299
+ /**
300
+ * Whether the Bun source plugins have served `abs` in this process, so an edit
301
+ * to it can be versioned in place. A file never served (a new one above all)
302
+ * is not: Bun's resolver may still hold a directory listing without it, which
303
+ * only a registry reset clears.
304
+ * @param {string} abs
305
+ * @returns {boolean}
306
+ */
307
+ export function isServedModule(abs) {
308
+ const st = state();
309
+ return !!st.loaded && st.loaded.has(abs);
310
+ }
311
+
312
+ /** Whether module versions are on in this process (the Node resolve hook, or Bun's source plugins). @returns {boolean} */
313
+ export function hotHooksInstalled() {
314
+ return state().installed;
315
+ }
316
+
317
+ /**
318
+ * The version the hook stamps on a module, 0 when it was never bumped.
319
+ * @param {string} abs
320
+ * @returns {number}
321
+ */
322
+ export function moduleVersion(abs) {
323
+ return state().versions.get(abs) || 0;
324
+ }
325
+
326
+ /**
327
+ * The spellings a changed path can have in the hook's maps. The watcher
328
+ * reports a path under the app dir as given, while Node resolves module URLs to
329
+ * real paths, so a symlinked app dir needs both. A deleted file has no
330
+ * realpath, so its directory's is used.
331
+ * @param {string} abs
332
+ * @returns {string[]}
333
+ */
334
+ function spellings(abs) {
335
+ const out = [abs];
336
+ try {
337
+ const real = realpathSync(abs);
338
+ if (real !== abs) out.push(real);
339
+ } catch {
340
+ try {
341
+ const real = join(realpathSync(dirname(abs)), basename(abs));
342
+ if (real !== abs) out.push(real);
343
+ } catch { /* the directory is gone too */ }
344
+ }
345
+ return out;
346
+ }
347
+
348
+ /**
349
+ * Give the changed files and every transitive importer the hook has recorded
350
+ * ONE new version. The next import of any of them gets a new URL and evaluates
351
+ * fresh; everything else keeps its instance. Only app modules are versioned. A
352
+ * no-op returning an empty set when the hook is not installed.
353
+ * @param {Iterable<string>} absFiles absolute paths of the changed files
354
+ * @returns {Set<string>} every path that got the new version
355
+ */
356
+ export function bumpModuleVersions(absFiles) {
357
+ const st = state();
358
+ /** @type {Set<string>} */
359
+ const bumped = new Set();
360
+ if (!st.installed) return bumped;
361
+ const n = st.next++;
362
+ const stack = [];
363
+ for (const f of absFiles) stack.push(...spellings(f));
364
+ while (stack.length) {
365
+ const f = /** @type {string} */ (stack.pop());
366
+ if (bumped.has(f) || !isAppModule(f, st)) continue;
367
+ bumped.add(f);
368
+ st.versions.set(f, n);
369
+ const importers = st.importers.get(f);
370
+ if (importers) for (const i of importers) stack.push(i);
371
+ }
372
+ return bumped;
373
+ }