@tlockney/cdp-kernel 0.1.1 → 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 +274 -21
- 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/deno-config.d.ts +20 -0
- package/dist/deno-config.js +130 -0
- package/dist/globals-deno-compat.d.ts +21 -0
- package/dist/globals-deno-compat.js +10 -0
- package/dist/globals.d.ts +36 -0
- package/dist/globals.js +15 -0
- package/dist/kernel.d.ts +2 -1
- package/dist/kernel.js +126 -11
- package/dist/main.js +5 -5
- package/dist/package.d.ts +13 -5
- package/dist/package.js +33 -8
- package/dist/target.d.ts +78 -7
- package/dist/target.js +67 -12
- package/dist/transform.d.ts +25 -1
- package/dist/transform.js +150 -11
- 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 +27 -4
- package/runtime/bootstrap.mjs +78 -7
- package/runtime/bun-channel.d.mts +3 -0
- package/runtime/bun-channel.mjs +58 -0
- package/runtime/comm.d.mts +46 -0
- package/runtime/comm.mjs +108 -0
- package/runtime/deno-compat.mjs +1 -0
- package/runtime/display.d.mts +11 -0
- package/runtime/display.mjs +11 -1
- 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/node-modules-resolve.d.mts +8 -0
- package/runtime/node-modules-resolve.mjs +106 -0
- package/runtime/widget.d.mts +40 -0
- package/runtime/widget.mjs +208 -0
- package/runtime/workerd-worker.mjs +189 -0
package/README.md
CHANGED
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
A Jupyter kernel extracted from Deno's (`deno jupyter`, v2.9.6) that drives
|
|
4
4
|
any V8 runtime over the Chrome DevTools Protocol. The kernel host runs on
|
|
5
5
|
Node or Deno; the code you type runs in a separate **target** runtime:
|
|
6
|
-
Node or Deno
|
|
7
|
-
|
|
6
|
+
Node or Deno over CDP. Bun, workerd and celld evaluate cells in-process
|
|
7
|
+
instead (see [Bun](#bun), [workerd](#workerd) and [celld](#celld)). In
|
|
8
|
+
principle, anything else whose inspector speaks CDP works too.
|
|
8
9
|
|
|
9
10
|
This is early software. It passes a conformance suite derived from Deno's
|
|
10
|
-
own Jupyter integration tests on
|
|
11
|
+
own Jupyter integration tests on every target, and it's packaged for npm and
|
|
11
12
|
JSR as `@tlockney/cdp-kernel`, but it hasn't seen much real use yet. The
|
|
12
13
|
feasibility study behind it covers the design,
|
|
13
14
|
the other options considered (workerd, Bun, rusty_v8, FFI), and the
|
|
14
|
-
evidence: [
|
|
15
|
+
evidence: [read it online](https://claude.ai/artifact/S66TnTmhCzPy2EW7jbeeue)
|
|
16
|
+
(also in the repo as
|
|
17
|
+
[`docs/life-after-deno-jupyter.html`](docs/life-after-deno-jupyter.html)). It
|
|
18
|
+
was written before packaging, so it calls the project a spike.
|
|
15
19
|
The scripts that produced its runtime findings are in [`probes/`](probes/).
|
|
16
20
|
|
|
17
21
|
## Why this shape
|
|
@@ -37,9 +41,9 @@ So the extraction is:
|
|
|
37
41
|
|
|
38
42
|
## Install
|
|
39
43
|
|
|
40
|
-
Each install writes a Jupyter kernelspec: `cdp-node`, `cdp-deno`,
|
|
41
|
-
`cdp-node-deno` (Node with the Deno compatibility namespace)
|
|
42
|
-
kernel in JupyterLab or VS Code.
|
|
44
|
+
Each install writes a Jupyter kernelspec: `cdp-node`, `cdp-deno`,
|
|
45
|
+
`cdp-node-deno` (Node with the Deno compatibility namespace), `cdp-bun`,
|
|
46
|
+
`cdp-workerd`, or `cdp-celld`. Then pick the kernel in JupyterLab or VS Code.
|
|
43
47
|
|
|
44
48
|
**From npm** (host runs on Node 24+; Node 26 is what's been tested):
|
|
45
49
|
|
|
@@ -48,6 +52,9 @@ npm install -g @tlockney/cdp-kernel
|
|
|
48
52
|
cdp-kernel install --runtime node
|
|
49
53
|
cdp-kernel install --runtime deno
|
|
50
54
|
cdp-kernel install --runtime node --deno-compat
|
|
55
|
+
cdp-kernel install --runtime bun
|
|
56
|
+
cdp-kernel install --runtime workerd
|
|
57
|
+
cdp-kernel install --runtime celld
|
|
51
58
|
```
|
|
52
59
|
|
|
53
60
|
The kernelspec records the absolute paths of `node` and the installed
|
|
@@ -62,7 +69,21 @@ deno run -A jsr:@tlockney/cdp-kernel/cli install --runtime deno
|
|
|
62
69
|
The kernelspec pins the release it was installed from
|
|
63
70
|
(`jsr:@tlockney/cdp-kernel@<version>/cli`). On first launch the target's
|
|
64
71
|
runtime modules are copied into `~/.cache/cdp-kernel/runtime/`. A Node
|
|
65
|
-
target needs `node` on `PATH
|
|
72
|
+
target needs `node` on `PATH`, and a Bun target needs `bun` (1.4 is what's
|
|
73
|
+
been tested). A workerd target uses the `workerd` in the notebook
|
|
74
|
+
directory's `node_modules` (a Workers project has one through wrangler),
|
|
75
|
+
or the one on `PATH` (`npm install -g workerd`; 2026-10-15 is what's been
|
|
76
|
+
tested). A celld target uses `~/.local/bin/celld`, where celld's installer
|
|
77
|
+
puts it, or the one on `PATH` (0.6.2 is what's been tested; celld has no
|
|
78
|
+
Windows build).
|
|
79
|
+
|
|
80
|
+
Deno 2.9+ refuses package versions less than 24 hours old by default
|
|
81
|
+
([minimum dependency age](https://docs.deno.com/go/minimum-dependency-age)).
|
|
82
|
+
In a release's first day, the install command fails with "blocked by the
|
|
83
|
+
minimum dependency age policy". Wait out the day, or install an earlier
|
|
84
|
+
release. Passing `--min-dep-age=0` gets the install through, but the
|
|
85
|
+
kernelspec doesn't carry the flag, so the kernel still won't launch until
|
|
86
|
+
the release is a day old.
|
|
66
87
|
|
|
67
88
|
**From a checkout**, for working on the kernel itself:
|
|
68
89
|
|
|
@@ -71,6 +92,9 @@ npm ci
|
|
|
71
92
|
npm run install:node # kernelspec cdp-node
|
|
72
93
|
npm run install:deno # kernelspec cdp-deno
|
|
73
94
|
npm run install:node-deno # kernelspec cdp-node-deno
|
|
95
|
+
npm run install:bun # kernelspec cdp-bun
|
|
96
|
+
npm run install:workerd # kernelspec cdp-workerd
|
|
97
|
+
npm run install:celld # kernelspec cdp-celld
|
|
74
98
|
```
|
|
75
99
|
|
|
76
100
|
These kernelspecs point at the checkout, so don't move it after installing.
|
|
@@ -93,11 +117,214 @@ In a cell:
|
|
|
93
117
|
or `jsr:@scope/pkg@range`.
|
|
94
118
|
- On Node, `npm:` and `jsr:` install on first use into
|
|
95
119
|
`~/.cache/cdp-kernel/npm`.
|
|
96
|
-
- On Deno they use Deno's global cache
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
120
|
+
- On Deno they use Deno's global cache, unless the notebook is in a Deno
|
|
121
|
+
project (below).
|
|
122
|
+
- On Bun they install with `bun add` into `~/.cache/cdp-kernel/bun`. Other
|
|
123
|
+
bare imports resolve from the notebook's directory, so without a
|
|
124
|
+
`node_modules` there, Bun auto-installs them.
|
|
125
|
+
- On workerd, the host bundles every import other than a builtin
|
|
126
|
+
(`node:`, `cloudflare:`) with esbuild, using Workers' resolution
|
|
127
|
+
conditions, and sends it with the cell. `npm:` and `jsr:` install into
|
|
128
|
+
the npm cache first, as on Node.
|
|
129
|
+
- **Deno projects.** If a `deno.json` or `deno.jsonc` sits in the notebook's
|
|
130
|
+
directory or above it, a Deno target uses it, as `deno jupyter` does:
|
|
131
|
+
- its import map (`"@/": "./lib/"` aliases, mapped packages);
|
|
132
|
+
- its `nodeModulesDir` setting, with `package.json` dependencies from the
|
|
133
|
+
project's `node_modules`;
|
|
134
|
+
- its JSX and decorator `compilerOptions`: `jsx` (`react-jsx`,
|
|
135
|
+
`react-jsxdev`, `precompile`, `react`), `jsxImportSource`,
|
|
136
|
+
`jsxFactory`, `jsxFragmentFactory`, `experimentalDecorators`.
|
|
137
|
+
|
|
138
|
+
The import map wins over `node_modules`, as in Deno. The kernel never
|
|
139
|
+
writes to the project's lockfile. A `deno.json` that doesn't parse is
|
|
140
|
+
reported in the first cell's output, and the kernel runs as in a plain
|
|
141
|
+
directory. Outside a project, the target runs with
|
|
142
|
+
`--node-modules-dir=none --no-lock`.
|
|
143
|
+
- `prompt()` and `confirm()` work over the stdin channel, except on
|
|
144
|
+
workerd. Interrupt keeps state, except for a busy cell on Bun or workerd
|
|
145
|
+
(below).
|
|
146
|
+
|
|
147
|
+
## Bun
|
|
148
|
+
|
|
149
|
+
Bun's `--inspect` speaks the WebKit Inspector Protocol, which lacks what the
|
|
150
|
+
CDP backend relies on: `replMode`, `Runtime.addBinding`,
|
|
151
|
+
`Runtime.terminateExecution`, and top-level `await` in `Runtime.evaluate`.
|
|
152
|
+
So the Bun target has its own backend (`src/bun-target.ts`,
|
|
153
|
+
`runtime/bun-channel.mjs`):
|
|
154
|
+
|
|
155
|
+
- **No inspector.** The host and Bun exchange JSON lines over a pipe on
|
|
156
|
+
fd 3, which only the host holds, so there is no port to discover.
|
|
157
|
+
- **Cells run in-process.** Each cell becomes global code wrapped in an
|
|
158
|
+
async function (`transformCellForEval` in `src/transform.ts`). Its
|
|
159
|
+
top-level declarations become properties of `globalThis`, so later
|
|
160
|
+
cells, and closures from this one, see them. This is the
|
|
161
|
+
tslab/IJavascript technique.
|
|
162
|
+
- **Same output ordering.** stdout and stderr stay pipes, ordered against
|
|
163
|
+
display messages by the same barrier markers as the CDP targets.
|
|
164
|
+
- **Interrupt.** A cell that's waiting on a promise is interrupted in place,
|
|
165
|
+
keeping state. A busy one (`while (true) {}`) can't be reached, so Bun is
|
|
166
|
+
stopped and restarts on the next cell without the earlier state. The cell
|
|
167
|
+
output says so.
|
|
168
|
+
|
|
169
|
+
What differs from the CDP targets is listed under [Known gaps](#known-gaps).
|
|
170
|
+
|
|
171
|
+
## workerd
|
|
172
|
+
|
|
173
|
+
workerd's inspector can evaluate code, but outside a request there is no
|
|
174
|
+
I/O context: timers, `fetch` and `crypto.randomUUID()` throw "Disallowed
|
|
175
|
+
operation called within global scope". So the workerd target runs its
|
|
176
|
+
cells inside a driver Worker (`src/workerd-target.ts`,
|
|
177
|
+
`runtime/workerd-worker.mjs`):
|
|
178
|
+
|
|
179
|
+
- **One session.** The host opens a single WebSocket to the driver, and
|
|
180
|
+
every cell runs inside that session's request. Cells get Workers'
|
|
181
|
+
`fetch`, `Request`, `caches`, timers and randomness, and anything one
|
|
182
|
+
cell makes carries over to the next: globals, a Durable Object stub, a
|
|
183
|
+
stream, a timer still pending.
|
|
184
|
+
- **A private socket.** Outside a Workers project, the host writes a
|
|
185
|
+
workerd config into a private temporary directory and serves the driver
|
|
186
|
+
on a Unix socket there, opened with a per-launch token. There is no TCP
|
|
187
|
+
port and no inspector.
|
|
188
|
+
- **Cells.** Cells are transformed as for Bun and run by workerd's
|
|
189
|
+
`UnsafeEval` binding (hence `--experimental`).
|
|
190
|
+
- **Output.** `console` output and display messages come back over the
|
|
191
|
+
session as they happen, in order.
|
|
192
|
+
- **Interrupt.** A cell that's waiting on something is abandoned in place,
|
|
193
|
+
keeping state, as on Bun. A busy one gets Bun's treatment too: workerd is
|
|
194
|
+
stopped and restarts on the next cell.
|
|
195
|
+
|
|
196
|
+
What workerd's own process prints, such as its warnings, goes to the Jupyter
|
|
197
|
+
server log, not the notebook.
|
|
198
|
+
|
|
199
|
+
### Workers projects
|
|
200
|
+
|
|
201
|
+
If a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml` sits in the
|
|
202
|
+
notebook's directory or above it, the driver runs on the project's own
|
|
203
|
+
Miniflare, the simulator `wrangler dev` uses, with the project's bindings
|
|
204
|
+
(`src/workers-project.ts`):
|
|
205
|
+
|
|
206
|
+
- `env` holds the bindings: whatever wrangler translates for Miniflare.
|
|
207
|
+
Vars, KV, D1 and Durable Objects are tested; R2, Queues and the others
|
|
208
|
+
go through the same path but aren't.
|
|
209
|
+
- The project's `main` is bundled into the driver, so its Durable Object
|
|
210
|
+
classes run, and `worker` is its default export:
|
|
211
|
+
`await worker.fetch(new Request("https://x/"), env)`.
|
|
212
|
+
- State lives in the project's `.wrangler/state/cdp-kernel`, beside
|
|
213
|
+
`wrangler dev`'s own, so the two never open the same Durable Object.
|
|
214
|
+
- The first cell's output names the bindings. If the project can't load,
|
|
215
|
+
for example because wrangler isn't installed in it, the first cell says
|
|
216
|
+
why and the kernel runs on plain workerd.
|
|
217
|
+
|
|
218
|
+
wrangler and Miniflare come from the project's `node_modules`, so they match
|
|
219
|
+
its versions. Miniflare 4 and 5 both work. Miniflare listens on a loopback
|
|
220
|
+
TCP port, so there the per-launch token is what keeps other local processes
|
|
221
|
+
out.
|
|
222
|
+
|
|
223
|
+
## celld
|
|
224
|
+
|
|
225
|
+
[celld](https://github.com/denoland/celld) runs Workers and Durable Objects
|
|
226
|
+
on your own storage. Like workerd it has no I/O outside a request, so the
|
|
227
|
+
celld target runs the same driver Worker over the same WebSocket session
|
|
228
|
+
(`src/celld-target.ts`). Cells, output, interrupts and host-bundled imports
|
|
229
|
+
all work as on workerd. The differences:
|
|
230
|
+
|
|
231
|
+
- **No `UnsafeEval` needed.** celld allows a plain global `eval`, so it runs
|
|
232
|
+
without workerd's `--experimental`.
|
|
233
|
+
- **One module.** The host bundles the driver, and a project's `main`, into
|
|
234
|
+
a single module, and gives celld the result with `no_bundle`. celld
|
|
235
|
+
doesn't need its own esbuild.
|
|
236
|
+
- **Loopback TCP.** celld listens only on TCP, so the session is a loopback
|
|
237
|
+
WebSocket opened with a per-launch token. The kernel picks a free port
|
|
238
|
+
and runs `celld dev --no-watch` on a generated config.
|
|
239
|
+
- **Projects.** In a celld project (a `wrangler.jsonc` or `wrangler.json`
|
|
240
|
+
at or above the notebook), the generated config copies the project's
|
|
241
|
+
compatibility settings and bindings: vars, KV, D1, R2, Durable Objects,
|
|
242
|
+
Queues, Workflows, services, Worker Loaders. `env` and `worker` then work
|
|
243
|
+
as on workerd. The generated config is written to the project's
|
|
244
|
+
`.celld/cdp-kernel` and removed when the kernel stops. The state stays
|
|
245
|
+
there across kernels, beside `celld dev`'s own `.celld/dev`. celld rejects `wrangler.toml`; a project
|
|
246
|
+
that can't load is reported in the first cell, and the driver runs alone.
|
|
247
|
+
- **Stopping.** `celld dev` runs its node in a separate process group and
|
|
248
|
+
waits for it to stop gracefully, which a busy cell never allows. So an
|
|
249
|
+
interrupt of a busy cell kills the node, then `celld dev`.
|
|
250
|
+
|
|
251
|
+
## Editor types
|
|
252
|
+
|
|
253
|
+
Cells run untyped, as in `deno jupyter`. Notebook code your editor
|
|
254
|
+
type-checks can declare the globals.
|
|
255
|
+
|
|
256
|
+
**Node notebooks.** The npm package ships the declarations. Add it as a dev
|
|
257
|
+
dependency, then reference it once from a cell or a `.d.ts` in the project:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
/// <reference types="@tlockney/cdp-kernel/globals" />
|
|
261
|
+
await jupyter.display(jupyter.md`typed`);
|
|
262
|
+
const name: string | null = prompt("Name?", "", { password: false });
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
That declares `jupyter`, `prompt` and `confirm`. With the `cdp-node-deno`
|
|
266
|
+
kernel, use `@tlockney/cdp-kernel/globals/deno-compat` instead: it adds the
|
|
267
|
+
`Deno` namespace the compatibility shim implements. A custom display is a
|
|
268
|
+
class with a `[jupyter.$display]()` method returning a MIME bundle.
|
|
269
|
+
`jupyter.$display` is `Symbol.for("Jupyter.display")` at runtime, but only
|
|
270
|
+
`jupyter.$display` carries the precise type.
|
|
271
|
+
|
|
272
|
+
**Deno notebooks.** Use `Deno.jupyter`, which Deno already types and which a
|
|
273
|
+
Deno target provides. The JSR package doesn't ship these globals, because
|
|
274
|
+
JSR doesn't allow packages to augment global types.
|
|
275
|
+
|
|
276
|
+
## Widgets and comms
|
|
277
|
+
|
|
278
|
+
`deno jupyter` has no comms. Here they're supported in both directions, so
|
|
279
|
+
anywidget-style widgets work in frontends with the `anywidget` extension:
|
|
280
|
+
JupyterLab, Notebook 7 and VS Code. Install it with
|
|
281
|
+
`pip install anywidget`; that's the frontend half, no Python kernel needed.
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
const counter = jupyter.widget({
|
|
285
|
+
esm: `export default {
|
|
286
|
+
render({ model, el }) {
|
|
287
|
+
const btn = document.createElement("button");
|
|
288
|
+
const update = () => { btn.textContent = \`count is \${model.get("count")}\`; };
|
|
289
|
+
btn.onclick = () => { model.set("count", model.get("count") + 1); model.save_changes(); };
|
|
290
|
+
model.on("change:count", update);
|
|
291
|
+
update();
|
|
292
|
+
el.append(btn);
|
|
293
|
+
},
|
|
294
|
+
};`,
|
|
295
|
+
state: { count: 0 },
|
|
296
|
+
});
|
|
297
|
+
counter.on("change:count", (value) => console.log("clicked to", value));
|
|
298
|
+
counter; // display it
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The model API:
|
|
302
|
+
|
|
303
|
+
- `get(key)`, and `set(key, value)` or `set({ ... })`, which syncs to every
|
|
304
|
+
view.
|
|
305
|
+
- `on("change:key" | "change" | "msg:custom", listener)` and `off(...)`.
|
|
306
|
+
- `send(content, buffers)` for custom messages.
|
|
307
|
+
- `close()`.
|
|
308
|
+
|
|
309
|
+
`Uint8Array`, `ArrayBuffer` and `DataView` values in the state travel as
|
|
310
|
+
binary buffers. JupyterLab restores widget state after a page reload over
|
|
311
|
+
the `jupyter.widget.control` comm.
|
|
312
|
+
|
|
313
|
+
The comm layer underneath is available directly, for other comm-based
|
|
314
|
+
protocols:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
const comm = jupyter.comm.open("my.target", { hello: 1 });
|
|
318
|
+
comm.onMsg(({ data, buffers }) => console.log(data, buffers.length));
|
|
319
|
+
comm.send({ ping: true });
|
|
320
|
+
jupyter.comm.registerTarget("echo", (comm) => comm.onMsg((m) => comm.send(m.data)));
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Comm handlers run like cells. Their output goes to the notebook as replies
|
|
324
|
+
to the message that triggered them, an exception is shown on stderr, and
|
|
325
|
+
interrupt stops a stuck handler. Messages are processed one at a time with
|
|
326
|
+
cell executions, as in ipykernel, so a widget event that arrives during a
|
|
327
|
+
long cell waits for the cell to finish.
|
|
101
328
|
|
|
102
329
|
## How the tricky parts work
|
|
103
330
|
|
|
@@ -114,7 +341,8 @@ In a cell:
|
|
|
114
341
|
```sh
|
|
115
342
|
npm run check # tsc --strict over src/ and test/
|
|
116
343
|
npm test # unit tests (node:test)
|
|
117
|
-
npm run e2e # conformance suite via jupyter_client/libzmq, on
|
|
344
|
+
npm run e2e # conformance suite via jupyter_client/libzmq, on every target variant
|
|
345
|
+
npm run test:lab # an anywidget counter in real JupyterLab, headless Chromium (node, deno, bun, workerd, celld)
|
|
118
346
|
uv run --with nbclient --with nbformat python examples/run_notebook.py # Deno-style notebook on deno + node-deno
|
|
119
347
|
```
|
|
120
348
|
|
|
@@ -151,7 +379,8 @@ that failed.
|
|
|
151
379
|
|
|
152
380
|
## Known gaps
|
|
153
381
|
|
|
154
|
-
-
|
|
382
|
+
- Widgets are anywidget-style (`jupyter.widget`). The classic ipywidgets
|
|
383
|
+
controls (sliders, dropdowns) are Python classes, so they're not available.
|
|
155
384
|
- No `inspect_request` content and no history. Deno has neither.
|
|
156
385
|
- No debugger.
|
|
157
386
|
- `npm:` on Node:
|
|
@@ -162,12 +391,36 @@ that failed.
|
|
|
162
391
|
- Results are formatted with `util.inspect` on Node, so they look slightly
|
|
163
392
|
different from Deno's `Deno.inspect`.
|
|
164
393
|
- WebGPU texture and buffer display is dropped.
|
|
165
|
-
-
|
|
166
|
-
-
|
|
167
|
-
|
|
168
|
-
-
|
|
169
|
-
|
|
170
|
-
|
|
394
|
+
- On Bun:
|
|
395
|
+
- Interrupting a busy cell restarts Bun and loses state. An awaiting cell
|
|
396
|
+
keeps it.
|
|
397
|
+
- Top-level declarations are properties of `globalThis`. A `const` can be
|
|
398
|
+
reassigned, and declaring a global's name (`const fetch = ...`) replaces
|
|
399
|
+
that global rather than shadowing it.
|
|
400
|
+
- Only a trailing expression statement shows a result: `x` does, but
|
|
401
|
+
`if (c) { x }` doesn't.
|
|
402
|
+
- Bun 1.4 can't import a module whose path contains `?`.
|
|
403
|
+
- No `--deno-compat` namespace.
|
|
404
|
+
- On workerd:
|
|
405
|
+
- All cells share one request, so it never ends. Unlike production
|
|
406
|
+
Workers, there is no per-request CPU or duration limit, and
|
|
407
|
+
`ctx.waitUntil` isn't available in a cell.
|
|
408
|
+
- No `prompt()` or `confirm()` answers: they return `null`, as without a
|
|
409
|
+
stdin channel. `jupyter.image(path)` can't read files; pass the bytes.
|
|
410
|
+
- Each imported module is bundled with its dependencies. Two imports that
|
|
411
|
+
share a dependency each get their own copy.
|
|
412
|
+
- Interrupting a busy cell restarts workerd and loses state.
|
|
413
|
+
- Top-level declarations and results behave as on Bun: declarations are
|
|
414
|
+
`globalThis` properties, and only a trailing expression shows a result.
|
|
415
|
+
- In a Workers project, `main` is bundled with esbuild directly, not with
|
|
416
|
+
wrangler's build. Custom `build` commands, `alias` and module `rules`
|
|
417
|
+
aren't applied.
|
|
418
|
+
- On celld, as on workerd, except:
|
|
419
|
+
- Only the binding and compatibility keys of a project's config are
|
|
420
|
+
copied. `assets`, `triggers` and `containers` aren't, and neither is
|
|
421
|
+
`.dev.vars`. Relative paths inside the copied keys resolve from
|
|
422
|
+
`.celld/cdp-kernel`, not from the project.
|
|
423
|
+
- Interrupting a busy cell restarts celld and loses state.
|
|
171
424
|
|
|
172
425
|
## Provenance
|
|
173
426
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { CdpParams, EvaluateResult, RemoteObject } from "./cdp.ts";
|
|
2
|
+
import { transformCellForEval } from "./transform.ts";
|
|
3
|
+
import { type KernelTarget, type TargetEvents } from "./target.ts";
|
|
4
|
+
/**
|
|
5
|
+
* How long an interrupted target has to answer a ping. A cell waiting on a
|
|
6
|
+
* promise leaves the event loop free, so the answer is near-instant; a busy
|
|
7
|
+
* one can't answer at all.
|
|
8
|
+
*/
|
|
9
|
+
export declare const INTERRUPT_GRACE_MS = 500;
|
|
10
|
+
export declare const RESTART_NOTE: string;
|
|
11
|
+
export declare function bunLaunchCommand(bootstrap: string, executable?: string): {
|
|
12
|
+
cmd: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
};
|
|
15
|
+
export declare class BunTarget implements KernelTarget {
|
|
16
|
+
#private;
|
|
17
|
+
readonly runtime = "bun";
|
|
18
|
+
readonly versions: {
|
|
19
|
+
runtime: string;
|
|
20
|
+
engine: string;
|
|
21
|
+
};
|
|
22
|
+
readonly transform: typeof transformCellForEval;
|
|
23
|
+
private constructor();
|
|
24
|
+
static launch(events: TargetEvents, options?: {
|
|
25
|
+
cwd?: string;
|
|
26
|
+
executable?: string;
|
|
27
|
+
}): Promise<BunTarget>;
|
|
28
|
+
evaluate(script: string): Promise<EvaluateResult>;
|
|
29
|
+
/**
|
|
30
|
+
* There's no terminateExecution, but a cell that's waiting on a promise
|
|
31
|
+
* leaves the event loop free: the target answers a ping, and the cell is
|
|
32
|
+
* abandoned with the session intact, like terminateExecution on an idle
|
|
33
|
+
* isolate (its pending work still runs). A busy cell can't be reached, so
|
|
34
|
+
* the process is stopped and restarts on the next cell, losing its state.
|
|
35
|
+
*/
|
|
36
|
+
interrupt(): Promise<void>;
|
|
37
|
+
flush(): Promise<void>;
|
|
38
|
+
broadcastResult(executionCount: number, result: RemoteObject): Promise<void>;
|
|
39
|
+
dispatchComm(msgType: string, content: CdpParams, metadata: CdpParams, buffers: Uint8Array[]): Promise<{
|
|
40
|
+
handled: boolean;
|
|
41
|
+
} | {
|
|
42
|
+
error: {
|
|
43
|
+
name: string;
|
|
44
|
+
message: string;
|
|
45
|
+
stack: string;
|
|
46
|
+
};
|
|
47
|
+
}>;
|
|
48
|
+
describeError(exception: RemoteObject): Promise<{
|
|
49
|
+
name: string;
|
|
50
|
+
message: string;
|
|
51
|
+
stack: string;
|
|
52
|
+
}>;
|
|
53
|
+
propertyNames(expression: string): Promise<string[]>;
|
|
54
|
+
/** None: top-level declarations here are properties of globalThis. */
|
|
55
|
+
lexicalNames(): Promise<string[]>;
|
|
56
|
+
replyInput(value: string | null): void;
|
|
57
|
+
kill(): void;
|
|
58
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// The Bun target (#7). Bun's --inspect speaks the WebKit Inspector Protocol,
|
|
2
|
+
// which has no replMode, Runtime.addBinding or Runtime.terminateExecution, so
|
|
3
|
+
// Bun isn't driven over an inspector at all. The host sends requests over a
|
|
4
|
+
// private pipe (fd 3) to runtime/bun-channel.mjs, which evaluates cells
|
|
5
|
+
// in-process (transformCellForEval). Output ordering is the CDP targets':
|
|
6
|
+
// stdout and stderr are pipes, ordered against channel messages by the
|
|
7
|
+
// bootstrap's barrier markers.
|
|
8
|
+
import { Buffer } from "node:buffer";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { transformCellForEval } from "./transform.js";
|
|
13
|
+
import { runtimeDir } from "./package.js";
|
|
14
|
+
import { Gates, Interrupted, isTargetMessage, PipeDemux } from "./target.js";
|
|
15
|
+
/**
|
|
16
|
+
* How long an interrupted target has to answer a ping. A cell waiting on a
|
|
17
|
+
* promise leaves the event loop free, so the answer is near-instant; a busy
|
|
18
|
+
* one can't answer at all.
|
|
19
|
+
*/
|
|
20
|
+
export const INTERRUPT_GRACE_MS = 500;
|
|
21
|
+
export const RESTART_NOTE = "[cdp-kernel] bun was busy and can't be interrupted in place, so it was stopped; " +
|
|
22
|
+
"it restarts on the next cell, without the earlier cells' state";
|
|
23
|
+
/** Rejects a request whose reply the host no longer waits for. */
|
|
24
|
+
class Abandoned extends Error {
|
|
25
|
+
}
|
|
26
|
+
function isDuplex(stream) {
|
|
27
|
+
return typeof stream?.write === "function" && typeof stream?.on === "function";
|
|
28
|
+
}
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return typeof value === "object" && value !== null;
|
|
31
|
+
}
|
|
32
|
+
export function bunLaunchCommand(bootstrap, executable) {
|
|
33
|
+
// No flags: Bun reads the notebook directory's bunfig.toml and package.json
|
|
34
|
+
// on its own, and auto-installs bare imports when there's no node_modules.
|
|
35
|
+
return { cmd: executable ?? "bun", args: [bootstrap] };
|
|
36
|
+
}
|
|
37
|
+
export class BunTarget {
|
|
38
|
+
runtime = "bun";
|
|
39
|
+
versions = { runtime: "", engine: "" };
|
|
40
|
+
transform = transformCellForEval;
|
|
41
|
+
#child;
|
|
42
|
+
#channel;
|
|
43
|
+
#stdout;
|
|
44
|
+
#stderr;
|
|
45
|
+
#gates = new Gates();
|
|
46
|
+
#pending = new Map();
|
|
47
|
+
#nextId = 0;
|
|
48
|
+
#messages = Promise.resolve();
|
|
49
|
+
#interrupted = false;
|
|
50
|
+
#stopNote;
|
|
51
|
+
constructor(child, channel, events) {
|
|
52
|
+
this.#child = child;
|
|
53
|
+
this.#channel = channel;
|
|
54
|
+
this.#stdout = new PipeDemux((text) => events.stream("stdout", text), (n) => this.#gates.wait(n));
|
|
55
|
+
this.#stderr = new PipeDemux((text) => events.stream("stderr", text), (n) => this.#gates.wait(n));
|
|
56
|
+
}
|
|
57
|
+
static async launch(events, options = {}) {
|
|
58
|
+
const bootstrap = join(await runtimeDir(new URL("../runtime/", import.meta.url).href), "bootstrap.mjs");
|
|
59
|
+
const { cmd, args } = bunLaunchCommand(bootstrap, options.executable);
|
|
60
|
+
const env = { ...process.env, FORCE_COLOR: "1" };
|
|
61
|
+
delete env.NO_COLOR;
|
|
62
|
+
const child = spawn(cmd, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe", "pipe"], env });
|
|
63
|
+
const channel = child.stdio[3];
|
|
64
|
+
if (!child.stdout || !child.stderr || !isDuplex(channel)) {
|
|
65
|
+
child.kill();
|
|
66
|
+
throw new Error("could not open the pipes to bun");
|
|
67
|
+
}
|
|
68
|
+
const target = new BunTarget(child, channel, events);
|
|
69
|
+
child.stdout.setEncoding("utf8");
|
|
70
|
+
child.stderr.setEncoding("utf8");
|
|
71
|
+
child.stdout.on("data", (chunk) => target.#stdout.push(chunk));
|
|
72
|
+
const ready = new Promise((resolve, reject) => {
|
|
73
|
+
let early = "";
|
|
74
|
+
const onEarly = (chunk) => (early += chunk);
|
|
75
|
+
child.stderr?.on("data", onEarly);
|
|
76
|
+
target.#onReady = (msg) => {
|
|
77
|
+
child.stderr?.off("data", onEarly);
|
|
78
|
+
if (early)
|
|
79
|
+
target.#stderr.push(early);
|
|
80
|
+
child.stderr?.on("data", (chunk) => target.#stderr.push(chunk));
|
|
81
|
+
resolve(msg);
|
|
82
|
+
};
|
|
83
|
+
child.once("error", reject);
|
|
84
|
+
child.once("exit", (code) => reject(new Error(`${cmd} exited (${code}) before it was ready:\n${early}`)));
|
|
85
|
+
});
|
|
86
|
+
target.#readChannel(events);
|
|
87
|
+
child.once("exit", (code) => target.#exited(code, events));
|
|
88
|
+
const { versions } = await ready;
|
|
89
|
+
if (isRecord(versions))
|
|
90
|
+
Object.assign(target.versions, versions);
|
|
91
|
+
return target;
|
|
92
|
+
}
|
|
93
|
+
#onReady = () => { };
|
|
94
|
+
#readChannel(events) {
|
|
95
|
+
let buffer = "";
|
|
96
|
+
this.#channel.setEncoding("utf8");
|
|
97
|
+
this.#channel.on("data", (chunk) => {
|
|
98
|
+
buffer += chunk;
|
|
99
|
+
for (let nl = buffer.indexOf("\n"); nl !== -1; nl = buffer.indexOf("\n")) {
|
|
100
|
+
const msg = JSON.parse(buffer.slice(0, nl));
|
|
101
|
+
buffer = buffer.slice(nl + 1);
|
|
102
|
+
if (isTargetMessage(msg))
|
|
103
|
+
this.#deliver(msg, events);
|
|
104
|
+
else if (isRecord(msg) && msg.kind === "reply")
|
|
105
|
+
this.#settle(msg);
|
|
106
|
+
else if (isRecord(msg) && msg.kind === "ready")
|
|
107
|
+
this.#onReady(msg);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
// A closed pipe is reported through the child's exit.
|
|
111
|
+
this.#channel.on("error", () => { });
|
|
112
|
+
}
|
|
113
|
+
#settle(reply) {
|
|
114
|
+
const id = Number(reply.id);
|
|
115
|
+
const pending = this.#pending.get(id);
|
|
116
|
+
if (!pending)
|
|
117
|
+
return; // abandoned
|
|
118
|
+
this.#pending.delete(id);
|
|
119
|
+
if (typeof reply.failure === "string")
|
|
120
|
+
pending.reject(new Error(reply.failure));
|
|
121
|
+
else
|
|
122
|
+
pending.resolve(reply);
|
|
123
|
+
}
|
|
124
|
+
#exited(code, events) {
|
|
125
|
+
this.#gates.openAll();
|
|
126
|
+
this.#stdout.close();
|
|
127
|
+
this.#stderr.close();
|
|
128
|
+
events.exit(code, this.#stopNote);
|
|
129
|
+
this.#abandonPending(`bun exited (${code})`);
|
|
130
|
+
}
|
|
131
|
+
#abandonPending(reason) {
|
|
132
|
+
for (const pending of this.#pending.values())
|
|
133
|
+
pending.reject(new Abandoned(reason));
|
|
134
|
+
this.#pending.clear();
|
|
135
|
+
}
|
|
136
|
+
// Binding messages wait until both pipes have caught up to their marker,
|
|
137
|
+
// and are delivered strictly in order (as in Target).
|
|
138
|
+
#deliver(msg, events) {
|
|
139
|
+
this.#messages = this.#messages.then(async () => {
|
|
140
|
+
await Promise.all([this.#stdout.waitFor(msg.barrier), this.#stderr.waitFor(msg.barrier)]);
|
|
141
|
+
events.message(msg);
|
|
142
|
+
this.#gates.open(msg.barrier);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
#request(op, params = {}) {
|
|
146
|
+
const id = ++this.#nextId;
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
this.#pending.set(id, { resolve, reject });
|
|
149
|
+
this.#channel.write(JSON.stringify({ id, op, ...params }) + "\n");
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/** A request that interrupt() may cut short. */
|
|
153
|
+
async #interruptible(op, params, what) {
|
|
154
|
+
this.#interrupted = false;
|
|
155
|
+
try {
|
|
156
|
+
return await this.#request(op, params);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
if (err instanceof Abandoned || this.#interrupted)
|
|
160
|
+
throw new Interrupted(`${what} interrupted`);
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async evaluate(script) {
|
|
165
|
+
return (await this.#interruptible("eval", { code: script }, "Execution"));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* There's no terminateExecution, but a cell that's waiting on a promise
|
|
169
|
+
* leaves the event loop free: the target answers a ping, and the cell is
|
|
170
|
+
* abandoned with the session intact, like terminateExecution on an idle
|
|
171
|
+
* isolate (its pending work still runs). A busy cell can't be reached, so
|
|
172
|
+
* the process is stopped and restarts on the next cell, losing its state.
|
|
173
|
+
*/
|
|
174
|
+
async interrupt() {
|
|
175
|
+
this.#interrupted = true;
|
|
176
|
+
const answered = await Promise.race([
|
|
177
|
+
this.#request("ping").then(() => true, () => false),
|
|
178
|
+
new Promise((resolve) => setTimeout(() => resolve(false), INTERRUPT_GRACE_MS)),
|
|
179
|
+
]);
|
|
180
|
+
if (answered) {
|
|
181
|
+
this.#abandonPending("interrupted");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.#stopNote = RESTART_NOTE;
|
|
185
|
+
this.#child.kill("SIGKILL");
|
|
186
|
+
}
|
|
187
|
+
async flush() {
|
|
188
|
+
const { value } = await this.#request("barrier");
|
|
189
|
+
const barrier = Number(value);
|
|
190
|
+
await Promise.all([this.#stdout.waitFor(barrier), this.#stderr.waitFor(barrier)]);
|
|
191
|
+
await this.#messages;
|
|
192
|
+
}
|
|
193
|
+
async broadcastResult(executionCount, result) {
|
|
194
|
+
if (result.objectId === undefined)
|
|
195
|
+
return;
|
|
196
|
+
await this.#request("broadcast", { count: executionCount, handle: result.objectId });
|
|
197
|
+
}
|
|
198
|
+
async dispatchComm(msgType, content, metadata, buffers) {
|
|
199
|
+
const reply = await this.#interruptible("comm", {
|
|
200
|
+
msgType,
|
|
201
|
+
content,
|
|
202
|
+
metadata,
|
|
203
|
+
buffers: buffers.map((b) => Buffer.from(b).toString("base64")),
|
|
204
|
+
}, "Comm handler");
|
|
205
|
+
if (isRecord(reply.error)) {
|
|
206
|
+
const { name, message, stack } = reply.error;
|
|
207
|
+
return { error: { name: String(name), message: String(message), stack: String(stack) } };
|
|
208
|
+
}
|
|
209
|
+
return { handled: reply.handled === true };
|
|
210
|
+
}
|
|
211
|
+
async describeError(exception) {
|
|
212
|
+
if (exception.objectId === undefined)
|
|
213
|
+
return { name: "Error", message: exception.description ?? "", stack: "" };
|
|
214
|
+
const { value } = await this.#request("describe", { handle: exception.objectId });
|
|
215
|
+
return JSON.parse(typeof value === "string" ? value : '{"name":"Error","message":"","stack":""}');
|
|
216
|
+
}
|
|
217
|
+
async propertyNames(expression) {
|
|
218
|
+
const reply = await this.#request("props", { expr: expression }).catch(() => null);
|
|
219
|
+
return Array.isArray(reply?.names) ? reply.names.filter((n) => typeof n === "string") : [];
|
|
220
|
+
}
|
|
221
|
+
/** None: top-level declarations here are properties of globalThis. */
|
|
222
|
+
lexicalNames() {
|
|
223
|
+
return Promise.resolve([]);
|
|
224
|
+
}
|
|
225
|
+
replyInput(value) {
|
|
226
|
+
this.#child.stdin?.write(JSON.stringify({ value }) + "\n");
|
|
227
|
+
}
|
|
228
|
+
kill() {
|
|
229
|
+
this.#child.kill();
|
|
230
|
+
}
|
|
231
|
+
}
|