@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.
@@ -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
- if (e.name === 'node_modules' || e.name === '.webjs' || e.name === 'public' || e.name.startsWith('_')) continue;
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 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);
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
- // 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.
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 : 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