@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.
- package/README.md +166 -17
- package/dist/bun-target.d.ts +58 -0
- package/dist/bun-target.js +231 -0
- package/dist/celld-target.d.ts +39 -0
- package/dist/celld-target.js +274 -0
- package/dist/kernel.js +29 -10
- package/dist/main.js +5 -5
- package/dist/package.d.ts +2 -2
- package/dist/package.js +4 -1
- package/dist/target.d.ts +57 -7
- package/dist/target.js +6 -5
- package/dist/transform.d.ts +6 -0
- package/dist/transform.js +135 -6
- package/dist/workerd-target.d.ts +123 -0
- package/dist/workerd-target.js +450 -0
- package/dist/workers-project.d.ts +27 -0
- package/dist/workers-project.js +195 -0
- package/dist/ws-client.d.ts +32 -0
- package/dist/ws-client.js +166 -0
- package/package.json +17 -3
- package/runtime/bootstrap.mjs +39 -4
- package/runtime/bun-channel.d.mts +3 -0
- package/runtime/bun-channel.mjs +58 -0
- package/runtime/evaluator.d.mts +18 -0
- package/runtime/evaluator.mjs +83 -0
- package/runtime/node-hooks.d.mts +11 -0
- package/runtime/node-hooks.mjs +24 -12
- package/runtime/workerd-worker.mjs +189 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// The workerd target (#8). Cells run inside a driver Worker
|
|
2
|
+
// (runtime/workerd-worker.mjs): outside a request workerd has no IoContext,
|
|
3
|
+
// so timers, fetch and randomness throw. The host opens one WebSocket
|
|
4
|
+
// session to the driver and sends every request over it as a JSON message;
|
|
5
|
+
// all of them run in that session request's IoContext, so I/O objects and
|
|
6
|
+
// pending work carry over between cells (#26). Console output, iopub
|
|
7
|
+
// messages and replies come back the same way. Cells are transformed as for
|
|
8
|
+
// Bun (transformCellForEval).
|
|
9
|
+
//
|
|
10
|
+
// Outside a Workers project the host serves the driver on a workerd of its
|
|
11
|
+
// own, behind a Unix socket in a private temporary directory, with a
|
|
12
|
+
// per-launch token (RawWorkerd). In a project it uses the project's
|
|
13
|
+
// Miniflare instead, for its bindings (src/workers-project.ts).
|
|
14
|
+
//
|
|
15
|
+
// workerd can't load modules at runtime beyond its builtins, so whatever
|
|
16
|
+
// else a cell imports is bundled here with esbuild and sent with the cell.
|
|
17
|
+
import { Buffer } from "node:buffer";
|
|
18
|
+
import process from "node:process";
|
|
19
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { builtinModules } from "node:module";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { build } from "esbuild";
|
|
27
|
+
import { transformCellForEval } from "./transform.js";
|
|
28
|
+
import { runtimeDir } from "./package.js";
|
|
29
|
+
import { Interrupted, isTargetMessage } from "./target.js";
|
|
30
|
+
import { connectUnixWebSocket } from "./ws-client.js";
|
|
31
|
+
import { findWranglerConfig, startMiniflare } from "./workers-project.js";
|
|
32
|
+
import { ensureInstalled, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
|
|
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"];
|
|
35
|
+
/** See BunTarget: how long an interrupted cell has to show it isn't busy. */
|
|
36
|
+
export const INTERRUPT_GRACE_MS = 500;
|
|
37
|
+
export function restartNote(runtime) {
|
|
38
|
+
return `[cdp-kernel] ${runtime} was busy and can't be interrupted in place, so it was stopped; ` +
|
|
39
|
+
"it restarts on the next cell, without the earlier cells' state";
|
|
40
|
+
}
|
|
41
|
+
export const RESTART_NOTE = restartNote("workerd");
|
|
42
|
+
// nodejs_compat is on by default from this compatibility date, and workerd
|
|
43
|
+
// warns when it's also listed.
|
|
44
|
+
const NODEJS_COMPAT_DEFAULT = "2026-08-04";
|
|
45
|
+
const BUILTIN = /^(?:node|cloudflare|workerd):/;
|
|
46
|
+
export function workerdConfig({ socket, compatibilityDate }) {
|
|
47
|
+
const modules = WORKER_MODULES.map((name) => `(name = ${JSON.stringify(name)}, esModule = embed ${JSON.stringify(name)})`);
|
|
48
|
+
const flags = compatibilityDate < NODEJS_COMPAT_DEFAULT ? `["nodejs_compat"]` : "[]";
|
|
49
|
+
return `using Workerd = import "/workerd/workerd.capnp";
|
|
50
|
+
|
|
51
|
+
const config :Workerd.Config = (
|
|
52
|
+
services = [
|
|
53
|
+
(name = "cdpk", worker = .driver),
|
|
54
|
+
(name = "internet", network = (allow = ["public", "private", "local"], tlsOptions = (trustBrowserCas = true))),
|
|
55
|
+
],
|
|
56
|
+
sockets = [(name = "host", address = ${JSON.stringify(`unix:${socket}`)}, http = (), service = "cdpk")],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const driver :Workerd.Worker = (
|
|
60
|
+
modules = [
|
|
61
|
+
${modules.join(",\n ")},
|
|
62
|
+
],
|
|
63
|
+
compatibilityDate = ${JSON.stringify(compatibilityDate)},
|
|
64
|
+
compatibilityFlags = ${flags},
|
|
65
|
+
globalOutbound = "internet",
|
|
66
|
+
bindings = [
|
|
67
|
+
(name = "UNSAFE", unsafeEval = void),
|
|
68
|
+
(name = "TOKEN", fromEnvironment = "CDPK_TOKEN"),
|
|
69
|
+
(name = "META", fromEnvironment = "CDPK_META"),
|
|
70
|
+
],
|
|
71
|
+
);
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A Workers project has workerd in its node_modules (wrangler and miniflare
|
|
76
|
+
* depend on it), so look there first, from the notebook's directory up;
|
|
77
|
+
* otherwise use the one on PATH.
|
|
78
|
+
*/
|
|
79
|
+
export function findWorkerd(cwd) {
|
|
80
|
+
for (let dir = cwd;; dir = dirname(dir)) {
|
|
81
|
+
const bin = join(dir, "node_modules", ".bin", process.platform === "win32" ? "workerd.cmd" : "workerd");
|
|
82
|
+
if (existsSync(bin))
|
|
83
|
+
return bin;
|
|
84
|
+
if (dirname(dir) === dir)
|
|
85
|
+
return "workerd";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** "workerd 2026-10-15" -> "2026-10-15": the newest date it supports. */
|
|
89
|
+
export function compatibilityDateOf(versionOutput) {
|
|
90
|
+
const m = /\b(\d{4}-\d{2}-\d{2})\b/.exec(versionOutput);
|
|
91
|
+
if (!m)
|
|
92
|
+
throw new Error(`can't read a date from workerd --version: ${versionOutput.trim()}`);
|
|
93
|
+
return m[1];
|
|
94
|
+
}
|
|
95
|
+
/** The literal specifiers a transformed cell imports, in order. */
|
|
96
|
+
export function importSpecifiers(script) {
|
|
97
|
+
const found = new Set();
|
|
98
|
+
for (const m of script.matchAll(/__cdpk\.import\(("(?:[^"\\]|\\.)*")/g)) {
|
|
99
|
+
const specifier = JSON.parse(m[1]);
|
|
100
|
+
if (typeof specifier === "string")
|
|
101
|
+
found.add(specifier);
|
|
102
|
+
}
|
|
103
|
+
return [...found];
|
|
104
|
+
}
|
|
105
|
+
const NODE_BUILTINS = builtinModules.filter((name) => !name.startsWith("_"));
|
|
106
|
+
/**
|
|
107
|
+
* Bundles `specifier`, as imported from `cwd`, into global code whose value
|
|
108
|
+
* is a promise of the module's namespace. npm: and jsr: install into the
|
|
109
|
+
* npm cache, as on a Node target. Builtins stay external and are handed to
|
|
110
|
+
* the bundle as `require` (workerd-worker.mjs's requireFor). A specifier
|
|
111
|
+
* that can't be bundled becomes code that throws when imported, so the
|
|
112
|
+
* error appears in the cell.
|
|
113
|
+
*/
|
|
114
|
+
export async function bundleModule(specifier, cwd) {
|
|
115
|
+
try {
|
|
116
|
+
let entry = specifier;
|
|
117
|
+
let resolveDir = cwd;
|
|
118
|
+
const pkg = specifier.startsWith("npm:")
|
|
119
|
+
? parsePackageSpecifier(specifier.slice(4))
|
|
120
|
+
: specifier.startsWith("jsr:")
|
|
121
|
+
? jsrToNpm(specifier.slice(4))
|
|
122
|
+
: null;
|
|
123
|
+
if (pkg) {
|
|
124
|
+
const { dir, alias } = ensureInstalled(pkg);
|
|
125
|
+
entry = alias + pkg.subpath;
|
|
126
|
+
resolveDir = dir;
|
|
127
|
+
}
|
|
128
|
+
const result = await build({
|
|
129
|
+
stdin: { contents: `import * as ns from ${JSON.stringify(entry)};\nexport { ns };\n`, resolveDir, loader: "js" },
|
|
130
|
+
bundle: true,
|
|
131
|
+
write: false,
|
|
132
|
+
format: "iife",
|
|
133
|
+
globalName: "__cdpk_bundle",
|
|
134
|
+
platform: "browser",
|
|
135
|
+
// What wrangler resolves for Workers.
|
|
136
|
+
conditions: ["workerd", "worker", "browser"],
|
|
137
|
+
mainFields: ["browser", "module", "main"],
|
|
138
|
+
external: ["node:*", "cloudflare:*", "workerd:*", ...NODE_BUILTINS],
|
|
139
|
+
target: "esnext",
|
|
140
|
+
metafile: true,
|
|
141
|
+
logLevel: "silent",
|
|
142
|
+
});
|
|
143
|
+
const externals = new Set();
|
|
144
|
+
for (const output of Object.values(result.metafile.outputs)) {
|
|
145
|
+
for (const imported of output.imports)
|
|
146
|
+
if (imported.external)
|
|
147
|
+
externals.add(imported.path);
|
|
148
|
+
}
|
|
149
|
+
return `(async () => { const require = await __cdpk.requireFor(${JSON.stringify([...externals])});\n` +
|
|
150
|
+
`${result.outputFiles[0].text}\nreturn __cdpk_bundle.ns; })()`;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
// esbuild's failures carry structured errors; their text reads best.
|
|
154
|
+
const errors = err.errors;
|
|
155
|
+
const texts = Array.isArray(errors) ? errors.map((e) => e?.text).filter((t) => typeof t === "string") : [];
|
|
156
|
+
const message = texts.length > 0 ? texts.join("; ") : err instanceof Error ? err.message : String(err);
|
|
157
|
+
return `Promise.reject(new TypeError(${JSON.stringify(`Cannot import "${specifier}": ${message}`)}))`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Rejects a request whose reply the host no longer waits for. */
|
|
161
|
+
class Abandoned extends Error {
|
|
162
|
+
}
|
|
163
|
+
function isRecord(value) {
|
|
164
|
+
return typeof value === "object" && value !== null;
|
|
165
|
+
}
|
|
166
|
+
/** What import.meta says in a cell: the notebook's directory, not workerd's. */
|
|
167
|
+
export function cellMeta(cwd) {
|
|
168
|
+
const replUrl = new URL("$cdpk$repl.mjs", pathToFileURL(cwd.endsWith("/") ? cwd : cwd + "/"));
|
|
169
|
+
return { url: replUrl.href, dirname: cwd, filename: join(cwd, "$cdpk$repl.mjs") };
|
|
170
|
+
}
|
|
171
|
+
/** The driver on a workerd of its own, behind a Unix socket. */
|
|
172
|
+
export class RawWorkerd {
|
|
173
|
+
runtime;
|
|
174
|
+
channel;
|
|
175
|
+
#child;
|
|
176
|
+
constructor(child, channel, runtime) {
|
|
177
|
+
this.#child = child;
|
|
178
|
+
this.channel = channel;
|
|
179
|
+
this.runtime = runtime;
|
|
180
|
+
}
|
|
181
|
+
static async start(cwd, executable) {
|
|
182
|
+
const workerd = executable ?? findWorkerd(cwd);
|
|
183
|
+
const compatibilityDate = compatibilityDateOf(execFileSync(workerd, ["--version"], { encoding: "utf8" }));
|
|
184
|
+
const source = await runtimeDir(new URL("../runtime/", import.meta.url).href);
|
|
185
|
+
// mkdtemp makes the directory 0700: only this user reaches the socket,
|
|
186
|
+
// and the token stops anything else running as them by accident.
|
|
187
|
+
const dir = mkdtempSync(join(tmpdir(), "cdpk-workerd-"));
|
|
188
|
+
const socket = join(dir, "sock");
|
|
189
|
+
for (const name of WORKER_MODULES)
|
|
190
|
+
copyFileSync(join(source, name), join(dir, name));
|
|
191
|
+
writeFileSync(join(dir, "config.capnp"), workerdConfig({ socket, compatibilityDate }));
|
|
192
|
+
const token = randomBytes(32).toString("hex");
|
|
193
|
+
const env = { ...process.env, CDPK_TOKEN: token, CDPK_META: JSON.stringify(cellMeta(cwd)) };
|
|
194
|
+
// --experimental: the UnsafeEval binding.
|
|
195
|
+
const child = spawn(workerd, ["serve", "config.capnp", "--experimental"], { cwd: dir, stdio: ["ignore", "pipe", "pipe"], env });
|
|
196
|
+
child.once("exit", () => rmSync(dir, { recursive: true, force: true }));
|
|
197
|
+
// Cell output is captured inside the Worker; what workerd itself prints
|
|
198
|
+
// (its own warnings, errors from background work) goes to the server log.
|
|
199
|
+
let early = "";
|
|
200
|
+
const log = (chunk) => {
|
|
201
|
+
early += chunk.toString();
|
|
202
|
+
process.stderr.write(`[cdp-kernel workerd] ${chunk}`);
|
|
203
|
+
};
|
|
204
|
+
child.stdout?.on("data", log);
|
|
205
|
+
child.stderr?.on("data", log);
|
|
206
|
+
const exited = new Promise((_, reject) => {
|
|
207
|
+
child.once("error", reject);
|
|
208
|
+
child.once("exit", (code) => reject(new Error(`workerd exited (${code}) before it was ready:\n${early}`)));
|
|
209
|
+
});
|
|
210
|
+
exited.catch(() => { });
|
|
211
|
+
const ready = (async () => {
|
|
212
|
+
for (let i = 0;; i++) {
|
|
213
|
+
try {
|
|
214
|
+
return await connectUnixWebSocket(socket, { headers: { "x-cdpk-token": token } });
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
if (i >= 200)
|
|
218
|
+
throw err;
|
|
219
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
try {
|
|
224
|
+
return new RawWorkerd(child, await Promise.race([ready, exited]), compatibilityDate);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
child.kill();
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
stop(now) {
|
|
232
|
+
this.#child.kill(now ? "SIGKILL" : "SIGTERM");
|
|
233
|
+
}
|
|
234
|
+
onExit(listener) {
|
|
235
|
+
this.#child.once("exit", listener);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
export class WorkerdTarget {
|
|
239
|
+
runtime;
|
|
240
|
+
versions;
|
|
241
|
+
notice;
|
|
242
|
+
transform = transformCellForEval;
|
|
243
|
+
#host;
|
|
244
|
+
#cwd;
|
|
245
|
+
#events;
|
|
246
|
+
/** Specifiers whose bundle this workerd has been sent. */
|
|
247
|
+
#sent = new Set();
|
|
248
|
+
#pending = new Map();
|
|
249
|
+
/** Requests that interrupt() abandons when the driver can still answer. */
|
|
250
|
+
#interruptible = new Set();
|
|
251
|
+
#nextId = 0;
|
|
252
|
+
#interrupted = false;
|
|
253
|
+
#stopNote;
|
|
254
|
+
#exitedOnce = false;
|
|
255
|
+
constructor(host, cwd, events, runtime) {
|
|
256
|
+
this.runtime = runtime;
|
|
257
|
+
this.#host = host;
|
|
258
|
+
this.versions = { runtime: host.runtime, engine: "V8" };
|
|
259
|
+
this.#cwd = cwd;
|
|
260
|
+
this.#events = events;
|
|
261
|
+
host.channel.onMessage((text) => this.#receive(text));
|
|
262
|
+
host.channel.onClose(() => this.#exited(null));
|
|
263
|
+
host.onExit((code) => this.#exited(code));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* In a Workers project (a wrangler config at or above `cwd`), the
|
|
267
|
+
* project's own Miniflare runs the driver with the project's bindings;
|
|
268
|
+
* otherwise a workerd of its own does (#26).
|
|
269
|
+
*/
|
|
270
|
+
static async launch(events, options = {}) {
|
|
271
|
+
const cwd = options.cwd ?? process.cwd();
|
|
272
|
+
const project = findWranglerConfig(cwd);
|
|
273
|
+
let host = null;
|
|
274
|
+
let notice;
|
|
275
|
+
if (project !== null) {
|
|
276
|
+
try {
|
|
277
|
+
host = await startMiniflare(project, cwd);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
// Like a broken deno.json (#13): say why in the first cell, and run
|
|
281
|
+
// without the project.
|
|
282
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
283
|
+
notice = `[cdp-kernel] couldn't load the Workers project at ${project}: ${reason}; running without its bindings`;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
host ??= await RawWorkerd.start(cwd, options.executable);
|
|
287
|
+
return await WorkerdTarget.open(host, cwd, events, "workerd", notice);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* A target over a started host's session. `notice` (a project that
|
|
291
|
+
* failed to load) is replaced by the bindings when the host has a project.
|
|
292
|
+
*/
|
|
293
|
+
static async open(host, cwd, events, runtime, notice) {
|
|
294
|
+
const target = new WorkerdTarget(host, cwd, events, runtime);
|
|
295
|
+
try {
|
|
296
|
+
const { v8, bindings, worker } = await target.#request("info");
|
|
297
|
+
if (typeof v8 === "string" && v8)
|
|
298
|
+
target.versions.engine = `V8 ${v8}`;
|
|
299
|
+
if (host.project !== undefined) {
|
|
300
|
+
const names = Array.isArray(bindings) ? bindings.map(String).sort().join(", ") : "";
|
|
301
|
+
notice = `[cdp-kernel] Workers project ${host.project}: env has ${names || "no bindings"}` +
|
|
302
|
+
(worker === true ? "; worker is its default export" : "");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
target.kill();
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
target.notice = notice;
|
|
310
|
+
return target;
|
|
311
|
+
}
|
|
312
|
+
#receive(text) {
|
|
313
|
+
const msg = JSON.parse(text);
|
|
314
|
+
if (isRecord(msg) && msg.kind === "stream") {
|
|
315
|
+
this.#events.stream(msg.name === "stderr" ? "stderr" : "stdout", String(msg.text));
|
|
316
|
+
}
|
|
317
|
+
else if (isTargetMessage(msg)) {
|
|
318
|
+
this.#events.message(msg);
|
|
319
|
+
}
|
|
320
|
+
else if (isRecord(msg) && msg.kind === "reply") {
|
|
321
|
+
const id = Number(msg.id);
|
|
322
|
+
const pending = this.#pending.get(id);
|
|
323
|
+
if (!pending)
|
|
324
|
+
return; // abandoned
|
|
325
|
+
this.#pending.delete(id);
|
|
326
|
+
this.#interruptible.delete(id);
|
|
327
|
+
if (typeof msg.failure === "string")
|
|
328
|
+
pending.reject(new Error(msg.failure));
|
|
329
|
+
else
|
|
330
|
+
pending.resolve(msg);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
#exited(code) {
|
|
334
|
+
if (this.#exitedOnce)
|
|
335
|
+
return;
|
|
336
|
+
this.#exitedOnce = true;
|
|
337
|
+
// The exit (and its note) is reported before the requests it cut off
|
|
338
|
+
// fail, so the note lands in the interrupted cell's output.
|
|
339
|
+
this.#events.exit(code, this.#stopNote);
|
|
340
|
+
for (const pending of this.#pending.values())
|
|
341
|
+
pending.reject(new Abandoned(`workerd exited (${code})`));
|
|
342
|
+
this.#pending.clear();
|
|
343
|
+
}
|
|
344
|
+
#request(op, params = {}, interruptible = false) {
|
|
345
|
+
const id = ++this.#nextId;
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
this.#pending.set(id, { resolve, reject });
|
|
348
|
+
if (interruptible)
|
|
349
|
+
this.#interruptible.add(id);
|
|
350
|
+
try {
|
|
351
|
+
this.#host.channel.send(JSON.stringify({ id, op, ...params }));
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
this.#pending.delete(id);
|
|
355
|
+
reject(new Abandoned("the workerd session is closed"));
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async #interruptibly(op, params, what) {
|
|
360
|
+
this.#interrupted = false;
|
|
361
|
+
try {
|
|
362
|
+
return await this.#request(op, params, true);
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
if (err instanceof Abandoned || this.#interrupted)
|
|
366
|
+
throw new Interrupted(`${what} interrupted`);
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async evaluate(script) {
|
|
371
|
+
const modules = {};
|
|
372
|
+
for (const specifier of importSpecifiers(script)) {
|
|
373
|
+
if (BUILTIN.test(specifier) || this.#sent.has(specifier))
|
|
374
|
+
continue;
|
|
375
|
+
modules[specifier] = await bundleModule(specifier, this.#cwd);
|
|
376
|
+
// A failed bundle is rebuilt next time, after the user fixes it.
|
|
377
|
+
if (!modules[specifier].startsWith("Promise.reject("))
|
|
378
|
+
this.#sent.add(specifier);
|
|
379
|
+
}
|
|
380
|
+
return (await this.#interruptibly("eval", { code: script, modules }, "Execution"));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* A cell that's waiting on something leaves the driver free: it answers a
|
|
384
|
+
* ping, and the cell is abandoned with the session intact (its pending
|
|
385
|
+
* work still runs, as on Bun). A busy cell can't be reached, so workerd is
|
|
386
|
+
* stopped and restarts on the next cell.
|
|
387
|
+
*/
|
|
388
|
+
async interrupt() {
|
|
389
|
+
this.#interrupted = true;
|
|
390
|
+
const running = [...this.#interruptible];
|
|
391
|
+
const answered = await Promise.race([
|
|
392
|
+
this.#request("ping").then(() => true, () => false),
|
|
393
|
+
new Promise((resolve) => setTimeout(() => resolve(false), INTERRUPT_GRACE_MS)),
|
|
394
|
+
]);
|
|
395
|
+
if (answered) {
|
|
396
|
+
for (const id of running) {
|
|
397
|
+
this.#pending.get(id)?.reject(new Abandoned("interrupted"));
|
|
398
|
+
this.#pending.delete(id);
|
|
399
|
+
this.#interruptible.delete(id);
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.#stopNote = restartNote(this.runtime);
|
|
404
|
+
this.#host.stop(true);
|
|
405
|
+
}
|
|
406
|
+
/** Output arrives on the session before the reply it precedes. */
|
|
407
|
+
flush() {
|
|
408
|
+
return Promise.resolve();
|
|
409
|
+
}
|
|
410
|
+
async broadcastResult(executionCount, result) {
|
|
411
|
+
if (result.objectId === undefined)
|
|
412
|
+
return;
|
|
413
|
+
await this.#request("broadcast", { count: executionCount, handle: result.objectId });
|
|
414
|
+
}
|
|
415
|
+
async dispatchComm(msgType, content, metadata, buffers) {
|
|
416
|
+
const reply = await this.#interruptibly("comm", {
|
|
417
|
+
msgType,
|
|
418
|
+
content,
|
|
419
|
+
metadata,
|
|
420
|
+
buffers: buffers.map((b) => Buffer.from(b).toString("base64")),
|
|
421
|
+
}, "Comm handler");
|
|
422
|
+
if (isRecord(reply.error)) {
|
|
423
|
+
const { name, message, stack } = reply.error;
|
|
424
|
+
return { error: { name: String(name), message: String(message), stack: String(stack) } };
|
|
425
|
+
}
|
|
426
|
+
return { handled: reply.handled === true };
|
|
427
|
+
}
|
|
428
|
+
async describeError(exception) {
|
|
429
|
+
if (exception.objectId === undefined)
|
|
430
|
+
return { name: "Error", message: exception.description ?? "", stack: "" };
|
|
431
|
+
const { value } = await this.#request("describe", { handle: exception.objectId });
|
|
432
|
+
return JSON.parse(typeof value === "string" ? value : '{"name":"Error","message":"","stack":""}');
|
|
433
|
+
}
|
|
434
|
+
async propertyNames(expression) {
|
|
435
|
+
const reply = await this.#request("props", { expr: expression }).catch(() => null);
|
|
436
|
+
return Array.isArray(reply?.names) ? reply.names.filter((n) => typeof n === "string") : [];
|
|
437
|
+
}
|
|
438
|
+
/** None: top-level declarations here are properties of globalThis. */
|
|
439
|
+
lexicalNames() {
|
|
440
|
+
return Promise.resolve([]);
|
|
441
|
+
}
|
|
442
|
+
/** Never asked for: prompt() in workerd answers null without asking. */
|
|
443
|
+
replyInput() { }
|
|
444
|
+
kill() {
|
|
445
|
+
// workerd's graceful shutdown waits for open connections, the session
|
|
446
|
+
// included, so close it first.
|
|
447
|
+
this.#host.channel.close();
|
|
448
|
+
this.#host.stop(false);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type WorkerHost } from "./workerd-target.ts";
|
|
2
|
+
export declare const WRANGLER_CONFIGS: string[];
|
|
3
|
+
/** The nearest wrangler config at or above `cwd`, or null. */
|
|
4
|
+
export declare function findWranglerConfig(cwd: string): string | null;
|
|
5
|
+
/**
|
|
6
|
+
* The Worker's main module: the project's named exports (Durable Object
|
|
7
|
+
* classes and the like), the driver's fetch handler as the default, and the
|
|
8
|
+
* project's own default export as the `worker` global.
|
|
9
|
+
*/
|
|
10
|
+
export declare const PROJECT_MAIN: string;
|
|
11
|
+
/**
|
|
12
|
+
* The project's flags, plus nodejs_compat, which the driver needs for
|
|
13
|
+
* node:util, unless the project chose a variant itself. Its date can't say
|
|
14
|
+
* whether nodejs_compat is implied: an older Miniflare runs an older workerd
|
|
15
|
+
* than the date names. On a newer one the flag only draws a warning.
|
|
16
|
+
*/
|
|
17
|
+
export declare function compatibilityFlagsFor(flags: string[]): string[];
|
|
18
|
+
/** Packages import Node builtins by bare name too; workerd and celld want node:. */
|
|
19
|
+
export declare function builtinAliases(): {
|
|
20
|
+
[name: string]: string;
|
|
21
|
+
};
|
|
22
|
+
/** The project's main module as one ES module; builtins stay imports. */
|
|
23
|
+
export declare function bundleProject(main: string, define?: {
|
|
24
|
+
[name: string]: string;
|
|
25
|
+
}): Promise<string>;
|
|
26
|
+
/** Starts the driver on the project's Miniflare with its bindings. */
|
|
27
|
+
export declare function startMiniflare(configPath: string, cwd: string): Promise<WorkerHost>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// A Workers project's bindings for the workerd target (#26).
|
|
2
|
+
//
|
|
3
|
+
// When the notebook sits in a Workers project (a wrangler config at or above
|
|
4
|
+
// its directory), the driver Worker runs on the project's own Miniflare, the
|
|
5
|
+
// simulator `wrangler dev` uses, so cells get the project's KV, D1, R2,
|
|
6
|
+
// Durable Objects and vars as `env`, and its default export as `worker`.
|
|
7
|
+
// wrangler turns the config into Miniflare options
|
|
8
|
+
// (unstable_getMiniflareWorkerOptions); the project's main module is
|
|
9
|
+
// bundled next to the driver, so its Durable Object classes are exported
|
|
10
|
+
// from the same Worker. Neither wrangler nor Miniflare is a dependency of
|
|
11
|
+
// cdp-kernel: they're loaded from the project, so they match its version.
|
|
12
|
+
import { builtinModules, createRequire } from "node:module";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { build } from "esbuild";
|
|
17
|
+
import { runtimeDir } from "./package.js";
|
|
18
|
+
import { cellMeta, WORKER_MODULES } from "./workerd-target.js";
|
|
19
|
+
export const WRANGLER_CONFIGS = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
|
|
20
|
+
/** The nearest wrangler config at or above `cwd`, or null. */
|
|
21
|
+
export function findWranglerConfig(cwd) {
|
|
22
|
+
for (let dir = cwd;; dir = dirname(dir)) {
|
|
23
|
+
for (const name of WRANGLER_CONFIGS) {
|
|
24
|
+
if (existsSync(join(dir, name)))
|
|
25
|
+
return join(dir, name);
|
|
26
|
+
}
|
|
27
|
+
if (dirname(dir) === dir)
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The Worker's main module: the project's named exports (Durable Object
|
|
33
|
+
* classes and the like), the driver's fetch handler as the default, and the
|
|
34
|
+
* project's own default export as the `worker` global.
|
|
35
|
+
*/
|
|
36
|
+
export const PROJECT_MAIN = 'import * as project from "./project.mjs";\n' +
|
|
37
|
+
'export * from "./project.mjs";\n' +
|
|
38
|
+
'export { default } from "./workerd-worker.mjs";\n' +
|
|
39
|
+
"globalThis.worker = project.default;\n";
|
|
40
|
+
/**
|
|
41
|
+
* The project's flags, plus nodejs_compat, which the driver needs for
|
|
42
|
+
* node:util, unless the project chose a variant itself. Its date can't say
|
|
43
|
+
* whether nodejs_compat is implied: an older Miniflare runs an older workerd
|
|
44
|
+
* than the date names. On a newer one the flag only draws a warning.
|
|
45
|
+
*/
|
|
46
|
+
export function compatibilityFlagsFor(flags) {
|
|
47
|
+
if (flags.some((f) => /^(?:no_)?nodejs_compat/.test(f)))
|
|
48
|
+
return flags;
|
|
49
|
+
return [...flags, "nodejs_compat"];
|
|
50
|
+
}
|
|
51
|
+
const DEFAULT_COMPATIBILITY_DATE = "2026-08-04";
|
|
52
|
+
function isWrangler(value) {
|
|
53
|
+
return typeof value?.unstable_getMiniflareWorkerOptions === "function";
|
|
54
|
+
}
|
|
55
|
+
function isMiniflareModule(value) {
|
|
56
|
+
return typeof value?.Miniflare === "function";
|
|
57
|
+
}
|
|
58
|
+
function isRecord(value) {
|
|
59
|
+
return typeof value === "object" && value !== null;
|
|
60
|
+
}
|
|
61
|
+
/** Loads wrangler from the project, and the Miniflare that wrangler uses. */
|
|
62
|
+
function loadTooling(configPath) {
|
|
63
|
+
const fromProject = createRequire(join(dirname(configPath), "package.json"));
|
|
64
|
+
let wranglerPath;
|
|
65
|
+
try {
|
|
66
|
+
wranglerPath = fromProject.resolve("wrangler");
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error("wrangler isn't installed in the project (npm install --save-dev wrangler)");
|
|
70
|
+
}
|
|
71
|
+
const fromWrangler = createRequire(wranglerPath);
|
|
72
|
+
const wrangler = fromWrangler(wranglerPath);
|
|
73
|
+
const miniflare = fromWrangler("miniflare");
|
|
74
|
+
if (!isWrangler(wrangler))
|
|
75
|
+
throw new Error("this wrangler has no unstable_getMiniflareWorkerOptions");
|
|
76
|
+
if (!isMiniflareModule(miniflare))
|
|
77
|
+
throw new Error("couldn't load the Miniflare that wrangler uses");
|
|
78
|
+
const pkg = JSON.parse(readFileSync(fromWrangler.resolve("miniflare/package.json"), "utf8"));
|
|
79
|
+
const version = isRecord(pkg) && typeof pkg.version === "string" ? pkg.version : "";
|
|
80
|
+
return { wrangler, miniflare, version };
|
|
81
|
+
}
|
|
82
|
+
const NODE_BUILTINS = builtinModules.filter((name) => !name.startsWith("_") && !name.startsWith("node:"));
|
|
83
|
+
/** Packages import Node builtins by bare name too; workerd and celld want node:. */
|
|
84
|
+
export function builtinAliases() {
|
|
85
|
+
return Object.fromEntries(NODE_BUILTINS.map((name) => [name, `node:${name}`]));
|
|
86
|
+
}
|
|
87
|
+
/** The project's main module as one ES module; builtins stay imports. */
|
|
88
|
+
export async function bundleProject(main, define = {}) {
|
|
89
|
+
const result = await build({
|
|
90
|
+
entryPoints: [main],
|
|
91
|
+
bundle: true,
|
|
92
|
+
write: false,
|
|
93
|
+
format: "esm",
|
|
94
|
+
platform: "browser",
|
|
95
|
+
conditions: ["workerd", "worker", "browser"],
|
|
96
|
+
mainFields: ["browser", "module", "main"],
|
|
97
|
+
// Packages import Node builtins by bare name too; workerd wants node:.
|
|
98
|
+
alias: builtinAliases(),
|
|
99
|
+
external: ["node:*", "cloudflare:*", "workerd:*"],
|
|
100
|
+
define,
|
|
101
|
+
target: "esnext",
|
|
102
|
+
logLevel: "silent",
|
|
103
|
+
});
|
|
104
|
+
return result.outputFiles[0].text;
|
|
105
|
+
}
|
|
106
|
+
/** Miniflare's WebSocket as the session channel. */
|
|
107
|
+
function channelOf(ws) {
|
|
108
|
+
ws.accept();
|
|
109
|
+
return {
|
|
110
|
+
send: (text) => ws.send(text),
|
|
111
|
+
onMessage: (listener) => ws.addEventListener("message", (event) => listener(String(event.data))),
|
|
112
|
+
onClose: (listener) => ws.addEventListener("close", listener),
|
|
113
|
+
close() {
|
|
114
|
+
try {
|
|
115
|
+
ws.close();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Already closed.
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** The driver on the project's Miniflare. */
|
|
124
|
+
class MiniflareHost {
|
|
125
|
+
runtime;
|
|
126
|
+
project;
|
|
127
|
+
channel;
|
|
128
|
+
#mf;
|
|
129
|
+
#exitListeners = [];
|
|
130
|
+
#stopped = false;
|
|
131
|
+
constructor(mf, channel, version, project) {
|
|
132
|
+
this.#mf = mf;
|
|
133
|
+
this.channel = channel;
|
|
134
|
+
this.runtime = `miniflare ${version}`.trim();
|
|
135
|
+
this.project = project;
|
|
136
|
+
}
|
|
137
|
+
stop() {
|
|
138
|
+
if (this.#stopped)
|
|
139
|
+
return;
|
|
140
|
+
this.#stopped = true;
|
|
141
|
+
// dispose() ends workerd even mid-cell.
|
|
142
|
+
void this.#mf.dispose().catch(() => { }).finally(() => {
|
|
143
|
+
for (const listener of this.#exitListeners)
|
|
144
|
+
listener(null);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
onExit(listener) {
|
|
148
|
+
this.#exitListeners.push(listener);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Starts the driver on the project's Miniflare with its bindings. */
|
|
152
|
+
export async function startMiniflare(configPath, cwd) {
|
|
153
|
+
const { wrangler, miniflare, version } = loadTooling(configPath);
|
|
154
|
+
const projectDir = dirname(configPath);
|
|
155
|
+
const { workerOptions, main, define } = wrangler.unstable_getMiniflareWorkerOptions(configPath);
|
|
156
|
+
const source = await runtimeDir(new URL("../runtime/", import.meta.url).href);
|
|
157
|
+
const module = (path, contents) => ({ type: "ESModule", path, contents });
|
|
158
|
+
const modules = [
|
|
159
|
+
module("main.mjs", main ? PROJECT_MAIN : 'export { default } from "./workerd-worker.mjs";\n'),
|
|
160
|
+
...WORKER_MODULES.map((name) => module(name, readFileSync(join(source, name), "utf8"))),
|
|
161
|
+
...(main ? [module("project.mjs", await bundleProject(main, define))] : []),
|
|
162
|
+
];
|
|
163
|
+
const token = randomBytes(32).toString("hex");
|
|
164
|
+
const date = workerOptions.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE;
|
|
165
|
+
// Its own state directory, beside `wrangler dev`'s: two runtimes must
|
|
166
|
+
// never open the same Durable Object.
|
|
167
|
+
const state = join(projectDir, ".wrangler", "state", "cdp-kernel");
|
|
168
|
+
const v5 = typeof miniflare.convertV4MiniflareOptions === "function";
|
|
169
|
+
const options = {
|
|
170
|
+
...workerOptions,
|
|
171
|
+
name: "cdpk",
|
|
172
|
+
modules,
|
|
173
|
+
modulesRoot: "/",
|
|
174
|
+
unsafeEvalBinding: "UNSAFE",
|
|
175
|
+
compatibilityDate: date,
|
|
176
|
+
compatibilityFlags: compatibilityFlagsFor(workerOptions.compatibilityFlags ?? []),
|
|
177
|
+
bindings: { ...workerOptions.bindings, TOKEN: token, META: JSON.stringify(cellMeta(cwd)) },
|
|
178
|
+
// Miniflare 5 renamed Miniflare 4's defaultPersistRoot.
|
|
179
|
+
...(v5 ? { resourcePersistencePath: state } : { defaultPersistRoot: state }),
|
|
180
|
+
};
|
|
181
|
+
const mf = new miniflare.Miniflare(v5 && miniflare.convertV4MiniflareOptions ? miniflare.convertV4MiniflareOptions(options) : options);
|
|
182
|
+
try {
|
|
183
|
+
await mf.ready;
|
|
184
|
+
// Miniflare listens on a loopback TCP port; the token is what keeps
|
|
185
|
+
// other local processes from the driver.
|
|
186
|
+
const res = await mf.dispatchFetch("http://cdpk/", { headers: { Upgrade: "websocket", "x-cdpk-token": token } });
|
|
187
|
+
if (res.status !== 101 || res.webSocket === null)
|
|
188
|
+
throw new Error(`the driver answered ${res.status}: ${await res.text()}`);
|
|
189
|
+
return new MiniflareHost(mf, channelOf(res.webSocket), version, configPath);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
await mf.dispose().catch(() => { });
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|