@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.
Files changed (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. package/src/elision-report.js +0 -193
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The Next.js `cookies()` store: one per request, read from the request's
3
+ * `Cookie` header and writable from anywhere server-side in that request (a
4
+ * server action, a route handler, middleware, a page render). A write is
5
+ * visible to later reads in the same request and is applied to the outgoing
6
+ * response as `Set-Cookie` by the request handler (`applyPendingCookies`),
7
+ * so `cookies().set(...)` works with no `Response` in hand, as in Next.
8
+ *
9
+ * The shape and the serializer are ports of Next's `RequestCookies` /
10
+ * `ResponseCookies` (`@edge-runtime/cookies`): `get(name)` returns
11
+ * `{ name, value }`, `set(name, value, options)` or `set({ name, value, ... })`
12
+ * with `maxAge` in SECONDS, `path` defaulting to `/`, and `delete(name)`
13
+ * expiring the cookie.
14
+ *
15
+ * @module cookie-store
16
+ */
17
+
18
+ /**
19
+ * @typedef {{
20
+ * name: string, value: string, expires?: Date | number, maxAge?: number,
21
+ * domain?: string, path?: string, secure?: boolean, httpOnly?: boolean,
22
+ * sameSite?: 'lax' | 'strict' | 'none' | 'Lax' | 'Strict' | 'None' | boolean,
23
+ * partitioned?: boolean, priority?: 'low' | 'medium' | 'high',
24
+ * }} ResponseCookie
25
+ */
26
+
27
+ /**
28
+ * Parse a `Cookie` request header into name -> value (first occurrence wins,
29
+ * values percent-decoded when they decode cleanly).
30
+ * @param {string | null} header
31
+ * @returns {Map<string, string>}
32
+ */
33
+ export function parseCookieHeader(header) {
34
+ /** @type {Map<string, string>} */
35
+ const map = new Map();
36
+ if (!header) return map;
37
+ for (const pair of header.split(/;\s*/)) {
38
+ if (!pair) continue;
39
+ const eq = pair.indexOf('=');
40
+ const name = (eq === -1 ? pair : pair.slice(0, eq)).trim();
41
+ if (!name || map.has(name)) continue;
42
+ const raw = eq === -1 ? 'true' : pair.slice(eq + 1).trim();
43
+ let value = raw;
44
+ try { value = decodeURIComponent(raw); } catch { /* keep raw */ }
45
+ map.set(name, value);
46
+ }
47
+ return map;
48
+ }
49
+
50
+ /**
51
+ * Serialize a cookie as a `Set-Cookie` value (port of Next's stringifyCookie).
52
+ * @param {ResponseCookie} c
53
+ * @returns {string}
54
+ */
55
+ export function stringifyCookie(c) {
56
+ const sameSite = c.sameSite === true ? 'strict' : c.sameSite;
57
+ const attrs = [
58
+ c.path && `Path=${c.path}`,
59
+ (c.expires || c.expires === 0) && `Expires=${(typeof c.expires === 'number' ? new Date(c.expires) : c.expires).toUTCString()}`,
60
+ typeof c.maxAge === 'number' && `Max-Age=${c.maxAge}`,
61
+ c.domain && `Domain=${c.domain}`,
62
+ c.secure && 'Secure',
63
+ c.httpOnly && 'HttpOnly',
64
+ sameSite && `SameSite=${sameSite}`,
65
+ c.partitioned && 'Partitioned',
66
+ c.priority && `Priority=${c.priority}`,
67
+ ].filter(Boolean);
68
+ const head = `${c.name}=${encodeURIComponent(c.value ?? '')}`;
69
+ return attrs.length ? `${head}; ${attrs.join('; ')}` : head;
70
+ }
71
+
72
+ /**
73
+ * Next's normalizeCookie: a numeric `expires` becomes a Date, `maxAge` also
74
+ * sets `expires`, and `path` defaults to `/`.
75
+ * @param {ResponseCookie} cookie
76
+ * @returns {ResponseCookie}
77
+ */
78
+ function normalizeCookie(cookie) {
79
+ const c = { ...cookie };
80
+ if (typeof c.expires === 'number') c.expires = new Date(c.expires);
81
+ if (c.maxAge) c.expires = new Date(Date.now() + c.maxAge * 1000);
82
+ if (c.path == null) c.path = '/';
83
+ return c;
84
+ }
85
+
86
+ /**
87
+ * A request's cookie store. `pending` holds the `Set-Cookie` values to apply
88
+ * to the response, keyed by name so the last write to a name wins.
89
+ */
90
+ export class CookieStore {
91
+ /** @param {string | null} cookieHeader */
92
+ constructor(cookieHeader) {
93
+ /** @type {Map<string, string>} */
94
+ this.map = parseCookieHeader(cookieHeader);
95
+ /** @type {Map<string, string>} */
96
+ this.pending = new Map();
97
+ /**
98
+ * Set once the response's headers are decided (`sealCookies`). A write
99
+ * after that could never reach the browser, so it throws instead of being
100
+ * dropped, as Next refuses a cookie write during render.
101
+ */
102
+ this.sealed = false;
103
+ }
104
+
105
+ /** @param {string} op */
106
+ assertWritable(op) {
107
+ if (!this.sealed) return;
108
+ throw new Error(`[webjs] cookies().${op}() ran after the response headers were sent, so the cookie can never reach the browser. This happens inside a streamed render (a component under Suspense or <webjs-suspense>). Set cookies in a server action, a route handler, middleware, or the page render before anything streams.`);
109
+ }
110
+
111
+ /**
112
+ * @param {string | { name: string }} [arg]
113
+ * @returns {{ name: string, value: string } | undefined}
114
+ */
115
+ get(arg) {
116
+ const name = typeof arg === 'string' ? arg : arg && arg.name;
117
+ if (name == null || !this.map.has(name)) return undefined;
118
+ return { name, value: /** @type {string} */ (this.map.get(name)) };
119
+ }
120
+
121
+ /**
122
+ * @param {string | { name: string }} [arg]
123
+ * @returns {{ name: string, value: string }[]}
124
+ */
125
+ getAll(arg) {
126
+ const all = [...this.map].map(([name, value]) => ({ name, value }));
127
+ const name = typeof arg === 'string' ? arg : arg && arg.name;
128
+ return name == null ? all : all.filter((c) => c.name === name);
129
+ }
130
+
131
+ /** @param {string} name */
132
+ has(name) { return this.map.has(name); }
133
+
134
+ get size() { return this.map.size; }
135
+
136
+ /**
137
+ * `set(name, value, options?)` or `set({ name, value, ...options })`.
138
+ * @param {string | ResponseCookie} a
139
+ * @param {string} [value]
140
+ * @param {Omit<ResponseCookie, 'name' | 'value'>} [options]
141
+ */
142
+ set(a, value, options) {
143
+ this.assertWritable('set');
144
+ const cookie = typeof a === 'string' ? { ...(options || {}), name: a, value: String(value ?? '') } : { ...a, value: String(a.value ?? '') };
145
+ if (!cookie.name) throw new Error('cookies().set: a cookie name is required');
146
+ const c = normalizeCookie(/** @type {ResponseCookie} */ (cookie));
147
+ this.map.set(c.name, c.value);
148
+ this.pending.set(c.name, stringifyCookie(c));
149
+ return this;
150
+ }
151
+
152
+ /**
153
+ * Expire a cookie: `delete(name)` or `delete({ name, path, domain })`.
154
+ * @param {string | { name: string, path?: string, domain?: string }} a
155
+ */
156
+ delete(a) {
157
+ this.assertWritable('delete');
158
+ const opts = typeof a === 'string' ? { name: a } : a;
159
+ this.map.delete(opts.name);
160
+ this.pending.set(opts.name, stringifyCookie(normalizeCookie({ ...opts, value: '', expires: new Date(0) })));
161
+ return this;
162
+ }
163
+
164
+ /** @returns {IterableIterator<[string, { name: string, value: string }]>} */
165
+ [Symbol.iterator]() {
166
+ return [...this.map].map(([name, value]) => /** @type {[string, { name: string, value: string }]} */ ([name, { name, value }]))[Symbol.iterator]();
167
+ }
168
+
169
+ toString() {
170
+ return [...this.map].map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join('; ');
171
+ }
172
+ }
173
+
174
+ /**
175
+ * The view `cookies()` returns: the store's methods, also awaitable, so both
176
+ * `cookies().get('x')` and `(await cookies()).get('x')` (Next 15) work.
177
+ * @param {CookieStore} store
178
+ */
179
+ export function cookiesView(store) {
180
+ const view = {
181
+ get: store.get.bind(store),
182
+ getAll: store.getAll.bind(store),
183
+ has: store.has.bind(store),
184
+ set: (/** @type {any} */ a, /** @type {any} */ v, /** @type {any} */ o) => { store.set(a, v, o); return view; },
185
+ delete: (/** @type {any} */ a) => { store.delete(a); return view; },
186
+ toString: () => store.toString(),
187
+ [Symbol.iterator]: () => store[Symbol.iterator](),
188
+ };
189
+ Object.defineProperty(view, 'size', { get: () => store.size, enumerable: true });
190
+ const p = Promise.resolve(view);
191
+ for (const key of Reflect.ownKeys(view)) {
192
+ if (key === 'size') continue;
193
+ Object.defineProperty(p, key, { value: /** @type {any} */ (view)[key], enumerable: false, configurable: true });
194
+ }
195
+ Object.defineProperty(p, 'size', { get: () => store.size, enumerable: false });
196
+ return /** @type {Promise<typeof view> & typeof view} */ (/** @type {unknown} */ (p));
197
+ }
198
+
199
+ /**
200
+ * Add the store's pending `Set-Cookie` values to a response. The response is
201
+ * always re-created rather than mutated: a `Response.redirect()` (and any
202
+ * fetched response) has immutable headers, and a handler may return one
203
+ * module-level Response for every request, which would otherwise collect this
204
+ * visitor's session cookie and hand it to the next one.
205
+ * @param {Response} res
206
+ * @param {CookieStore | null | undefined} store
207
+ * @returns {Response}
208
+ */
209
+ export function applyPendingCookies(res, store) {
210
+ if (!store || !store.pending.size) return res;
211
+ const headers = new Headers(res.headers);
212
+ for (const v of store.pending.values()) headers.append('set-cookie', v);
213
+ try {
214
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
215
+ } catch {
216
+ // A status a Response cannot be constructed with (outside 200 to 599)
217
+ // keeps the old in-place append.
218
+ for (const v of store.pending.values()) res.headers.append('set-cookie', v);
219
+ return res;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Next.js's `RequestCookies` (`request.cookies` in middleware and a route
225
+ * handler) over a request's cookie store (#1686). Reads are the store's.
226
+ * `set`, `delete` and `clear` change only the cookies the rest of THIS request
227
+ * sees, the page's `cookies()` included, and send nothing to the browser: in
228
+ * Next a response cookie goes on the response (`NextResponse.cookies.set`),
229
+ * which in WebJs is `cookies().set()`.
230
+ * @param {CookieStore} store
231
+ */
232
+ export function requestCookiesView(store) {
233
+ /** @param {unknown} a */
234
+ const nameOf = (a) => String(a && typeof a === 'object' ? /** @type any */ (a).name : a);
235
+ const view = {
236
+ /** @param {any} a */
237
+ get: (a) => store.get(a),
238
+ /** @param {any} [a] */
239
+ getAll: (a) => store.getAll(a),
240
+ /** @param {string} name */
241
+ has: (name) => store.has(name),
242
+ get size() { return store.size; },
243
+ /**
244
+ * @param {string | { name: string, value: string }} a
245
+ * @param {string} [value]
246
+ */
247
+ set(a, value) {
248
+ const v = a && typeof a === 'object' ? a.value : value;
249
+ store.map.set(nameOf(a), String(v ?? ''));
250
+ return view;
251
+ },
252
+ /** @param {string | string[]} names */
253
+ delete(names) {
254
+ const removed = (Array.isArray(names) ? names : [names]).map((n) => store.map.delete(nameOf(n)));
255
+ return Array.isArray(names) ? removed : removed[0];
256
+ },
257
+ clear() { store.map.clear(); return view; },
258
+ [Symbol.iterator]: () => store[Symbol.iterator](),
259
+ toString: () => store.toString(),
260
+ };
261
+ return view;
262
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The `@webjsdev/core` export surface, read from the installed package's own
3
+ * source at check time.
4
+ *
5
+ * `core-imports-exist` and the dev overlay need to know which names each
6
+ * `@webjsdev/core` specifier really exports, as a runtime VALUE and as a
7
+ * TYPE. A hand-kept list would rot the first time core gains an export, so
8
+ * this reads the package the app actually resolves: `package.json` `exports`
9
+ * names the subpaths, each subpath's source module (the `source` condition, or
10
+ * a `default` that is a source file) names its runtime exports, and its
11
+ * `types` declaration names the types. `export * from` is followed on both
12
+ * sides. The root specifier is the union of `index.js` and `index-browser.js`,
13
+ * so a server-only export (`renderToString`) and a browser-only one both count.
14
+ */
15
+ import { readFileSync, existsSync } from 'node:fs';
16
+ import { createRequire } from 'node:module';
17
+ import { dirname, join, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { redactStringsAndTemplates } from './js-scan.js';
20
+
21
+ /**
22
+ * @typedef {{ runtime: Set<string>, types: Set<string>, hasDefault: boolean }} ModuleSurface
23
+ * @typedef {{ dir: string, subpaths: Map<string, ModuleSurface> }} CoreSurface
24
+ */
25
+
26
+ /** @type {Map<string, CoreSurface>} */
27
+ const SURFACE_CACHE = new Map();
28
+
29
+ /**
30
+ * Locate the `@webjsdev/core` package directory the app resolves, falling
31
+ * back to the copy this server package sits beside (the monorepo layout and a
32
+ * flat `node_modules/@webjsdev/` install alike).
33
+ * @param {string} [appDir]
34
+ * @returns {string | null}
35
+ */
36
+ export function resolveCoreDir(appDir) {
37
+ if (appDir) {
38
+ try {
39
+ const req = createRequire(join(appDir, 'package.json'));
40
+ return dirname(req.resolve('@webjsdev/core/package.json'));
41
+ } catch { /* not resolvable from the app; fall through */ }
42
+ }
43
+ const sibling = resolve(dirname(fileURLToPath(import.meta.url)), '../../core');
44
+ return existsSync(join(sibling, 'package.json')) ? sibling : null;
45
+ }
46
+
47
+ /**
48
+ * Read (and cache per core directory) the whole core export surface.
49
+ * @param {string} [appDir]
50
+ * @returns {CoreSurface | null}
51
+ */
52
+ export function coreSurface(appDir) {
53
+ const dir = resolveCoreDir(appDir);
54
+ if (!dir) return null;
55
+ const hit = SURFACE_CACHE.get(dir);
56
+ if (hit) return hit;
57
+ let pkg;
58
+ try { pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); } catch { return null; }
59
+ /** @type {Map<string, ModuleSurface>} */
60
+ const subpaths = new Map();
61
+ const exportsField = pkg.exports && typeof pkg.exports === 'object' ? pkg.exports : {};
62
+ for (const [key, entry] of Object.entries(exportsField)) {
63
+ if (!key.startsWith('.') || key === './package.json' || key.endsWith('/')) continue;
64
+ const spec = key === '.' ? '@webjsdev/core' : '@webjsdev/core' + key.slice(1);
65
+ const { sources, types } = entryFiles(dir, key, entry);
66
+ /** @type {ModuleSurface} */
67
+ const surface = { runtime: new Set(), types: new Set(), hasDefault: false };
68
+ for (const file of sources) {
69
+ const r = moduleExports(file, false, new Set());
70
+ for (const n of r.names) surface.runtime.add(n);
71
+ if (r.hasDefault) surface.hasDefault = true;
72
+ }
73
+ if (types) for (const n of moduleExports(types, true, new Set()).names) surface.types.add(n);
74
+ subpaths.set(spec, surface);
75
+ }
76
+ const out = { dir, subpaths };
77
+ SURFACE_CACHE.set(dir, out);
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * The source and declaration files one `exports` entry maps to. A built
83
+ * `dist/` default is skipped in favour of the `source` condition, since the
84
+ * bundle's export list is the source's.
85
+ * @param {string} dir
86
+ * @param {string} key
87
+ * @param {unknown} entry
88
+ * @returns {{ sources: string[], types: string | null }}
89
+ */
90
+ function entryFiles(dir, key, entry) {
91
+ /** @type {string[]} */
92
+ const sources = [];
93
+ let types = null;
94
+ if (typeof entry === 'string') {
95
+ sources.push(join(dir, entry));
96
+ } else if (entry && typeof entry === 'object') {
97
+ const e = /** @type {Record<string, unknown>} */ (entry);
98
+ const src = typeof e.source === 'string' ? e.source
99
+ : typeof e.default === 'string' && !e.default.includes('/dist/') ? e.default
100
+ : typeof e.import === 'string' ? e.import : null;
101
+ if (src) sources.push(join(dir, src));
102
+ if (typeof e.types === 'string') types = join(dir, e.types);
103
+ }
104
+ if (key === '.') {
105
+ const browser = join(dir, 'index-browser.js');
106
+ if (existsSync(browser) && !sources.includes(browser)) sources.push(browser);
107
+ if (!sources.length) sources.push(join(dir, 'index.js'));
108
+ }
109
+ return { sources: sources.filter((f) => existsSync(f)), types: types && existsSync(types) ? types : null };
110
+ }
111
+
112
+ /**
113
+ * The names one module exports, following `export * from` relative edges.
114
+ * Comments and string / template bodies are masked first, except that the
115
+ * `from '...'` specifier is read back from the raw source at the same offset.
116
+ * @param {string} file
117
+ * @param {boolean} dts read declaration syntax (`interface`, `type`, `declare`)
118
+ * @param {Set<string>} seen
119
+ * @returns {{ names: Set<string>, hasDefault: boolean }}
120
+ */
121
+ function moduleExports(file, dts, seen) {
122
+ const names = new Set();
123
+ let hasDefault = false;
124
+ if (seen.has(file)) return { names, hasDefault };
125
+ seen.add(file);
126
+ let src;
127
+ try { src = readFileSync(file, 'utf8'); } catch { return { names, hasDefault }; }
128
+ const scan = redactStringsAndTemplates(src);
129
+ const decl = dts
130
+ ? /\bexport\s+(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\*?|const|let|var|class|interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/g
131
+ : /\bexport\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
132
+ let m;
133
+ while ((m = decl.exec(scan))) names.add(m[1]);
134
+ if (/\bexport\s+default\b/.test(scan)) hasDefault = true;
135
+ const clause = /\bexport\s+(?:type\s+)?\{([^}]*)\}/g;
136
+ while ((m = clause.exec(scan))) {
137
+ for (const part of m[1].split(',')) {
138
+ const p = part.trim().replace(/^type\s+/, '');
139
+ if (!p) continue;
140
+ const alias = p.split(/\s+as\s+/);
141
+ const name = (alias[1] || alias[0]).trim();
142
+ if (name === 'default') hasDefault = true;
143
+ else if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
144
+ }
145
+ }
146
+ const star = /\bexport\s+(?:type\s+)?\*\s*(?:as\s+([A-Za-z_$][\w$]*)\s*)?from\s*(['"])([^'"]*)\2/g;
147
+ while ((m = star.exec(scan))) {
148
+ if (m[1]) { names.add(m[1]); continue; }
149
+ // The specifier body is a plain string, kept verbatim by the mask.
150
+ const target = resolveRelative(file, m[3], dts);
151
+ if (!target) continue;
152
+ for (const n of moduleExports(target, dts, seen).names) names.add(n);
153
+ }
154
+ return { names, hasDefault };
155
+ }
156
+
157
+ /**
158
+ * Resolve a relative `export * from` specifier; in a declaration file `x.js`
159
+ * means the sibling `x.d.ts`.
160
+ * @param {string} from
161
+ * @param {string} spec
162
+ * @param {boolean} dts
163
+ * @returns {string | null}
164
+ */
165
+ function resolveRelative(from, spec, dts) {
166
+ if (!spec.startsWith('.')) return null;
167
+ const abs = resolve(dirname(from), spec);
168
+ if (dts) {
169
+ const candidates = [abs.replace(/\.m?js$/, '.d.ts'), abs, abs + '.d.ts'];
170
+ for (const c of candidates) if (c.endsWith('.ts') && existsSync(c)) return c;
171
+ return null;
172
+ }
173
+ return existsSync(abs) ? abs : null;
174
+ }
175
+
176
+ /**
177
+ * Whether `name` is a TYPE somewhere in core with no runtime value anywhere,
178
+ * the shape a plain (non-`type`) import of it fails on at load.
179
+ * @param {CoreSurface} surface
180
+ * @param {string} name
181
+ * @returns {boolean}
182
+ */
183
+ export function isTypeOnlyAnywhere(surface, name) {
184
+ let inTypes = false;
185
+ for (const s of surface.subpaths.values()) {
186
+ if (s.runtime.has(name)) return false;
187
+ if (s.types.has(name)) inTypes = true;
188
+ }
189
+ return inTypes;
190
+ }
@@ -12,8 +12,17 @@
12
12
  * plugin's `onLoad` is not served from that copy, so it is always fresh.
13
13
  *
14
14
  * The trade is that `bun --hot` does not watch a module a plugin served
15
- * (#1550), so the dev server's own watcher asks for the registry reset itself
16
- * (`pokeHotSentinel` in `dev/hot-host.js`) after an edit to any app module.
15
+ * (#1550). The dev server's own watcher sees the edit instead, and the served
16
+ * source carries the module versions (#1689): `rewriteAppSource` records each
17
+ * module's import edges and points an import of an edited module, or of a
18
+ * module above one, at its absolute path plus `?webjs-v=<n>`
19
+ * (`dev/esm-hot-hooks.js` holds the versions). Bun keys a module by its query,
20
+ * so the edited chain evaluates fresh while every other module keeps its
21
+ * instance and its module-scope state. Only what versions cannot place (a
22
+ * new or deleted file, the extensionless temp file `sed -i` renames over a
23
+ * module)
24
+ * still asks `bun --hot` for a registry reset (`pokeHotSentinel` in
25
+ * `dev/hot-host.js`).
17
26
  *
18
27
  * The filter matches app modules only: never `node_modules`, and never a
19
28
  * `*.server.*` module, which the `'use server'` seed plugin serves. When dev
@@ -23,8 +32,9 @@
23
32
  * @module dev/bun-app-source
24
33
  */
25
34
  import { readFileSync, existsSync, statSync } from 'node:fs';
26
- import { resolve as resolvePath } from 'node:path';
27
- import { expandImportAlias } from '../module-graph.js';
35
+ import { resolve as resolvePath, isAbsolute } from 'node:path';
36
+ import { expandImportAlias, importSpecifierRanges, resolveImport } from '../module-graph.js';
37
+ import { moduleVersion, recordModuleImport, recordModuleLoad, isVersionedModule } from './esm-hot-hooks.js';
28
38
 
29
39
  const ROOTS = Symbol.for('webjs.dev.bunAppSourceRoots');
30
40
 
@@ -61,7 +71,7 @@ export function registerBunAppSource(appDir) {
61
71
  const abs = args.path.split('?')[0];
62
72
  // A read failure (the file is gone) propagates, which Bun reports as
63
73
  // the load error it would have raised itself.
64
- return { contents: rewriteAppAliases(readFileSync(abs, 'utf8'), abs), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' };
74
+ return { contents: rewriteAppSource(readFileSync(abs, 'utf8'), abs), loader: /\.m?ts$/.test(abs) ? 'ts' : 'js' };
65
75
  });
66
76
  },
67
77
  });
@@ -97,3 +107,83 @@ export function rewriteAppAliases(src, file) {
97
107
  try { return existsSync(abs) && statSync(abs).isFile() ? `${lead}${q}${abs}${q}` : m; } catch { return m; }
98
108
  });
99
109
  }
110
+
111
+ /**
112
+ * Everything the dev server does to an app module's source on Bun before Bun
113
+ * sees it: the `#` alias rewrite, then the module versions (#1689). Every Bun
114
+ * plugin that serves app source in dev calls this one function (this file's,
115
+ * the `'use server'` seed plugin's passthrough, the source-locations plugin),
116
+ * so a module is versioned whichever of them serves it. A no-op outside a
117
+ * registered app root, so production source is untouched.
118
+ *
119
+ * @param {string} src
120
+ * @param {string} file absolute path of the module, no query
121
+ * @returns {string}
122
+ */
123
+ export function rewriteAppSource(src, file) {
124
+ return versionServedImports(rewriteAppAliases(src, file), file);
125
+ }
126
+
127
+ /**
128
+ * Record each import edge of an app module and point every import of a
129
+ * bumped module at its absolute path plus `?webjs-v=<n>` (`&webjs-v=<n>` on a
130
+ * specifier that already has a query), the Bun half of the Node resolve hook in
131
+ * `dev/esm-hot-hooks.js`. Static imports, `export … from` and string-literal
132
+ * dynamic imports count. A type-only clause, a specifier inside a string,
133
+ * template or comment, and a bare specifier are left alone, and so is a target
134
+ * that does not exist, so Bun reports a missing module exactly as before. The
135
+ * absolute path rather than the written spelling, because it is the key Bun
136
+ * gives the module anyway, and a query on `./x.js` naming `x.ts` would be one
137
+ * more resolver path to trust.
138
+ *
139
+ * @param {string} src source after the alias rewrite
140
+ * @param {string} file absolute path of the module, no query
141
+ * @returns {string}
142
+ */
143
+ export function versionServedImports(src, file) {
144
+ if (!isVersionedModule(file)) return src;
145
+ recordModuleLoad(file);
146
+ const g = /** @type {any} */ (globalThis);
147
+ const roots = g[ROOTS] instanceof Set ? [...g[ROOTS]] : [];
148
+ const appDir = roots.find((r) => file.startsWith(r + '/'));
149
+ if (!appDir) return src;
150
+ /** @type {Array<{ start: number, end: number, text: string }>} */
151
+ const edits = [];
152
+ const seen = new Set();
153
+ for (const { start, end, spec } of importSpecifierRanges(src)) {
154
+ if (seen.has(start)) continue;
155
+ seen.add(start);
156
+ const target = serverTarget(spec, file, appDir);
157
+ if (!target) continue;
158
+ recordModuleImport(file, target);
159
+ const n = moduleVersion(target);
160
+ if (!n) continue;
161
+ let exists = false;
162
+ try { exists = statSync(target).isFile(); } catch { /* missing: leave it */ }
163
+ if (!exists) continue;
164
+ const text = spec.includes('?') ? `${spec}&webjs-v=${n}` : `${target}?webjs-v=${n}`;
165
+ edits.push({ start, end, text });
166
+ }
167
+ if (!edits.length) return src;
168
+ edits.sort((a, b) => b.start - a.start);
169
+ let out = src;
170
+ for (const { start, end, text } of edits) out = out.slice(0, start) + text + out.slice(end);
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * The file an app module's import specifier names, or null for a bare
176
+ * specifier. An absolute specifier is a filesystem path here (the alias
177
+ * rewrite produces them), not the app-root path a browser specifier is, so it
178
+ * is taken as is rather than through `resolveImport`.
179
+ * @param {string} spec
180
+ * @param {string} file
181
+ * @param {string} appDir
182
+ * @returns {string | null}
183
+ */
184
+ function serverTarget(spec, file, appDir) {
185
+ const bare = spec.split('?')[0];
186
+ if (isAbsolute(bare)) return bare;
187
+ if (!bare.startsWith('.') && !expandImportAlias(bare, appDir)) return null;
188
+ return resolveImport(bare, file, appDir);
189
+ }
package/src/dev/config.js CHANGED
@@ -9,52 +9,8 @@ import { readCspConfig } from '../csp.js';
9
9
  import { readBodyLimits, computeServerTimeouts } from '../body-limit.js';
10
10
 
11
11
  /**
12
- * Read the `WEBJS_ELIDE` environment override, if set.
13
- * `0` / `false` / `off` / `no` (case-insensitive) force elision OFF;
14
- * `1` / `true` / `on` / `yes` force it ON. Any other value, or an unset
15
- * variable, returns `undefined` so the caller falls through to the
16
- * `package.json` switch. The env override is the deploy-time / ops escape
17
- * hatch: force-disable elision to rule it out while debugging a wrong-strip
18
- * without editing committed code, or force-enable it regardless of an
19
- * app's `package.json`. It is also the seam the differential elision test
20
- * uses to render the same app on and off in one process.
21
- * @returns {boolean | undefined}
22
- */
23
- function elideEnvOverride() {
24
- const raw = process.env.WEBJS_ELIDE;
25
- if (raw == null || raw === '') return undefined;
26
- const v = String(raw).trim().toLowerCase();
27
- if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
28
- if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
29
- return undefined;
30
- }
31
-
32
- /**
33
- * Read the project-level elision switch.
34
- * Precedence: the `WEBJS_ELIDE` env override wins when set, otherwise the
35
- * `package.json` `{ "webjs": { "elide": false } }` switch disables
36
- * display-only and inert-route elision app-wide (everything ships, like
37
- * before the feature existed). Any other value, or an absent key, leaves
38
- * elision enabled (the default). Re-read on every rebuild so toggling
39
- * either control takes effect without a server restart.
40
- * @param {string} appDir
41
- * @returns {Promise<boolean>}
42
- */
43
- export async function readElideEnabled(appDir) {
44
- const override = elideEnvOverride();
45
- if (override !== undefined) return override;
46
- try {
47
- const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
48
- if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
49
- } catch {
50
- // No package.json, malformed JSON, or unreadable. Keep the default.
51
- }
52
- return true;
53
- }
54
-
55
- /**
56
- * Read the `WEBJS_SEED` environment override, if set. Same grammar as
57
- * `WEBJS_ELIDE`: `0`/`false`/`off`/`no` force seeding OFF, `1`/`true`/`on`/`yes`
12
+ * Read the `WEBJS_SEED` environment override, if set. `0`/`false`/`off`/`no`
13
+ * force seeding OFF, `1`/`true`/`on`/`yes`
58
14
  * force it ON, anything else (or unset) falls through to the package.json
59
15
  * switch. The deploy-time / ops escape hatch and the seam tests use to render
60
16
  * an app with seeding on and off in one process.
@@ -73,11 +29,11 @@ function seedEnvOverride() {
73
29
  * Read the project-level SSR action-seeding switch (#472). Precedence: the
74
30
  * `WEBJS_SEED` env override wins when set, otherwise the package.json
75
31
  * `{ "webjs": { "seed": false } }` switch disables it (the client re-fetches on
76
- * hydration as it did before the feature). Default ON (opt-out), mirroring
77
- * elision: any value other than the literal `false` keeps seeding enabled.
32
+ * hydration as it did before the feature). Default ON (opt-out): any value
33
+ * other than the literal `false` keeps seeding enabled.
78
34
  *
79
- * Seeding installs a process-global `module.registerHooks` load hook, so unlike
80
- * elision it is read ONCE at boot (not re-read per rebuild): toggling it needs a
35
+ * Seeding installs a process-global `module.registerHooks` load hook, so it is
36
+ * read ONCE at boot (not re-read per rebuild): toggling it needs a
81
37
  * restart, like a deploy.
82
38
  * @param {string} appDir
83
39
  * @returns {Promise<boolean>}
@@ -101,7 +57,7 @@ export async function readSeedEnabled(appDir) {
101
57
  * `{ "webjs": { "clientRouter": false } }` opts the WHOLE app out (pure MPA,
102
58
  * full-page navigation), which the render path signals to the browser so the
103
59
  * core bundle's module-end auto-enable is skipped. Any other value, or an
104
- * absent key, keeps the router on. Re-read on every rebuild (alongside elide)
60
+ * absent key, keeps the router on. Re-read on every rebuild
105
61
  * so toggling it takes effect without a server restart.
106
62
  * @param {string} appDir
107
63
  * @returns {Promise<boolean>}