@webjsdev/server 0.7.2 → 0.8.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.
- package/README.md +10 -5
- package/index.js +21 -3
- package/package.json +4 -6
- package/src/actions.js +6 -6
- package/src/auth.js +1 -1
- package/src/cache.js +19 -2
- package/src/check.js +226 -95
- package/src/component-elision.js +797 -0
- package/src/component-scanner.js +8 -2
- package/src/context.js +36 -0
- package/src/crypto-utils.js +65 -0
- package/src/dev.js +479 -94
- package/src/importmap.js +282 -20
- package/src/js-scan.js +288 -0
- package/src/module-graph.js +150 -13
- package/src/rate-limit.js +100 -12
- package/src/script-tag-json.js +63 -0
- package/src/session.js +60 -14
- package/src/ssr.js +133 -17
- package/src/vendor.js +1231 -103
- package/src/websocket.js +3 -1
package/src/module-graph.js
CHANGED
|
@@ -12,11 +12,33 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
15
16
|
import { join, resolve, dirname, extname } from 'node:path';
|
|
16
17
|
|
|
17
18
|
/** @type {RegExp} match static `import … from '…'` and `import '…'` */
|
|
18
19
|
const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* @type {RegExp} match `export … from '…'` re-exports.
|
|
23
|
+
* Examples:
|
|
24
|
+
* export * from './bar';
|
|
25
|
+
* export { x } from './bar';
|
|
26
|
+
* export { x as y } from './bar';
|
|
27
|
+
* export type { T } from './bar';
|
|
28
|
+
* export {
|
|
29
|
+
* a,
|
|
30
|
+
* b,
|
|
31
|
+
* } from './bar'; <-- multi-line, very common in real barrel files
|
|
32
|
+
*
|
|
33
|
+
* Barrel files are common (`lib/index.ts` re-exports its siblings),
|
|
34
|
+
* and the graph must follow these edges or downstream consumers of
|
|
35
|
+
* the barrel see authorisation 404s on the underlying files. The
|
|
36
|
+
* gap class excludes quotes and `;` (so the lazy match cannot cross
|
|
37
|
+
* a statement boundary) but DOES allow newlines, so multi-line
|
|
38
|
+
* brace lists are caught.
|
|
39
|
+
*/
|
|
40
|
+
const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
|
|
41
|
+
|
|
20
42
|
/**
|
|
21
43
|
* @typedef {Map<string, Set<string>>} ModuleGraph
|
|
22
44
|
* A map of absolute file path → Set of absolute file paths it imports.
|
|
@@ -40,12 +62,18 @@ export async function buildModuleGraph(appDir) {
|
|
|
40
62
|
* Entry files themselves are NOT included (they're already preloaded by the
|
|
41
63
|
* boot script).
|
|
42
64
|
*
|
|
65
|
+
* `skip` files are neither included nor traversed into: used to prune
|
|
66
|
+
* display-only components (and the subtree reachable only through them)
|
|
67
|
+
* from preload hints, since their imports are stripped from the served
|
|
68
|
+
* source and the browser never fetches them.
|
|
69
|
+
*
|
|
43
70
|
* @param {ModuleGraph} graph
|
|
44
71
|
* @param {string[]} entryFiles absolute paths
|
|
45
72
|
* @param {string} appDir
|
|
73
|
+
* @param {Set<string>} [skip] absolute paths to exclude and not traverse
|
|
46
74
|
* @returns {string[]} absolute paths of transitive deps
|
|
47
75
|
*/
|
|
48
|
-
export function transitiveDeps(graph, entryFiles, appDir) {
|
|
76
|
+
export function transitiveDeps(graph, entryFiles, appDir, skip) {
|
|
49
77
|
/** @type {Set<string>} */
|
|
50
78
|
const visited = new Set(entryFiles);
|
|
51
79
|
/** @type {string[]} */
|
|
@@ -59,6 +87,7 @@ export function transitiveDeps(graph, entryFiles, appDir) {
|
|
|
59
87
|
if (!deps) continue;
|
|
60
88
|
for (const dep of deps) {
|
|
61
89
|
if (visited.has(dep)) continue;
|
|
90
|
+
if (skip && skip.has(dep)) continue;
|
|
62
91
|
visited.add(dep);
|
|
63
92
|
// Only include files within the app dir (skip node_modules, core, etc.)
|
|
64
93
|
if (dep.startsWith(appDir)) {
|
|
@@ -70,6 +99,74 @@ export function transitiveDeps(graph, entryFiles, appDir) {
|
|
|
70
99
|
return result;
|
|
71
100
|
}
|
|
72
101
|
|
|
102
|
+
/** @type {RegExp} files the dev server NEVER serves as source: it
|
|
103
|
+
* returns a stub instead. We stop graph traversal at these boundaries
|
|
104
|
+
* because the browser never sees their transitive imports anyway. */
|
|
105
|
+
const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Compute the set of files reachable from a set of browser-entry files.
|
|
109
|
+
*
|
|
110
|
+
* Same idea as Next.js's bundler-produced manifest: the static import
|
|
111
|
+
* graph from each page / layout / error / loading / not-found / component
|
|
112
|
+
* entry is the authoritative set of "files the browser may legitimately
|
|
113
|
+
* fetch as ES modules". Anything outside this set is server-only or
|
|
114
|
+
* unrelated and must not be served over HTTP.
|
|
115
|
+
*
|
|
116
|
+
* Result includes the entries themselves PLUS all transitive deps, all
|
|
117
|
+
* restricted to absolute paths under `appDir`. Files outside `appDir`
|
|
118
|
+
* (node_modules, @webjsdev/core, vendor URLs) are excluded; those have
|
|
119
|
+
* their own routing layers (`/__webjs/core/*`, `/__webjs/vendor/*`).
|
|
120
|
+
*
|
|
121
|
+
* Traversal stops at `.server.{js,ts,mjs,mts}` files. They ARE in the
|
|
122
|
+
* result (so a client import like `import { fn } from './x.server.ts'`
|
|
123
|
+
* resolves to the RPC stub and the gate lets the request through), but
|
|
124
|
+
* we do not walk INTO them. The browser only ever sees the RPC stub or
|
|
125
|
+
* the throw-at-load stub for those files, so a non-server file imported
|
|
126
|
+
* ONLY by a server file is never legitimately requested by the
|
|
127
|
+
* browser and should stay out of the authorisation set. Matches
|
|
128
|
+
* Next.js's behaviour, where the bundler emits server-component and
|
|
129
|
+
* server-action code into separate chunks that the client bundle
|
|
130
|
+
* never references.
|
|
131
|
+
*
|
|
132
|
+
* The dev server uses this as a runtime authorization gate before
|
|
133
|
+
* serving any `.{js,mjs,ts,mts,css,svg,…}` URL: in-set → served (still
|
|
134
|
+
* subject to the `.server.{js,ts}` stub guardrail), out-of-set → 404.
|
|
135
|
+
*
|
|
136
|
+
* @param {ModuleGraph} graph
|
|
137
|
+
* @param {string[]} entryFiles absolute paths of browser-bound entries
|
|
138
|
+
* @param {string} appDir
|
|
139
|
+
* @returns {Set<string>}
|
|
140
|
+
*/
|
|
141
|
+
export function reachableFromEntries(graph, entryFiles, appDir) {
|
|
142
|
+
/** @type {Set<string>} */
|
|
143
|
+
const visited = new Set();
|
|
144
|
+
/** @type {string[]} */
|
|
145
|
+
const queue = [];
|
|
146
|
+
for (const entry of entryFiles) {
|
|
147
|
+
if (!entry || !entry.startsWith(appDir)) continue;
|
|
148
|
+
visited.add(entry);
|
|
149
|
+
queue.push(entry);
|
|
150
|
+
}
|
|
151
|
+
while (queue.length) {
|
|
152
|
+
const file = /** @type {string} */ (queue.shift());
|
|
153
|
+
// Stop at server-file boundaries. The file itself stays in the
|
|
154
|
+
// visited set so its URL is servable (yields a stub at request
|
|
155
|
+
// time), but we don't add its imports because the browser never
|
|
156
|
+
// sees them.
|
|
157
|
+
if (SERVER_FILE_RE.test(file)) continue;
|
|
158
|
+
const deps = graph.get(file);
|
|
159
|
+
if (!deps) continue;
|
|
160
|
+
for (const dep of deps) {
|
|
161
|
+
if (visited.has(dep)) continue;
|
|
162
|
+
if (!dep.startsWith(appDir)) continue;
|
|
163
|
+
visited.add(dep);
|
|
164
|
+
queue.push(dep);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return visited;
|
|
168
|
+
}
|
|
169
|
+
|
|
73
170
|
/**
|
|
74
171
|
* Recursively walk a directory, parse imports, and populate the graph.
|
|
75
172
|
* @param {string} dir
|
|
@@ -81,7 +178,17 @@ async function walk(dir, appDir, graph) {
|
|
|
81
178
|
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
82
179
|
catch { return; }
|
|
83
180
|
for (const e of entries) {
|
|
84
|
-
|
|
181
|
+
// Skip filesystem locations the browser-bound graph never
|
|
182
|
+
// touches: node_modules (huge, npm deps reach the browser via
|
|
183
|
+
// the importmap, not direct fs paths), .webjs (framework cache),
|
|
184
|
+
// public/ (served by a separate route with its own containment
|
|
185
|
+
// check). Do NOT skip `_*` dirs: the `_private` / `_components`
|
|
186
|
+
// / `_lib` convention is a ROUTER-ignore mechanism (router.js
|
|
187
|
+
// line 100), but files inside are still importable by pages and
|
|
188
|
+
// layouts, so the graph walker must enter them or the gate
|
|
189
|
+
// 404s legitimate imports.
|
|
190
|
+
if (e.name === 'node_modules' || e.name === '.webjs' || e.name === 'public') continue;
|
|
191
|
+
if (e.name.startsWith('.')) continue;
|
|
85
192
|
const full = join(dir, e.name);
|
|
86
193
|
if (e.isDirectory()) {
|
|
87
194
|
await walk(full, appDir, graph);
|
|
@@ -105,12 +212,14 @@ async function parseFile(file, appDir, graph) {
|
|
|
105
212
|
catch { return; }
|
|
106
213
|
|
|
107
214
|
const deps = new Set();
|
|
108
|
-
for (const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
215
|
+
for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
|
|
216
|
+
for (const m of src.matchAll(re)) {
|
|
217
|
+
const spec = m[1];
|
|
218
|
+
// Only resolve relative imports within the project.
|
|
219
|
+
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
|
|
220
|
+
const resolved = resolveImport(spec, file, appDir);
|
|
221
|
+
if (resolved) deps.add(resolved);
|
|
222
|
+
}
|
|
114
223
|
}
|
|
115
224
|
if (deps.size) graph.set(file, deps);
|
|
116
225
|
}
|
|
@@ -124,7 +233,7 @@ async function parseFile(file, appDir, graph) {
|
|
|
124
233
|
* @param {string} appDir
|
|
125
234
|
* @returns {string | null}
|
|
126
235
|
*/
|
|
127
|
-
function resolveImport(spec, fromFile, appDir) {
|
|
236
|
+
export function resolveImport(spec, fromFile, appDir) {
|
|
128
237
|
const base = dirname(fromFile);
|
|
129
238
|
let target;
|
|
130
239
|
if (spec.startsWith('/')) {
|
|
@@ -133,9 +242,37 @@ function resolveImport(spec, fromFile, appDir) {
|
|
|
133
242
|
} else {
|
|
134
243
|
target = resolve(base, spec);
|
|
135
244
|
}
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
245
|
+
// Sync exact-then-fallback resolution. The graph is advisory (it
|
|
246
|
+
// drives preload hints, not module loading), so a wrong entry is
|
|
247
|
+
// harmless: the browser just gets a redundant preload that 404s.
|
|
248
|
+
// But emitting a working modulepreload when the user wrote
|
|
249
|
+
// `import x from './foo'` (no extension) is much better than
|
|
250
|
+
// leaving the resolver waterfall to discover it lazily, so probe
|
|
251
|
+
// existsSync for the common fallbacks the JSDoc above promises.
|
|
252
|
+
if (existsSync(target)) return target;
|
|
253
|
+
if (!extname(target)) {
|
|
254
|
+
for (const ext of ['.ts', '.js', '.mts', '.mjs']) {
|
|
255
|
+
if (existsSync(target + ext)) return target + ext;
|
|
256
|
+
}
|
|
257
|
+
for (const ext of ['.ts', '.js']) {
|
|
258
|
+
const indexed = join(target, 'index' + ext);
|
|
259
|
+
if (existsSync(indexed)) return indexed;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// `.js` import maps to a `.ts` sibling: TypeScript's "rewrite to
|
|
263
|
+
// .js at runtime" convention. The browser asks for the `.js`
|
|
264
|
+
// path; the dev server's source branch falls through to the
|
|
265
|
+
// sibling. Mirror that here so the resolved path matches the
|
|
266
|
+
// file actually on disk (and the authorization gate sees the
|
|
267
|
+
// same path the request handler resolves to).
|
|
268
|
+
if (/\.js$/.test(target)) {
|
|
269
|
+
const tsAbs = target.replace(/\.js$/, '.ts');
|
|
270
|
+
if (existsSync(tsAbs)) return tsAbs;
|
|
271
|
+
const mtsAbs = target.replace(/\.js$/, '.mts');
|
|
272
|
+
if (existsSync(mtsAbs)) return mtsAbs;
|
|
273
|
+
}
|
|
274
|
+
// Optimistic fallback: return the original resolution so the graph
|
|
275
|
+
// still has an entry, even though the path may 404 on the browser.
|
|
276
|
+
// Matches prior behavior.
|
|
140
277
|
return target;
|
|
141
278
|
}
|
package/src/rate-limit.js
CHANGED
|
@@ -31,22 +31,24 @@ import { getStore } from './cache.js';
|
|
|
31
31
|
* key?: string | ((req: Request) => string | Promise<string>),
|
|
32
32
|
* message?: string,
|
|
33
33
|
* store?: import('./cache.js').CacheStore,
|
|
34
|
+
* trustProxy?: boolean,
|
|
34
35
|
* }} opts
|
|
35
36
|
* @returns {(req: Request, next: () => Promise<Response>) => Promise<Response>}
|
|
36
37
|
*/
|
|
37
38
|
export function rateLimit(opts = {}) {
|
|
38
39
|
const windowMs = parseWindow(opts.window ?? '1m');
|
|
39
40
|
const max = opts.max ?? 60;
|
|
40
|
-
const keyFn = typeof opts.key === 'function' ? opts.key :
|
|
41
|
+
const keyFn = typeof opts.key === 'function' ? opts.key : null;
|
|
41
42
|
const keyPrefix = typeof opts.key === 'string' ? opts.key : '';
|
|
42
43
|
const message = opts.message ?? 'Too Many Requests';
|
|
43
|
-
|
|
44
|
-
//
|
|
44
|
+
const trustProxy = opts.trustProxy === true;
|
|
45
|
+
// Use the provided store, or fall back to the global cache store.
|
|
46
|
+
// Whatever was set via `setStore()` at app startup (in-memory by default).
|
|
45
47
|
const store = opts.store || null;
|
|
46
48
|
|
|
47
49
|
return async function rateLimitMiddleware(req, next) {
|
|
48
50
|
const s = store || getStore();
|
|
49
|
-
const raw =
|
|
51
|
+
const raw = keyFn ? await keyFn(req) : clientIp(req, { trustProxy });
|
|
50
52
|
const key = `rl:${keyPrefix}${raw}`;
|
|
51
53
|
|
|
52
54
|
const count = await s.increment(key, windowMs);
|
|
@@ -77,14 +79,100 @@ export function rateLimit(opts = {}) {
|
|
|
77
79
|
};
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Header name the framework stamps onto every incoming request with
|
|
84
|
+
* the TCP socket's remote address. Surfaces the socket IP through the
|
|
85
|
+
* Web `Request` boundary (which has no `.socket` property of its own).
|
|
86
|
+
*
|
|
87
|
+
* `dev.js`'s `toWebRequest` strips any inbound copy of this header
|
|
88
|
+
* BEFORE adding its own, so clients cannot spoof it from the wire.
|
|
89
|
+
*/
|
|
90
|
+
const REMOTE_IP_HEADER = 'x-webjs-remote-ip';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the client IP for rate-limit bucket keying.
|
|
94
|
+
*
|
|
95
|
+
* `trustProxy: false` (default, safe everywhere): read ONLY the
|
|
96
|
+
* framework-stamped `x-webjs-remote-ip` header. Under `startServer`
|
|
97
|
+
* the framework derives it from the actual TCP socket and strips
|
|
98
|
+
* any inbound copy via `toWebRequest`, so clients cannot spoof it.
|
|
99
|
+
* Under `createRequestHandler` (embedded use) the host adapter MUST
|
|
100
|
+
* call `stampRemoteIp(req, remoteAddress)` first, otherwise the
|
|
101
|
+
* adapter may pass forged inbound headers straight through and the
|
|
102
|
+
* "cannot spoof" guarantee no longer holds. Forwarded-IP headers
|
|
103
|
+
* (`x-forwarded-for`, `cf-connecting-ip`, `x-real-ip`) are IGNORED
|
|
104
|
+
* regardless. Fallback `_anon_` covers requests that arrive without
|
|
105
|
+
* a stamped IP.
|
|
106
|
+
*
|
|
107
|
+
* `trustProxy: true`: honour forwarded-IP headers, preferring the
|
|
108
|
+
* leftmost entry of `X-Forwarded-For`, then `CF-Connecting-IP`,
|
|
109
|
+
* then `X-Real-IP`, then the framework-stamped remote IP, then
|
|
110
|
+
* `_anon_`. Production deploys MUST run behind a reverse proxy that
|
|
111
|
+
* STRIPS inbound `X-Forwarded-For` before adding its own, otherwise
|
|
112
|
+
* trust-proxy reintroduces the spoofability this option exists to
|
|
113
|
+
* defend against.
|
|
114
|
+
*
|
|
115
|
+
* @param {Request} req
|
|
116
|
+
* @param {{ trustProxy?: boolean }} [opts]
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function clientIp(req, opts = {}) {
|
|
120
|
+
if (opts.trustProxy === true) {
|
|
121
|
+
return (
|
|
122
|
+
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
|
|
123
|
+
req.headers.get('cf-connecting-ip') ||
|
|
124
|
+
req.headers.get('x-real-ip') ||
|
|
125
|
+
req.headers.get(REMOTE_IP_HEADER) ||
|
|
126
|
+
'_anon_'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return req.headers.get(REMOTE_IP_HEADER) || '_anon_';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return a Request equivalent to `req` but with `x-webjs-remote-ip`
|
|
134
|
+
* stripped from inbound headers and re-set to `remoteAddress`.
|
|
135
|
+
*
|
|
136
|
+
* `startServer`'s built-in HTTP path does this internally via
|
|
137
|
+
* `toWebRequest`. Embedded adapters (`createRequestHandler` running
|
|
138
|
+
* under Express / Bun / Deno / edge runtimes) MUST call this helper
|
|
139
|
+
* before invoking `app.handle(req)`, otherwise a malicious client
|
|
140
|
+
* can include `x-webjs-remote-ip: <fake>` on the wire and webjs's
|
|
141
|
+
* rate-limit `clientIp(req)` will trust it.
|
|
142
|
+
*
|
|
143
|
+
* Body and method are preserved verbatim. The new Request consumes
|
|
144
|
+
* the original's body stream, so do not reuse the original afterwards.
|
|
145
|
+
*
|
|
146
|
+
* ```js
|
|
147
|
+
* // express adapter
|
|
148
|
+
* app.use(async (req, res) => {
|
|
149
|
+
* const webReq = new Request(..., { headers: req.headers, ... });
|
|
150
|
+
* const safe = stampRemoteIp(webReq, req.socket.remoteAddress);
|
|
151
|
+
* const webRes = await handler.handle(safe);
|
|
152
|
+
* // write webRes back to res
|
|
153
|
+
* });
|
|
154
|
+
* ```
|
|
155
|
+
*
|
|
156
|
+
* @param {Request} req
|
|
157
|
+
* @param {string | null | undefined} remoteAddress trusted socket IP
|
|
158
|
+
* @returns {Request}
|
|
159
|
+
*/
|
|
160
|
+
export function stampRemoteIp(req, remoteAddress) {
|
|
161
|
+
const headers = new Headers(req.headers);
|
|
162
|
+
headers.delete(REMOTE_IP_HEADER);
|
|
163
|
+
if (remoteAddress) headers.set(REMOTE_IP_HEADER, remoteAddress);
|
|
164
|
+
/** @type {RequestInit & { duplex?: string }} */
|
|
165
|
+
const init = { method: req.method, headers };
|
|
166
|
+
// Preserve AbortSignal so host-side cancellation propagates
|
|
167
|
+
// (e.g. client disconnects mid-request). The framework's body
|
|
168
|
+
// stream has its own teardown, but downstream consumers may
|
|
169
|
+
// listen on the signal directly.
|
|
170
|
+
if (req.signal) init.signal = req.signal;
|
|
171
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
172
|
+
init.body = req.body;
|
|
173
|
+
init.duplex = 'half';
|
|
174
|
+
}
|
|
175
|
+
return new Request(req.url, init);
|
|
88
176
|
}
|
|
89
177
|
|
|
90
178
|
/** @param {number | string} w @returns {number} milliseconds */
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON serialization safe for interpolation inside an HTML `<script>`
|
|
3
|
+
* tag body.
|
|
4
|
+
*
|
|
5
|
+
* Four escape concerns when JSON ends up inside `<script>...<` + `/script>`:
|
|
6
|
+
*
|
|
7
|
+
* 1. The substring `<` + `/` is treated by the HTML parser as a
|
|
8
|
+
* script-element close even mid-string. A JSON string value
|
|
9
|
+
* containing `<` + `/script>` would close the host tag and let
|
|
10
|
+
* arbitrary content after it become regular HTML (or another
|
|
11
|
+
* inline script). Escape it to `<\/` (valid in JS strings,
|
|
12
|
+
* ignored by HTML parser).
|
|
13
|
+
*
|
|
14
|
+
* 2. The HTML5 tokenizer transitions into the "script-data-escaped"
|
|
15
|
+
* state when it sees `<!--` inside a script body. From that state
|
|
16
|
+
* a subsequent `<script>` (any casing) enters "script-data-double-
|
|
17
|
+
* escaped", where `</script>` no longer terminates the host
|
|
18
|
+
* element until a matching `-->` is seen. A vendor URL or import
|
|
19
|
+
* key containing the right `<!--...<script>...</script>` shape
|
|
20
|
+
* could survive escape (1) and still break out of the importmap.
|
|
21
|
+
* Escape both `<!--` and `-->` to `<\!--` / `--\>`, which JS
|
|
22
|
+
* parses as the same string literals but the HTML tokenizer no
|
|
23
|
+
* longer matches as comment-state transitions.
|
|
24
|
+
*
|
|
25
|
+
* 3. The Unicode line / paragraph separator code points (U+2028 and
|
|
26
|
+
* U+2029) are valid in JSON strings but legacy JavaScript treated
|
|
27
|
+
* them as line terminators inside source. Modern JS (ES2019+)
|
|
28
|
+
* accepts them, but encoding defensively keeps output compatible
|
|
29
|
+
* with older parsers and is what every major framework does.
|
|
30
|
+
*
|
|
31
|
+
* Use this anywhere a `JSON.stringify` output is interpolated inside
|
|
32
|
+
* a `<script>...<` + `/script>` body (importmap content, env shim,
|
|
33
|
+
* lazy registry, boot module imports).
|
|
34
|
+
*
|
|
35
|
+
* Built with String.fromCharCode and constructed RegExp to keep the
|
|
36
|
+
* source file pure ASCII (no literal U+2028 / U+2029 / script-close).
|
|
37
|
+
*
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
const SCRIPT_CLOSE = '<' + '/';
|
|
42
|
+
const COMMENT_OPEN = '<!--';
|
|
43
|
+
const COMMENT_CLOSE = '-->';
|
|
44
|
+
const LS = String.fromCharCode(0x2028);
|
|
45
|
+
const PS = String.fromCharCode(0x2029);
|
|
46
|
+
const ESCAPE_RE = new RegExp(
|
|
47
|
+
'<' + '\\/' +
|
|
48
|
+
'|<!--' +
|
|
49
|
+
'|-->' +
|
|
50
|
+
'|[\\u2028\\u2029]',
|
|
51
|
+
'g',
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export function jsonForScriptTag(value) {
|
|
55
|
+
return JSON.stringify(value).replace(ESCAPE_RE, (ch) => {
|
|
56
|
+
if (ch === SCRIPT_CLOSE) return '<' + '\\/';
|
|
57
|
+
if (ch === COMMENT_OPEN) return '<\\!--';
|
|
58
|
+
if (ch === COMMENT_CLOSE) return '--\\>';
|
|
59
|
+
if (ch === LS) return '\\u2028';
|
|
60
|
+
if (ch === PS) return '\\u2029';
|
|
61
|
+
return ch;
|
|
62
|
+
});
|
|
63
|
+
}
|
package/src/session.js
CHANGED
|
@@ -21,7 +21,44 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { getStore } from './cache.js';
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
// -- Web Crypto helpers ------------------------------------------------------
|
|
26
|
+
// Same shape as auth.js. We duplicate here rather than share a module
|
|
27
|
+
// because the helpers are small and the two consumers want different
|
|
28
|
+
// import surfaces.
|
|
29
|
+
|
|
30
|
+
const enc = new TextEncoder();
|
|
31
|
+
|
|
32
|
+
/** @param {string} secret @returns {Promise<CryptoKey>} */
|
|
33
|
+
async function hmacKey(secret) {
|
|
34
|
+
return crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {ArrayBuffer | ArrayBufferView} buf @returns {string} */
|
|
38
|
+
function b64url(buf) {
|
|
39
|
+
let bytes;
|
|
40
|
+
if (buf instanceof Uint8Array) bytes = buf;
|
|
41
|
+
else if (buf instanceof ArrayBuffer) bytes = new Uint8Array(buf);
|
|
42
|
+
else bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
43
|
+
let s = '';
|
|
44
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
45
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {string} str @returns {Uint8Array} */
|
|
49
|
+
function unb64url(str) {
|
|
50
|
+
const bin = atob(str.replace(/-/g, '+').replace(/_/g, '/'));
|
|
51
|
+
const out = new Uint8Array(bin.length);
|
|
52
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Generate a fresh base64url-encoded random 24-byte ID. Sync via Web Crypto. */
|
|
57
|
+
function randomId() {
|
|
58
|
+
const bytes = new Uint8Array(24);
|
|
59
|
+
crypto.getRandomValues(bytes);
|
|
60
|
+
return b64url(bytes);
|
|
61
|
+
}
|
|
25
62
|
|
|
26
63
|
// ---------------------------------------------------------------------------
|
|
27
64
|
// Session class
|
|
@@ -46,7 +83,7 @@ export class Session {
|
|
|
46
83
|
* @param {{ data?: Record<string, unknown>, flash?: Record<string, unknown> }} [initial]
|
|
47
84
|
*/
|
|
48
85
|
constructor(id, initial) {
|
|
49
|
-
this.#id = id ||
|
|
86
|
+
this.#id = id || randomId();
|
|
50
87
|
this.#data = new Map(Object.entries(initial?.data || {}));
|
|
51
88
|
this.#flash = new Map(Object.entries(initial?.flash || {}));
|
|
52
89
|
if (this.#flash.size > 0) this.#dirty = true;
|
|
@@ -117,7 +154,7 @@ export class Session {
|
|
|
117
154
|
regenerateId(deleteOld = false) {
|
|
118
155
|
if (this.#destroyed) throw new Error('Session has been destroyed');
|
|
119
156
|
if (deleteOld) this.#deleteId = this.#id;
|
|
120
|
-
this.#id =
|
|
157
|
+
this.#id = randomId();
|
|
121
158
|
this.#dirty = true;
|
|
122
159
|
}
|
|
123
160
|
}
|
|
@@ -204,20 +241,29 @@ export const storeSession = storeSessionStorage;
|
|
|
204
241
|
// Cookie helpers
|
|
205
242
|
// ---------------------------------------------------------------------------
|
|
206
243
|
|
|
207
|
-
function sign(value, secret) {
|
|
208
|
-
|
|
244
|
+
async function sign(value, secret) {
|
|
245
|
+
const sig = await crypto.subtle.sign('HMAC', await hmacKey(secret), enc.encode(value));
|
|
246
|
+
return `${value}.${b64url(sig)}`;
|
|
209
247
|
}
|
|
210
248
|
|
|
211
|
-
function unsign(input, secret) {
|
|
249
|
+
async function unsign(input, secret) {
|
|
212
250
|
const idx = input.lastIndexOf('.');
|
|
213
251
|
if (idx < 1) return null;
|
|
214
252
|
const value = input.slice(0, idx);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
253
|
+
// crypto.subtle.verify is constant-time; replaces node:crypto's
|
|
254
|
+
// explicit timingSafeEqual. Wrap in try/catch because malformed
|
|
255
|
+
// base64 throws inside unb64url.
|
|
256
|
+
try {
|
|
257
|
+
const ok = await crypto.subtle.verify(
|
|
258
|
+
'HMAC',
|
|
259
|
+
await hmacKey(secret),
|
|
260
|
+
unb64url(input.slice(idx + 1)),
|
|
261
|
+
enc.encode(value),
|
|
262
|
+
);
|
|
263
|
+
return ok ? value : null;
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
221
267
|
}
|
|
222
268
|
|
|
223
269
|
function parseCookies(header) {
|
|
@@ -284,7 +330,7 @@ export function session(opts = {}) {
|
|
|
284
330
|
return async function sessionMiddleware(req, next) {
|
|
285
331
|
const cookies = parseCookies(req.headers.get('cookie') || '');
|
|
286
332
|
const rawCookie = cookies[cookieName] || '';
|
|
287
|
-
const unsigned = rawCookie ? unsign(rawCookie, secret) : null;
|
|
333
|
+
const unsigned = rawCookie ? await unsign(rawCookie, secret) : null;
|
|
288
334
|
|
|
289
335
|
// Storage creates the Session
|
|
290
336
|
const s = await storage.read(unsigned);
|
|
@@ -303,7 +349,7 @@ export function session(opts = {}) {
|
|
|
303
349
|
} else if (cookieValue !== null) {
|
|
304
350
|
// Changed: sign and set
|
|
305
351
|
try {
|
|
306
|
-
resp.headers.append('set-cookie', serializeCookie(cookieName, sign(cookieValue, secret), cookieOpts));
|
|
352
|
+
resp.headers.append('set-cookie', serializeCookie(cookieName, await sign(cookieValue, secret), cookieOpts));
|
|
307
353
|
} catch {}
|
|
308
354
|
}
|
|
309
355
|
// null = no change
|