@webjsdev/server 0.7.3 → 0.8.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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Lightweight module dependency graph.
3
3
  *
4
- * At startup, scans the app directory and builds an in-memory map of
4
+ * On the first request (lazily, via `ensureReady`), scans the app directory and builds an in-memory map of
5
5
  * `file → Set<imported files>`. The SSR pipeline queries this graph to
6
6
  * emit *complete* modulepreload hints: including transitive dependencies
7
7
  * of components: so the browser can fetch the entire tree in parallel
@@ -12,11 +12,33 @@
12
12
  */
13
13
 
14
14
  import { readFile, readdir, stat } from 'node:fs/promises';
15
- import { join, resolve, dirname, extname } from 'node:path';
15
+ import { existsSync } from 'node:fs';
16
+ import { join, resolve, dirname, extname, sep } 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.
@@ -31,7 +53,18 @@ const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
31
53
  export async function buildModuleGraph(appDir) {
32
54
  /** @type {ModuleGraph} */
33
55
  const graph = new Map();
34
- await walk(appDir, appDir, graph);
56
+ /** @type {Set<string>} every file walked this build (graph holds only files
57
+ * with deps, so a separate set is needed to know what is still live). */
58
+ const seen = new Set();
59
+ await walk(appDir, appDir, graph, seen);
60
+ // Evict parse-cache entries for files no longer in the tree (a rebuild after
61
+ // a rename or delete), so a long dev session does not accumulate dead
62
+ // entries. Scoped to appDir so a multi-app process (tests, dogfood smoke)
63
+ // keeps other apps' entries.
64
+ const prefix = appDir.endsWith(sep) ? appDir : appDir + sep;
65
+ for (const key of PARSE_CACHE.keys()) {
66
+ if ((key === appDir || key.startsWith(prefix)) && !seen.has(key)) PARSE_CACHE.delete(key);
67
+ }
35
68
  return graph;
36
69
  }
37
70
 
@@ -40,12 +73,18 @@ export async function buildModuleGraph(appDir) {
40
73
  * Entry files themselves are NOT included (they're already preloaded by the
41
74
  * boot script).
42
75
  *
76
+ * `skip` files are neither included nor traversed into: used to prune
77
+ * display-only components (and the subtree reachable only through them)
78
+ * from preload hints, since their imports are stripped from the served
79
+ * source and the browser never fetches them.
80
+ *
43
81
  * @param {ModuleGraph} graph
44
82
  * @param {string[]} entryFiles absolute paths
45
83
  * @param {string} appDir
84
+ * @param {Set<string>} [skip] absolute paths to exclude and not traverse
46
85
  * @returns {string[]} absolute paths of transitive deps
47
86
  */
48
- export function transitiveDeps(graph, entryFiles, appDir) {
87
+ export function transitiveDeps(graph, entryFiles, appDir, skip) {
49
88
  /** @type {Set<string>} */
50
89
  const visited = new Set(entryFiles);
51
90
  /** @type {string[]} */
@@ -59,6 +98,7 @@ export function transitiveDeps(graph, entryFiles, appDir) {
59
98
  if (!deps) continue;
60
99
  for (const dep of deps) {
61
100
  if (visited.has(dep)) continue;
101
+ if (skip && skip.has(dep)) continue;
62
102
  visited.add(dep);
63
103
  // Only include files within the app dir (skip node_modules, core, etc.)
64
104
  if (dep.startsWith(appDir)) {
@@ -70,27 +110,120 @@ export function transitiveDeps(graph, entryFiles, appDir) {
70
110
  return result;
71
111
  }
72
112
 
113
+ /** @type {RegExp} files the dev server NEVER serves as source: it
114
+ * returns a stub instead. We stop graph traversal at these boundaries
115
+ * because the browser never sees their transitive imports anyway. */
116
+ const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
117
+
118
+ /**
119
+ * Compute the set of files reachable from a set of browser-entry files.
120
+ *
121
+ * Same idea as Next.js's bundler-produced manifest: the static import
122
+ * graph from each page / layout / error / loading / not-found / component
123
+ * entry is the authoritative set of "files the browser may legitimately
124
+ * fetch as ES modules". Anything outside this set is server-only or
125
+ * unrelated and must not be served over HTTP.
126
+ *
127
+ * Result includes the entries themselves PLUS all transitive deps, all
128
+ * restricted to absolute paths under `appDir`. Files outside `appDir`
129
+ * (node_modules, @webjsdev/core, vendor URLs) are excluded; those have
130
+ * their own routing layers (`/__webjs/core/*`, `/__webjs/vendor/*`).
131
+ *
132
+ * Traversal stops at `.server.{js,ts,mjs,mts}` files. They ARE in the
133
+ * result (so a client import like `import { fn } from './x.server.ts'`
134
+ * resolves to the RPC stub and the gate lets the request through), but
135
+ * we do not walk INTO them. The browser only ever sees the RPC stub or
136
+ * the throw-at-load stub for those files, so a non-server file imported
137
+ * ONLY by a server file is never legitimately requested by the
138
+ * browser and should stay out of the authorisation set. Matches
139
+ * Next.js's behaviour, where the bundler emits server-component and
140
+ * server-action code into separate chunks that the client bundle
141
+ * never references.
142
+ *
143
+ * The dev server uses this as a runtime authorization gate before
144
+ * serving any `.{js,mjs,ts,mts,css,svg,…}` URL: in-set → served (still
145
+ * subject to the `.server.{js,ts}` stub guardrail), out-of-set → 404.
146
+ *
147
+ * @param {ModuleGraph} graph
148
+ * @param {string[]} entryFiles absolute paths of browser-bound entries
149
+ * @param {string} appDir
150
+ * @returns {Set<string>}
151
+ */
152
+ export function reachableFromEntries(graph, entryFiles, appDir) {
153
+ /** @type {Set<string>} */
154
+ const visited = new Set();
155
+ /** @type {string[]} */
156
+ const queue = [];
157
+ for (const entry of entryFiles) {
158
+ if (!entry || !entry.startsWith(appDir)) continue;
159
+ visited.add(entry);
160
+ queue.push(entry);
161
+ }
162
+ while (queue.length) {
163
+ const file = /** @type {string} */ (queue.shift());
164
+ // Stop at server-file boundaries. The file itself stays in the
165
+ // visited set so its URL is servable (yields a stub at request
166
+ // time), but we don't add its imports because the browser never
167
+ // sees them.
168
+ if (SERVER_FILE_RE.test(file)) continue;
169
+ const deps = graph.get(file);
170
+ if (!deps) continue;
171
+ for (const dep of deps) {
172
+ if (visited.has(dep)) continue;
173
+ if (!dep.startsWith(appDir)) continue;
174
+ visited.add(dep);
175
+ queue.push(dep);
176
+ }
177
+ }
178
+ return visited;
179
+ }
180
+
73
181
  /**
74
182
  * Recursively walk a directory, parse imports, and populate the graph.
75
183
  * @param {string} dir
76
184
  * @param {string} appDir
77
185
  * @param {ModuleGraph} graph
78
186
  */
79
- async function walk(dir, appDir, graph) {
187
+ async function walk(dir, appDir, graph, seen) {
80
188
  let entries;
81
189
  try { entries = await readdir(dir, { withFileTypes: true }); }
82
190
  catch { return; }
83
191
  for (const e of entries) {
84
- if (e.name === 'node_modules' || e.name === '.webjs' || e.name === 'public' || e.name.startsWith('_')) continue;
192
+ // Skip filesystem locations the browser-bound graph never
193
+ // touches: node_modules (huge, npm deps reach the browser via
194
+ // the importmap, not direct fs paths), .webjs (framework cache),
195
+ // public/ (served by a separate route with its own containment
196
+ // check). Do NOT skip `_*` dirs: the `_private` / `_components`
197
+ // / `_lib` convention is a ROUTER-ignore mechanism (router.js
198
+ // line 100), but files inside are still importable by pages and
199
+ // layouts, so the graph walker must enter them or the gate
200
+ // 404s legitimate imports.
201
+ if (e.name === 'node_modules' || e.name === '.webjs' || e.name === 'public') continue;
202
+ if (e.name.startsWith('.')) continue;
85
203
  const full = join(dir, e.name);
86
204
  if (e.isDirectory()) {
87
- await walk(full, appDir, graph);
205
+ await walk(full, appDir, graph, seen);
88
206
  } else if (/\.(js|ts|mjs|mts)$/.test(e.name)) {
89
- await parseFile(full, appDir, graph);
207
+ await parseFile(full, appDir, graph, seen);
90
208
  }
91
209
  }
92
210
  }
93
211
 
212
+ /**
213
+ * mtime-keyed parse cache so a rebuild re-reads only files that actually
214
+ * changed. `buildModuleGraph` re-walks the (cheap) directory tree on every
215
+ * rebuild, but reading + regex-parsing each file is the cost; on an unchanged
216
+ * file the cached import set is reused after a single `stat`. This makes
217
+ * rebuilds incremental for large apps without restructuring the caller.
218
+ * Keyed by mtime AND size: a same-tick edit that also changes the file length
219
+ * is caught even on coarse-resolution filesystems where mtime alone could miss.
220
+ * @type {Map<string, { mtimeMs: number, size: number, deps: Set<string> }>}
221
+ */
222
+ const PARSE_CACHE = new Map();
223
+
224
+ /** Introspection for tests/ops: is `file` currently in the parse cache? */
225
+ export function _parseCacheHas(file) { return PARSE_CACHE.has(file); }
226
+
94
227
  /**
95
228
  * Parse a single file's imports and add them to the graph.
96
229
  * Only resolves relative imports (bare specifiers are npm deps, not in the graph).
@@ -99,19 +232,32 @@ async function walk(dir, appDir, graph) {
99
232
  * @param {string} appDir
100
233
  * @param {ModuleGraph} graph
101
234
  */
102
- async function parseFile(file, appDir, graph) {
235
+ async function parseFile(file, appDir, graph, seen) {
236
+ let mtimeMs, size;
237
+ try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; }
238
+ catch { return; }
239
+ seen?.add(file); // mark live (both cache-hit and miss paths) for cache eviction
240
+ const cached = PARSE_CACHE.get(file);
241
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
242
+ if (cached.deps.size) graph.set(file, cached.deps);
243
+ return;
244
+ }
245
+
103
246
  let src;
104
247
  try { src = await readFile(file, 'utf8'); }
105
248
  catch { return; }
106
249
 
107
250
  const deps = new Set();
108
- for (const m of src.matchAll(IMPORT_RE)) {
109
- const spec = m[1];
110
- // Only resolve relative imports within the project
111
- if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
112
- const resolved = resolveImport(spec, file, appDir);
113
- if (resolved) deps.add(resolved);
251
+ for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
252
+ for (const m of src.matchAll(re)) {
253
+ const spec = m[1];
254
+ // Only resolve relative imports within the project.
255
+ if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
256
+ const resolved = resolveImport(spec, file, appDir);
257
+ if (resolved) deps.add(resolved);
258
+ }
114
259
  }
260
+ PARSE_CACHE.set(file, { mtimeMs, size, deps });
115
261
  if (deps.size) graph.set(file, deps);
116
262
  }
117
263
 
@@ -124,7 +270,7 @@ async function parseFile(file, appDir, graph) {
124
270
  * @param {string} appDir
125
271
  * @returns {string | null}
126
272
  */
127
- function resolveImport(spec, fromFile, appDir) {
273
+ export function resolveImport(spec, fromFile, appDir) {
128
274
  const base = dirname(fromFile);
129
275
  let target;
130
276
  if (spec.startsWith('/')) {
@@ -133,9 +279,37 @@ function resolveImport(spec, fromFile, appDir) {
133
279
  } else {
134
280
  target = resolve(base, spec);
135
281
  }
136
- // Exact match check: we can't use async `stat` in a sync resolver, so we
137
- // store the resolved path optimistically. The graph is advisory (for preload
138
- // hints), not load-bearing, so a wrong entry is harmless: the browser will
139
- // just get a redundant preload that 404s and is ignored.
282
+ // Sync exact-then-fallback resolution. The graph is advisory (it
283
+ // drives preload hints, not module loading), so a wrong entry is
284
+ // harmless: the browser just gets a redundant preload that 404s.
285
+ // But emitting a working modulepreload when the user wrote
286
+ // `import x from './foo'` (no extension) is much better than
287
+ // leaving the resolver waterfall to discover it lazily, so probe
288
+ // existsSync for the common fallbacks the JSDoc above promises.
289
+ if (existsSync(target)) return target;
290
+ if (!extname(target)) {
291
+ for (const ext of ['.ts', '.js', '.mts', '.mjs']) {
292
+ if (existsSync(target + ext)) return target + ext;
293
+ }
294
+ for (const ext of ['.ts', '.js']) {
295
+ const indexed = join(target, 'index' + ext);
296
+ if (existsSync(indexed)) return indexed;
297
+ }
298
+ }
299
+ // `.js` import maps to a `.ts` sibling: TypeScript's "rewrite to
300
+ // .js at runtime" convention. The browser asks for the `.js`
301
+ // path; the dev server's source branch falls through to the
302
+ // sibling. Mirror that here so the resolved path matches the
303
+ // file actually on disk (and the authorization gate sees the
304
+ // same path the request handler resolves to).
305
+ if (/\.js$/.test(target)) {
306
+ const tsAbs = target.replace(/\.js$/, '.ts');
307
+ if (existsSync(tsAbs)) return tsAbs;
308
+ const mtsAbs = target.replace(/\.js$/, '.mts');
309
+ if (existsSync(mtsAbs)) return mtsAbs;
310
+ }
311
+ // Optimistic fallback: return the original resolution so the graph
312
+ // still has an entry, even though the path may 404 on the browser.
313
+ // Matches prior behavior.
140
314
  return target;
141
315
  }
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 : defaultKey;
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
- // Use the provided store, or fall back to the global cache store -
44
- // whatever was set via `setStore()` at app startup (in-memory by default).
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 = typeof opts.key === 'function' ? await keyFn(req) : defaultKey(req);
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
- /** @param {Request} req */
81
- function defaultKey(req) {
82
- return (
83
- req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
84
- req.headers.get('cf-connecting-ip') ||
85
- req.headers.get('x-real-ip') ||
86
- '_anon_'
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
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
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 || randomBytes(24).toString('base64url');
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 = randomBytes(24).toString('base64url');
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
- return `${value}.${createHmac('sha256', secret).update(value).digest('base64url')}`;
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
- const expected = createHmac('sha256', secret).update(value).digest('base64url');
216
- const sigBuf = Buffer.from(input.slice(idx + 1));
217
- const expBuf = Buffer.from(expected);
218
- if (sigBuf.length !== expBuf.length) return null;
219
- if (!timingSafeEqual(sigBuf, expBuf)) return null;
220
- return value;
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