@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
|
@@ -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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev server's "has my edit settled, and did it break anything" state
|
|
3
|
+
* (#1664), behind `GET /__webjs/dev/status` and the `webjs status` command.
|
|
4
|
+
*
|
|
5
|
+
* An agent editing a running app could not tell when the reload finished or
|
|
6
|
+
* whether it errored, so it slept a few seconds, fetched a page, and grepped
|
|
7
|
+
* the HTML for "Server error". The reload frame does not answer it either: it
|
|
8
|
+
* is sent before anything is re-imported, so a syntax error only surfaces on
|
|
9
|
+
* the next request.
|
|
10
|
+
*
|
|
11
|
+
* This module holds two things the handler feeds:
|
|
12
|
+
*
|
|
13
|
+
* - A short ring of recent errors: render, TS strip, rebuild, route,
|
|
14
|
+
* action, middleware, metadata, and request-pipeline failures, each as a
|
|
15
|
+
* dev error frame (message, file, line, code frame). The overlay keeps only
|
|
16
|
+
* the newest one, and never saw an action error at all. A rebuild clears
|
|
17
|
+
* every kind EXCEPT `render`: a page broken by an edit stays broken across
|
|
18
|
+
* an unrelated rebuild, so its error stays listed until that page renders
|
|
19
|
+
* again (`renderSucceeded`).
|
|
20
|
+
* - Whether a change is still in flight: a watcher event not yet rebuilt, or
|
|
21
|
+
* a rebuild still running. The handler adds readiness on top, since the
|
|
22
|
+
* lazy analysis is what actually re-imports the app.
|
|
23
|
+
*
|
|
24
|
+
* Dev only. Nothing here runs in production, where the endpoint is a 404.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** How many recent errors the ring keeps. */
|
|
30
|
+
export const DEV_STATUS_MAX_ERRORS = 20;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How long a watcher event may stay pending without a rebuild before it is
|
|
34
|
+
* treated as settled anyway (an event that classified to nothing, a poke that
|
|
35
|
+
* never re-ran), so a status wait can never hang on a lost signal.
|
|
36
|
+
*/
|
|
37
|
+
export const PENDING_CHANGE_TTL_MS = 10_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {{
|
|
41
|
+
* kind: string, message: string, file?: string | null, line?: number | null,
|
|
42
|
+
* column?: number | null, codeFrame?: string | null, hint?: string | null,
|
|
43
|
+
* url?: string | null, at: string,
|
|
44
|
+
* }} DevStatusError
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {{ now?: () => number }} [opts] `now` is injectable for tests.
|
|
49
|
+
*/
|
|
50
|
+
export function createDevStatus({ now = Date.now } = {}) {
|
|
51
|
+
/** @type {DevStatusError[]} */
|
|
52
|
+
let errors = [];
|
|
53
|
+
let rebuilds = 0;
|
|
54
|
+
let rebuildsInFlight = 0;
|
|
55
|
+
/** @type {number | null} */
|
|
56
|
+
let changeAt = null;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
/**
|
|
60
|
+
* Record an error frame (the shape `buildDevErrorFrame` returns).
|
|
61
|
+
* @param {Record<string, unknown>} frame
|
|
62
|
+
*/
|
|
63
|
+
record(frame) {
|
|
64
|
+
if (!frame || typeof frame !== 'object') return;
|
|
65
|
+
const entry = /** @type {DevStatusError} */ ({
|
|
66
|
+
kind: String(frame.kind || 'error'),
|
|
67
|
+
message: String(frame.message || ''),
|
|
68
|
+
file: /** @type {any} */ (frame.file) ?? null,
|
|
69
|
+
line: /** @type {any} */ (frame.line) ?? null,
|
|
70
|
+
column: /** @type {any} */ (frame.column) ?? null,
|
|
71
|
+
codeFrame: /** @type {any} */ (frame.codeFrame) ?? null,
|
|
72
|
+
hint: /** @type {any} */ (frame.hint) ?? null,
|
|
73
|
+
url: /** @type {any} */ (frame.url) ?? null,
|
|
74
|
+
at: new Date(now()).toISOString(),
|
|
75
|
+
});
|
|
76
|
+
// The same failure seen again (a broken page requested twice) is one
|
|
77
|
+
// entry; the newer one replaces it, so `at` is the latest sighting.
|
|
78
|
+
errors = errors.filter((e) => !(e.kind === entry.kind && e.message === entry.message
|
|
79
|
+
&& e.file === entry.file && e.line === entry.line && e.url === entry.url));
|
|
80
|
+
errors.push(entry);
|
|
81
|
+
if (errors.length > DEV_STATUS_MAX_ERRORS) errors = errors.slice(-DEV_STATUS_MAX_ERRORS);
|
|
82
|
+
},
|
|
83
|
+
/** A watcher saw an app change that a rebuild will follow. */
|
|
84
|
+
noteChange() { changeAt = now(); },
|
|
85
|
+
/**
|
|
86
|
+
* A rebuild started. It builds every change noted so far, so the pending
|
|
87
|
+
* marker clears HERE; a change noted while it runs sets it again and keeps
|
|
88
|
+
* the status unsettled until the rebuild that change schedules.
|
|
89
|
+
*/
|
|
90
|
+
rebuildStarted() { rebuildsInFlight++; changeAt = null; },
|
|
91
|
+
/**
|
|
92
|
+
* A rebuild finished.
|
|
93
|
+
* @param {{ cleared?: boolean }} [opts] `cleared` drops the recorded errors
|
|
94
|
+
* that described the build just replaced. Render errors are kept: only a
|
|
95
|
+
* successful render of that page proves it was fixed.
|
|
96
|
+
*/
|
|
97
|
+
rebuildFinished({ cleared = true } = {}) {
|
|
98
|
+
rebuildsInFlight = Math.max(0, rebuildsInFlight - 1);
|
|
99
|
+
rebuilds++;
|
|
100
|
+
if (cleared) errors = errors.filter((e) => e.kind === 'render');
|
|
101
|
+
},
|
|
102
|
+
/**
|
|
103
|
+
* A page rendered without a server error, so its recorded render error is
|
|
104
|
+
* stale. Each candidate is matched with its query string ignored.
|
|
105
|
+
* @param {Array<string | null | undefined>} urls the page's path, and its
|
|
106
|
+
* public url (base path included) as the overlay stamps it
|
|
107
|
+
*/
|
|
108
|
+
renderSucceeded(urls) {
|
|
109
|
+
const paths = new Set(urls.filter(Boolean).map((u) => String(u).split('?')[0]));
|
|
110
|
+
errors = errors.filter((e) => !(e.kind === 'render' && e.url && paths.has(String(e.url).split('?')[0])));
|
|
111
|
+
},
|
|
112
|
+
/** True while a change is waiting on, or inside, a rebuild. */
|
|
113
|
+
changing() {
|
|
114
|
+
if (rebuildsInFlight > 0) return true;
|
|
115
|
+
if (changeAt === null) return false;
|
|
116
|
+
if (now() - changeAt > PENDING_CHANGE_TTL_MS) { changeAt = null; return false; }
|
|
117
|
+
return true;
|
|
118
|
+
},
|
|
119
|
+
/** @returns {{ rebuilds: number, errors: DevStatusError[] }} */
|
|
120
|
+
snapshot() { return { rebuilds, errors: errors.slice() }; },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -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
|
+
}
|