@tlockney/cdp-kernel 0.2.0 → 0.3.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.
@@ -0,0 +1,32 @@
1
+ import { Buffer } from "node:buffer";
2
+ export declare const OP_CONTINUATION = 0;
3
+ export declare const OP_TEXT = 1;
4
+ export declare const OP_CLOSE = 8;
5
+ export declare const OP_PING = 9;
6
+ export declare const OP_PONG = 10;
7
+ export interface Frame {
8
+ fin: boolean;
9
+ opcode: number;
10
+ payload: Buffer;
11
+ }
12
+ /** One frame; clients must mask what they send. */
13
+ export declare function encodeFrame(opcode: number, payload: Buffer, mask?: Buffer | null): Buffer;
14
+ /** Splits a byte stream into frames, holding back a partial one. */
15
+ export declare class FrameDecoder {
16
+ #private;
17
+ push(chunk: Buffer): Frame[];
18
+ }
19
+ export declare function acceptKey(key: string): string;
20
+ export interface WsChannel {
21
+ send(text: string): void;
22
+ onMessage(listener: (text: string) => void): void;
23
+ onClose(listener: () => void): void;
24
+ close(): void;
25
+ }
26
+ /** Opens a WebSocket over the Unix socket at `socketPath`. */
27
+ export declare function connectUnixWebSocket(socketPath: string, { path, headers }?: {
28
+ path?: string;
29
+ headers?: {
30
+ [name: string]: string;
31
+ };
32
+ }): Promise<WsChannel>;
@@ -0,0 +1,166 @@
1
+ // A minimal WebSocket client (RFC 6455) over a Unix socket, for the workerd
2
+ // target's session with its driver Worker (#26). The WebSocket that Node
3
+ // and Deno provide can't connect to a Unix socket, and a TCP listener would
4
+ // be reachable by every local process. Text messages only: that's all the
5
+ // session carries.
6
+ import { Buffer } from "node:buffer";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { connect } from "node:net";
9
+ const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
10
+ export const OP_CONTINUATION = 0x0;
11
+ export const OP_TEXT = 0x1;
12
+ export const OP_CLOSE = 0x8;
13
+ export const OP_PING = 0x9;
14
+ export const OP_PONG = 0xa;
15
+ /** One frame; clients must mask what they send. */
16
+ export function encodeFrame(opcode, payload, mask = randomBytes(4)) {
17
+ const len = payload.length;
18
+ const head = len < 126 ? 2 : len < 0x10000 ? 4 : 10;
19
+ const out = Buffer.alloc(head + (mask ? 4 : 0) + len);
20
+ out[0] = 0x80 | opcode;
21
+ const maskBit = mask ? 0x80 : 0;
22
+ if (len < 126) {
23
+ out[1] = maskBit | len;
24
+ }
25
+ else if (len < 0x10000) {
26
+ out[1] = maskBit | 126;
27
+ out.writeUInt16BE(len, 2);
28
+ }
29
+ else {
30
+ out[1] = maskBit | 127;
31
+ out.writeBigUInt64BE(BigInt(len), 2);
32
+ }
33
+ if (mask) {
34
+ mask.copy(out, head);
35
+ for (let i = 0; i < len; i++)
36
+ out[head + 4 + i] = payload[i] ^ mask[i & 3];
37
+ }
38
+ else {
39
+ payload.copy(out, head);
40
+ }
41
+ return out;
42
+ }
43
+ /** Splits a byte stream into frames, holding back a partial one. */
44
+ export class FrameDecoder {
45
+ #buffer = Buffer.alloc(0);
46
+ push(chunk) {
47
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
48
+ const frames = [];
49
+ for (;;) {
50
+ const b = this.#buffer;
51
+ if (b.length < 2)
52
+ break;
53
+ const masked = (b[1] & 0x80) !== 0;
54
+ let len = b[1] & 0x7f;
55
+ let offset = 2;
56
+ if (len === 126) {
57
+ if (b.length < 4)
58
+ break;
59
+ len = b.readUInt16BE(2);
60
+ offset = 4;
61
+ }
62
+ else if (len === 127) {
63
+ if (b.length < 10)
64
+ break;
65
+ len = Number(b.readBigUInt64BE(2));
66
+ offset = 10;
67
+ }
68
+ const maskAt = offset;
69
+ if (masked)
70
+ offset += 4;
71
+ if (b.length < offset + len)
72
+ break;
73
+ const payload = Buffer.from(b.subarray(offset, offset + len));
74
+ if (masked)
75
+ for (let i = 0; i < len; i++)
76
+ payload[i] ^= b[maskAt + (i & 3)];
77
+ frames.push({ fin: (b[0] & 0x80) !== 0, opcode: b[0] & 0x0f, payload });
78
+ this.#buffer = b.subarray(offset + len);
79
+ }
80
+ return frames;
81
+ }
82
+ }
83
+ export function acceptKey(key) {
84
+ return createHash("sha1").update(key + GUID).digest("base64");
85
+ }
86
+ /** Opens a WebSocket over the Unix socket at `socketPath`. */
87
+ export function connectUnixWebSocket(socketPath, { path = "/", headers = {} } = {}) {
88
+ return new Promise((resolve, reject) => {
89
+ const socket = connect({ path: socketPath });
90
+ const key = randomBytes(16).toString("base64");
91
+ const messageListeners = [];
92
+ const closeListeners = [];
93
+ let open = false;
94
+ let closed = false;
95
+ let head = Buffer.alloc(0);
96
+ const decoder = new FrameDecoder();
97
+ let parts = [];
98
+ const finish = () => {
99
+ if (closed)
100
+ return;
101
+ closed = true;
102
+ if (!open)
103
+ reject(new Error("the WebSocket closed before it opened"));
104
+ for (const listener of closeListeners)
105
+ listener();
106
+ };
107
+ const write = (opcode, payload) => {
108
+ if (!closed)
109
+ socket.write(encodeFrame(opcode, payload));
110
+ };
111
+ const channel = {
112
+ send: (text) => write(OP_TEXT, Buffer.from(text)),
113
+ onMessage: (listener) => void messageListeners.push(listener),
114
+ onClose: (listener) => void closeListeners.push(listener),
115
+ close() {
116
+ write(OP_CLOSE, Buffer.alloc(0));
117
+ socket.end();
118
+ },
119
+ };
120
+ const onFrames = (frames) => {
121
+ for (const frame of frames) {
122
+ if (frame.opcode === OP_TEXT || frame.opcode === OP_CONTINUATION) {
123
+ parts.push(frame.payload);
124
+ if (frame.fin) {
125
+ const text = Buffer.concat(parts).toString("utf8");
126
+ parts = [];
127
+ for (const listener of messageListeners)
128
+ listener(text);
129
+ }
130
+ }
131
+ else if (frame.opcode === OP_PING) {
132
+ write(OP_PONG, frame.payload);
133
+ }
134
+ else if (frame.opcode === OP_CLOSE) {
135
+ socket.end();
136
+ }
137
+ }
138
+ };
139
+ socket.on("data", (chunk) => {
140
+ if (open)
141
+ return onFrames(decoder.push(chunk));
142
+ head = Buffer.concat([head, chunk]);
143
+ const end = head.indexOf("\r\n\r\n");
144
+ if (end === -1)
145
+ return;
146
+ const response = head.subarray(0, end).toString("latin1");
147
+ const accept = /^sec-websocket-accept:\s*(\S+)/im.exec(response)?.[1];
148
+ if (!/^HTTP\/1\.1 101 /.test(response) || accept !== acceptKey(key)) {
149
+ socket.destroy();
150
+ reject(new Error(`the WebSocket handshake failed: ${response.split("\r\n")[0]}`));
151
+ return;
152
+ }
153
+ open = true;
154
+ resolve(channel);
155
+ onFrames(decoder.push(head.subarray(end + 4)));
156
+ });
157
+ socket.on("error", (err) => {
158
+ if (!open)
159
+ reject(err);
160
+ });
161
+ socket.on("close", finish);
162
+ const extra = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join("");
163
+ socket.write(`GET ${path} HTTP/1.1\r\nHost: cdpk\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` +
164
+ `Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n${extra}\r\n`);
165
+ });
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.2.0",
3
+ "version": "0.3.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>",
@@ -8,7 +8,16 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/tlockney/cdp-kernel.git"
10
10
  },
11
- "keywords": ["jupyter", "kernel", "typescript", "notebook", "deno", "node", "cdp", "devtools-protocol"],
11
+ "keywords": [
12
+ "jupyter",
13
+ "kernel",
14
+ "typescript",
15
+ "notebook",
16
+ "deno",
17
+ "node",
18
+ "cdp",
19
+ "devtools-protocol"
20
+ ],
12
21
  "type": "module",
13
22
  "engines": {
14
23
  "node": ">=24"
@@ -44,6 +53,9 @@
44
53
  "install:node": "node src/main.ts install --runtime node",
45
54
  "install:deno": "node src/main.ts install --runtime deno",
46
55
  "install:node-deno": "node src/main.ts install --runtime node --deno-compat",
56
+ "install:bun": "node src/main.ts install --runtime bun",
57
+ "install:workerd": "node src/main.ts install --runtime workerd",
58
+ "install:celld": "node src/main.ts install --runtime celld",
47
59
  "prepack": "npm run build",
48
60
  "test": "node --test test/*.test.ts",
49
61
  "e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py",
@@ -56,6 +68,8 @@
56
68
  },
57
69
  "devDependencies": {
58
70
  "@types/node": "^24.19.2",
59
- "typescript": "^5.9.3"
71
+ "typescript": "^5.9.3",
72
+ "workerd": "1.20261015.1",
73
+ "wrangler": "4.151.0"
60
74
  }
61
75
  }
@@ -4,14 +4,17 @@
4
4
  // then drives it over CDP. Everything the host needs from inside the
5
5
  // runtime lives on globalThis.__cdpk; output to the host goes through the
6
6
  // `__cdpk_send` CDP binding (Runtime.addBinding) plus plain stdout/stderr.
7
+ // Bun is launched as `bun runtime/bootstrap.mjs` and driven over a pipe
8
+ // instead; see bun-channel.mjs.
7
9
  //
8
- // Plain JavaScript on purpose: the same file must load unmodified in Node
9
- // and in Deno.
10
+ // Plain JavaScript on purpose: the same file must load unmodified in Node,
11
+ // Deno and Bun.
10
12
 
11
13
  import { fileURLToPath, pathToFileURL } from "node:url";
12
14
  import { createJupyter } from "./display.mjs";
13
15
 
14
16
  const isDeno = typeof globalThis.Deno?.version?.deno === "string";
17
+ const isBun = typeof globalThis.Bun?.version === "string";
15
18
  const ENC = new TextEncoder();
16
19
 
17
20
  const cwd = isDeno ? Deno.cwd() : process.cwd();
@@ -77,7 +80,7 @@ function resolveRelative(specifier) {
77
80
  return /^\.{0,2}\//.test(specifier) ? new URL(specifier, replUrl).href : specifier;
78
81
  }
79
82
 
80
- if (!isDeno) {
83
+ if (!isDeno && !isBun) {
81
84
  // Deno resolves npm:/jsr: natively; Node gets a resolve hook that adds
82
85
  // them and re-parents cell imports to the notebook's directory.
83
86
  const { installNodeResolveHooks } = await import("./node-hooks.mjs");
@@ -108,12 +111,36 @@ async function denoImport(specifier, options) {
108
111
  }
109
112
  }
110
113
 
114
+ // Bun has neither npm: nor jsr:, and would resolve a bare name from this
115
+ // file's directory. npm:/jsr: install into a per-user cache as on Node
116
+ // (with `bun add`); anything else bare resolves from the notebook's
117
+ // directory, so its node_modules, or Bun's auto-install, supplies it.
118
+ const packages = isBun ? await import("./node-hooks.mjs") : null;
119
+
120
+ function bunResolve(specifier) {
121
+ let pkg = null;
122
+ if (specifier.startsWith("npm:")) pkg = packages.parsePackageSpecifier(specifier.slice(4));
123
+ else if (specifier.startsWith("jsr:")) pkg = packages.jsrToNpm(specifier.slice(4));
124
+ if (pkg) {
125
+ const { dir, alias } = packages.ensureInstalled(pkg, "bun");
126
+ return Bun.resolveSync(alias + pkg.subpath, dir);
127
+ }
128
+ // Relative, absolute, or a URL scheme such as node:, bun: or file:.
129
+ if (/^(?:\.{0,2}\/|[a-z][a-z0-9+.-]*:)/i.test(specifier)) return resolveRelative(specifier);
130
+ return Bun.resolveSync(specifier, cwd);
131
+ }
132
+
111
133
  globalThis.__cdpk = {
112
134
  // Cell code awaits this first; see PRELUDE in src/transform.ts.
113
135
  tick: () => new Promise((resolve) => setTimeout(resolve, 0)),
114
136
  // Node: the resolve hook re-parents these imports. Deno would resolve
115
137
  // relative paths against this file, so denoImport does it here.
116
- import: (specifier, options) => (isDeno ? denoImport(specifier, options) : import(specifier, options)),
138
+ import: (specifier, options) =>
139
+ isDeno
140
+ ? denoImport(specifier, options)
141
+ : isBun
142
+ ? import(bunResolve(specifier), options)
143
+ : import(specifier, options),
117
144
  meta: {
118
145
  url: replUrl.href,
119
146
  dirname: cwd,
@@ -182,6 +209,14 @@ if (isDeno) {
182
209
  // Keep the event loop alive between cells.
183
210
  setInterval(() => {}, 1 << 30);
184
211
 
212
+ if (isBun) {
213
+ const { startChannel } = await import("./bun-channel.mjs");
214
+ const { createHandler } = await import("./evaluator.mjs");
215
+ startChannel(createHandler(globalThis.__cdpk), {
216
+ versions: { runtime: Bun.version, engine: `JavaScriptCore (WebKit ${process.versions.webkit.slice(0, 12)})` },
217
+ });
218
+ }
219
+
185
220
  async function platform() {
186
221
  if (isDeno) {
187
222
  const stdinBuf = new Uint8Array(1);
@@ -0,0 +1,3 @@
1
+ import type { Handler } from "./evaluator.mjs";
2
+
3
+ export function startChannel(handle: Handler, ready: { [key: string]: unknown }, fd?: number): void;
@@ -0,0 +1,58 @@
1
+ // The Bun target's side of the host connection (#7), in place of CDP.
2
+ //
3
+ // Bun's --inspect speaks the WebKit Inspector Protocol, which has no
4
+ // replMode, Runtime.addBinding or Runtime.terminateExecution, so the host
5
+ // doesn't drive Bun over an inspector. It sends requests as JSON lines over
6
+ // fd 3, a pipe only the host holds (there is no port to discover), and the
7
+ // cells are evaluated here, in-process, by evaluator.mjs. Replies, and the
8
+ // iopub messages that the CDP binding carries for other targets, go back the
9
+ // same way. stdout and stderr stay ordinary pipes, ordered against those
10
+ // messages by bootstrap.mjs's barrier markers.
11
+
12
+ import fs from "node:fs";
13
+
14
+ const CHANNEL_FD = 3;
15
+
16
+ /** Synchronous, like a CDP binding call, so output order is kept. */
17
+ function writeAll(fd, text) {
18
+ const bytes = Buffer.from(text);
19
+ for (let offset = 0; offset < bytes.length;) {
20
+ try {
21
+ offset += fs.writeSync(fd, bytes, offset);
22
+ } catch (err) {
23
+ // The reader's side of the pipe is full; the fd may be non-blocking.
24
+ if (err.code !== "EAGAIN") throw err;
25
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1);
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Serves `handle` (see evaluator.mjs) over fd 3 and installs
32
+ * globalThis.__cdpk_send, the function bootstrap.mjs sends iopub messages
33
+ * through. `ready` is sent first, once the target can take requests.
34
+ */
35
+ export function startChannel(handle, ready, fd = CHANNEL_FD) {
36
+ const send = (message) => writeAll(fd, JSON.stringify(message) + "\n");
37
+ Object.defineProperty(globalThis, "__cdpk_send", {
38
+ value: (payload) => writeAll(fd, payload + "\n"),
39
+ enumerable: false,
40
+ });
41
+
42
+ let buffer = "";
43
+ const input = fs.createReadStream(null, { fd, autoClose: false, encoding: "utf8" });
44
+ input.on("data", (chunk) => {
45
+ buffer += chunk;
46
+ for (let nl = buffer.indexOf("\n"); nl !== -1; nl = buffer.indexOf("\n")) {
47
+ const request = JSON.parse(buffer.slice(0, nl));
48
+ buffer = buffer.slice(nl + 1);
49
+ handle(request).then(
50
+ (reply) => send({ kind: "reply", id: request.id, ...reply }),
51
+ (err) => send({ kind: "reply", id: request.id, failure: String(err?.stack ?? err) }),
52
+ );
53
+ }
54
+ });
55
+ // The host is gone; don't linger as an orphan.
56
+ input.on("end", () => process.exit(0));
57
+ send({ kind: "ready", ...ready });
58
+ }
@@ -0,0 +1,18 @@
1
+ /** The parts of globalThis.__cdpk the evaluator calls. */
2
+ export interface EvaluatorShim {
3
+ broadcastResult(executionCount: number, value: unknown): void | Promise<void>;
4
+ describeError(value: unknown): string;
5
+ commDispatch(msgType: string, content: object, metadata: object, buffers64: string[]): Promise<unknown>;
6
+ barrier(): number;
7
+ }
8
+
9
+ /** A request from the host: an `op` plus its parameters. */
10
+ export interface EvaluatorRequest {
11
+ op: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export type Handler = (request: EvaluatorRequest) => Promise<{ [key: string]: unknown }>;
16
+
17
+ export function propertyNames(value: unknown): string[];
18
+ export function createHandler(shim: EvaluatorShim, evaluate?: (code: string) => unknown): Handler;
@@ -0,0 +1,83 @@
1
+ // The in-process evaluator behind the targets that don't use CDP: Bun (#7),
2
+ // over a pipe (bun-channel.mjs), and workerd (#8), inside a request
3
+ // (workerd-worker.mjs). Cells arrive as global code from
4
+ // src/transform.ts's transformCellForEval, whose value is a promise of the
5
+ // boxed result. Requests and replies mirror the CDP calls the kernel makes
6
+ // of the other targets.
7
+ //
8
+ // No imports: this file must load in every target.
9
+
10
+ /** Own and inherited property names, as Runtime.getProperties lists them. */
11
+ export function propertyNames(value) {
12
+ if (value === null || value === undefined) return [];
13
+ const names = new Set();
14
+ for (let o = Object(value); o !== null; o = Object.getPrototypeOf(o)) {
15
+ for (const name of Object.getOwnPropertyNames(o)) names.add(name);
16
+ }
17
+ return [...names];
18
+ }
19
+
20
+ /**
21
+ * Answers the host's requests. `shim` is globalThis.__cdpk; `evaluate` runs
22
+ * global code. Values the host refers to later (a result to display, an
23
+ * error to describe) stay here under a handle, the stand-in for a CDP
24
+ * objectId.
25
+ */
26
+ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
27
+ const handles = new Map();
28
+ let nextHandle = 0;
29
+ const remote = (value) => {
30
+ const objectId = `h${++nextHandle}`;
31
+ handles.set(objectId, value);
32
+ return { type: value === null ? "object" : typeof value, objectId };
33
+ };
34
+ const take = (objectId) => {
35
+ const value = handles.get(objectId);
36
+ handles.delete(objectId);
37
+ return value;
38
+ };
39
+
40
+ return async function handle(request) {
41
+ switch (request.op) {
42
+ case "ping":
43
+ return {};
44
+ case "eval": {
45
+ // The host uses a result's handle before its next cell.
46
+ handles.clear();
47
+ try {
48
+ const box = await evaluate(request.code);
49
+ return { result: box === undefined ? { type: "undefined" } : remote(box[0]) };
50
+ } catch (err) {
51
+ return {
52
+ exceptionDetails: { text: "Uncaught", lineNumber: 0, columnNumber: 0, exception: remote(err) },
53
+ };
54
+ }
55
+ }
56
+ case "broadcast":
57
+ await shim.broadcastResult(request.count, take(request.handle));
58
+ return {};
59
+ case "describe":
60
+ return { value: shim.describeError(take(request.handle)) };
61
+ case "comm":
62
+ try {
63
+ const handled = await shim.commDispatch(request.msgType, request.content, request.metadata, request.buffers);
64
+ return { handled: handled === true };
65
+ } catch (err) {
66
+ return { error: JSON.parse(shim.describeError(err)) };
67
+ }
68
+ case "props": {
69
+ let value;
70
+ try {
71
+ value = evaluate(`(${request.expr})`);
72
+ } catch {
73
+ return { names: [] };
74
+ }
75
+ return { names: propertyNames(value) };
76
+ }
77
+ case "barrier":
78
+ return { value: shim.barrier() };
79
+ default:
80
+ throw new Error(`unknown request ${JSON.stringify(request.op)}`);
81
+ }
82
+ };
83
+ }
@@ -7,4 +7,15 @@ export interface PackageSpecifier {
7
7
  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
+ export type Installer = "npm" | "bun";
11
+ export function installCommand(
12
+ installer: Installer,
13
+ alias: string,
14
+ name: string,
15
+ range: string,
16
+ ): { cmd: string; args: string[] };
17
+ export function ensureInstalled(
18
+ pkg: { name: string; range: string },
19
+ installer?: Installer,
20
+ ): { dir: string; alias: string };
10
21
  export function installNodeResolveHooks(urls: { shimUrl: string; replUrl: string }): void;
@@ -11,7 +11,9 @@
11
11
  // Uses module.registerHooks (synchronous, in-thread; Node >= 23.5), so the
12
12
  // install can run with spawnSync inside resolve.
13
13
 
14
- import { registerHooks } from "node:module";
14
+ // Not a named import: Bun, which loads this file for its npm:/jsr: helpers,
15
+ // has no registerHooks, and a missing named export fails at link time.
16
+ import module from "node:module";
15
17
  import { spawnSync } from "node:child_process";
16
18
  import { createHash } from "node:crypto";
17
19
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
@@ -41,8 +43,12 @@ export function aliasFor(name, range) {
41
43
  return `cdpk-${digest}`;
42
44
  }
43
45
 
44
- function cacheDir() {
45
- const dir = process.env.CDPK_CACHE_DIR ?? join(homedir(), ".cache", "cdp-kernel", "npm");
46
+ // Each installer keeps its own cache, so npm's and Bun's lockfiles never
47
+ // meet; both read the @jsr registry from .npmrc.
48
+ 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);
46
52
  if (!existsSync(join(dir, "package.json"))) {
47
53
  mkdirSync(dir, { recursive: true });
48
54
  writeFileSync(join(dir, "package.json"), '{ "private": true }\n');
@@ -51,24 +57,30 @@ function cacheDir() {
51
57
  return dir;
52
58
  }
53
59
 
54
- function ensureInstalled({ name, range }) {
55
- const dir = cacheDir();
60
+ /** The command that installs name@range under `alias` with `installer`. */
61
+ export function installCommand(installer, alias, name, range) {
62
+ const spec = `${alias}@npm:${name}@${range}`;
63
+ // 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] };
66
+ }
67
+
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);
56
71
  const alias = aliasFor(name, range);
57
72
  if (!existsSync(join(dir, "node_modules", alias, "package.json"))) {
58
- const result = spawnSync(
59
- "npm",
60
- ["install", "--no-audit", "--no-fund", "--loglevel=error", `${alias}@npm:${name}@${range}`],
61
- { cwd: dir, encoding: "utf8" },
62
- );
73
+ const { cmd, args } = installCommand(installer, alias, name, range);
74
+ const result = spawnSync(cmd, args, { cwd: dir, encoding: "utf8" });
63
75
  if (result.status !== 0) {
64
- throw new Error(`npm install ${name}@${range} failed:\n${result.stderr || result.error}`);
76
+ throw new Error(`${installer} install ${name}@${range} failed:\n${result.stderr || result.error}`);
65
77
  }
66
78
  }
67
79
  return { dir, alias };
68
80
  }
69
81
 
70
82
  export function installNodeResolveHooks({ shimUrl, replUrl }) {
71
- registerHooks({
83
+ module.registerHooks({
72
84
  resolve(specifier, context, nextResolve) {
73
85
  let pkg = null;
74
86
  if (specifier.startsWith("npm:")) pkg = parsePackageSpecifier(specifier.slice(4));