@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.
- package/README.md +4 -1
- package/index.d.ts +793 -125
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +97 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +269 -0
- package/src/core-exports.js +190 -0
- package/src/csrf.js +21 -3
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +142 -66
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +71 -2
- package/src/testing.js +103 -21
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- 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: `
|
|
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:
|
|
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)
|
|
16
|
-
*
|
|
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:
|
|
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
|
+
}
|