@webjsdev/server 0.8.86 → 0.8.88
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/package.json +1 -1
- package/src/action-middleware.js +2 -1
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +30 -24
- package/src/action-signal.js +2 -1
- package/src/broadcast.js +2 -1
- package/src/bun-transpiler-cache.js +64 -0
- package/src/cache.js +11 -5
- package/src/context.js +2 -1
- package/src/dev/bun-app-source.js +35 -2
- package/src/dev/handler.js +4 -7
- package/src/dev/server.js +6 -1
- package/src/dev/watch-ignore.js +124 -0
- package/src/html-cache.js +9 -5
- package/src/instrumentation.js +2 -1
- package/src/process-shared.js +34 -0
- package/src/session.js +2 -1
- package/src/ssr/head.js +33 -11
- package/src/dev/bun-alias-resolve.js +0 -71
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.88",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/action-middleware.js
CHANGED
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
* The framework ships no middleware; it only runs the chain. Server-only.
|
|
23
23
|
*/
|
|
24
24
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
25
|
+
import { shared } from './process-shared.js';
|
|
25
26
|
|
|
26
27
|
/** Per-request accumulated context, readable by the action via actionContext(). */
|
|
27
|
-
const als = new AsyncLocalStorage();
|
|
28
|
+
const als = shared('action-middleware-als', () => new AsyncLocalStorage());
|
|
28
29
|
|
|
29
30
|
/** Shared empty context returned outside an action (a direct server-to-server call). */
|
|
30
31
|
const EMPTY = Object.freeze({});
|
package/src/action-seed-bun.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* serverFileRe: RegExp,
|
|
37
37
|
* }} helpers
|
|
38
38
|
*/
|
|
39
|
-
export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe }) {
|
|
39
|
+
export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe, rewriteAliases = (s) => s }) {
|
|
40
40
|
Bun.plugin({
|
|
41
41
|
name: 'webjs-action-seed',
|
|
42
42
|
setup(build) {
|
|
@@ -60,7 +60,7 @@ export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverF
|
|
|
60
60
|
// Fail-open: any faceting error serves the raw source (no seeding for
|
|
61
61
|
// this module), the Bun analog of the Node hook's `nextLoad` fallback.
|
|
62
62
|
}
|
|
63
|
-
return { contents: src, loader };
|
|
63
|
+
return { contents: rewriteAliases(src, absPath), loader };
|
|
64
64
|
});
|
|
65
65
|
},
|
|
66
66
|
});
|
package/src/action-seed.js
CHANGED
|
@@ -69,18 +69,21 @@ import { hashFile } from './actions.js';
|
|
|
69
69
|
import { isStreamable } from './action-stream.js';
|
|
70
70
|
import { serverRuntime } from './listener-core.js';
|
|
71
71
|
import { redactStringsAndTemplates } from './js-scan.js';
|
|
72
|
+
import { shared } from './process-shared.js';
|
|
72
73
|
|
|
73
74
|
/** Ambient per-render seed collector. `Map<key, value>` or undefined. */
|
|
74
|
-
const als = new AsyncLocalStorage();
|
|
75
|
+
const als = shared('action-seed-als', () => new AsyncLocalStorage());
|
|
75
76
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The hook's switches, shared by every copy of this module in the process
|
|
79
|
+
* (#1590): under `bun --hot` an app's `'use server'` facade imports a fresh copy
|
|
80
|
+
* after a reload, and it must see the switches the running server set.
|
|
81
|
+
* seedEnabled: whether seed COLLECTION is on (the `webjs.seed` switch).
|
|
82
|
+
* devMode: dev mode, threaded from `dev.js` at boot; gates the determinism assertion.
|
|
83
|
+
* hookInstalled: whether the load hook is installed at all (so identity is available).
|
|
84
|
+
* registered: idempotency guard, `module.registerHooks` must run at most once.
|
|
85
|
+
*/
|
|
86
|
+
const _flags = shared('action-seed-flags', () => ({ seedEnabled: false, devMode: false, hookInstalled: false, registered: false }));
|
|
84
87
|
|
|
85
88
|
/**
|
|
86
89
|
* `action function -> { file, fnName }` for every export the hook wrapped.
|
|
@@ -103,10 +106,10 @@ let _registered = false;
|
|
|
103
106
|
*
|
|
104
107
|
* @type {WeakMap<Function, { file: string, fnName: string }[]>}
|
|
105
108
|
*/
|
|
106
|
-
const _identity = new WeakMap();
|
|
109
|
+
const _identity = shared('action-identity', () => new WeakMap());
|
|
107
110
|
|
|
108
111
|
/** Memoized `absPath -> hash` so a hot action call does not re-hash per call. */
|
|
109
|
-
const _hashCache = new Map();
|
|
112
|
+
const _hashCache = shared('action-hash-cache', () => new Map());
|
|
110
113
|
|
|
111
114
|
/** This module's own URL, embedded into the generated facade's import. */
|
|
112
115
|
const SELF_URL = import.meta.url;
|
|
@@ -118,7 +121,7 @@ const SELF_URL = import.meta.url;
|
|
|
118
121
|
* @returns {boolean}
|
|
119
122
|
*/
|
|
120
123
|
export function seedingEnabled() {
|
|
121
|
-
return
|
|
124
|
+
return _flags.seedEnabled && _flags.hookInstalled;
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
/**
|
|
@@ -128,7 +131,7 @@ export function seedingEnabled() {
|
|
|
128
131
|
* @returns {boolean}
|
|
129
132
|
*/
|
|
130
133
|
export function identityHookInstalled() {
|
|
131
|
-
return
|
|
134
|
+
return _flags.hookInstalled;
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
/**
|
|
@@ -204,7 +207,7 @@ async function recordSeed(collector, file, fnName, args, value) {
|
|
|
204
207
|
// legitimate second call with different arguments has a different key and
|
|
205
208
|
// cannot false-fire. In its OWN try/catch so a diagnostic failure can never
|
|
206
209
|
// skip `collector.set` and turn observability into a dropped seed.
|
|
207
|
-
if (
|
|
210
|
+
if (_flags.devMode && collector.has(key)) {
|
|
208
211
|
try { await assertDeterministic(collector.get(key), value, hash, fnName); } catch { /* never affect the seed */ }
|
|
209
212
|
}
|
|
210
213
|
collector.set(key, value);
|
|
@@ -214,7 +217,7 @@ async function recordSeed(collector, file, fnName, args, value) {
|
|
|
214
217
|
}
|
|
215
218
|
|
|
216
219
|
/** Warned-once ids, keyed `hash/fn` so the Set is bounded by the action count. */
|
|
217
|
-
const _nonDeterministic = new Set();
|
|
220
|
+
const _nonDeterministic = shared('action-seed-nondeterministic', () => new Set());
|
|
218
221
|
|
|
219
222
|
/**
|
|
220
223
|
* Warn (once per action function) when one render recorded two DIFFERENT results
|
|
@@ -276,7 +279,7 @@ export function __actionWrap(file, fnName, orig) {
|
|
|
276
279
|
const list = _identity.get(orig) || [];
|
|
277
280
|
if (!list.some((e) => e.file === file && e.fnName === fnName)) list.push({ file, fnName });
|
|
278
281
|
_identity.set(orig, list);
|
|
279
|
-
if (!
|
|
282
|
+
if (!_flags.seedEnabled) return orig;
|
|
280
283
|
const wrapped = seedProxy(file, fnName, orig);
|
|
281
284
|
// The wrapper is a fresh object every time, so it shares the TARGET's list
|
|
282
285
|
// rather than starting one of its own.
|
|
@@ -644,10 +647,10 @@ function seedLoadHook(url, context, nextLoad) {
|
|
|
644
647
|
* @returns {Promise<void>}
|
|
645
648
|
*/
|
|
646
649
|
export async function registerActionHooks(opts = {}) {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (
|
|
650
|
-
|
|
650
|
+
_flags.seedEnabled = opts.seed !== false;
|
|
651
|
+
_flags.devMode = opts.dev === true;
|
|
652
|
+
if (_flags.registered) return;
|
|
653
|
+
_flags.registered = true;
|
|
651
654
|
|
|
652
655
|
if (serverRuntime() === 'bun') {
|
|
653
656
|
// Once per PROCESS, not per module instance (#1575): `bun --hot` re-runs
|
|
@@ -656,12 +659,15 @@ export async function registerActionHooks(opts = {}) {
|
|
|
656
659
|
// edit (the first one registered keeps answering anyway).
|
|
657
660
|
const g = /** @type {any} */ (globalThis);
|
|
658
661
|
const installed = Symbol.for('webjs.actionSeed.bunPlugin');
|
|
659
|
-
if (g[installed]) {
|
|
662
|
+
if (g[installed]) { _flags.hookInstalled = true; return; }
|
|
660
663
|
g[installed] = true;
|
|
661
664
|
// Bun has no module.registerHooks; install the same facade via Bun.plugin.
|
|
662
665
|
const { installBunSeedPlugin } = await import('./action-seed-bun.js');
|
|
663
|
-
|
|
664
|
-
|
|
666
|
+
// Dev rewrites the app's `#` imports to absolute paths (#1575); the
|
|
667
|
+
// rewrite reads the app roots dev registered, so it is a no-op in prod.
|
|
668
|
+
const { rewriteAppAliases } = await import('./dev/bun-app-source.js');
|
|
669
|
+
installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE, rewriteAliases: rewriteAppAliases });
|
|
670
|
+
_flags.hookInstalled = true;
|
|
665
671
|
return;
|
|
666
672
|
}
|
|
667
673
|
|
|
@@ -676,7 +682,7 @@ export async function registerActionHooks(opts = {}) {
|
|
|
676
682
|
}
|
|
677
683
|
|
|
678
684
|
nodeModule.registerHooks({ load: seedLoadHook });
|
|
679
|
-
|
|
685
|
+
_flags.hookInstalled = true;
|
|
680
686
|
}
|
|
681
687
|
|
|
682
688
|
/**
|
package/src/action-signal.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* `fetch(url, { signal: actionSignal() })` line is safe everywhere.
|
|
10
10
|
*/
|
|
11
11
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
12
|
+
import { shared } from './process-shared.js';
|
|
12
13
|
|
|
13
|
-
const als = new AsyncLocalStorage();
|
|
14
|
+
const als = shared('action-signal-als', () => new AsyncLocalStorage());
|
|
14
15
|
|
|
15
16
|
/** A shared signal that never aborts, returned outside an action. */
|
|
16
17
|
const NEVER = (() => {
|
package/src/broadcast.js
CHANGED
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
*
|
|
18
18
|
* @module broadcast
|
|
19
19
|
*/
|
|
20
|
+
import { shared } from './process-shared.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Per-path WebSocket client registry.
|
|
23
24
|
* @type {Map<string, Set<import('ws').WebSocket>>}
|
|
24
25
|
*/
|
|
25
|
-
const pathClients = new Map();
|
|
26
|
+
const pathClients = shared('broadcast-clients', () => new Map());
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Register a WebSocket client for a path. Called internally by the
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evict Bun transpiler-cache entries written by @webjsdev/server 0.8.85 and
|
|
3
|
+
* 0.8.86 (#1575).
|
|
4
|
+
*
|
|
5
|
+
* Those versions resolved the app's `#` imports through a Bun runtime
|
|
6
|
+
* `onResolve` in dev. Bun caches the transpile of every source over 50KB on
|
|
7
|
+
* disk, keyed by the file's CONTENT, with the resolved import paths baked in,
|
|
8
|
+
* so a large app module kept a broken `file:/...` path (or an absolute path
|
|
9
|
+
* into whichever checkout first ran it) after the fix: same bytes, same key,
|
|
10
|
+
* same poisoned output, a 500 on every request that reaches it. Nothing in a
|
|
11
|
+
* fixed version writes such an entry any more, but the old entries outlive the
|
|
12
|
+
* upgrade.
|
|
13
|
+
*
|
|
14
|
+
* The cache is only a cache, so the cure is to empty it once per machine: the
|
|
15
|
+
* first WebJs server on Bun at or after this version removes the entries and
|
|
16
|
+
* leaves a marker beside them, and every later start sees the marker and does
|
|
17
|
+
* nothing. Bun rebuilds what it needs on the next load.
|
|
18
|
+
*
|
|
19
|
+
* @module bun-transpiler-cache
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const MARKER = 'webjs-1575-evicted';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The directory Bun keeps its runtime transpiler cache in, or null when the
|
|
29
|
+
* cache is off (`BUN_RUNTIME_TRANSPILER_CACHE_PATH` set to `0` or empty).
|
|
30
|
+
* @param {NodeJS.ProcessEnv} env
|
|
31
|
+
* @param {string} home
|
|
32
|
+
* @returns {string | null}
|
|
33
|
+
*/
|
|
34
|
+
export function bunTranspilerCacheDir(env = process.env, home = homedir()) {
|
|
35
|
+
const set = env.BUN_RUNTIME_TRANSPILER_CACHE_PATH;
|
|
36
|
+
if (set !== undefined) return set === '0' || set === '' ? null : set;
|
|
37
|
+
return join(env.XDG_CACHE_HOME || join(home, '.cache'), 'bun', '@t@');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Empty the Bun transpiler cache once per machine (see the module comment).
|
|
42
|
+
* Never throws: a cache it cannot read or write is left alone.
|
|
43
|
+
* @param {{ env?: NodeJS.ProcessEnv, home?: string, isBun?: boolean }} [opts]
|
|
44
|
+
* @returns {number} entries removed (0 when there was nothing to do)
|
|
45
|
+
*/
|
|
46
|
+
export function evictPoisonedBunTranspilerCache({ env = process.env, home = homedir(), isBun = !!process.versions.bun } = {}) {
|
|
47
|
+
if (!isBun) return 0;
|
|
48
|
+
const dir = bunTranspilerCacheDir(env, home);
|
|
49
|
+
if (!dir) return 0;
|
|
50
|
+
const marker = join(dirname(dir), MARKER);
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(marker)) return 0;
|
|
53
|
+
let removed = 0;
|
|
54
|
+
if (existsSync(dir)) {
|
|
55
|
+
for (const name of readdirSync(dir)) {
|
|
56
|
+
try { rmSync(join(dir, name), { recursive: true, force: true }); removed++; } catch { /* in use; leave it */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
writeFileSync(marker, `emptied by @webjsdev/server at ${new Date().toISOString()} (#1575)\n`);
|
|
60
|
+
return removed;
|
|
61
|
+
} catch {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/cache.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* @module cache
|
|
16
16
|
*/
|
|
17
|
+
import { shared } from './process-shared.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* @typedef {Object} CacheStore
|
|
@@ -228,8 +229,13 @@ export function redisStore(opts = {}) {
|
|
|
228
229
|
};
|
|
229
230
|
}
|
|
230
231
|
|
|
231
|
-
/**
|
|
232
|
-
|
|
232
|
+
/**
|
|
233
|
+
* The default store lives in process-shared state (#1590), so an app module
|
|
234
|
+
* that imported a fresh copy of this package after a `bun --hot` reload reads
|
|
235
|
+
* and writes the same cache as the server.
|
|
236
|
+
* @type {{ store: CacheStore | null }}
|
|
237
|
+
*/
|
|
238
|
+
const _defaultStore = shared('cache-default-store', () => ({ store: null }));
|
|
233
239
|
|
|
234
240
|
/**
|
|
235
241
|
* Get the default cache store. Memory store unless explicitly set via
|
|
@@ -238,8 +244,8 @@ let _defaultStore = null;
|
|
|
238
244
|
* @returns {CacheStore}
|
|
239
245
|
*/
|
|
240
246
|
export function getStore() {
|
|
241
|
-
if (!_defaultStore) _defaultStore = memoryStore();
|
|
242
|
-
return _defaultStore;
|
|
247
|
+
if (!_defaultStore.store) _defaultStore.store = memoryStore();
|
|
248
|
+
return _defaultStore.store;
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
/**
|
|
@@ -253,5 +259,5 @@ export function getStore() {
|
|
|
253
259
|
* @param {CacheStore} store
|
|
254
260
|
*/
|
|
255
261
|
export function setStore(store) {
|
|
256
|
-
_defaultStore = store;
|
|
262
|
+
_defaultStore.store = store;
|
|
257
263
|
}
|
package/src/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { parseCookies } from './csrf.js';
|
|
3
3
|
import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
4
|
+
import { shared } from './process-shared.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Per-request context backed by AsyncLocalStorage. Lets server-side code
|
|
@@ -39,7 +40,7 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
|
39
40
|
*/
|
|
40
41
|
|
|
41
42
|
/** @type {AsyncLocalStorage<Store>} */
|
|
42
|
-
const als = new AsyncLocalStorage();
|
|
43
|
+
const als = shared('request-als', () => new AsyncLocalStorage());
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Run `fn` with the given request bound as the current context.
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
*
|
|
23
23
|
* @module dev/bun-app-source
|
|
24
24
|
*/
|
|
25
|
-
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
26
|
+
import { resolve as resolvePath } from 'node:path';
|
|
27
|
+
import { expandImportAlias } from '../module-graph.js';
|
|
26
28
|
|
|
27
29
|
const ROOTS = Symbol.for('webjs.dev.bunAppSourceRoots');
|
|
28
30
|
|
|
@@ -59,8 +61,39 @@ export function registerBunAppSource(appDir) {
|
|
|
59
61
|
const abs = args.path.split('?')[0];
|
|
60
62
|
// A read failure (the file is gone) propagates, which Bun reports as
|
|
61
63
|
// the load error it would have raised itself.
|
|
62
|
-
return { contents: readFileSync(abs, 'utf8'), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' };
|
|
64
|
+
return { contents: rewriteAppAliases(readFileSync(abs, 'utf8'), abs), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' };
|
|
63
65
|
});
|
|
64
66
|
},
|
|
65
67
|
});
|
|
66
68
|
}
|
|
69
|
+
|
|
70
|
+
/** Static `import`/`export … from '#x'` and `import('#x')` with a literal specifier. */
|
|
71
|
+
const ALIAS_SPEC_RE = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(#[^'"\s]+)\2/g;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rewrite the app's `#` import specifiers in a module's source to the absolute
|
|
75
|
+
* path they name (#1575), when that file exists. Bun's resolver serves a `#`
|
|
76
|
+
* import of a file created after it listed the directory as "Cannot find
|
|
77
|
+
* module" under `bun --hot`, and a runtime `onResolve` cannot stand in for it:
|
|
78
|
+
* its result reaches a dynamic `import()` as a broken `file:/...` path (the
|
|
79
|
+
* 0.8.85 / 0.8.86 regression). An absolute path resolves through neither.
|
|
80
|
+
* Anything that is not an app alias, or names no file yet, is left as written,
|
|
81
|
+
* so Bun reports a missing module exactly as before.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} src
|
|
84
|
+
* @param {string} file absolute path of the module
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function rewriteAppAliases(src, file) {
|
|
88
|
+
if (!src.includes('#')) return src;
|
|
89
|
+
const g = /** @type {any} */ (globalThis);
|
|
90
|
+
const roots = g[ROOTS] instanceof Set ? [...g[ROOTS]] : [];
|
|
91
|
+
const appDir = roots.find((r) => file.startsWith(r + '/'));
|
|
92
|
+
if (!appDir || file.includes('/node_modules/')) return src;
|
|
93
|
+
return src.replace(ALIAS_SPEC_RE, (m, lead, q, spec) => {
|
|
94
|
+
const rel = expandImportAlias(spec, appDir);
|
|
95
|
+
if (!rel) return m;
|
|
96
|
+
const abs = resolvePath(appDir, rel);
|
|
97
|
+
try { return existsSync(abs) && statSync(abs).isFile() ? `${lead}${q}${abs}${q}` : m; } catch { return m; }
|
|
98
|
+
});
|
|
99
|
+
}
|
package/src/dev/handler.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
-
import { registerBunAliasResolver } from './bun-alias-resolve.js';
|
|
4
3
|
import { registerBunAppSource } from './bun-app-source.js';
|
|
4
|
+
import { evictPoisonedBunTranspilerCache } from '../bun-transpiler-cache.js';
|
|
5
5
|
import { serverRuntime } from '../listener-core.js';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { join, relative, resolve, sep } from 'node:path';
|
|
@@ -135,6 +135,9 @@ export async function createRequestHandler(opts) {
|
|
|
135
135
|
// on Bun. Doing it here pays the (one-time) amaro import up front and surfaces
|
|
136
136
|
// a missing-amaro error at boot rather than on the first `.ts` request.
|
|
137
137
|
await ensureStripper();
|
|
138
|
+
// Old (0.8.85 / 0.8.86) Bun transpiler-cache entries carry broken import
|
|
139
|
+
// paths keyed by unchanged file content; empty the cache once (#1575).
|
|
140
|
+
evictPoisonedBunTranspilerCache();
|
|
138
141
|
const appDir = resolve(opts.appDir);
|
|
139
142
|
// Load <appDir>/.env into process.env BEFORE anything else.
|
|
140
143
|
// buildActionIndex below imports server-only files (lib/*.server.ts,
|
|
@@ -300,12 +303,6 @@ export async function createRequestHandler(opts) {
|
|
|
300
303
|
// what a bound `<form action=${action}>` resolves through, so gating the hook
|
|
301
304
|
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
302
305
|
await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
|
|
303
|
-
// Bun's resolver keeps a stale directory listing for a `#` import under
|
|
304
|
-
// `bun --hot` (#1575); resolve the app's aliases ourselves in dev.
|
|
305
|
-
if (dev && serverRuntime() === 'bun') {
|
|
306
|
-
registerBunAliasResolver(appDir);
|
|
307
|
-
try { const real = realpathSync(appDir); if (real !== appDir) registerBunAliasResolver(real); } catch { /* appDir only */ }
|
|
308
|
-
}
|
|
309
306
|
|
|
310
307
|
// Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
|
|
311
308
|
// `webjs.dev.sourceLocations: true`) under `webjs dev`
|
package/src/dev/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { strongerVerdict } from '../dev-classify.js';
|
|
3
|
+
import { createWatchIgnore } from './watch-ignore.js';
|
|
3
4
|
import { watchTree } from './watch-tree.js';
|
|
4
5
|
import { relative, resolve } from 'node:path';
|
|
5
6
|
import {
|
|
@@ -352,8 +353,12 @@ export async function startServer(opts) {
|
|
|
352
353
|
logger.warn(`file watcher skipped ${err && err.path ? err.path : 'a path'} (${err && err.code ? err.code : String(err)})`);
|
|
353
354
|
};
|
|
354
355
|
const watchRoot = (root) => {
|
|
356
|
+
// Output nothing serves (`*.log`, coverage/, ...) and the root's
|
|
357
|
+
// `.gitignore` never reload the page: `npm run dev > dev.log` made every
|
|
358
|
+
// log line a reload (watch-ignore.js).
|
|
359
|
+
const unserved = createWatchIgnore(root);
|
|
355
360
|
const close = watchTree(root, {
|
|
356
|
-
ignore: shouldIgnoreWatchPath,
|
|
361
|
+
ignore: (rel) => shouldIgnoreWatchPath(rel) || unserved(rel),
|
|
357
362
|
onEvent: (filename) => {
|
|
358
363
|
// A regenerate output (#967) is a build product the server itself
|
|
359
364
|
// writes on request; ignoring it stops a spurious rebuild + reload
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which changed paths the dev watcher must NOT turn into a rebuild and a
|
|
3
|
+
* browser reload, beyond the framework's own artefacts (`shouldIgnoreWatchPath`
|
|
4
|
+
* in server.js).
|
|
5
|
+
*
|
|
6
|
+
* The dev server watches the app root, so any file written there reached the
|
|
7
|
+
* reload path, including files nothing serves. The common case is the server's
|
|
8
|
+
* own output: `npm run dev > dev.log` makes every log line a reload, which wipes
|
|
9
|
+
* streamed and live UI in the open page mid-check. So two more rules:
|
|
10
|
+
*
|
|
11
|
+
* - Build and tool output that is never served: `*.log`, `coverage/`, `.cache/`,
|
|
12
|
+
* `.nyc_output/`, `test-results/`, `playwright-report/`, `.turbo/`, editor swap
|
|
13
|
+
* and backup files, `.DS_Store`.
|
|
14
|
+
* - Whatever the app's root `.gitignore` ignores. A gitignored file is by
|
|
15
|
+
* definition not part of the app's source. The one exception is `.env*`,
|
|
16
|
+
* which is gitignored on purpose but IS input the server reads, so its edits
|
|
17
|
+
* keep the behaviour they had.
|
|
18
|
+
*
|
|
19
|
+
* The `.gitignore` support is the common subset: comments, blank lines, `!`
|
|
20
|
+
* negation, a trailing `/` for directories, a leading or inner `/` anchoring the
|
|
21
|
+
* pattern to the root, and the `*`, `**` and `?` wildcards. Last match wins, and
|
|
22
|
+
* a path is ignored when it or any directory above it is, as in git.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const OUTPUT = /(?:^|[\\/])(?:coverage|\.cache|\.nyc_output|test-results|playwright-report|\.turbo)(?:[\\/]|$)|\.log$|(?:^|[\\/])\.DS_Store$|\.swp$|~$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tool output that is never served, whatever `.gitignore` says.
|
|
31
|
+
* @param {string} rel path relative to the app root
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isUnservedOutput(rel) {
|
|
35
|
+
return OUTPUT.test(rel || '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One `.gitignore` glob as a RegExp over a `/`-separated path.
|
|
40
|
+
* @param {string} glob
|
|
41
|
+
* @returns {RegExp}
|
|
42
|
+
*/
|
|
43
|
+
function globToRegExp(glob) {
|
|
44
|
+
let re = '';
|
|
45
|
+
for (let i = 0; i < glob.length; i++) {
|
|
46
|
+
const c = glob[i];
|
|
47
|
+
if (c === '*') {
|
|
48
|
+
if (glob[i + 1] === '*') {
|
|
49
|
+
i++;
|
|
50
|
+
if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
|
|
51
|
+
} else re += '[^/]*';
|
|
52
|
+
} else if (c === '?') re += '[^/]';
|
|
53
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
}
|
|
55
|
+
return new RegExp(`^${re}$`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse `.gitignore` text into ordered rules.
|
|
60
|
+
* @param {string} text
|
|
61
|
+
* @returns {{ re: RegExp, negate: boolean, dirOnly: boolean, anchored: boolean }[]}
|
|
62
|
+
*/
|
|
63
|
+
export function parseGitignore(text) {
|
|
64
|
+
const rules = [];
|
|
65
|
+
for (let line of String(text || '').split(/\r?\n/)) {
|
|
66
|
+
line = line.replace(/\s+$/, '');
|
|
67
|
+
if (!line || line.startsWith('#')) continue;
|
|
68
|
+
const negate = line.startsWith('!');
|
|
69
|
+
if (negate) line = line.slice(1);
|
|
70
|
+
const dirOnly = line.endsWith('/');
|
|
71
|
+
if (dirOnly) line = line.slice(0, -1);
|
|
72
|
+
const anchored = line.includes('/');
|
|
73
|
+
if (line.startsWith('/')) line = line.slice(1);
|
|
74
|
+
if (!line) continue;
|
|
75
|
+
rules.push({ re: globToRegExp(line), negate, dirOnly, anchored });
|
|
76
|
+
}
|
|
77
|
+
return rules;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether `.gitignore` rules ignore `rel` (a file) or one of its directories.
|
|
82
|
+
* @param {ReturnType<typeof parseGitignore>} rules
|
|
83
|
+
* @param {string} rel
|
|
84
|
+
* @param {boolean} [leafIsDir] the path itself is a directory (a created dir is reported by name)
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
export function isGitignored(rules, rel, leafIsDir = false) {
|
|
88
|
+
if (!rules.length) return false;
|
|
89
|
+
const parts = String(rel || '').replace(/\\/g, '/').replace(/^\.\//, '').split('/').filter(Boolean);
|
|
90
|
+
for (let depth = 1; depth <= parts.length; depth++) {
|
|
91
|
+
const path = parts.slice(0, depth).join('/');
|
|
92
|
+
const name = parts[depth - 1];
|
|
93
|
+
const isDir = depth < parts.length || leafIsDir;
|
|
94
|
+
let ignored = false;
|
|
95
|
+
for (const r of rules) {
|
|
96
|
+
if (r.dirOnly && !isDir) continue;
|
|
97
|
+
if (r.anchored ? r.re.test(path) : r.re.test(name)) ignored = !r.negate;
|
|
98
|
+
}
|
|
99
|
+
// A directory git ignores is never descended into, so a negation below it
|
|
100
|
+
// cannot bring a file back.
|
|
101
|
+
if (ignored && isDir) return true;
|
|
102
|
+
if (depth === parts.length) return ignored;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The extra watch filter for one app root: unserved output plus that root's
|
|
109
|
+
* `.gitignore` (read once; `.env*` excluded).
|
|
110
|
+
* @param {string} root
|
|
111
|
+
* @returns {(rel: string) => boolean}
|
|
112
|
+
*/
|
|
113
|
+
export function createWatchIgnore(root) {
|
|
114
|
+
let rules = [];
|
|
115
|
+
try { rules = parseGitignore(readFileSync(join(root, '.gitignore'), 'utf8')); } catch { /* no .gitignore */ }
|
|
116
|
+
return (rel) => {
|
|
117
|
+
const p = String(rel || '').replace(/\\/g, '/');
|
|
118
|
+
if (isUnservedOutput(p)) return true;
|
|
119
|
+
if (/(?:^|\/)\.env(?:\.[^/]*)?$/.test(p)) return false;
|
|
120
|
+
let dir = false;
|
|
121
|
+
if (rules.length) { try { dir = statSync(join(root, p)).isDirectory(); } catch { /* gone or unreadable */ } }
|
|
122
|
+
return isGitignored(rules, p, dir);
|
|
123
|
+
};
|
|
124
|
+
}
|
package/src/html-cache.js
CHANGED
|
@@ -30,6 +30,7 @@ import { createHash } from 'node:crypto';
|
|
|
30
30
|
import { publishedBuildId, basePath } from './importmap.js';
|
|
31
31
|
import { stripBasePath } from './base-path.js';
|
|
32
32
|
import { dynamicAccessed, getRequest } from './context.js';
|
|
33
|
+
import { shared } from './process-shared.js';
|
|
33
34
|
|
|
34
35
|
/** Namespace prefix for every cached-HTML key, so a flush can target it. */
|
|
35
36
|
const KEY_PREFIX = 'webjs:html:';
|
|
@@ -51,9 +52,12 @@ export const HTML_CACHE_MARKER = 'x-webjs-html-cache';
|
|
|
51
52
|
* clear cannot enumerate keys), and the store TTL eventually reclaims the
|
|
52
53
|
* orphaned entries. A single process clears its own memory store this way
|
|
53
54
|
* synchronously; a Redis-backed multi-process deploy bumps per process and
|
|
54
|
-
* leans on the TTL for the rest (a best-effort global flush).
|
|
55
|
+
* leans on the TTL for the rest (a best-effort global flush). Held in
|
|
56
|
+
* process-shared state (#1590), so a `revalidateAll()` from an app module that
|
|
57
|
+
* imported a fresh copy of this package after a `bun --hot` reload still
|
|
58
|
+
* reaches the server's keys.
|
|
55
59
|
*/
|
|
56
|
-
|
|
60
|
+
const _generation = shared('html-cache-generation', () => ({ n: 0 }));
|
|
57
61
|
|
|
58
62
|
/**
|
|
59
63
|
* A fingerprint of the served app source, folded into every cache key (#318).
|
|
@@ -170,7 +174,7 @@ export function htmlCacheKey(url) {
|
|
|
170
174
|
// the importmap-only build id misses. Empty (dev / no fingerprint) collapses
|
|
171
175
|
// to the prior key shape, so an unconfigured app is byte-identical.
|
|
172
176
|
const appfp = _appSourceFp ? `${_appSourceFp}:` : '';
|
|
173
|
-
return `${KEY_PREFIX}${build}:${appfp}${_generation}:${originKey(url)}:${url.pathname}${search}`;
|
|
177
|
+
return `${KEY_PREFIX}${build}:${appfp}${_generation.n}:${originKey(url)}:${url.pathname}${search}`;
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
/**
|
|
@@ -417,10 +421,10 @@ function warnDynamicRevalidateOnce(pathname) {
|
|
|
417
421
|
|
|
418
422
|
/** Evict ALL cached HTML for this process. @returns {void} */
|
|
419
423
|
export function revalidateAll() {
|
|
420
|
-
_generation++;
|
|
424
|
+
_generation.n++;
|
|
421
425
|
}
|
|
422
426
|
|
|
423
427
|
/** Internal: current generation, folded into keys by `htmlCacheKey`. */
|
|
424
428
|
export function htmlCacheGeneration() {
|
|
425
|
-
return _generation;
|
|
429
|
+
return _generation.n;
|
|
426
430
|
}
|
package/src/instrumentation.js
CHANGED
|
@@ -19,6 +19,7 @@ import { join } from 'node:path';
|
|
|
19
19
|
import { stat } from 'node:fs/promises';
|
|
20
20
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
21
21
|
import { devImport } from './dev-import.js';
|
|
22
|
+
import { shared } from './process-shared.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Per-boot handoff channel for a `register()`-registered error sink. An
|
|
@@ -29,7 +30,7 @@ import { devImport } from './dev-import.js';
|
|
|
29
30
|
* context propagates across the `await register()` awaits.
|
|
30
31
|
* @type {AsyncLocalStorage<{ onError: ((error: unknown, ctx?: any) => void) | null }>}
|
|
31
32
|
*/
|
|
32
|
-
const _als = new AsyncLocalStorage();
|
|
33
|
+
const _als = shared('instrumentation-als', () => new AsyncLocalStorage());
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Register an error sink from inside `instrumentation.register()`. Composes with
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide state for the parts of `@webjsdev/server` an app calls into.
|
|
3
|
+
*
|
|
4
|
+
* Under `bun --hot` a dev reload gives the app a fresh module registry while
|
|
5
|
+
* the first run's server keeps running (`dev/hot-host.js`, #1575). An app
|
|
6
|
+
* module that imports `@webjsdev/server` after a reload therefore gets a NEW
|
|
7
|
+
* copy of this package, whose module variables start empty, while the server
|
|
8
|
+
* that handles the request is still the first copy. Anything the two must
|
|
9
|
+
* agree on has to live outside the module: a request bound with the first
|
|
10
|
+
* copy's `AsyncLocalStorage` is invisible to the second copy's `getRequest()`,
|
|
11
|
+
* so `auth()`, `cookies()` and `headers()` inside a page or a `'use server'`
|
|
12
|
+
* function saw no request after the first edit and a signed-in page rendered
|
|
13
|
+
* as signed out (#1590).
|
|
14
|
+
*
|
|
15
|
+
* `shared(name, make)` returns the one value for `name` in this process,
|
|
16
|
+
* creating it on first use, keyed by `Symbol.for` exactly as core keys its own
|
|
17
|
+
* shared state. Use it for request-scoped storage and for any registry or
|
|
18
|
+
* store an app reads or writes through the package's API.
|
|
19
|
+
*
|
|
20
|
+
* @module process-shared
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @template T
|
|
25
|
+
* @param {string} name
|
|
26
|
+
* @param {() => T} make
|
|
27
|
+
* @returns {T}
|
|
28
|
+
*/
|
|
29
|
+
export function shared(name, make) {
|
|
30
|
+
const key = Symbol.for(`webjs.server.${name}`);
|
|
31
|
+
const g = /** @type {any} */ (globalThis);
|
|
32
|
+
if (!(key in g)) g[key] = make();
|
|
33
|
+
return g[key];
|
|
34
|
+
}
|
package/src/session.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { getStore } from './cache.js';
|
|
24
24
|
import { markDynamicAccess } from './context.js';
|
|
25
|
+
import { shared } from './process-shared.js';
|
|
25
26
|
|
|
26
27
|
// -- Web Crypto helpers ------------------------------------------------------
|
|
27
28
|
// Same shape as auth.js. We duplicate here rather than share a module
|
|
@@ -293,7 +294,7 @@ function serializeCookie(name, value, opts) {
|
|
|
293
294
|
// ---------------------------------------------------------------------------
|
|
294
295
|
|
|
295
296
|
/** @type {WeakMap<Request, Session>} */
|
|
296
|
-
const sessionMap = new WeakMap();
|
|
297
|
+
const sessionMap = shared('session-map', () => new WeakMap());
|
|
297
298
|
|
|
298
299
|
// ---------------------------------------------------------------------------
|
|
299
300
|
// Session middleware
|
package/src/ssr/head.js
CHANGED
|
@@ -309,6 +309,38 @@ export function hoistHeadTags(headHtml, bodyHtml) {
|
|
|
309
309
|
/** Internal helper re-exported for unit testing. */
|
|
310
310
|
export const _hoistHeadTags = hoistHeadTags;
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The initial-load Suspense resolver: swaps each streamed
|
|
314
|
+
* `<template data-webjs-resolve>` into its placeholder.
|
|
315
|
+
*
|
|
316
|
+
* Two triggers call it. Every streamed template is followed by an inline
|
|
317
|
+
* script that calls `__webjsResolve(id)`, which runs only after the parser has
|
|
318
|
+
* closed the template. A MutationObserver also catches templates inserted some
|
|
319
|
+
* other way. The observer must NOT act on a template the parser is still
|
|
320
|
+
* filling: the parser inserts the `<template>` element first and appends its
|
|
321
|
+
* content as bytes arrive, so when the response splits inside a boundary (a
|
|
322
|
+
* network chunk, or the parser yielding under load) the observer saw a
|
|
323
|
+
* truncated `content` and swapped in half the markup. The inline script then
|
|
324
|
+
* found the template already gone, so the truncation stuck (a `<muted-text>`
|
|
325
|
+
* with no projected text, for one). A template with a next sibling is
|
|
326
|
+
* complete, since the parser only adds a following node after the closing
|
|
327
|
+
* tag; one with none is left to its inline script.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} n the ` nonce="..."` attribute, or ''
|
|
330
|
+
* @returns {string}
|
|
331
|
+
*/
|
|
332
|
+
export function suspenseBootScript(n) {
|
|
333
|
+
return `<script${n}>(function(){` +
|
|
334
|
+
`function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
|
|
335
|
+
`var b=document.getElementById(id);if(t&&b){b.replaceWith(t.content.cloneNode(true));t.remove();}}` +
|
|
336
|
+
`window.__webjsResolve=r;` +
|
|
337
|
+
`if(typeof MutationObserver!=='undefined'){` +
|
|
338
|
+
`new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){` +
|
|
339
|
+
`if(n.nodeType===1&&n.tagName==='TEMPLATE'&&n.dataset.webjsResolve&&n.nextSibling){r(n.dataset.webjsResolve);}` +
|
|
340
|
+
`});});}).observe(document.documentElement,{childList:true,subtree:true});}` +
|
|
341
|
+
`})()</script>`;
|
|
342
|
+
}
|
|
343
|
+
|
|
312
344
|
/**
|
|
313
345
|
* Serialize a Next.js-shaped viewport object into the comma-separated
|
|
314
346
|
* `content` string the meta tag expects. Recognised fields:
|
|
@@ -394,17 +426,7 @@ export function wrapHead(opts) {
|
|
|
394
426
|
? (reloadState ? `<meta name="webjs-dev-reload" content="${escapeAttr(JSON.stringify(reloadState))}">\n` : '') +
|
|
395
427
|
`<script type="module"${n} src="${escapeAttr(withBasePath('/__webjs/reload.js', bp))}"></script>`
|
|
396
428
|
: '';
|
|
397
|
-
const suspenseBoot = opts.streaming
|
|
398
|
-
? `<script${n}>(function(){` +
|
|
399
|
-
`function r(id){var t=document.querySelector('template[data-webjs-resolve="'+id+'"]');` +
|
|
400
|
-
`var b=document.getElementById(id);if(t&&b){b.replaceWith(t.content.cloneNode(true));t.remove();}}` +
|
|
401
|
-
`window.__webjsResolve=r;` +
|
|
402
|
-
`if(typeof MutationObserver!=='undefined'){` +
|
|
403
|
-
`new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){` +
|
|
404
|
-
`if(n.nodeType===1&&n.tagName==='TEMPLATE'&&n.dataset.webjsResolve){r(n.dataset.webjsResolve);}` +
|
|
405
|
-
`});});}).observe(document.documentElement,{childList:true,subtree:true});}` +
|
|
406
|
-
`})()</script>`
|
|
407
|
-
: '';
|
|
429
|
+
const suspenseBoot = opts.streaming ? suspenseBootScript(n) : '';
|
|
408
430
|
|
|
409
431
|
const m = opts.metadata || {};
|
|
410
432
|
const metaTags = [];
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolve the app's `#` subpath imports ourselves under `webjs dev` on Bun
|
|
3
|
-
* (#1575).
|
|
4
|
-
*
|
|
5
|
-
* Bun caches a directory's entries when it resolves through it and, under
|
|
6
|
-
* `bun --hot`, refreshes that cache only for a directory it watches. It does
|
|
7
|
-
* not watch a directory that holds only modules a `Bun.plugin` served (an
|
|
8
|
-
* `actions/` folder of `*.server.ts` files), and a `package.json "imports"`
|
|
9
|
-
* specifier (`#modules/x/new.ts`) resolves through that stale listing, while a
|
|
10
|
-
* relative one does not. So a file an agent added next to an action was
|
|
11
|
-
* "Cannot find module" for the rest of the process, through every reload. This
|
|
12
|
-
* `onResolve` expands the alias through the same map the module graph uses
|
|
13
|
-
* (`expandImportAlias`) and hands Bun an absolute path that exists, which Bun
|
|
14
|
-
* then loads normally (the seed plugin's `onLoad` still applies by path). A
|
|
15
|
-
* specifier it cannot place falls through to Bun's own resolver, so an error
|
|
16
|
-
* reads exactly as it would without it.
|
|
17
|
-
*
|
|
18
|
-
* Dev only (production never reloads) and Bun only (Node's resolver reads the
|
|
19
|
-
* file system on every resolve).
|
|
20
|
-
*
|
|
21
|
-
* @module dev/bun-alias-resolve
|
|
22
|
-
*/
|
|
23
|
-
import { existsSync, statSync } from 'node:fs';
|
|
24
|
-
import { resolve, sep } from 'node:path';
|
|
25
|
-
import { expandImportAlias } from '../module-graph.js';
|
|
26
|
-
|
|
27
|
-
const ROOTS = Symbol.for('webjs.dev.bunAliasRoots');
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Resolve `spec` imported by `importer` against the app's `#` alias map, or
|
|
31
|
-
* null when it is not an app alias or the target is not a file.
|
|
32
|
-
*
|
|
33
|
-
* @param {string} spec
|
|
34
|
-
* @param {string} importer absolute path, possibly with a query
|
|
35
|
-
* @param {string} appDir absolute
|
|
36
|
-
* @returns {string | null}
|
|
37
|
-
*/
|
|
38
|
-
export function resolveAppAlias(spec, importer, appDir) {
|
|
39
|
-
if (!spec.startsWith('#')) return null;
|
|
40
|
-
const from = String(importer || '').split('?')[0];
|
|
41
|
-
if (!from.startsWith(appDir + sep) || from.includes(`${sep}node_modules${sep}`)) return null;
|
|
42
|
-
const rel = expandImportAlias(spec, appDir);
|
|
43
|
-
if (!rel) return null;
|
|
44
|
-
const abs = resolve(appDir, rel);
|
|
45
|
-
try { return existsSync(abs) && statSync(abs).isFile() ? abs : null; } catch { return null; }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Install the resolver for `appDir`, once per process.
|
|
50
|
-
* @param {string} appDir absolute
|
|
51
|
-
*/
|
|
52
|
-
export function registerBunAliasResolver(appDir) {
|
|
53
|
-
const g = /** @type {any} */ (globalThis);
|
|
54
|
-
if (!g.Bun || typeof g.Bun.plugin !== 'function') return;
|
|
55
|
-
if (!(g[ROOTS] instanceof Set)) g[ROOTS] = new Set();
|
|
56
|
-
if (g[ROOTS].has(appDir)) return;
|
|
57
|
-
g[ROOTS].add(appDir);
|
|
58
|
-
g.Bun.plugin({
|
|
59
|
-
name: 'webjs-dev-alias',
|
|
60
|
-
setup(build) {
|
|
61
|
-
build.onResolve({ filter: /^#/ }, (args) => {
|
|
62
|
-
try {
|
|
63
|
-
const path = resolveAppAlias(args.path, args.importer, appDir);
|
|
64
|
-
return path ? { path } : undefined;
|
|
65
|
-
} catch {
|
|
66
|
-
return undefined;
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
}
|