@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.
- package/README.md +4 -1
- package/index.d.ts +793 -125
- 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 +97 -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/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 +269 -0
- package/src/core-exports.js +190 -0
- package/src/csrf.js +21 -3
- 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 +142 -66
- 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 +71 -2
- package/src/testing.js +103 -21
- 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/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 `
|
|
13
|
-
*
|
|
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)
|
|
77
|
-
*
|
|
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
|
|
80
|
-
*
|
|
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
|
|
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
|
+
}
|