@tlockney/cdp-kernel 0.4.0 → 0.5.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.
@@ -9,15 +9,19 @@
9
9
  // one package can coexist (Deno keeps a global cache the same way).
10
10
  // - jsr:@scope/name[@range][/subpath] maps onto JSR's npm compatibility
11
11
  // registry (npm.jsr.io) as @jsr/scope__name.
12
+ // - TypeScript files a cell imports load with Node's own type stripping
13
+ // where it can; syntax that needs code generation (enum, namespaces with
14
+ // values, parameter properties) is compiled by esbuild instead (#73).
15
+ // Node can't load JSX at all, so .tsx/.jsx files are always compiled (#91).
12
16
  // Uses module.registerHooks (synchronous, in-thread; Node >= 23.5), so the
13
17
  // install can run with spawnSync inside resolve.
14
18
 
15
19
  // Not a named import: Bun, which loads this file for its npm:/jsr: helpers,
16
20
  // has no registerHooks, and a missing named export fails at link time.
17
21
  import module from "node:module";
18
- import { spawnSync } from "node:child_process";
22
+ import { spawn, spawnSync } from "node:child_process";
19
23
  import { createHash } from "node:crypto";
20
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
24
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
21
25
  import { homedir } from "node:os";
22
26
  import { dirname, join } from "node:path";
23
27
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -44,12 +48,19 @@ export function aliasFor(name, range) {
44
48
  return `cdpk-${digest}`;
45
49
  }
46
50
 
47
- // Each installer keeps its own cache, so npm's and Bun's lockfiles never
48
- // meet; both read the @jsr registry from .npmrc.
51
+ /**
52
+ * Each installer keeps its own cache, so npm's and Bun's lockfiles never
53
+ * meet. Both sit beside the runtime cache (cacheRoot in src/package.ts);
54
+ * CDPK_CACHE_DIR overrides npm's.
55
+ */
56
+ export function cacheDirPath(installer, env = process.env, home = homedir()) {
57
+ if (installer === "npm" && env.CDPK_CACHE_DIR) return env.CDPK_CACHE_DIR;
58
+ return join(env.XDG_CACHE_HOME || join(home, ".cache"), "cdp-kernel", installer);
59
+ }
60
+
61
+ // Both caches read the @jsr registry from .npmrc.
49
62
  function cacheDir(installer) {
50
- const dir = installer === "npm"
51
- ? process.env.CDPK_CACHE_DIR ?? join(homedir(), ".cache", "cdp-kernel", "npm")
52
- : join(homedir(), ".cache", "cdp-kernel", installer);
63
+ const dir = cacheDirPath(installer);
53
64
  if (!existsSync(join(dir, "package.json"))) {
54
65
  mkdirSync(dir, { recursive: true });
55
66
  writeFileSync(join(dir, "package.json"), '{ "private": true }\n');
@@ -94,32 +105,55 @@ export function findLockFile(dir) {
94
105
 
95
106
  /** `{ version: 1, packages: { "name@range": "exact version" } }`; `{}` counts as empty. */
96
107
  export function readLock(path) {
97
- const parsed = JSON.parse(readFileSync(path, "utf8") || "{}");
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(readFileSync(path, "utf8") || "{}");
111
+ } catch (err) {
112
+ throw new Error(`cannot read ${path}: ${err instanceof Error ? err.message : err}`);
113
+ }
98
114
  const packages = parsed && typeof parsed.packages === "object" && parsed.packages !== null ? parsed.packages : {};
99
115
  return { version: 1, packages };
100
116
  }
101
117
 
102
- /** Sorted, so a lockfile's diff shows only what changed. */
118
+ /**
119
+ * Sorted, so a lockfile's diff shows only what changed. Written to a
120
+ * temporary file and renamed into place, so a crash mid-write can't leave
121
+ * a truncated lockfile (#144).
122
+ */
103
123
  export function writeLock(path, lock) {
104
124
  const packages = Object.fromEntries(Object.entries(lock.packages).sort(([a], [b]) => a.localeCompare(b)));
105
- writeFileSync(path, JSON.stringify({ version: 1, packages }, null, 2) + "\n");
125
+ const tmp = `${path}.${process.pid}.tmp`;
126
+ writeFileSync(tmp, JSON.stringify({ version: 1, packages }, null, 2) + "\n");
127
+ renameSync(tmp, path);
106
128
  }
107
129
 
108
130
  // --- Serialized installs (#14) -------------------------------------------------
109
131
  // Kernels share one cache per installer, and npm/bun both rewrite its
110
132
  // package.json and node_modules, so installs take a lock: a directory, which
111
- // mkdir creates atomically. A lock left by a process that died is taken over.
133
+ // mkdir creates atomically. A lock left by a process that died is taken over;
134
+ // one whose holder is alive is waited on.
112
135
 
113
136
  const LOCK_DIR = ".install-lock";
114
- const STALE_MS = 10 * 60 * 1000;
137
+ /** How long a waiter waits for a live holder before giving up. */
138
+ const WAIT_MS = 10 * 60 * 1000;
139
+ /** A lock this old is taken over even if its pid is alive: the pid was reused. */
140
+ const ABANDONED_MS = 60 * 60 * 1000;
115
141
 
116
142
  function sleep(ms) {
117
143
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
118
144
  }
119
145
 
146
+ function holderPid(lockDir) {
147
+ try {
148
+ return Number(readFileSync(join(lockDir, "pid"), "utf8"));
149
+ } catch {
150
+ return NaN;
151
+ }
152
+ }
153
+
120
154
  function holderAlive(lockDir) {
121
155
  try {
122
- const pid = Number(readFileSync(join(lockDir, "pid"), "utf8"));
156
+ const pid = holderPid(lockDir);
123
157
  if (!Number.isInteger(pid) || pid <= 0) return false;
124
158
  process.kill(pid, 0);
125
159
  return true;
@@ -129,15 +163,13 @@ function holderAlive(lockDir) {
129
163
  }
130
164
  }
131
165
 
132
- /** Runs `fn` holding the cache's install lock. Synchronous, like the resolve hook. */
133
- export function withInstallLock(dir, fn, { timeoutMs = STALE_MS } = {}) {
134
- const lockDir = join(dir, LOCK_DIR);
135
- const deadline = Date.now() + timeoutMs;
166
+ /** Takes the install lock at `lockDir` if it's free: false means wait and try again. */
167
+ function tryLock(lockDir, deadline) {
136
168
  for (;;) {
137
169
  try {
138
170
  mkdirSync(lockDir);
139
171
  writeFileSync(join(lockDir, "pid"), String(process.pid));
140
- break;
172
+ return true;
141
173
  } catch (err) {
142
174
  if (err?.code !== "EEXIST") throw err;
143
175
  let age = 0;
@@ -146,19 +178,46 @@ export function withInstallLock(dir, fn, { timeoutMs = STALE_MS } = {}) {
146
178
  } catch {
147
179
  continue; // released meanwhile
148
180
  }
149
- // A holder that died, or one stuck for longer than any install takes.
150
- if ((age > 1000 && !holderAlive(lockDir)) || age > STALE_MS) {
181
+ // A holder that died. The grace period covers the gap between mkdir
182
+ // and writing the pid. A live holder is waited on rather than stolen
183
+ // from, which would run a second install alongside its own (#144),
184
+ // unless the lock is so old its pid must have been reused.
185
+ if ((age > 1000 && !holderAlive(lockDir)) || age > ABANDONED_MS) {
151
186
  rmSync(lockDir, { recursive: true, force: true });
152
187
  continue;
153
188
  }
154
189
  if (Date.now() > deadline) throw new Error(`timed out waiting for the install lock ${lockDir}`);
155
- sleep(100);
190
+ return false;
156
191
  }
157
192
  }
193
+ }
194
+
195
+ /** Removes the lock only if still ours: a waiter that took it over owns it now. */
196
+ function releaseLock(lockDir) {
197
+ if (holderPid(lockDir) === process.pid) rmSync(lockDir, { recursive: true, force: true });
198
+ }
199
+
200
+ /** Runs `fn` holding the cache's install lock. Synchronous, like the resolve hook. */
201
+ export function withInstallLock(dir, fn, { timeoutMs = WAIT_MS } = {}) {
202
+ const lockDir = join(dir, LOCK_DIR);
203
+ const deadline = Date.now() + timeoutMs;
204
+ while (!tryLock(lockDir, deadline)) sleep(100);
158
205
  try {
159
206
  return fn();
160
207
  } finally {
161
- rmSync(lockDir, { recursive: true, force: true });
208
+ releaseLock(lockDir);
209
+ }
210
+ }
211
+
212
+ /** withInstallLock for a caller that mustn't block its event loop while it waits. */
213
+ export async function withInstallLockAsync(dir, fn, { timeoutMs = WAIT_MS } = {}) {
214
+ const lockDir = join(dir, LOCK_DIR);
215
+ const deadline = Date.now() + timeoutMs;
216
+ while (!tryLock(lockDir, deadline)) await new Promise((resolve) => setTimeout(resolve, 100));
217
+ try {
218
+ return await fn();
219
+ } finally {
220
+ releaseLock(lockDir);
162
221
  }
163
222
  }
164
223
 
@@ -170,8 +229,62 @@ function installedVersion(dir, alias) {
170
229
  }
171
230
  }
172
231
 
232
+ /**
233
+ * How to spawn an install command on `platform`. On Windows npm is npm.cmd,
234
+ * which Node runs only through a shell (#51). cmd.exe reads ^ < > | & as
235
+ * operators, and semver ranges use them, so each argument is double-quoted,
236
+ * where cmd.exe leaves them alone. A " or % would still be read, so they're
237
+ * refused; no package name or valid range contains either.
238
+ */
239
+ export function spawnCommand(cmd, args, platform = process.platform) {
240
+ if (platform !== "win32" || cmd !== "npm") return { cmd, args, shell: false };
241
+ const bad = args.find((arg) => /["%\r\n]/.test(arg));
242
+ if (bad !== undefined) throw new TypeError(`can't pass ${JSON.stringify(bad)} to npm through cmd.exe`);
243
+ return { cmd, args: args.map((arg) => `"${arg}"`), shell: true };
244
+ }
245
+
173
246
  function spawnInstall(cmd, args, cwd, env = {}) {
174
- return spawnSync(cmd, args, { cwd, encoding: "utf8", env: { ...process.env, ...env } });
247
+ const spawned = spawnCommand(cmd, args);
248
+ return spawnSync(spawned.cmd, spawned.args, { cwd, encoding: "utf8", env: { ...process.env, ...env }, shell: spawned.shell });
249
+ }
250
+
251
+ /** spawnInstall without blocking: the kernel host keeps answering while it runs (#149). */
252
+ function spawnInstallAsync(cmd, args, cwd, env = {}) {
253
+ const spawned = spawnCommand(cmd, args);
254
+ return new Promise((resolve) => {
255
+ let stderr = "";
256
+ const child = spawn(spawned.cmd, spawned.args, {
257
+ cwd,
258
+ env: { ...process.env, ...env },
259
+ shell: spawned.shell,
260
+ stdio: ["ignore", "ignore", "pipe"],
261
+ });
262
+ child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
263
+ child.on("error", (error) => resolve({ status: null, stderr, error }));
264
+ child.on("close", (status) => resolve({ status, stderr }));
265
+ });
266
+ }
267
+
268
+ /** The version to install for `name@range`: the locked one, if the lockfile has it. */
269
+ function lockedRequest(name, range, lockFile) {
270
+ const lock = lockFile ? readLock(lockFile) : null;
271
+ const locked = lock?.packages[`${name}@${range}`];
272
+ const version = typeof locked === "string" ? locked : range;
273
+ return { lock, locked: typeof locked === "string", version, alias: aliasFor(name, version) };
274
+ }
275
+
276
+ /** Records an unlocked request's installed version in the lockfile. */
277
+ function recordLock(dir, name, range, lockFile, { lock, locked, alias }) {
278
+ if (!lock || locked) return;
279
+ const resolved = installedVersion(dir, alias);
280
+ if (resolved !== null) {
281
+ lock.packages[`${name}@${range}`] = resolved;
282
+ writeLock(lockFile, lock);
283
+ }
284
+ }
285
+
286
+ function checkInstall(result, installer, name, version) {
287
+ if (result.status !== 0) throw new Error(`${installer} install ${name}@${version} failed:\n${result.stderr || result.error}`);
175
288
  }
176
289
 
177
290
  /**
@@ -182,32 +295,124 @@ function spawnInstall(cmd, args, cwd, env = {}) {
182
295
  */
183
296
  export function ensureInstalled({ name, range }, installer = "npm", { lockFile = null, run = spawnInstall } = {}) {
184
297
  const dir = cacheDir(installer);
185
- const key = `${name}@${range}`;
186
298
  return withInstallLock(dir, () => {
187
- const lock = lockFile ? readLock(lockFile) : null;
188
- const locked = lock?.packages[key];
189
- const version = typeof locked === "string" ? locked : range;
190
- const alias = aliasFor(name, version);
191
- if (installedVersion(dir, alias) === null) {
192
- const { cmd, args, env } = installCommand(installer, alias, name, version);
193
- const result = run(cmd, args, dir, env);
194
- if (result.status !== 0) {
195
- throw new Error(`${installer} install ${name}@${version} failed:\n${result.stderr || result.error}`);
196
- }
299
+ const request = lockedRequest(name, range, lockFile);
300
+ if (installedVersion(dir, request.alias) === null) {
301
+ const { cmd, args, env } = installCommand(installer, request.alias, name, request.version);
302
+ checkInstall(run(cmd, args, dir, env), installer, name, request.version);
197
303
  }
198
- if (lock && typeof locked !== "string") {
199
- const resolved = installedVersion(dir, alias);
200
- if (resolved !== null) {
201
- lock.packages[key] = resolved;
202
- writeLock(lockFile, lock);
203
- }
304
+ recordLock(dir, name, range, lockFile, request);
305
+ return { dir, alias: request.alias };
306
+ });
307
+ }
308
+
309
+ /** ensureInstalled for the kernel host (workerd's bundler), which mustn't block (#149). */
310
+ export function ensureInstalledAsync({ name, range }, installer = "npm", { lockFile = null, run = spawnInstallAsync } = {}) {
311
+ const dir = cacheDir(installer);
312
+ return withInstallLockAsync(dir, async () => {
313
+ const request = lockedRequest(name, range, lockFile);
314
+ if (installedVersion(dir, request.alias) === null) {
315
+ const { cmd, args, env } = installCommand(installer, request.alias, name, request.version);
316
+ checkInstall(await run(cmd, args, dir, env), installer, name, request.version);
204
317
  }
205
- return { dir, alias };
318
+ recordLock(dir, name, range, lockFile, request);
319
+ return { dir, alias: request.alias };
206
320
  });
207
321
  }
208
322
 
323
+ // --- Imported TypeScript (#73) --------------------------------------------------
324
+ // Node strips types in place, so line and column numbers stay those of the
325
+ // file, but it rejects TypeScript that needs code generation. Such a file
326
+ // is compiled with esbuild, as cells are, with an inline source map. The
327
+ // host passes its esbuild binary in CDPK_ESBUILD_BINARY: the runtime files
328
+ // may have been copied away from any node_modules (src/package.ts), and the
329
+ // esbuild binary, unlike its JavaScript API, works from anywhere.
330
+
331
+ // Both match a file: URL's path, so a query or fragment (`./x.ts?v=2`, the
332
+ // cache-busting idiom for a module being edited) doesn't hide the extension (#145).
333
+ const TS_FILE = /\.[cm]?ts$/;
334
+ // Node has no format for these extensions, so its loader is never asked.
335
+ const JSX_FILE = /\.([jt]sx)$/;
336
+ const TS_FORMATS = { "module-typescript": "module", "commonjs-typescript": "commonjs" };
337
+
338
+ function isUnsupportedSyntax(err) {
339
+ return err?.code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX";
340
+ }
341
+
342
+ /** Node's strip-only transform, without its once-per-process ExperimentalWarning. */
343
+ function stripTypes(source) {
344
+ const emitWarning = process.emitWarning;
345
+ process.emitWarning = function (warning, ...rest) {
346
+ if (String(warning?.message ?? warning).includes("stripTypeScriptTypes")) return;
347
+ return emitWarning.call(this, warning, ...rest);
348
+ };
349
+ try {
350
+ return module.stripTypeScriptTypes(source, { mode: "strip" });
351
+ } finally {
352
+ process.emitWarning = emitWarning;
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Compiles one TypeScript (or, with loader "tsx"/"jsx", JSX) file with the
358
+ * esbuild binary at `binary`, as cells are compiled. JSX takes esbuild's
359
+ * default, classic React.createElement, which is what cells use on Node
360
+ * (no project config there); a file's own @jsx/@jsxImportSource pragma wins.
361
+ */
362
+ export function compileTypeScript(source, filename, binary, run = spawnSync, loader = "ts") {
363
+ const args = [
364
+ `--loader=${loader}`,
365
+ "--target=esnext",
366
+ "--sourcemap=inline",
367
+ `--sourcefile=${filename}`,
368
+ '--tsconfig-raw={"compilerOptions":{"verbatimModuleSyntax":true}}',
369
+ "--log-level=error",
370
+ "--color=false",
371
+ ];
372
+ const result = run(binary, args, { input: source, encoding: "utf8", maxBuffer: 1 << 30 });
373
+ if (result.status !== 0) {
374
+ throw new SyntaxError(`${filename}: ${(result.stderr || String(result.error)).trim()}`);
375
+ }
376
+ return result.stdout;
377
+ }
378
+
379
+ /** The load hook: Node's result unless it would reject the file's TypeScript. */
380
+ export function loadTypeScript(url, context, nextLoad, binary) {
381
+ const path = url.startsWith("file:") ? new URL(url).pathname : "";
382
+ const jsx = JSX_FILE.exec(path);
383
+ if (binary && jsx) {
384
+ // Notebooks import ES modules, so it's compiled as one.
385
+ const filename = fileURLToPath(url);
386
+ const source = compileTypeScript(readFileSync(filename, "utf8"), filename, binary, spawnSync, jsx[1]);
387
+ return { format: "module", source, shortCircuit: true };
388
+ }
389
+ if (!binary || !TS_FILE.test(path)) return nextLoad(url, context);
390
+ let result;
391
+ try {
392
+ result = nextLoad(url, context);
393
+ } catch (err) {
394
+ // A .ts file outside a package.json "type" is stripped while Node
395
+ // detects its format, so the rejection surfaces here. Notebooks import
396
+ // ES modules, so it's compiled as one.
397
+ if (!isUnsupportedSyntax(err)) throw err;
398
+ const filename = fileURLToPath(url);
399
+ return { format: "module", source: compileTypeScript(readFileSync(filename, "utf8"), filename, binary), shortCircuit: true };
400
+ }
401
+ const format = TS_FORMATS[result.format];
402
+ if (format === undefined || typeof module.stripTypeScriptTypes !== "function") return result;
403
+ const source = typeof result.source === "string" ? result.source : new TextDecoder().decode(result.source);
404
+ try {
405
+ return { ...result, format, source: stripTypes(source) };
406
+ } catch (err) {
407
+ if (!isUnsupportedSyntax(err)) throw err;
408
+ return { ...result, format, source: compileTypeScript(source, fileURLToPath(url), binary) };
409
+ }
410
+ }
411
+
209
412
  export function installNodeResolveHooks({ shimUrl, replUrl }) {
413
+ const esbuild = process.env.CDPK_ESBUILD_BINARY;
210
414
  module.registerHooks({
415
+ load: (url, context, nextLoad) => loadTypeScript(url, context, nextLoad, esbuild),
211
416
  resolve(specifier, context, nextResolve) {
212
417
  let pkg = null;
213
418
  if (specifier.startsWith("npm:")) pkg = parsePackageSpecifier(specifier.slice(4));
@@ -51,6 +51,44 @@ Object.assign(console, {
51
51
  error: stream("stderr"),
52
52
  });
53
53
 
54
+ // An error thrown later by a cell's timer or promise goes to stderr, as on
55
+ // the other targets (runtime/bootstrap.mjs). Workers has no global "error"
56
+ // event, so timer callbacks report their own (#148), and so do the
57
+ // promises they return: celld doesn't dispatch "unhandledrejection".
58
+ function reportUncaught(kind, err) {
59
+ emit({ kind: "stream", name: "stderr", text: `${kind}: ${err instanceof Error ? (err.stack ?? err.message) : inspect(err)}\n` });
60
+ }
61
+ // A rejection both runtimes' events report is shown once.
62
+ const reported = new WeakSet();
63
+ function reportRejection(reason) {
64
+ if (typeof reason === "object" && reason !== null) {
65
+ if (reported.has(reason)) return;
66
+ reported.add(reason);
67
+ }
68
+ reportUncaught("Uncaught (in promise)", reason);
69
+ }
70
+ globalThis.addEventListener("unhandledrejection", (event) => {
71
+ event.preventDefault();
72
+ reportRejection(event.reason);
73
+ });
74
+ globalThis.process?.on?.("unhandledRejection", reportRejection);
75
+ const reporting = (callback) =>
76
+ typeof callback !== "function" ? callback : function (...args) {
77
+ let result;
78
+ try {
79
+ result = callback.apply(this, args);
80
+ } catch (err) {
81
+ reportUncaught("Uncaught", err);
82
+ return;
83
+ }
84
+ if (typeof result?.then === "function") result.then(undefined, reportRejection);
85
+ return result;
86
+ };
87
+ for (const name of ["setTimeout", "setInterval", "queueMicrotask"]) {
88
+ const original = globalThis[name];
89
+ globalThis[name] = { [name]: (callback, ...rest) => original(reporting(callback), ...rest) }[name];
90
+ }
91
+
54
92
  const jupyter = createJupyter({
55
93
  inspect: (value) => inspect(value, { colors: true, depth: 4 }),
56
94
  base64,
@@ -131,13 +169,23 @@ function install(env) {
131
169
 
132
170
  let evaluate = null;
133
171
 
172
+ // Modules whose load rejected. Importing one again gives its error again,
173
+ // and the next eval reply names it, so the host bundles it afresh for the
174
+ // next import (#147), which replaces it here.
175
+ const rejected = new Set();
176
+ const unreported = new Set();
177
+
134
178
  /** Evaluates the bundles a cell brings, once each. */
135
179
  async function loadModules(bundles = {}) {
136
180
  for (const [specifier, script] of Object.entries(bundles)) {
137
- if (modules.has(specifier)) continue;
181
+ if (modules.has(specifier) && !rejected.has(specifier)) continue;
182
+ rejected.delete(specifier);
138
183
  const loading = Promise.resolve().then(() => evaluate(script));
139
- // A module that failed to load is retried by the next import of it.
140
- loading.catch(() => modules.delete(specifier));
184
+ loading.catch(() => {
185
+ if (modules.get(specifier) !== loading) return;
186
+ rejected.add(specifier);
187
+ unreported.add(specifier);
188
+ });
141
189
  modules.set(specifier, loading);
142
190
  }
143
191
  }
@@ -151,8 +199,31 @@ async function answer(req) {
151
199
  worker: globalThis.worker !== undefined,
152
200
  };
153
201
  }
154
- if (req.op === "eval") await loadModules(req.modules);
155
- return await handle(req);
202
+ if (req.op !== "eval") return await handle(req);
203
+ await loadModules(req.modules);
204
+ const reply = await handle(req);
205
+ // After the cell, so a load that failed in it is counted.
206
+ const failedModules = [...unreported];
207
+ unreported.clear();
208
+ return { ...reply, failedModules };
209
+ }
210
+
211
+ const encoder = new TextEncoder();
212
+
213
+ /**
214
+ * Compares in constant time: under celld and Miniflare the driver listens on
215
+ * loopback TCP, where any local user can try tokens. The length (fixed by
216
+ * the host) is not secret.
217
+ */
218
+ function tokenMatches(token, expected) {
219
+ if (typeof token !== "string" || typeof expected !== "string") return false;
220
+ const a = encoder.encode(token);
221
+ const b = encoder.encode(expected);
222
+ if (a.byteLength !== b.byteLength) return false;
223
+ if (typeof crypto.subtle.timingSafeEqual === "function") return crypto.subtle.timingSafeEqual(a, b);
224
+ let diff = 0;
225
+ for (let i = 0; i < a.byteLength; i++) diff |= a[i] ^ b[i];
226
+ return diff === 0;
156
227
  }
157
228
 
158
229
  export default {
@@ -160,7 +231,7 @@ export default {
160
231
  // A header, or the query string for a WebSocket client that can't set
161
232
  // headers (celld's session uses the host's built-in WebSocket).
162
233
  const token = request.headers.get("x-cdpk-token") ?? new URL(request.url).searchParams.get("token");
163
- if (token !== env.TOKEN) return new Response("forbidden", { status: 403 });
234
+ if (!tokenMatches(token, env.TOKEN)) return new Response("forbidden", { status: 403 });
164
235
  if (request.headers.get("upgrade") !== "websocket") return new Response("expected a WebSocket", { status: 426 });
165
236
  evaluate ??= install(env);
166
237