@tlockney/cdp-kernel 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/transform.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import { transformSync } from "esbuild";
18
18
  import { parse } from "acorn";
19
19
  import { full, recursive } from "acorn-walk";
20
+ import { LineMap } from "./sourcemap.js";
20
21
  export const SHIM = "__cdpk";
21
22
  // Each cell first yields one macrotask. User code then runs as an ordinary
22
23
  // task instead of nested inside the inspector's dispatch of Runtime.evaluate,
@@ -33,7 +34,8 @@ function jsxSettings(jsx) {
33
34
  }
34
35
  return { jsx: "transform", jsxFactory: jsx?.factory, jsxFragment: jsx?.fragment };
35
36
  }
36
- function stripTypes(code, options) {
37
+ /** esbuild's output, with its source map's `mappings` when asked for. */
38
+ function stripTypesMapped(code, options, sourcemap) {
37
39
  const compilerOptions = {
38
40
  verbatimModuleSyntax: true,
39
41
  ...(options.experimentalDecorators ? { experimentalDecorators: true } : {}),
@@ -42,13 +44,19 @@ function stripTypes(code, options) {
42
44
  let firstError;
43
45
  for (const loader of ["tsx", "ts"]) {
44
46
  try {
45
- return transformSync(code, {
47
+ const result = transformSync(code, {
46
48
  loader,
47
49
  target: "esnext",
48
50
  ...jsxSettings(options.jsx),
49
51
  tsconfigRaw: { compilerOptions },
50
52
  logLevel: "silent",
51
- }).code;
53
+ ...(sourcemap ? { sourcemap: "external" } : {}),
54
+ });
55
+ const map = sourcemap ? JSON.parse(result.map) : null;
56
+ const mappings = typeof map?.mappings === "string"
57
+ ? map.mappings
58
+ : "";
59
+ return { code: result.code, mappings };
52
60
  }
53
61
  catch (err) {
54
62
  firstError ??= err;
@@ -57,6 +65,9 @@ function stripTypes(code, options) {
57
65
  const message = firstError instanceof Error ? firstError.message : String(firstError);
58
66
  throw new CellSyntaxError(message.replace(/^Transform failed with \d+ errors?:\n/, ""));
59
67
  }
68
+ function stripTypes(code, options) {
69
+ return stripTypesMapped(code, options, false).code;
70
+ }
60
71
  function importedName(spec) {
61
72
  const imported = spec.imported;
62
73
  if (!imported)
@@ -140,12 +151,25 @@ export function rewriteModuleSyntax(js) {
140
151
  // keyword edit doesn't overlap its arguments), so apply back to front.
141
152
  return applyEdits(js, edits);
142
153
  }
154
+ function newlines(text) {
155
+ let count = 0;
156
+ for (const char of text)
157
+ if (char === "\n")
158
+ count++;
159
+ return count;
160
+ }
143
161
  function applyEdits(js, edits) {
144
162
  // Back to front; at a shared start, a replacement goes before an insertion.
145
163
  edits.sort((a, b) => b.start - a.start || b.end - a.end);
146
164
  let out = js;
147
- for (const edit of edits)
148
- out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
165
+ for (const edit of edits) {
166
+ // Keep every later line where it was, so a cell's lines still map to
167
+ // esbuild's (the debugger relies on it, #18): a multi-line import
168
+ // rewritten to one line is padded with the newlines it replaced.
169
+ const lost = newlines(js.slice(edit.start, edit.end)) - newlines(edit.text);
170
+ const text = lost > 0 ? edit.text + "\n".repeat(lost) : edit.text;
171
+ out = out.slice(0, edit.start) + text + out.slice(edit.end);
172
+ }
149
173
  return out;
150
174
  }
151
175
  /** Adds the names a binding pattern declares. */
@@ -266,6 +290,15 @@ function hoistForEval(js) {
266
290
  export function transformCell(code, options = {}) {
267
291
  return PRELUDE + rewriteModuleSyntax(stripTypes(code, options));
268
292
  }
293
+ /**
294
+ * transformCell, plus the map between the cell's lines and the script's,
295
+ * for the debugger (#18). The rewrite keeps lines (see applyEdits), so the
296
+ * script's lines are esbuild's, after the prelude's one.
297
+ */
298
+ export function transformCellWithLines(code, options = {}) {
299
+ const stripped = stripTypesMapped(code, options, true);
300
+ return { script: PRELUDE + rewriteModuleSyntax(stripped.code), lines: new LineMap(stripped.mappings, newlines(PRELUDE)) };
301
+ }
269
302
  /**
270
303
  * Like transformCell, for a target that evaluates cells itself, as global
271
304
  * code (an indirect eval), instead of through the inspector's replMode: see
@@ -115,6 +115,8 @@ export declare class WorkerdTarget implements KernelTarget {
115
115
  stack: string;
116
116
  }>;
117
117
  propertyNames(expression: string): Promise<string[]>;
118
+ /** Resolved by property descriptor in the driver, so nothing runs (#10). */
119
+ inspect(path: string, detail: number): Promise<string | null>;
118
120
  /** None: top-level declarations here are properties of globalThis. */
119
121
  lexicalNames(): Promise<string[]>;
120
122
  /** Never asked for: prompt() in workerd answers null without asking. */
@@ -29,9 +29,9 @@ import { runtimeDir } from "./package.js";
29
29
  import { Interrupted, isTargetMessage } from "./target.js";
30
30
  import { connectUnixWebSocket } from "./ws-client.js";
31
31
  import { findWranglerConfig, startMiniflare } from "./workers-project.js";
32
- import { ensureInstalled, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
32
+ import { ensureInstalled, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
33
33
  /** The driver Worker's modules; the first is its main module. */
34
- export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "evaluator.mjs"];
34
+ export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "evaluator.mjs", "introspect.mjs"];
35
35
  /** See BunTarget: how long an interrupted cell has to show it isn't busy. */
36
36
  export const INTERRUPT_GRACE_MS = 500;
37
37
  export function restartNote(runtime) {
@@ -121,7 +121,7 @@ export async function bundleModule(specifier, cwd) {
121
121
  ? jsrToNpm(specifier.slice(4))
122
122
  : null;
123
123
  if (pkg) {
124
- const { dir, alias } = ensureInstalled(pkg);
124
+ const { dir, alias } = ensureInstalled(pkg, "npm", { lockFile: findLockFile(cwd) });
125
125
  entry = alias + pkg.subpath;
126
126
  resolveDir = dir;
127
127
  }
@@ -435,6 +435,11 @@ export class WorkerdTarget {
435
435
  const reply = await this.#request("props", { expr: expression }).catch(() => null);
436
436
  return Array.isArray(reply?.names) ? reply.names.filter((n) => typeof n === "string") : [];
437
437
  }
438
+ /** Resolved by property descriptor in the driver, so nothing runs (#10). */
439
+ async inspect(path, detail) {
440
+ const reply = await this.#request("inspect", { path, detail }).catch(() => null);
441
+ return typeof reply?.text === "string" ? reply.text : null;
442
+ }
438
443
  /** None: top-level declarations here are properties of globalThis. */
439
444
  lexicalNames() {
440
445
  return Promise.resolve([]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Deno's Jupyter kernel, extracted to run notebooks on any V8 runtime over the Chrome DevTools Protocol",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Lockney <thomas@lockney.net>",
@@ -57,7 +57,7 @@
57
57
  "install:workerd": "node src/main.ts install --runtime workerd",
58
58
  "install:celld": "node src/main.ts install --runtime celld",
59
59
  "prepack": "npm run build",
60
- "test": "node --test test/*.test.ts",
60
+ "test": "node --test --test-timeout=300000 test/*.test.ts",
61
61
  "e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py",
62
62
  "test:lab": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/lab_widgets.py"
63
63
  },
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
14
  import { createJupyter } from "./display.mjs";
15
+ import { describeValue, inspectPath, resolvePath } from "./introspect.mjs";
15
16
 
16
17
  const isDeno = typeof globalThis.Deno?.version?.deno === "string";
17
18
  const isBun = typeof globalThis.Bun?.version === "string";
@@ -26,7 +27,7 @@ const cwdUrl = pathToFileURL(cwd.endsWith("/") ? cwd : cwd + "/");
26
27
  // Stand-in referrer for cell code, like Deno's ./$deno$jupyter_repl.mts.
27
28
  const replUrl = new URL("$cdpk$repl.mjs", cwdUrl);
28
29
 
29
- const { inspect, readFile, writeOut, writeErr, readStdinLine } = await platform();
30
+ const { inspect, inspectPlain, readFile, writeOut, writeErr, readStdinLine } = await platform();
30
31
 
31
32
  function base64(bytes) {
32
33
  let binary = "";
@@ -122,7 +123,7 @@ function bunResolve(specifier) {
122
123
  if (specifier.startsWith("npm:")) pkg = packages.parsePackageSpecifier(specifier.slice(4));
123
124
  else if (specifier.startsWith("jsr:")) pkg = packages.jsrToNpm(specifier.slice(4));
124
125
  if (pkg) {
125
- const { dir, alias } = packages.ensureInstalled(pkg, "bun");
126
+ const { dir, alias } = packages.ensureInstalled(pkg, "bun", { lockFile: packages.findLockFile(cwd) });
126
127
  return Bun.resolveSync(alias + pkg.subpath, dir);
127
128
  }
128
129
  // Relative, absolute, or a URL scheme such as node:, bun: or file:.
@@ -148,7 +149,19 @@ globalThis.__cdpk = {
148
149
  resolve: (specifier) => new URL(specifier, replUrl).href,
149
150
  },
150
151
  barrier,
152
+ // inspect_request (#10). CDP targets find the value with
153
+ // throwOnSideEffect and describe it here; Bun resolves the path here
154
+ // too, by descriptor, so nothing runs.
155
+ describeForInspect: (value, detail) => describeValue(value, { inspect: inspectPlain, detail }),
156
+ inspectPath: (path, detail) => inspectPath(path, { inspect: inspectPlain, detail }),
157
+ resolvePath,
151
158
  broadcastResult: jupyter.broadcastResult,
159
+ // The debugger's rich variable viewer (#18), synchronously: it runs while
160
+ // the target is paused. A value whose $display is async shows as text.
161
+ richBundle(value) {
162
+ const bundle = jupyter.formatNow(value);
163
+ return JSON.stringify(typeof bundle?.then === "function" ? { "text/plain": inspectPlain(value) } : bundle);
164
+ },
152
165
  // Frontend comm messages (#9), from the host's shell channel. Handlers
153
166
  // run after one macrotask, like cells, so interrupt can reach them.
154
167
  async commDispatch(msgType, content, metadata, buffers64) {
@@ -222,6 +235,7 @@ async function platform() {
222
235
  const stdinBuf = new Uint8Array(1);
223
236
  return {
224
237
  inspect: (v) => Deno.inspect(v, { colors: true, depth: 4 }),
238
+ inspectPlain: (v) => Deno.inspect(v, { colors: false, depth: 2 }),
225
239
  readFile: (p) => Deno.readFileSync(p),
226
240
  writeOut: (s) => Deno.stdout.writeSync(ENC.encode(s)),
227
241
  writeErr: (s) => Deno.stderr.writeSync(ENC.encode(s)),
@@ -233,6 +247,7 @@ async function platform() {
233
247
  const buf = Buffer.alloc(1);
234
248
  return {
235
249
  inspect: (v) => util.inspect(v, { colors: true, depth: 4 }),
250
+ inspectPlain: (v) => util.inspect(v, { colors: false, depth: 2 }),
236
251
  readFile: (p) => fs.readFileSync(p),
237
252
  writeOut: (s) => process.stdout.write(s),
238
253
  writeErr: (s) => process.stderr.write(s),
@@ -161,7 +161,9 @@ export function createDenoShim({ jupyter }) {
161
161
  rename: async((from, to) => fsp.rename(toPath(from), toPath(to))),
162
162
  renameSync: sync((from, to) => fs.renameSync(toPath(from), toPath(to))),
163
163
  realPath: async((p) => fsp.realpath(toPath(p))),
164
- realPathSync: sync((p) => fs.realpathSync(toPath(p))),
164
+ // The native call, like fsp.realpath above: the JavaScript one leaves
165
+ // Windows 8.3 names (RUNNER~1) unexpanded, so the two disagreed (#32).
166
+ realPathSync: sync((p) => fs.realpathSync.native(toPath(p))),
165
167
  makeTempDir: async((options) =>
166
168
  fsp.mkdtemp(path.join(options?.dir ?? os.tmpdir(), options?.prefix ?? "deno-"))
167
169
  ),
@@ -47,6 +47,8 @@ export interface JupyterNamespace {
47
47
  export interface Jupyter {
48
48
  namespace: JupyterNamespace;
49
49
  broadcastResult(executionCount: number, result: unknown): Promise<void>;
50
+ /** format without awaiting; a $display method's promise is returned as is. */
51
+ formatNow(obj: unknown): unknown;
50
52
  confirm(message?: string): boolean;
51
53
  prompt(message?: string, defaultValue?: string, options?: { password?: boolean }): string | null;
52
54
  /** Routes a frontend comm message into the kernel's comms. */
@@ -146,7 +146,14 @@ function isMediaBundle(obj) {
146
146
  */
147
147
  export function createJupyter(host) {
148
148
  async function format(obj) {
149
- if (hasDisplaySymbol(obj)) return await obj[$display]();
149
+ return await formatNow(obj);
150
+ }
151
+
152
+ // format without awaiting: the debugger's variable viewer formats values
153
+ // while the target is paused, when no promise settles (#18). A $display
154
+ // method's promise is the caller's to handle.
155
+ function formatNow(obj) {
156
+ if (hasDisplaySymbol(obj)) return obj[$display]();
150
157
  if (typeof obj !== "object" || obj === null) return { "text/plain": host.inspect(obj) };
151
158
  if (isCanvasLike(obj)) {
152
159
  const [prefix, data] = obj.toDataURL().split(",");
@@ -240,6 +247,7 @@ export function createJupyter(host) {
240
247
  widget: createWidgetFactory(comms.api, $display),
241
248
  },
242
249
  broadcastResult,
250
+ formatNow,
243
251
  confirm,
244
252
  prompt,
245
253
  commDispatch: comms.dispatch,
@@ -4,6 +4,10 @@ export interface EvaluatorShim {
4
4
  describeError(value: unknown): string;
5
5
  commDispatch(msgType: string, content: object, metadata: object, buffers64: string[]): Promise<unknown>;
6
6
  barrier(): number;
7
+ /** inspect_request's text for a dotted path, or null (#10). */
8
+ inspectPath(path: string, detail: number): string | null;
9
+ /** Resolves a dotted path without running getters; see introspect.mjs. */
10
+ resolvePath?(path: string): import("./introspect.mjs").Resolved;
7
11
  }
8
12
 
9
13
  /** A request from the host: an `op` plus its parameters. */
@@ -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
+ }
@@ -13,9 +13,27 @@ export function installCommand(
13
13
  alias: string,
14
14
  name: string,
15
15
  range: string,
16
- ): { cmd: string; args: string[] };
16
+ ): { cmd: string; args: string[]; env?: { [name: string]: string } };
17
+ /** Runs an install; `env` is added to the process environment. */
18
+ export type InstallRunner = (
19
+ cmd: string,
20
+ args: string[],
21
+ cwd: string,
22
+ env?: { [name: string]: string },
23
+ ) => { status: number | null; stderr?: string; error?: Error };
17
24
  export function ensureInstalled(
18
25
  pkg: { name: string; range: string },
19
26
  installer?: Installer,
27
+ options?: { lockFile?: string | null; run?: InstallRunner },
20
28
  ): { dir: string; alias: string };
29
+
30
+ export const LOCK_FILE: string;
31
+ export interface Lock {
32
+ version: 1;
33
+ packages: { [nameAtRange: string]: string };
34
+ }
35
+ export function findLockFile(dir: string): string | null;
36
+ export function readLock(path: string): Lock;
37
+ export function writeLock(path: string, lock: Lock): void;
38
+ export function withInstallLock<T>(dir: string, fn: () => T, options?: { timeoutMs?: number }): T;
21
39
  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
@@ -16,10 +17,10 @@
16
17
  import module from "node:module";
17
18
  import { spawnSync } from "node:child_process";
18
19
  import { createHash } from "node:crypto";
19
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
20
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
20
21
  import { homedir } from "node:os";
21
- import { join } from "node:path";
22
- import { pathToFileURL } from "node:url";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
24
 
24
25
  const SPEC_RE = /^((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(\/.*)?$/;
25
26
 
@@ -58,25 +59,151 @@ function cacheDir(installer) {
58
59
  }
59
60
 
60
61
  /** The command that installs name@range under `alias` with `installer`. */
62
+ // - Saved exactly (#40): npm records `npm:react@19.0.0` as `^19.0.0` by
63
+ // default, and every later install into the shared cache re-resolves the
64
+ // ranges, so pinned packages drifted (react 19.3.1 beside react-dom
65
+ // 19.0.0). With an exact save, a version stays put and a range is frozen
66
+ // at what it first resolved to. Bun already saves exactly; the flag says so.
67
+ // - JSR packages come as tarballs from npm.jsr.io, which npm 12 refuses as
68
+ // "remote" unless allowed (#39). `root` is the narrowest value that works
69
+ // (`true`, which looks right, is invalid and warns), and only the JSR
70
+ // install gets it.
61
71
  export function installCommand(installer, alias, name, range) {
62
72
  const spec = `${alias}@npm:${name}@${range}`;
63
73
  // 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] };
74
+ if (installer === "bun") return { cmd: process.execPath, args: ["add", "--silent", "--exact", spec] };
75
+ const args = ["install", "--no-audit", "--no-fund", "--loglevel=error", "--save-exact", spec];
76
+ return name.startsWith("@jsr/") ? { cmd: "npm", args, env: { npm_config_allow_remote: "root" } } : { cmd: "npm", args };
66
77
  }
67
78
 
68
- /** Installs a package into the installer's cache unless it's there already. */
69
- export function ensureInstalled({ name, range }, installer = "npm") {
70
- 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}`);
79
+ // --- Lockfile (#14) -----------------------------------------------------------
80
+ // A cdp-kernel.lock.json at or above the notebook pins each npm:/jsr:
81
+ // request to the exact version it first resolved to, so the same notebook
82
+ // gets the same packages on another machine. Opt-in: create the file (with
83
+ // `{}` in it) to start locking; the kernel then fills it in.
84
+
85
+ export const LOCK_FILE = "cdp-kernel.lock.json";
86
+
87
+ /** The nearest cdp-kernel.lock.json at or above `dir`, or null. */
88
+ export function findLockFile(dir) {
89
+ for (let at = dir;; at = dirname(at)) {
90
+ if (existsSync(join(at, LOCK_FILE))) return join(at, LOCK_FILE);
91
+ if (dirname(at) === at) return null;
92
+ }
93
+ }
94
+
95
+ /** `{ version: 1, packages: { "name@range": "exact version" } }`; `{}` counts as empty. */
96
+ export function readLock(path) {
97
+ const parsed = JSON.parse(readFileSync(path, "utf8") || "{}");
98
+ const packages = parsed && typeof parsed.packages === "object" && parsed.packages !== null ? parsed.packages : {};
99
+ return { version: 1, packages };
100
+ }
101
+
102
+ /** Sorted, so a lockfile's diff shows only what changed. */
103
+ export function writeLock(path, lock) {
104
+ 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");
106
+ }
107
+
108
+ // --- Serialized installs (#14) -------------------------------------------------
109
+ // Kernels share one cache per installer, and npm/bun both rewrite its
110
+ // 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.
112
+
113
+ const LOCK_DIR = ".install-lock";
114
+ const STALE_MS = 10 * 60 * 1000;
115
+
116
+ function sleep(ms) {
117
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
118
+ }
119
+
120
+ function holderAlive(lockDir) {
121
+ try {
122
+ const pid = Number(readFileSync(join(lockDir, "pid"), "utf8"));
123
+ if (!Number.isInteger(pid) || pid <= 0) return false;
124
+ process.kill(pid, 0);
125
+ return true;
126
+ } catch (err) {
127
+ // EPERM: alive, but someone else's.
128
+ return err?.code === "EPERM";
129
+ }
130
+ }
131
+
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;
136
+ for (;;) {
137
+ try {
138
+ mkdirSync(lockDir);
139
+ writeFileSync(join(lockDir, "pid"), String(process.pid));
140
+ break;
141
+ } catch (err) {
142
+ if (err?.code !== "EEXIST") throw err;
143
+ let age = 0;
144
+ try {
145
+ age = Date.now() - statSync(lockDir).mtimeMs;
146
+ } catch {
147
+ continue; // released meanwhile
148
+ }
149
+ // A holder that died, or one stuck for longer than any install takes.
150
+ if ((age > 1000 && !holderAlive(lockDir)) || age > STALE_MS) {
151
+ rmSync(lockDir, { recursive: true, force: true });
152
+ continue;
153
+ }
154
+ if (Date.now() > deadline) throw new Error(`timed out waiting for the install lock ${lockDir}`);
155
+ sleep(100);
77
156
  }
78
157
  }
79
- return { dir, alias };
158
+ try {
159
+ return fn();
160
+ } finally {
161
+ rmSync(lockDir, { recursive: true, force: true });
162
+ }
163
+ }
164
+
165
+ function installedVersion(dir, alias) {
166
+ try {
167
+ return JSON.parse(readFileSync(join(dir, "node_modules", alias, "package.json"), "utf8")).version ?? null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ function spawnInstall(cmd, args, cwd, env = {}) {
174
+ return spawnSync(cmd, args, { cwd, encoding: "utf8", env: { ...process.env, ...env } });
175
+ }
176
+
177
+ /**
178
+ * Installs a package into the installer's cache unless it's there already.
179
+ * With a lockfile, a locked request installs its exact version, and an
180
+ * unlocked one is recorded once installed. `run` is the install command
181
+ * runner, replaceable in tests.
182
+ */
183
+ export function ensureInstalled({ name, range }, installer = "npm", { lockFile = null, run = spawnInstall } = {}) {
184
+ const dir = cacheDir(installer);
185
+ const key = `${name}@${range}`;
186
+ 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
+ }
197
+ }
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
+ }
204
+ }
205
+ return { dir, alias };
206
+ });
80
207
  }
81
208
 
82
209
  export function installNodeResolveHooks({ shimUrl, replUrl }) {
@@ -86,7 +213,8 @@ export function installNodeResolveHooks({ shimUrl, replUrl }) {
86
213
  if (specifier.startsWith("npm:")) pkg = parsePackageSpecifier(specifier.slice(4));
87
214
  else if (specifier.startsWith("jsr:")) pkg = jsrToNpm(specifier.slice(4));
88
215
  if (pkg) {
89
- const { dir, alias } = ensureInstalled(pkg);
216
+ const lockFile = findLockFile(fileURLToPath(new URL(".", replUrl)));
217
+ const { dir, alias } = ensureInstalled(pkg, "npm", { lockFile });
90
218
  const parentURL = pathToFileURL(join(dir, "package.json")).href;
91
219
  return nextResolve(alias + pkg.subpath, { ...context, parentURL });
92
220
  }