@webjsdev/server 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +97 -15
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. package/src/elision-report.js +0 -193
@@ -0,0 +1,269 @@
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
+ * (or `delete(name, { path, domain })`) 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)`, `delete(name, { path, domain })` or
154
+ * `delete({ name, path, domain })`. A cookie set on a path or domain is only
155
+ * cleared by a `Set-Cookie` naming the same one, so both forms carry them.
156
+ * @param {string | { name: string, path?: string, domain?: string }} a
157
+ * @param {{ path?: string, domain?: string, secure?: boolean, httpOnly?: boolean, sameSite?: ResponseCookie['sameSite'], partitioned?: boolean }} [options]
158
+ */
159
+ delete(a, options) {
160
+ this.assertWritable('delete');
161
+ const opts = typeof a === 'string' ? { ...(options || {}), name: a } : a;
162
+ this.map.delete(opts.name);
163
+ // A `maxAge` or `expires` the caller passed (options shared with `set`)
164
+ // would otherwise outlive the deletion: `normalizeCookie` lets `maxAge`
165
+ // replace the epoch `expires`, and the cookie stayed alive for that long.
166
+ const { maxAge: _maxAge, expires: _expires, ...scope } = /** @type {any} */ (opts);
167
+ this.pending.set(opts.name, stringifyCookie(normalizeCookie({ ...scope, value: '', expires: new Date(0) })));
168
+ return this;
169
+ }
170
+
171
+ /** @returns {IterableIterator<[string, { name: string, value: string }]>} */
172
+ [Symbol.iterator]() {
173
+ return [...this.map].map(([name, value]) => /** @type {[string, { name: string, value: string }]} */ ([name, { name, value }]))[Symbol.iterator]();
174
+ }
175
+
176
+ toString() {
177
+ return [...this.map].map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join('; ');
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The view `cookies()` returns: the store's methods, also awaitable, so both
183
+ * `cookies().get('x')` and `(await cookies()).get('x')` (Next 15) work.
184
+ * @param {CookieStore} store
185
+ */
186
+ export function cookiesView(store) {
187
+ const view = {
188
+ get: store.get.bind(store),
189
+ getAll: store.getAll.bind(store),
190
+ has: store.has.bind(store),
191
+ set: (/** @type {any} */ a, /** @type {any} */ v, /** @type {any} */ o) => { store.set(a, v, o); return view; },
192
+ delete: (/** @type {any} */ a, /** @type {any} */ o) => { store.delete(a, o); return view; },
193
+ toString: () => store.toString(),
194
+ [Symbol.iterator]: () => store[Symbol.iterator](),
195
+ };
196
+ Object.defineProperty(view, 'size', { get: () => store.size, enumerable: true });
197
+ const p = Promise.resolve(view);
198
+ for (const key of Reflect.ownKeys(view)) {
199
+ if (key === 'size') continue;
200
+ Object.defineProperty(p, key, { value: /** @type {any} */ (view)[key], enumerable: false, configurable: true });
201
+ }
202
+ Object.defineProperty(p, 'size', { get: () => store.size, enumerable: false });
203
+ return /** @type {Promise<typeof view> & typeof view} */ (/** @type {unknown} */ (p));
204
+ }
205
+
206
+ /**
207
+ * Add the store's pending `Set-Cookie` values to a response. The response is
208
+ * always re-created rather than mutated: a `Response.redirect()` (and any
209
+ * fetched response) has immutable headers, and a handler may return one
210
+ * module-level Response for every request, which would otherwise collect this
211
+ * visitor's session cookie and hand it to the next one.
212
+ * @param {Response} res
213
+ * @param {CookieStore | null | undefined} store
214
+ * @returns {Response}
215
+ */
216
+ export function applyPendingCookies(res, store) {
217
+ if (!store || !store.pending.size) return res;
218
+ const headers = new Headers(res.headers);
219
+ for (const v of store.pending.values()) headers.append('set-cookie', v);
220
+ try {
221
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
222
+ } catch {
223
+ // A status a Response cannot be constructed with (outside 200 to 599)
224
+ // keeps the old in-place append.
225
+ for (const v of store.pending.values()) res.headers.append('set-cookie', v);
226
+ return res;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Next.js's `RequestCookies` (`request.cookies` in middleware and a route
232
+ * handler) over a request's cookie store (#1686). Reads are the store's.
233
+ * `set`, `delete` and `clear` change only the cookies the rest of THIS request
234
+ * sees, the page's `cookies()` included, and send nothing to the browser: in
235
+ * Next a response cookie goes on the response (`NextResponse.cookies.set`),
236
+ * which in WebJs is `cookies().set()`.
237
+ * @param {CookieStore} store
238
+ */
239
+ export function requestCookiesView(store) {
240
+ /** @param {unknown} a */
241
+ const nameOf = (a) => String(a && typeof a === 'object' ? /** @type any */ (a).name : a);
242
+ const view = {
243
+ /** @param {any} a */
244
+ get: (a) => store.get(a),
245
+ /** @param {any} [a] */
246
+ getAll: (a) => store.getAll(a),
247
+ /** @param {string} name */
248
+ has: (name) => store.has(name),
249
+ get size() { return store.size; },
250
+ /**
251
+ * @param {string | { name: string, value: string }} a
252
+ * @param {string} [value]
253
+ */
254
+ set(a, value) {
255
+ const v = a && typeof a === 'object' ? a.value : value;
256
+ store.map.set(nameOf(a), String(v ?? ''));
257
+ return view;
258
+ },
259
+ /** @param {string | string[]} names */
260
+ delete(names) {
261
+ const removed = (Array.isArray(names) ? names : [names]).map((n) => store.map.delete(nameOf(n)));
262
+ return Array.isArray(names) ? removed : removed[0];
263
+ },
264
+ clear() { store.map.clear(); return view; },
265
+ [Symbol.iterator]: () => store[Symbol.iterator](),
266
+ toString: () => store.toString(),
267
+ };
268
+ return view;
269
+ }
@@ -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
+ }
package/src/csrf.js CHANGED
@@ -97,6 +97,23 @@ export function requestHost(req) {
97
97
  return hostOf(req.url);
98
98
  }
99
99
 
100
+ /**
101
+ * The request's `Origin` as a clean `scheme://host[:port]` for a refusal
102
+ * message, so the message never echoes arbitrary header text. An origin
103
+ * carries no path, query or credentials, so nothing secret can reach it.
104
+ * @param {string | null} origin
105
+ */
106
+ function originLabel(origin) {
107
+ if (!origin) return 'no Origin';
108
+ if (origin === 'null') return 'Origin null (a sandboxed frame or a file: page)';
109
+ try { return `Origin ${new URL(origin).origin}`; } catch { return 'an unparseable Origin'; }
110
+ }
111
+
112
+ /** What to do about a refused request, appended to every reason. */
113
+ const ORIGIN_FIX = 'A browser form posted from this app passes; a hand-built request (curl, fetch in a test) '
114
+ + 'should send no Origin, or the origin of the host it addresses. A trusted other origin goes in '
115
+ + '"webjs": { "allowedOrigins": [...] } in package.json.';
116
+
100
117
  /** Is the request's `Origin` host in the configured allowlist? */
101
118
  function originAllowed(req, allowedOrigins) {
102
119
  const origin = req.headers.get('origin');
@@ -116,7 +133,8 @@ function originAllowed(req, allowedOrigins) {
116
133
  * Cross-origin (CSRF) verification for a state-changing action request.
117
134
  * @param {Request} req
118
135
  * @param {string[]} [allowedOrigins] hosts or full origins allowed cross-site
119
- * @returns {{ ok: boolean, reason?: string }}
136
+ * @returns {{ ok: boolean, reason?: string }} on a refusal, `reason` names the
137
+ * headers that caused it and the fix, safe to send back in the 403
120
138
  */
121
139
  export function verifyOrigin(req, allowedOrigins = []) {
122
140
  // Primary: the Sec-Fetch-Site fetch-metadata header (browser-set, not
@@ -127,7 +145,7 @@ export function verifyOrigin(req, allowedOrigins = []) {
127
145
  // 'same-site' or 'cross-site': reject unless the source origin is trusted.
128
146
  return originAllowed(req, allowedOrigins)
129
147
  ? { ok: true }
130
- : { ok: false, reason: `cross-origin request (Sec-Fetch-Site: ${secFetchSite})` };
148
+ : { ok: false, reason: `the browser marked it Sec-Fetch-Site: ${secFetchSite.slice(0, 32)} (${originLabel(req.headers.get('origin'))}, host ${requestHost(req).slice(0, 255) || '(none)'}), and that origin is not in webjs.allowedOrigins. ${ORIGIN_FIX}` };
131
149
  }
132
150
  // Fallback (no Sec-Fetch-Site, older browser): compare Origin host to host.
133
151
  const origin = req.headers.get('origin');
@@ -137,7 +155,7 @@ export function verifyOrigin(req, allowedOrigins = []) {
137
155
  if (sourceHost && host && sourceHost === host) return { ok: true };
138
156
  return originAllowed(req, allowedOrigins)
139
157
  ? { ok: true }
140
- : { ok: false, reason: `origin ${sourceHost || '(none)'} does not match host ${host || '(none)'}` };
158
+ : { ok: false, reason: `${originLabel(origin)} does not match this request's host ${host.slice(0, 255) || '(none)'}. ${ORIGIN_FIX}` };
141
159
  }
142
160
 
143
161
  /**
@@ -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
+ }