@tlockney/cdp-kernel 0.3.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.
Files changed (56) hide show
  1. package/README.md +60 -404
  2. package/THIRD-PARTY-NOTICES +62 -0
  3. package/dist/bun-target.d.ts +4 -2
  4. package/dist/bun-target.js +35 -8
  5. package/dist/cdp.d.ts +6 -1
  6. package/dist/cdp.js +25 -10
  7. package/dist/celld-target.d.ts +1 -1
  8. package/dist/celld-target.js +46 -8
  9. package/dist/children.d.ts +7 -0
  10. package/dist/children.js +32 -0
  11. package/dist/debugger.d.ts +42 -0
  12. package/dist/debugger.js +545 -0
  13. package/dist/globals.d.ts +3 -3
  14. package/dist/globals.js +3 -3
  15. package/dist/history.d.ts +18 -0
  16. package/dist/history.js +45 -0
  17. package/dist/kernel.js +211 -32
  18. package/dist/main.js +3 -1
  19. package/dist/mod.d.ts +5 -3
  20. package/dist/mod.js +5 -3
  21. package/dist/murmur2.d.ts +2 -0
  22. package/dist/murmur2.js +41 -0
  23. package/dist/package.d.ts +16 -2
  24. package/dist/package.js +26 -3
  25. package/dist/sourcemap.d.ts +22 -0
  26. package/dist/sourcemap.js +86 -0
  27. package/dist/target.d.ts +39 -3
  28. package/dist/target.js +281 -30
  29. package/dist/text.d.ts +11 -1
  30. package/dist/text.js +72 -2
  31. package/dist/transform.d.ts +19 -0
  32. package/dist/transform.js +74 -13
  33. package/dist/wire.d.ts +8 -1
  34. package/dist/wire.js +3 -1
  35. package/dist/workerd-target.d.ts +9 -1
  36. package/dist/workerd-target.js +108 -9
  37. package/dist/ws-client.d.ts +5 -1
  38. package/dist/ws-client.js +46 -13
  39. package/dist/zmtp.d.ts +7 -1
  40. package/dist/zmtp.js +30 -1
  41. package/package.json +10 -4
  42. package/runtime/bootstrap.mjs +138 -12
  43. package/runtime/bun-channel.d.mts +3 -0
  44. package/runtime/bun-channel.mjs +2 -2
  45. package/runtime/comm.mjs +11 -1
  46. package/runtime/deno-compat.d.mts +2 -0
  47. package/runtime/deno-compat.mjs +17 -2
  48. package/runtime/display.d.mts +2 -0
  49. package/runtime/display.mjs +17 -2
  50. package/runtime/evaluator.d.mts +4 -0
  51. package/runtime/evaluator.mjs +8 -0
  52. package/runtime/introspect.d.mts +13 -0
  53. package/runtime/introspect.mjs +120 -0
  54. package/runtime/node-hooks.d.mts +61 -1
  55. package/runtime/node-hooks.mjs +354 -21
  56. package/runtime/workerd-worker.mjs +82 -6
@@ -66,6 +66,12 @@ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
66
66
  return { error: JSON.parse(shim.describeError(err)) };
67
67
  }
68
68
  case "props": {
69
+ // Completion's expression is a dotted path; resolved by descriptor
70
+ // where the shim can, so completing `obj.` never runs a getter (#10).
71
+ if (typeof shim.resolvePath === "function") {
72
+ const resolved = shim.resolvePath(request.expr);
73
+ return { names: resolved.found && !resolved.accessor ? propertyNames(resolved.value) : [] };
74
+ }
69
75
  let value;
70
76
  try {
71
77
  value = evaluate(`(${request.expr})`);
@@ -74,6 +80,8 @@ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
74
80
  }
75
81
  return { names: propertyNames(value) };
76
82
  }
83
+ case "inspect":
84
+ return { text: shim.inspectPath(request.path, request.detail ?? 0) };
77
85
  case "barrier":
78
86
  return { value: shim.barrier() };
79
87
  default:
@@ -0,0 +1,13 @@
1
+ export type Resolved =
2
+ | { found: false }
3
+ | { found: true; accessor: true; name: string }
4
+ | { found: true; value: unknown; accessor?: undefined };
5
+
6
+ export function resolvePath(path: string, root?: object): Resolved;
7
+ // deno-lint-ignore ban-types -- classes and functions alike
8
+ export function signatureOf(fn: Function): string;
9
+ export function describeValue(value: unknown, options: { inspect: (value: unknown) => string; detail?: number }): string;
10
+ export function inspectPath(
11
+ path: string,
12
+ options: { inspect: (value: unknown) => string; detail?: number; root?: object },
13
+ ): string | null;
@@ -0,0 +1,120 @@
1
+ // What inspect_request (#10) shows for a value: the text of a JupyterLab
2
+ // tooltip (Shift-Tab) or inspector panel. Shared by every target; the host
3
+ // decides how the value is found (src/target.ts: CDP's throwOnSideEffect;
4
+ // in-process targets: resolvePath, below).
5
+ //
6
+ // No imports: this file must load in every target. `inspect` (util.inspect
7
+ // or Deno.inspect) is passed in.
8
+
9
+ const PREVIEW_CHARS = 1500;
10
+ const SOURCE_CHARS = 4000;
11
+ const MAX_PROPERTIES = 40;
12
+
13
+ const IDENTIFIER_PATH = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
14
+
15
+ /**
16
+ * Looks up a dotted path such as "a.b.c" from globalThis, by property
17
+ * descriptor, so nothing runs: a getter along the way is reported rather
18
+ * than called. In-process targets (Bun, workerd, celld) have no way to
19
+ * evaluate without side effects, so inspection and completion use this.
20
+ */
21
+ export function resolvePath(path, root = globalThis) {
22
+ if (!IDENTIFIER_PATH.test(path)) return { found: false };
23
+ let value = root;
24
+ for (const name of path.split(".")) {
25
+ if (value === null || value === undefined) return { found: false };
26
+ let descriptor;
27
+ for (let o = Object(value); o !== null && descriptor === undefined; o = Object.getPrototypeOf(o)) {
28
+ descriptor = Object.getOwnPropertyDescriptor(o, name);
29
+ }
30
+ if (descriptor === undefined) return { found: false };
31
+ if (!("value" in descriptor)) return { found: true, accessor: true, name };
32
+ value = descriptor.value;
33
+ }
34
+ return { found: true, value };
35
+ }
36
+
37
+ function truncate(text, limit) {
38
+ return text.length > limit ? `${text.slice(0, limit)}… (${text.length - limit} more characters)` : text;
39
+ }
40
+
41
+ /** "function add(a, b) {...}" -> "add(a, b)"; "class K {...}" -> "class K". */
42
+ export function signatureOf(fn) {
43
+ let source;
44
+ try {
45
+ source = Function.prototype.toString.call(fn);
46
+ } catch {
47
+ return `${fn.name || "anonymous"}(…)`;
48
+ }
49
+ if (/^class\b/.test(source)) return `class ${fn.name || "(anonymous)"}`;
50
+ const params = /^[^(]*\(([^)]*)\)/.exec(source)?.[1];
51
+ const native = /\{\s*\[native code\]\s*\}$/.test(source);
52
+ // One line, however the parameters were spread over the source.
53
+ const written = (params ?? "").replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim();
54
+ const args = native ? Array.from({ length: fn.length }, (_, i) => `arg${i}`).join(", ") : written;
55
+ const arrow = !/^(?:async\s+)?function\b/.test(source) && /=>/.test(source.split("{")[0] ?? "");
56
+ const name = fn.name || (arrow ? "(arrow function)" : "anonymous");
57
+ const prefix = /^async\b/.test(source) ? "async " : "";
58
+ return `${prefix}${name}(${args})`;
59
+ }
60
+
61
+ function typeOf(value) {
62
+ if (value === null) return "null";
63
+ if (typeof value !== "object" && typeof value !== "function") return typeof value;
64
+ const name = Object.getPrototypeOf(value)?.constructor?.name;
65
+ if (typeof value === "function") return /^class\b/.test(safeSource(value)) ? "class" : "function";
66
+ return name && name !== "Object" ? `object (${name})` : "object";
67
+ }
68
+
69
+ function safeSource(fn) {
70
+ try {
71
+ return Function.prototype.toString.call(fn);
72
+ } catch {
73
+ return "";
74
+ }
75
+ }
76
+
77
+ function propertyNames(value) {
78
+ if ((typeof value !== "object" && typeof value !== "function") || value === null) return [];
79
+ try {
80
+ // A function's own internals aren't worth listing; older V8 (Node 24)
81
+ // also gives sloppy-mode functions own `arguments` and `caller`.
82
+ const internal = ["length", "name", "prototype", "arguments", "caller"];
83
+ return Object.getOwnPropertyNames(value).filter((n) => !(typeof value === "function" && internal.includes(n)));
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The tooltip text for a value. `detail` 1 (Shift-Tab twice, or the
91
+ * inspector panel) adds a function's source.
92
+ */
93
+ export function describeValue(value, { inspect, detail = 0 }) {
94
+ const lines = [`Type: ${typeOf(value)}`];
95
+ if (typeof value === "function") lines.push(`Signature: ${signatureOf(value)}`);
96
+ let preview;
97
+ try {
98
+ preview = inspect(value);
99
+ } catch (err) {
100
+ preview = `(inspect failed: ${err instanceof Error ? err.message : String(err)})`;
101
+ }
102
+ lines.push(`Value: ${truncate(preview, PREVIEW_CHARS)}`);
103
+ const names = propertyNames(value);
104
+ if (names.length > 0) {
105
+ const shown = names.slice(0, MAX_PROPERTIES).join(", ");
106
+ lines.push(`Properties: ${shown}${names.length > MAX_PROPERTIES ? `, … (${names.length - MAX_PROPERTIES} more)` : ""}`);
107
+ }
108
+ if (detail > 0 && typeof value === "function") lines.push(`Source:\n${truncate(safeSource(value), SOURCE_CHARS)}`);
109
+ return lines.join("\n");
110
+ }
111
+
112
+ /** inspect_request for in-process targets: resolvePath, then describeValue. */
113
+ export function inspectPath(path, { inspect, detail = 0, root = globalThis }) {
114
+ const resolved = resolvePath(path, root);
115
+ if (!resolved.found) return null;
116
+ if (resolved.accessor) {
117
+ return `Type: accessor property\n${resolved.name} has a getter, which isn't called for inspection: it could have side effects.`;
118
+ }
119
+ return describeValue(resolved.value, { inspect, detail });
120
+ }
@@ -8,14 +8,74 @@ export function parsePackageSpecifier(spec: string): PackageSpecifier;
8
8
  export function jsrToNpm(spec: string): PackageSpecifier;
9
9
  export function aliasFor(name: string, range: string): string;
10
10
  export type Installer = "npm" | "bun";
11
+ /** Where `installer` keeps its packages: $XDG_CACHE_HOME/cdp-kernel/<installer>, or CDPK_CACHE_DIR for npm. */
12
+ export function cacheDirPath(
13
+ installer: Installer,
14
+ env?: { [name: string]: string | undefined },
15
+ home?: string,
16
+ ): string;
11
17
  export function installCommand(
12
18
  installer: Installer,
13
19
  alias: string,
14
20
  name: string,
15
21
  range: string,
16
- ): { cmd: string; args: string[] };
22
+ ): { cmd: string; args: string[]; env?: { [name: string]: string } };
23
+ /** How to spawn an install command; on Windows, npm runs through cmd.exe. */
24
+ export function spawnCommand(
25
+ cmd: string,
26
+ args: string[],
27
+ platform?: string,
28
+ ): { cmd: string; args: string[]; shell: boolean };
29
+ /** Runs an install; `env` is added to the process environment. */
30
+ export type InstallRunner = (
31
+ cmd: string,
32
+ args: string[],
33
+ cwd: string,
34
+ env?: { [name: string]: string },
35
+ ) => { status: number | null; stderr?: string; error?: Error };
17
36
  export function ensureInstalled(
18
37
  pkg: { name: string; range: string },
19
38
  installer?: Installer,
39
+ options?: { lockFile?: string | null; run?: InstallRunner },
20
40
  ): { dir: string; alias: string };
41
+ /** ensureInstalled without blocking the event loop; `run` may return a promise. */
42
+ export function ensureInstalledAsync(
43
+ pkg: { name: string; range: string },
44
+ installer?: Installer,
45
+ options?: {
46
+ lockFile?: string | null;
47
+ run?: (...args: Parameters<InstallRunner>) => ReturnType<InstallRunner> | Promise<ReturnType<InstallRunner>>;
48
+ },
49
+ ): Promise<{ dir: string; alias: string }>;
50
+
51
+ export const LOCK_FILE: string;
52
+ export interface Lock {
53
+ version: 1;
54
+ packages: { [nameAtRange: string]: string };
55
+ }
56
+ export function findLockFile(dir: string): string | null;
57
+ export function readLock(path: string): Lock;
58
+ export function writeLock(path: string, lock: Lock): void;
59
+ export function withInstallLock<T>(dir: string, fn: () => T, options?: { timeoutMs?: number }): T;
60
+ export function withInstallLockAsync<T>(dir: string, fn: () => T | Promise<T>, options?: { timeoutMs?: number }): Promise<T>;
61
+ /** Compiles one TypeScript or JSX file with the esbuild binary at `binary`; `run` is replaceable in tests. */
62
+ export function compileTypeScript(
63
+ source: string,
64
+ filename: string,
65
+ binary: string,
66
+ run?: (cmd: string, args: string[], options: object) => { status: number | null; stdout: string; stderr: string; error?: Error },
67
+ loader?: "ts" | "tsx" | "jsx",
68
+ ): string;
69
+ export interface LoadResult {
70
+ format?: string | null;
71
+ source?: string | ArrayBuffer | ArrayBufferView | null;
72
+ shortCircuit?: boolean;
73
+ }
74
+ /** The load hook: Node's own result unless its type stripping would reject the file; .tsx/.jsx always compiled. */
75
+ export function loadTypeScript(
76
+ url: string,
77
+ context: object,
78
+ nextLoad: (url: string, context: object) => LoadResult,
79
+ binary: string | undefined,
80
+ ): LoadResult;
21
81
  export function installNodeResolveHooks(urls: { shimUrl: string; replUrl: string }): void;
@@ -1,3 +1,4 @@
1
+ // @ts-self-types="./node-hooks.d.mts"
1
2
  // Node-only module resolution for cell code: the part of Deno's module
2
3
  // loader a notebook leans on most.
3
4
  // - Imports issued by cell code arrive with the bootstrap module as their
@@ -8,18 +9,22 @@
8
9
  // one package can coexist (Deno keeps a global cache the same way).
9
10
  // - jsr:@scope/name[@range][/subpath] maps onto JSR's npm compatibility
10
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).
11
16
  // Uses module.registerHooks (synchronous, in-thread; Node >= 23.5), so the
12
17
  // install can run with spawnSync inside resolve.
13
18
 
14
19
  // Not a named import: Bun, which loads this file for its npm:/jsr: helpers,
15
20
  // has no registerHooks, and a missing named export fails at link time.
16
21
  import module from "node:module";
17
- import { spawnSync } from "node:child_process";
22
+ import { spawn, spawnSync } from "node:child_process";
18
23
  import { createHash } from "node:crypto";
19
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
24
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
20
25
  import { homedir } from "node:os";
21
- import { join } from "node:path";
22
- import { pathToFileURL } from "node:url";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
28
 
24
29
  const SPEC_RE = /^((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(\/.*)?$/;
25
30
 
@@ -43,12 +48,19 @@ export function aliasFor(name, range) {
43
48
  return `cdpk-${digest}`;
44
49
  }
45
50
 
46
- // Each installer keeps its own cache, so npm's and Bun's lockfiles never
47
- // 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.
48
62
  function cacheDir(installer) {
49
- const dir = installer === "npm"
50
- ? process.env.CDPK_CACHE_DIR ?? join(homedir(), ".cache", "cdp-kernel", "npm")
51
- : join(homedir(), ".cache", "cdp-kernel", installer);
63
+ const dir = cacheDirPath(installer);
52
64
  if (!existsSync(join(dir, "package.json"))) {
53
65
  mkdirSync(dir, { recursive: true });
54
66
  writeFileSync(join(dir, "package.json"), '{ "private": true }\n');
@@ -58,35 +70,356 @@ function cacheDir(installer) {
58
70
  }
59
71
 
60
72
  /** The command that installs name@range under `alias` with `installer`. */
73
+ // - Saved exactly (#40): npm records `npm:react@19.0.0` as `^19.0.0` by
74
+ // default, and every later install into the shared cache re-resolves the
75
+ // ranges, so pinned packages drifted (react 19.3.1 beside react-dom
76
+ // 19.0.0). With an exact save, a version stays put and a range is frozen
77
+ // at what it first resolved to. Bun already saves exactly; the flag says so.
78
+ // - JSR packages come as tarballs from npm.jsr.io, which npm 12 refuses as
79
+ // "remote" unless allowed (#39). `root` is the narrowest value that works
80
+ // (`true`, which looks right, is invalid and warns), and only the JSR
81
+ // install gets it.
61
82
  export function installCommand(installer, alias, name, range) {
62
83
  const spec = `${alias}@npm:${name}@${range}`;
63
84
  // The Bun target runs this inside Bun, so it uses its own binary.
64
- if (installer === "bun") return { cmd: process.execPath, args: ["add", "--silent", spec] };
65
- return { cmd: "npm", args: ["install", "--no-audit", "--no-fund", "--loglevel=error", spec] };
85
+ if (installer === "bun") return { cmd: process.execPath, args: ["add", "--silent", "--exact", spec] };
86
+ const args = ["install", "--no-audit", "--no-fund", "--loglevel=error", "--save-exact", spec];
87
+ return name.startsWith("@jsr/") ? { cmd: "npm", args, env: { npm_config_allow_remote: "root" } } : { cmd: "npm", args };
88
+ }
89
+
90
+ // --- Lockfile (#14) -----------------------------------------------------------
91
+ // A cdp-kernel.lock.json at or above the notebook pins each npm:/jsr:
92
+ // request to the exact version it first resolved to, so the same notebook
93
+ // gets the same packages on another machine. Opt-in: create the file (with
94
+ // `{}` in it) to start locking; the kernel then fills it in.
95
+
96
+ export const LOCK_FILE = "cdp-kernel.lock.json";
97
+
98
+ /** The nearest cdp-kernel.lock.json at or above `dir`, or null. */
99
+ export function findLockFile(dir) {
100
+ for (let at = dir;; at = dirname(at)) {
101
+ if (existsSync(join(at, LOCK_FILE))) return join(at, LOCK_FILE);
102
+ if (dirname(at) === at) return null;
103
+ }
104
+ }
105
+
106
+ /** `{ version: 1, packages: { "name@range": "exact version" } }`; `{}` counts as empty. */
107
+ export function readLock(path) {
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
+ }
114
+ const packages = parsed && typeof parsed.packages === "object" && parsed.packages !== null ? parsed.packages : {};
115
+ return { version: 1, packages };
116
+ }
117
+
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
+ */
123
+ export function writeLock(path, lock) {
124
+ const packages = Object.fromEntries(Object.entries(lock.packages).sort(([a], [b]) => a.localeCompare(b)));
125
+ const tmp = `${path}.${process.pid}.tmp`;
126
+ writeFileSync(tmp, JSON.stringify({ version: 1, packages }, null, 2) + "\n");
127
+ renameSync(tmp, path);
128
+ }
129
+
130
+ // --- Serialized installs (#14) -------------------------------------------------
131
+ // Kernels share one cache per installer, and npm/bun both rewrite its
132
+ // package.json and node_modules, so installs take a lock: a directory, which
133
+ // mkdir creates atomically. A lock left by a process that died is taken over;
134
+ // one whose holder is alive is waited on.
135
+
136
+ const LOCK_DIR = ".install-lock";
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;
141
+
142
+ function sleep(ms) {
143
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
144
+ }
145
+
146
+ function holderPid(lockDir) {
147
+ try {
148
+ return Number(readFileSync(join(lockDir, "pid"), "utf8"));
149
+ } catch {
150
+ return NaN;
151
+ }
66
152
  }
67
153
 
68
- /** Installs a package into the installer's cache unless it's there already. */
69
- export function ensureInstalled({ name, range }, installer = "npm") {
154
+ function holderAlive(lockDir) {
155
+ try {
156
+ const pid = holderPid(lockDir);
157
+ if (!Number.isInteger(pid) || pid <= 0) return false;
158
+ process.kill(pid, 0);
159
+ return true;
160
+ } catch (err) {
161
+ // EPERM: alive, but someone else's.
162
+ return err?.code === "EPERM";
163
+ }
164
+ }
165
+
166
+ /** Takes the install lock at `lockDir` if it's free: false means wait and try again. */
167
+ function tryLock(lockDir, deadline) {
168
+ for (;;) {
169
+ try {
170
+ mkdirSync(lockDir);
171
+ writeFileSync(join(lockDir, "pid"), String(process.pid));
172
+ return true;
173
+ } catch (err) {
174
+ if (err?.code !== "EEXIST") throw err;
175
+ let age = 0;
176
+ try {
177
+ age = Date.now() - statSync(lockDir).mtimeMs;
178
+ } catch {
179
+ continue; // released meanwhile
180
+ }
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) {
186
+ rmSync(lockDir, { recursive: true, force: true });
187
+ continue;
188
+ }
189
+ if (Date.now() > deadline) throw new Error(`timed out waiting for the install lock ${lockDir}`);
190
+ return false;
191
+ }
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);
205
+ try {
206
+ return fn();
207
+ } finally {
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);
221
+ }
222
+ }
223
+
224
+ function installedVersion(dir, alias) {
225
+ try {
226
+ return JSON.parse(readFileSync(join(dir, "node_modules", alias, "package.json"), "utf8")).version ?? null;
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
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
+
246
+ function spawnInstall(cmd, args, cwd, 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}`);
288
+ }
289
+
290
+ /**
291
+ * Installs a package into the installer's cache unless it's there already.
292
+ * With a lockfile, a locked request installs its exact version, and an
293
+ * unlocked one is recorded once installed. `run` is the install command
294
+ * runner, replaceable in tests.
295
+ */
296
+ export function ensureInstalled({ name, range }, installer = "npm", { lockFile = null, run = spawnInstall } = {}) {
297
+ const dir = cacheDir(installer);
298
+ return withInstallLock(dir, () => {
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);
303
+ }
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 } = {}) {
70
311
  const dir = cacheDir(installer);
71
- const alias = aliasFor(name, range);
72
- if (!existsSync(join(dir, "node_modules", alias, "package.json"))) {
73
- const { cmd, args } = installCommand(installer, alias, name, range);
74
- const result = spawnSync(cmd, args, { cwd: dir, encoding: "utf8" });
75
- if (result.status !== 0) {
76
- throw new Error(`${installer} install ${name}@${range} failed:\n${result.stderr || result.error}`);
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);
77
317
  }
318
+ recordLock(dir, name, range, lockFile, request);
319
+ return { dir, alias: request.alias };
320
+ });
321
+ }
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) };
78
409
  }
79
- return { dir, alias };
80
410
  }
81
411
 
82
412
  export function installNodeResolveHooks({ shimUrl, replUrl }) {
413
+ const esbuild = process.env.CDPK_ESBUILD_BINARY;
83
414
  module.registerHooks({
415
+ load: (url, context, nextLoad) => loadTypeScript(url, context, nextLoad, esbuild),
84
416
  resolve(specifier, context, nextResolve) {
85
417
  let pkg = null;
86
418
  if (specifier.startsWith("npm:")) pkg = parsePackageSpecifier(specifier.slice(4));
87
419
  else if (specifier.startsWith("jsr:")) pkg = jsrToNpm(specifier.slice(4));
88
420
  if (pkg) {
89
- const { dir, alias } = ensureInstalled(pkg);
421
+ const lockFile = findLockFile(fileURLToPath(new URL(".", replUrl)));
422
+ const { dir, alias } = ensureInstalled(pkg, "npm", { lockFile });
90
423
  const parentURL = pathToFileURL(join(dir, "package.json")).href;
91
424
  return nextResolve(alias + pkg.subpath, { ...context, parentURL });
92
425
  }