@webjsdev/server 0.8.86 → 0.8.87

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.86",
3
+ "version": "0.8.87",
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",
@@ -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({});
@@ -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
  });
@@ -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
- /** Whether seed COLLECTION is on (the `webjs.seed` switch). */
77
- let _seedEnabled = false;
78
- /** Dev mode, threaded from `dev.js` at boot. Gates the determinism assertion. */
79
- let _devMode = false;
80
- /** Whether the load hook is installed at all (so identity is available). */
81
- let _hookInstalled = false;
82
- /** Idempotency guard: `module.registerHooks` must run at most once. */
83
- let _registered = false;
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 _seedEnabled && _hookInstalled;
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 _hookInstalled;
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 (_devMode && collector.has(key)) {
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 (!_seedEnabled) return orig;
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
- _seedEnabled = opts.seed !== false;
648
- _devMode = opts.dev === true;
649
- if (_registered) return;
650
- _registered = true;
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]) { _hookInstalled = true; return; }
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
- installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE });
664
- _hookInstalled = true;
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
- _hookInstalled = true;
685
+ _flags.hookInstalled = true;
680
686
  }
681
687
 
682
688
  /**
@@ -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
- /** @type {CacheStore | null} */
232
- let _defaultStore = null;
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
+ }
@@ -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/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
- let _generation = 0;
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
  }
@@ -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
@@ -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
- }