@tlockney/cdp-kernel 0.0.0-stage → 0.1.1

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 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,177 @@
1
- # Temporary Holding Version
1
+ # cdp-kernel
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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`).
141
+ 4. Publishes to npm (`npm publish`).
142
+
143
+ Both registries authenticate the workflow through GitHub OIDC, so no tokens
144
+ are stored:
145
+ - **npm:** a trusted publisher for `tlockney/cdp-kernel` + `publish.yml`.
146
+ - **JSR:** the package is linked to this repo in its JSR settings.
147
+
148
+ Neither registry lets a version be republished. Each job skips a version
149
+ its registry already has, so re-running a release only fills in the half
150
+ that failed.
151
+
152
+ ## Known gaps
153
+
154
+ - No comms, so no ipywidgets. Deno has none either.
155
+ - No `inspect_request` content and no history. Deno has neither.
156
+ - No debugger.
157
+ - `npm:` on Node:
158
+ - one npm alias per `name@range`;
159
+ - no lockfile;
160
+ - peer dependencies resolve by real name from the shared cache.
161
+ - No type checking. Deno doesn't type-check REPL cells either.
162
+ - Results are formatted with `util.inspect` on Node, so they look slightly
163
+ different from Deno's `Deno.inspect`.
164
+ - WebGPU texture and buffer display is dropped.
165
+ - Targets:
166
+ - **Bun** speaks the WebKit inspector protocol: no `replMode`, bindings,
167
+ `terminateExecution`, or top-level await in `Runtime.evaluate`.
168
+ - **workerd** forbids timers and I/O outside a request.
169
+
170
+ Both need a different backend; see the writeup.
171
+
172
+ ## Provenance
173
+
174
+ `src/zmtp.ts`, `src/wire.ts`, `src/kernel.ts`, `src/text.ts` and
175
+ `runtime/display.mjs` are ports of Deno's `cli/js/jupyter_kernel.js` and
176
+ `cli/js/40_jupyter.js` at v2.9.6 (commit `e518fbd`), Copyright 2018-2026 the
177
+ 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
+ }
@@ -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
+ }