@tlockney/cdp-kernel 0.0.0-stage → 0.1.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/LICENSE +22 -0
- package/README.md +170 -2
- package/dist/cdp.d.ts +41 -0
- package/dist/cdp.js +89 -0
- package/dist/kernel.d.ts +20 -0
- package/dist/kernel.js +362 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +88 -0
- package/dist/mod.d.ts +23 -0
- package/dist/mod.js +22 -0
- package/dist/package.d.ts +29 -0
- package/dist/package.js +95 -0
- package/dist/target.d.ts +84 -0
- package/dist/target.js +350 -0
- package/dist/text.d.ts +14 -0
- package/dist/text.js +69 -0
- package/dist/transform.d.ts +13 -0
- package/dist/transform.js +145 -0
- package/dist/wire.d.ts +35 -0
- package/dist/wire.js +94 -0
- package/dist/zmtp.d.ts +49 -0
- package/dist/zmtp.js +241 -0
- package/package.json +50 -4
- package/runtime/bootstrap.mjs +190 -0
- package/runtime/deno-compat.d.mts +116 -0
- package/runtime/deno-compat.mjs +185 -0
- package/runtime/display.d.mts +48 -0
- package/runtime/display.mjs +237 -0
- package/runtime/node-hooks.d.mts +10 -0
- package/runtime/node-hooks.mjs +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Lockney
|
|
4
|
+
Portions Copyright (c) 2018-2026 the Deno authors
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,171 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cdp-kernel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Jupyter kernel extracted from Deno's (`deno jupyter`, v2.9.6) that drives
|
|
4
|
+
any V8 runtime over the Chrome DevTools Protocol. The kernel host runs on
|
|
5
|
+
Node or Deno; the code you type runs in a separate **target** runtime:
|
|
6
|
+
Node or Deno today, and anything else whose inspector speaks CDP in
|
|
7
|
+
principle.
|
|
8
|
+
|
|
9
|
+
This is early software. It passes a conformance suite derived from Deno's
|
|
10
|
+
own Jupyter integration tests on both targets, and it's packaged for npm and
|
|
11
|
+
JSR as `@tlockney/cdp-kernel`, but it hasn't seen much real use yet. The
|
|
12
|
+
feasibility study behind it covers the design,
|
|
13
|
+
the other options considered (workerd, Bun, rusty_v8, FFI), and the
|
|
14
|
+
evidence: [`docs/life-after-deno-jupyter.html`](docs/life-after-deno-jupyter.html).
|
|
15
|
+
The scripts that produced its runtime findings are in [`probes/`](probes/).
|
|
16
|
+
|
|
17
|
+
## Why this shape
|
|
18
|
+
|
|
19
|
+
Deno 2.9's kernel is already two halves joined by thirteen ops
|
|
20
|
+
(denoland/deno#34083 rewrote it in JS and dropped libzmq):
|
|
21
|
+
|
|
22
|
+
- `cli/js/jupyter_kernel.js`: ZMTP and the Jupyter wire protocol in plain
|
|
23
|
+
JS over TCP.
|
|
24
|
+
- A REPL isolate driven by CDP calls: `Runtime.evaluate` with
|
|
25
|
+
`replMode: true`, `callFunctionOn`, `getProperties`,
|
|
26
|
+
`globalLexicalScopeNames`.
|
|
27
|
+
|
|
28
|
+
So the extraction is:
|
|
29
|
+
|
|
30
|
+
- Port the protocol half to `node:net` and `node:crypto`: `src/zmtp.ts`,
|
|
31
|
+
`src/wire.ts`, `src/kernel.ts`.
|
|
32
|
+
- Point the CDP half at another runtime's `--inspect` WebSocket:
|
|
33
|
+
`src/target.ts`, `src/cdp.ts`.
|
|
34
|
+
- Replace what Deno's module loader did with esbuild + acorn
|
|
35
|
+
(`src/transform.ts`) and Node resolve hooks (`runtime/node-hooks.mjs`).
|
|
36
|
+
- Port `Deno.jupyter` (`40_jupyter.js`) to `runtime/display.mjs`.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
Each install writes a Jupyter kernelspec: `cdp-node`, `cdp-deno`, or
|
|
41
|
+
`cdp-node-deno` (Node with the Deno compatibility namespace). Then pick the
|
|
42
|
+
kernel in JupyterLab or VS Code.
|
|
43
|
+
|
|
44
|
+
**From npm** (host runs on Node 24+; Node 26 is what's been tested):
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm install -g @tlockney/cdp-kernel
|
|
48
|
+
cdp-kernel install --runtime node
|
|
49
|
+
cdp-kernel install --runtime deno
|
|
50
|
+
cdp-kernel install --runtime node --deno-compat
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The kernelspec records the absolute paths of `node` and the installed
|
|
54
|
+
package. Re-run `install` after upgrading either.
|
|
55
|
+
|
|
56
|
+
**From JSR** (host runs on Deno):
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
deno run -A jsr:@tlockney/cdp-kernel/cli install --runtime deno
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The kernelspec pins the release it was installed from
|
|
63
|
+
(`jsr:@tlockney/cdp-kernel@<version>/cli`). On first launch the target's
|
|
64
|
+
runtime modules are copied into `~/.cache/cdp-kernel/runtime/`. A Node
|
|
65
|
+
target needs `node` on `PATH`.
|
|
66
|
+
|
|
67
|
+
**From a checkout**, for working on the kernel itself:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
npm ci
|
|
71
|
+
npm run install:node # kernelspec cdp-node
|
|
72
|
+
npm run install:deno # kernelspec cdp-deno
|
|
73
|
+
npm run install:node-deno # kernelspec cdp-node-deno
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
These kernelspecs point at the checkout, so don't move it after installing.
|
|
77
|
+
|
|
78
|
+
In a cell:
|
|
79
|
+
|
|
80
|
+
- `jupyter.display`, `jupyter.md`, `jupyter.html`, `jupyter.svg`,
|
|
81
|
+
`jupyter.image`, `jupyter.format`, `jupyter.broadcast`, and
|
|
82
|
+
`Symbol.for("Jupyter.display")`: the same API as `Deno.jupyter`.
|
|
83
|
+
- `Deno.jupyter` also works:
|
|
84
|
+
- On a Deno target it is a proxy over the real namespace. Deno's own
|
|
85
|
+
getter throws outside `deno jupyter`.
|
|
86
|
+
- On Node it needs `--deno-compat`. That installs a `Deno` namespace for
|
|
87
|
+
file, env and process APIs (`runtime/deno-compat.mjs`).
|
|
88
|
+
- The shim is opt-in because libraries detect Deno with `typeof Deno`, and
|
|
89
|
+
would take Deno code paths that call APIs it doesn't cover.
|
|
90
|
+
- Imports work: TypeScript, TSX, enums, top-level `await`, and `const`
|
|
91
|
+
redeclaration across cells. Specifiers can be `./local.ts`, bare packages
|
|
92
|
+
from the notebook's `node_modules`, `node:` builtins, and `npm:pkg@range`
|
|
93
|
+
or `jsr:@scope/pkg@range`.
|
|
94
|
+
- On Node, `npm:` and `jsr:` install on first use into
|
|
95
|
+
`~/.cache/cdp-kernel/npm`.
|
|
96
|
+
- On Deno they use Deno's global cache. The target runs with
|
|
97
|
+
`--node-modules-dir=none --no-lock`, so a project-local `node_modules`
|
|
98
|
+
isn't used.
|
|
99
|
+
- `prompt()` and `confirm()` work over the stdin channel. Interrupt keeps
|
|
100
|
+
state.
|
|
101
|
+
|
|
102
|
+
## How the tricky parts work
|
|
103
|
+
|
|
104
|
+
| Problem | Approach |
|
|
105
|
+
|---|---|
|
|
106
|
+
| Inspector-evaluated code has no module referrer, so Node rejects `import()` | Static imports are rewritten to `var x = (await __cdpk.import("m")).x` (Deno's `var_decl_imports`), and `import()` and `import.meta` go through the bootstrap module. A resolve hook re-parents those imports to the notebook directory. |
|
|
107
|
+
| A busy loop inside `Runtime.evaluate` blocks every inspector message, so interrupt is never seen | Each cell starts with `await __cdpk.tick()` (one macrotask), so user code runs as an ordinary task. `Runtime.terminateExecution` then arrives on a second CDP session. The host abandons the evaluate, whose promise never settles. |
|
|
108
|
+
| stdout/stderr arrive over pipes and display messages over the WebSocket, so they race | Every display message is preceded by a numbered marker on both pipes. The host releases the message only after both pipes reach the marker, and holds later pipe output until the message is out. |
|
|
109
|
+
| `prompt()` must be synchronous | The target sends an `input_request` through a CDP binding, then blocks on a `readSync` of stdin. The host writes the reply as one JSON line. |
|
|
110
|
+
| Uncaught errors from timers would kill the target | `uncaughtException` and `unhandledrejection` handlers report them to stderr. |
|
|
111
|
+
|
|
112
|
+
## Tests
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
npm run check # tsc --strict over src/ and test/
|
|
116
|
+
npm test # unit tests (node:test)
|
|
117
|
+
npm run e2e # conformance suite via jupyter_client/libzmq, on node, deno, node-deno
|
|
118
|
+
uv run --with nbclient --with nbformat python examples/run_notebook.py # Deno-style notebook on deno + node-deno
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The e2e suite follows Deno's `tests/integration/jupyter_tests.rs` and adds
|
|
122
|
+
the gaps those tests leave: display ordering, TS, imports, `npm:`/`jsr:`,
|
|
123
|
+
completion, and `update_display_data`. Set `CDPK_E2E_OFFLINE=1` to skip the
|
|
124
|
+
registry-dependent checks. Set `CDPK_E2E_CLI` to run it through a different
|
|
125
|
+
CLI: an installed npm bin, or `deno run -A src/main.ts` to test a Deno host.
|
|
126
|
+
|
|
127
|
+
## Releasing
|
|
128
|
+
|
|
129
|
+
One version number lives in three places: `package.json`, `deno.jsonc` and
|
|
130
|
+
`src/package.ts`. `test/package.test.ts` fails if they disagree. To release:
|
|
131
|
+
|
|
132
|
+
1. Bump all three, commit, and merge to `main`.
|
|
133
|
+
2. Tag and push: `git tag v0.1.1 && git push origin v0.1.1`.
|
|
134
|
+
|
|
135
|
+
The `publish` workflow then:
|
|
136
|
+
|
|
137
|
+
1. Checks that the tag matches the version.
|
|
138
|
+
2. Runs the type check, the unit tests, and the e2e suite against the packed
|
|
139
|
+
npm tarball and against a Deno host.
|
|
140
|
+
3. Publishes to JSR (`deno publish`, authenticated by GitHub OIDC).
|
|
141
|
+
4. Publishes to npm (`npm publish`).
|
|
142
|
+
|
|
143
|
+
Neither registry lets a version be republished, so a failed half needs a new
|
|
144
|
+
patch version.
|
|
145
|
+
|
|
146
|
+
## Known gaps
|
|
147
|
+
|
|
148
|
+
- No comms, so no ipywidgets. Deno has none either.
|
|
149
|
+
- No `inspect_request` content and no history. Deno has neither.
|
|
150
|
+
- No debugger.
|
|
151
|
+
- `npm:` on Node:
|
|
152
|
+
- one npm alias per `name@range`;
|
|
153
|
+
- no lockfile;
|
|
154
|
+
- peer dependencies resolve by real name from the shared cache.
|
|
155
|
+
- No type checking. Deno doesn't type-check REPL cells either.
|
|
156
|
+
- Results are formatted with `util.inspect` on Node, so they look slightly
|
|
157
|
+
different from Deno's `Deno.inspect`.
|
|
158
|
+
- WebGPU texture and buffer display is dropped.
|
|
159
|
+
- Targets:
|
|
160
|
+
- **Bun** speaks the WebKit inspector protocol: no `replMode`, bindings,
|
|
161
|
+
`terminateExecution`, or top-level await in `Runtime.evaluate`.
|
|
162
|
+
- **workerd** forbids timers and I/O outside a request.
|
|
163
|
+
|
|
164
|
+
Both need a different backend; see the writeup.
|
|
165
|
+
|
|
166
|
+
## Provenance
|
|
167
|
+
|
|
168
|
+
`src/zmtp.ts`, `src/wire.ts`, `src/kernel.ts`, `src/text.ts` and
|
|
169
|
+
`runtime/display.mjs` are ports of Deno's `cli/js/jupyter_kernel.js` and
|
|
170
|
+
`cli/js/40_jupyter.js` at v2.9.6 (commit `e518fbd`), Copyright 2018-2026 the
|
|
171
|
+
Deno authors, MIT license. Each file header says which parts.
|
package/dist/cdp.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type CdpParams = {
|
|
2
|
+
[key: string]: unknown;
|
|
3
|
+
};
|
|
4
|
+
export interface RemoteObject {
|
|
5
|
+
type: string;
|
|
6
|
+
subtype?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
value?: unknown;
|
|
9
|
+
unserializableValue?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
objectId?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ExceptionDetails {
|
|
14
|
+
text: string;
|
|
15
|
+
lineNumber: number;
|
|
16
|
+
columnNumber: number;
|
|
17
|
+
exception?: RemoteObject;
|
|
18
|
+
}
|
|
19
|
+
export interface EvaluateResult {
|
|
20
|
+
result: RemoteObject;
|
|
21
|
+
exceptionDetails?: ExceptionDetails;
|
|
22
|
+
}
|
|
23
|
+
export declare class CdpError extends Error {
|
|
24
|
+
readonly code: number;
|
|
25
|
+
constructor(method: string, code: number, message: string);
|
|
26
|
+
}
|
|
27
|
+
/** Thrown into a pending call when the caller abandons it (interrupt). */
|
|
28
|
+
export declare class CdpAbandoned extends Error {
|
|
29
|
+
}
|
|
30
|
+
export declare class CdpSession {
|
|
31
|
+
#private;
|
|
32
|
+
readonly closed: Promise<void>;
|
|
33
|
+
private constructor();
|
|
34
|
+
static connect(url: string): Promise<CdpSession>;
|
|
35
|
+
on(method: string, listener: (params: CdpParams) => void): void;
|
|
36
|
+
send(method: string, params?: CdpParams): Promise<CdpParams>;
|
|
37
|
+
/** Reject every in-flight call; their late responses are then ignored. */
|
|
38
|
+
abandonPending(): void;
|
|
39
|
+
close(): void;
|
|
40
|
+
}
|
|
41
|
+
export declare function asEvaluateResult(raw: CdpParams): EvaluateResult;
|
package/dist/cdp.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// A small Chrome DevTools Protocol client over the runtime's --inspect
|
|
2
|
+
// WebSocket. Uses the global WebSocket (Node 22+, Deno, Bun).
|
|
3
|
+
export class CdpError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(method, code, message) {
|
|
6
|
+
super(`${method}: ${message} (${code})`);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Thrown into a pending call when the caller abandons it (interrupt). */
|
|
11
|
+
export class CdpAbandoned extends Error {
|
|
12
|
+
}
|
|
13
|
+
function isResponse(msg) {
|
|
14
|
+
return typeof msg === "object" && msg !== null && typeof msg.id === "number";
|
|
15
|
+
}
|
|
16
|
+
function isEvent(msg) {
|
|
17
|
+
return typeof msg === "object" && msg !== null && typeof msg.method === "string";
|
|
18
|
+
}
|
|
19
|
+
export class CdpSession {
|
|
20
|
+
#ws;
|
|
21
|
+
#nextId = 0;
|
|
22
|
+
#pending = new Map();
|
|
23
|
+
#listeners = new Map();
|
|
24
|
+
closed;
|
|
25
|
+
constructor(ws) {
|
|
26
|
+
this.#ws = ws;
|
|
27
|
+
ws.addEventListener("message", (event) => {
|
|
28
|
+
const msg = JSON.parse(String(event.data));
|
|
29
|
+
if (isResponse(msg)) {
|
|
30
|
+
const pending = this.#pending.get(msg.id);
|
|
31
|
+
if (!pending)
|
|
32
|
+
return;
|
|
33
|
+
this.#pending.delete(msg.id);
|
|
34
|
+
if (msg.error) {
|
|
35
|
+
pending.reject(new CdpError(pending.method, msg.error.code, msg.error.message));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
pending.resolve(msg.result ?? {});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else if (isEvent(msg)) {
|
|
42
|
+
for (const listener of this.#listeners.get(msg.method) ?? [])
|
|
43
|
+
listener(msg.params);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
this.closed = new Promise((resolve) => {
|
|
47
|
+
ws.addEventListener("close", () => {
|
|
48
|
+
for (const pending of this.#pending.values()) {
|
|
49
|
+
pending.reject(new Error("CDP connection closed"));
|
|
50
|
+
}
|
|
51
|
+
this.#pending.clear();
|
|
52
|
+
resolve();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
static async connect(url) {
|
|
57
|
+
const ws = new WebSocket(url);
|
|
58
|
+
await new Promise((resolve, reject) => {
|
|
59
|
+
ws.addEventListener("open", () => resolve(), { once: true });
|
|
60
|
+
ws.addEventListener("error", () => reject(new Error(`cannot connect to ${url}`)), { once: true });
|
|
61
|
+
});
|
|
62
|
+
return new CdpSession(ws);
|
|
63
|
+
}
|
|
64
|
+
on(method, listener) {
|
|
65
|
+
const list = this.#listeners.get(method) ?? [];
|
|
66
|
+
list.push(listener);
|
|
67
|
+
this.#listeners.set(method, list);
|
|
68
|
+
}
|
|
69
|
+
send(method, params = {}) {
|
|
70
|
+
const id = ++this.#nextId;
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
this.#pending.set(id, { method, resolve, reject });
|
|
73
|
+
this.#ws.send(JSON.stringify({ id, method, params }));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Reject every in-flight call; their late responses are then ignored. */
|
|
77
|
+
abandonPending() {
|
|
78
|
+
for (const pending of this.#pending.values()) {
|
|
79
|
+
pending.reject(new CdpAbandoned(`${pending.method} abandoned`));
|
|
80
|
+
}
|
|
81
|
+
this.#pending.clear();
|
|
82
|
+
}
|
|
83
|
+
close() {
|
|
84
|
+
this.#ws.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function asEvaluateResult(raw) {
|
|
88
|
+
return raw;
|
|
89
|
+
}
|
package/dist/kernel.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type RuntimeName } from "./target.ts";
|
|
2
|
+
export interface ConnectionInfo {
|
|
3
|
+
ip: string;
|
|
4
|
+
key: string;
|
|
5
|
+
transport: string;
|
|
6
|
+
signature_scheme: string;
|
|
7
|
+
shell_port: number;
|
|
8
|
+
control_port: number;
|
|
9
|
+
stdin_port: number;
|
|
10
|
+
iopub_port: number;
|
|
11
|
+
hb_port: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseConnectionInfo(json: string): ConnectionInfo;
|
|
14
|
+
export declare class Kernel {
|
|
15
|
+
#private;
|
|
16
|
+
constructor(conn: ConnectionInfo, runtime: RuntimeName, options?: {
|
|
17
|
+
denoCompat?: boolean;
|
|
18
|
+
});
|
|
19
|
+
run(): Promise<void>;
|
|
20
|
+
}
|