@tlockney/cdp-kernel 0.6.0 → 0.7.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/README.md +5 -3
- package/dist/browser/channels.d.ts +27 -0
- package/dist/browser/channels.js +90 -0
- package/dist/browser/kernel.js +29800 -0
- package/dist/browser/kernel.js.map +7 -0
- package/dist/browser/mod.d.ts +25 -0
- package/dist/browser/mod.js +41 -0
- package/dist/browser/target.d.ts +94 -0
- package/dist/browser/target.js +264 -0
- package/dist/browser/worker.js +1071 -0
- package/dist/browser/worker.js.map +7 -0
- package/dist/bun-target.d.ts +2 -0
- package/dist/bun-target.js +15 -2
- package/dist/cdp.d.ts +11 -0
- package/dist/cdp.js +34 -7
- package/dist/celld-target.js +3 -2
- package/dist/debugger.d.ts +2 -0
- package/dist/debugger.js +63 -11
- package/dist/kernel-core.d.ts +78 -0
- package/dist/kernel-core.js +824 -0
- package/dist/kernel.d.ts +6 -1
- package/dist/kernel.js +104 -723
- package/dist/main.js +11 -4
- package/dist/message.d.ts +34 -0
- package/dist/message.js +14 -0
- package/dist/package.d.ts +2 -2
- package/dist/package.js +2 -2
- package/dist/target-api.d.ts +92 -0
- package/dist/target-api.js +48 -0
- package/dist/target.d.ts +5 -76
- package/dist/target.js +62 -33
- package/dist/traceback.d.ts +75 -0
- package/dist/traceback.js +200 -0
- package/dist/transform.d.ts +1 -0
- package/dist/transform.js +8 -1
- package/dist/wire.d.ts +4 -24
- package/dist/wire.js +3 -12
- package/dist/workerd-target.d.ts +6 -1
- package/dist/workerd-target.js +56 -12
- package/dist/workers-project.d.ts +1 -1
- package/dist/workers-project.js +4 -3
- package/dist/zmq-channels.d.ts +14 -0
- package/dist/zmq-channels.js +59 -0
- package/package.json +9 -2
- package/runtime/bootstrap.mjs +2 -0
- package/runtime/browser-worker.mjs +202 -0
- package/runtime/cdn.d.mts +2 -0
- package/runtime/cdn.mjs +17 -0
- package/runtime/display.mjs +202 -1
- package/runtime/evaluator.mjs +2 -0
- package/runtime/format.d.mts +2 -0
- package/runtime/format.mjs +125 -0
- package/runtime/workerd-worker.mjs +2 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Structured tracebacks (#322): an error's stack as frames a frontend can
|
|
2
|
+
// render, link to cells and fold, alongside the classic `traceback` lines.
|
|
3
|
+
// The payload follows nteract's application/vnd.nteract.traceback+json
|
|
4
|
+
// (runtimed/runt, docs/adr/traceback-output-protocol.md): ename, evalue,
|
|
5
|
+
// frames with a source window and a `library` flag, and a plain `text`
|
|
6
|
+
// rendering. Building it must never cost the user their traceback: callers
|
|
7
|
+
// fall back to the classic lines if it throws.
|
|
8
|
+
//
|
|
9
|
+
// Cells run under a //# sourceURL naming them (cellName), so a frame in a
|
|
10
|
+
// cell, including one in a function an earlier cell defined, says which
|
|
11
|
+
// cell. Its line goes through the cell's source map, as for the debugger
|
|
12
|
+
// (src/sourcemap.ts), so it is the line the user wrote, not the line of the
|
|
13
|
+
// stripped JavaScript. Only lines are mapped: a cell frame has no column.
|
|
14
|
+
//
|
|
15
|
+
// Nothing here imports Node; reading other files' source is the caller's.
|
|
16
|
+
import { murmur2 } from "./murmur2.js";
|
|
17
|
+
export const TRACEBACK_MIME = "application/vnd.nteract.traceback+json";
|
|
18
|
+
/** Lines of source shown around a frame's line. */
|
|
19
|
+
const CONTEXT_BEFORE = 2;
|
|
20
|
+
const CONTEXT_AFTER = 2;
|
|
21
|
+
/** Longer stacks keep their first and last frames. */
|
|
22
|
+
const HEAD_FRAMES = 20;
|
|
23
|
+
const TAIL_FRAMES = 10;
|
|
24
|
+
/** The //# sourceURL a cell's script runs under while not debugging. */
|
|
25
|
+
export function cellName(code) {
|
|
26
|
+
return `$cdpk$cell-${murmur2(code, 0).toString(36)}.ts`;
|
|
27
|
+
}
|
|
28
|
+
// V8: " at fn (file:1:2)", " at file:1:2", " at async fn (file:1:2)",
|
|
29
|
+
// " at new Promise (<anonymous>)".
|
|
30
|
+
const V8_FRAME = /^\s*at (?:(.*?) \((.*?)(?::(\d+):(\d+))?\)|(.*?)(?::(\d+):(\d+))?)$/;
|
|
31
|
+
// JavaScriptCore and SpiderMonkey: "fn@file:1:2", "@file:1:2", "fn@[native code]".
|
|
32
|
+
const AT_FRAME = /^([^\s@]*)@(.+?)(?::(\d+):(\d+))?$/;
|
|
33
|
+
/** One line of a stack as a frame, or null for a line that isn't one (the message). */
|
|
34
|
+
export function parseFrame(text) {
|
|
35
|
+
const v8 = /^\s*at /.test(text) ? V8_FRAME.exec(text) : null;
|
|
36
|
+
if (v8) {
|
|
37
|
+
const named = v8[2] !== undefined;
|
|
38
|
+
const line = named ? v8[3] : v8[6];
|
|
39
|
+
const column = named ? v8[4] : v8[7];
|
|
40
|
+
return {
|
|
41
|
+
name: named ? v8[1] : "",
|
|
42
|
+
file: named ? v8[2] : v8[5],
|
|
43
|
+
line: line === undefined ? null : Number(line),
|
|
44
|
+
column: column === undefined ? null : Number(column),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const at = !/^\s/.test(text) && text.includes("@") ? AT_FRAME.exec(text) : null;
|
|
48
|
+
if (at) {
|
|
49
|
+
return {
|
|
50
|
+
name: at[1],
|
|
51
|
+
file: at[2],
|
|
52
|
+
line: at[3] === undefined ? null : Number(at[3]),
|
|
53
|
+
column: at[4] === undefined ? null : Number(at[4]),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Frames from packages, runtimes and the kernel itself, which a frontend
|
|
60
|
+
* can dim or fold: a hint, not a boundary.
|
|
61
|
+
*/
|
|
62
|
+
export function isLibraryFile(file, kernelFiles = []) {
|
|
63
|
+
if (["", "<anonymous>", "native", "[native code]", "unknown"].includes(file))
|
|
64
|
+
return true;
|
|
65
|
+
if (/^(?:node|ext|deno|bun|jsr|npm|internal):/.test(file))
|
|
66
|
+
return true;
|
|
67
|
+
if (/^https?:\/\/(?:jsr\.io|esm\.sh|cdn\.jsdelivr\.net|unpkg\.com|deno\.land)\//.test(file))
|
|
68
|
+
return true;
|
|
69
|
+
const path = file.replaceAll("\\", "/");
|
|
70
|
+
if (path.includes("/node_modules/") || path.includes("/npm/registry.npmjs.org/"))
|
|
71
|
+
return true;
|
|
72
|
+
return kernelFiles.some((prefix) => path.startsWith(prefix));
|
|
73
|
+
}
|
|
74
|
+
function window(source, line) {
|
|
75
|
+
const all = source.split("\n");
|
|
76
|
+
if (all.length > 1 && all[all.length - 1] === "")
|
|
77
|
+
all.pop();
|
|
78
|
+
const out = [];
|
|
79
|
+
for (let n = Math.max(1, line - CONTEXT_BEFORE); n <= Math.min(all.length, line + CONTEXT_AFTER); n++) {
|
|
80
|
+
const entry = { lineno: n, source: all[n - 1].replace(/\r$/, "") };
|
|
81
|
+
if (n === line)
|
|
82
|
+
entry.highlight = true;
|
|
83
|
+
out.push(entry);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** How a cell frame's location reads in the classic traceback. */
|
|
88
|
+
function cellLabel(cell) {
|
|
89
|
+
return cell.executionCount === null ? "cell" : `In[${cell.executionCount}]`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `stack` is the error's stack as the target describes it (src/target.ts's
|
|
93
|
+
* describeError), message lines included. `callFrames`, from CDP, name the
|
|
94
|
+
* scripts of frames the stack shows as "<anonymous>": Deno formats stacks
|
|
95
|
+
* itself and doesn't use a script's //# sourceURL.
|
|
96
|
+
*/
|
|
97
|
+
export function buildTraceback(error, sources, execution) {
|
|
98
|
+
const raw = error.stack || `${error.ename}: ${error.evalue}`;
|
|
99
|
+
const lines = [];
|
|
100
|
+
const frames = [];
|
|
101
|
+
const callFrames = error.callFrames ?? [];
|
|
102
|
+
let next = 0;
|
|
103
|
+
for (const text of raw.split("\n")) {
|
|
104
|
+
const parsed = parseFrame(text);
|
|
105
|
+
if (parsed === null) {
|
|
106
|
+
lines.push(text);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
let file = parsed.file;
|
|
110
|
+
if (parsed.line !== null) {
|
|
111
|
+
// CDP lists only frames with a position, in the same order.
|
|
112
|
+
const at = callFrames.findIndex((frame, i) => i >= next && frame.lineNumber + 1 === parsed.line && frame.columnNumber + 1 === parsed.column);
|
|
113
|
+
if (at !== -1) {
|
|
114
|
+
next = at + 1;
|
|
115
|
+
if (file === "<anonymous>" && callFrames[at].url)
|
|
116
|
+
file = callFrames[at].url;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const cell = sources.cell(file);
|
|
120
|
+
if (cell !== undefined && parsed.line !== null) {
|
|
121
|
+
// Script lines are 1-based; the map's are 0-based.
|
|
122
|
+
const lineno = cell.lines ? cell.lines.toOriginal(parsed.line - 1) + 1 : parsed.line;
|
|
123
|
+
const frame = {
|
|
124
|
+
filename: cellLabel(cell),
|
|
125
|
+
lineno,
|
|
126
|
+
name: parsed.name,
|
|
127
|
+
library: false,
|
|
128
|
+
lines: window(cell.code, lineno),
|
|
129
|
+
};
|
|
130
|
+
if (cell.executionCount !== null)
|
|
131
|
+
frame.execution_count = cell.executionCount;
|
|
132
|
+
if (cell.cellId !== null)
|
|
133
|
+
frame.cell_id = cell.cellId;
|
|
134
|
+
frame.source_ref = {
|
|
135
|
+
kind: "notebook_execution",
|
|
136
|
+
...(cell.executionCount !== null ? { execution_count: cell.executionCount } : {}),
|
|
137
|
+
...(cell.cellId !== null ? { cell_id: cell.cellId } : {}),
|
|
138
|
+
};
|
|
139
|
+
frames.push(frame);
|
|
140
|
+
const location = `${cellLabel(cell)}:${lineno}`;
|
|
141
|
+
lines.push(rewriteLocation(text, parsed, location));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
lines.push(text);
|
|
145
|
+
const library = isLibraryFile(file, sources.kernelFiles);
|
|
146
|
+
const frame = {
|
|
147
|
+
filename: file,
|
|
148
|
+
lineno: parsed.line ?? 0,
|
|
149
|
+
name: parsed.name,
|
|
150
|
+
library,
|
|
151
|
+
lines: [],
|
|
152
|
+
};
|
|
153
|
+
if (parsed.column !== null)
|
|
154
|
+
frame.colno = parsed.column;
|
|
155
|
+
if (!library && parsed.line !== null) {
|
|
156
|
+
const source = sources.readFile?.(file);
|
|
157
|
+
if (source !== undefined)
|
|
158
|
+
frame.lines = window(source, parsed.line);
|
|
159
|
+
}
|
|
160
|
+
frames.push(frame);
|
|
161
|
+
}
|
|
162
|
+
// What ran the cell (the kernel's evaluator, the runtime's own frames)
|
|
163
|
+
// comes after the cell's last frame and is never the user's.
|
|
164
|
+
const lastCell = frames.findLastIndex((frame) => frame.source_ref !== undefined);
|
|
165
|
+
let end = frames.length;
|
|
166
|
+
while (end > lastCell + 1 && lastCell !== -1 && frames[end - 1].library)
|
|
167
|
+
end--;
|
|
168
|
+
const kept = clip(frames.slice(0, end));
|
|
169
|
+
const payload = {
|
|
170
|
+
ename: error.ename,
|
|
171
|
+
evalue: error.evalue,
|
|
172
|
+
language: "typescript",
|
|
173
|
+
frames: kept,
|
|
174
|
+
syntax: null,
|
|
175
|
+
text: lines.join("\n"),
|
|
176
|
+
raw_text: raw,
|
|
177
|
+
};
|
|
178
|
+
if (execution.executionCount !== null || execution.cellId !== null) {
|
|
179
|
+
payload.execution = {
|
|
180
|
+
...(execution.executionCount !== null ? { execution_count: execution.executionCount } : {}),
|
|
181
|
+
...(execution.cellId !== null ? { cell_id: execution.cellId } : {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { lines, payload };
|
|
185
|
+
}
|
|
186
|
+
function rewriteLocation(text, frame, location) {
|
|
187
|
+
const original = `${frame.file}:${frame.line}${frame.column === null ? "" : `:${frame.column}`}`;
|
|
188
|
+
const at = text.lastIndexOf(original);
|
|
189
|
+
return at === -1 ? text : text.slice(0, at) + location + text.slice(at + original.length);
|
|
190
|
+
}
|
|
191
|
+
function clip(frames) {
|
|
192
|
+
if (frames.length <= HEAD_FRAMES + TAIL_FRAMES)
|
|
193
|
+
return frames;
|
|
194
|
+
const omitted = frames.length - HEAD_FRAMES - TAIL_FRAMES;
|
|
195
|
+
return [
|
|
196
|
+
...frames.slice(0, HEAD_FRAMES),
|
|
197
|
+
{ filename: "", lineno: 0, name: `… ${omitted} frames omitted …`, library: true, lines: [] },
|
|
198
|
+
...frames.slice(-TAIL_FRAMES),
|
|
199
|
+
];
|
|
200
|
+
}
|
package/dist/transform.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LineMap } from "./sourcemap.ts";
|
|
2
2
|
export declare const SHIM = "__cdpk";
|
|
3
|
+
export declare const YIELD = "await __cdpk.tick();\n";
|
|
3
4
|
/**
|
|
4
5
|
* Each replMode cell also ends by yielding one macrotask, so the script
|
|
5
6
|
* settles with no other microtasks queued ahead of the inspector's
|
package/dist/transform.js
CHANGED
|
@@ -23,7 +23,14 @@ export const SHIM = "__cdpk";
|
|
|
23
23
|
// which is what lets a Runtime.terminateExecution from the control session
|
|
24
24
|
// interrupt a busy loop. (Within that dispatch the inspector processes no
|
|
25
25
|
// other messages, so an interrupt would never be seen.)
|
|
26
|
-
|
|
26
|
+
//
|
|
27
|
+
// An expression that throws before its first await rejects the replMode
|
|
28
|
+
// result promise at once, inside that dispatch, and a GC before the
|
|
29
|
+
// inspector's then-callback runs fails the evaluate with "Promise was
|
|
30
|
+
// collected" (#329). Yielding first moves the throw into a task, where
|
|
31
|
+
// SETTLE's reasoning applies. The debug console uses YIELD for this.
|
|
32
|
+
export const YIELD = `await ${SHIM}.tick();\n`;
|
|
33
|
+
const PRELUDE = `'use strict'; ${YIELD}`;
|
|
27
34
|
/**
|
|
28
35
|
* Each replMode cell also ends by yielding one macrotask, so the script
|
|
29
36
|
* settles with no other microtasks queued ahead of the inspector's
|
package/dist/wire.d.ts
CHANGED
|
@@ -1,35 +1,15 @@
|
|
|
1
|
+
import { type Header, type JsonObject, type KernelMessage } from "./message.ts";
|
|
2
|
+
export { makeHeader, PROTOCOL_VERSION } from "./message.ts";
|
|
3
|
+
export type { Header, JsonObject, ReceivedHeader } from "./message.ts";
|
|
1
4
|
export declare const DELIMITER: Uint8Array<ArrayBuffer>;
|
|
2
|
-
export
|
|
3
|
-
export type JsonObject = {
|
|
4
|
-
[key: string]: unknown;
|
|
5
|
-
};
|
|
6
|
-
export interface Header extends JsonObject {
|
|
7
|
-
msg_id: string;
|
|
8
|
-
session: string;
|
|
9
|
-
date: string;
|
|
10
|
-
username: string;
|
|
11
|
-
msg_type: string;
|
|
12
|
-
version: string;
|
|
13
|
-
}
|
|
14
|
-
/** A header as received: the fields the kernel relies on, plus whatever else came. */
|
|
15
|
-
export interface ReceivedHeader extends JsonObject {
|
|
16
|
-
msg_id: string;
|
|
17
|
-
msg_type: string;
|
|
18
|
-
}
|
|
19
|
-
export interface Message {
|
|
5
|
+
export interface Message extends KernelMessage {
|
|
20
6
|
identities: Uint8Array[];
|
|
21
7
|
sig: string;
|
|
22
8
|
/** Raw bytes of header, parent_header, metadata, content as received. */
|
|
23
9
|
signedParts: Uint8Array[];
|
|
24
|
-
header: ReceivedHeader;
|
|
25
|
-
parentHeader: JsonObject;
|
|
26
|
-
metadata: JsonObject;
|
|
27
|
-
content: JsonObject;
|
|
28
|
-
buffers: Uint8Array[];
|
|
29
10
|
}
|
|
30
11
|
export declare function sign(key: string, parts: Uint8Array[]): string;
|
|
31
12
|
export declare function verify(key: string, parts: Uint8Array[], sig: string): boolean;
|
|
32
|
-
export declare function makeHeader(session: string, msgType: string): Header;
|
|
33
13
|
export interface EncodeOptions {
|
|
34
14
|
/** Defaults to a fresh header; pass one to know its msg_id. */
|
|
35
15
|
header?: Header;
|
package/dist/wire.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// Copyright 2018-2026 the Deno authors, MIT license. The structure is kept;
|
|
5
5
|
// WebCrypto's async HMAC is replaced with node:crypto's synchronous one.
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
|
-
import { createHmac,
|
|
7
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { makeHeader } from "./message.js";
|
|
9
|
+
export { makeHeader, PROTOCOL_VERSION } from "./message.js";
|
|
8
10
|
const ENC = new TextEncoder();
|
|
9
11
|
const DEC = new TextDecoder();
|
|
10
12
|
export const DELIMITER = ENC.encode("<IDS|MSG>");
|
|
11
|
-
export const PROTOCOL_VERSION = "5.3";
|
|
12
13
|
export function sign(key, parts) {
|
|
13
14
|
if (key.length === 0)
|
|
14
15
|
return "";
|
|
@@ -29,16 +30,6 @@ export function verify(key, parts, sig) {
|
|
|
29
30
|
const actual = Buffer.from(sig, "hex");
|
|
30
31
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
31
32
|
}
|
|
32
|
-
export function makeHeader(session, msgType) {
|
|
33
|
-
return {
|
|
34
|
-
msg_id: randomUUID(),
|
|
35
|
-
session,
|
|
36
|
-
date: new Date().toISOString(),
|
|
37
|
-
username: "kernel",
|
|
38
|
-
msg_type: msgType,
|
|
39
|
-
version: PROTOCOL_VERSION,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
33
|
export function encodeMessage(session, key, msgType, content, options = {}) {
|
|
43
34
|
const parts = [
|
|
44
35
|
options.header ?? makeHeader(session, msgType),
|
package/dist/workerd-target.d.ts
CHANGED
|
@@ -89,12 +89,16 @@ export declare class WorkerdTarget implements KernelTarget {
|
|
|
89
89
|
static launch(events: TargetEvents, options?: {
|
|
90
90
|
cwd?: string;
|
|
91
91
|
executable?: string;
|
|
92
|
+
startTimeoutMs?: number;
|
|
92
93
|
}): Promise<WorkerdTarget>;
|
|
93
94
|
/**
|
|
94
95
|
* A target over a started host's session. `notice` (a project that
|
|
95
96
|
* failed to load) is replaced by the bindings when the host has a project.
|
|
96
97
|
*/
|
|
97
|
-
static open(host: WorkerHost, cwd: string, events: TargetEvents, runtime: WorkersRuntime,
|
|
98
|
+
static open(host: WorkerHost, cwd: string, events: TargetEvents, runtime: WorkersRuntime, options?: {
|
|
99
|
+
notice?: string;
|
|
100
|
+
startTimeoutMs?: number;
|
|
101
|
+
}): Promise<WorkerdTarget>;
|
|
98
102
|
evaluate(script: string): Promise<EvaluateResult>;
|
|
99
103
|
/**
|
|
100
104
|
* A cell that's waiting on something leaves the driver free: it answers a
|
|
@@ -115,6 +119,7 @@ export declare class WorkerdTarget implements KernelTarget {
|
|
|
115
119
|
stack: string;
|
|
116
120
|
};
|
|
117
121
|
}>;
|
|
122
|
+
format(value: RemoteObject): Promise<CdpParams>;
|
|
118
123
|
describeError(exception: RemoteObject): Promise<{
|
|
119
124
|
name: string;
|
|
120
125
|
message: string;
|
package/dist/workerd-target.js
CHANGED
|
@@ -18,7 +18,7 @@ import { Buffer } from "node:buffer";
|
|
|
18
18
|
import process from "node:process";
|
|
19
19
|
import { execFileSync, spawn } from "node:child_process";
|
|
20
20
|
import { randomBytes } from "node:crypto";
|
|
21
|
-
import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { copyFileSync, existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { builtinModules } from "node:module";
|
|
23
23
|
import { tmpdir } from "node:os";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
@@ -28,7 +28,8 @@ import { asEvaluateResult } from "./cdp.js";
|
|
|
28
28
|
import { transformCellForEval } from "./transform.js";
|
|
29
29
|
import { runtimeDir } from "./package.js";
|
|
30
30
|
import { killOnExit } from "./children.js";
|
|
31
|
-
import { Interrupted, isTargetMessage } from "./target.js";
|
|
31
|
+
import { Interrupted, isTargetMessage, parseBundle, parseJson } from "./target.js";
|
|
32
|
+
import { withStartTimeout } from "./target-api.js";
|
|
32
33
|
import { connectUnixWebSocket } from "./ws-client.js";
|
|
33
34
|
import { findWranglerConfig, startMiniflare } from "./workers-project.js";
|
|
34
35
|
import { ensureInstalledAsync, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
|
|
@@ -188,8 +189,30 @@ function isWorkerdIn(pid, dir) {
|
|
|
188
189
|
try {
|
|
189
190
|
if (process.platform === "linux")
|
|
190
191
|
return readlinkSync(`/proc/${pid}/cwd`) === dir;
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
// No cwd to compare elsewhere: the process must at least be ours and
|
|
193
|
+
// look like a workerd (#335).
|
|
194
|
+
const [uid, ...args] = execFileSync("ps", ["-o", "uid=,args=", "-p", String(pid)], { encoding: "utf8" }).trim().split(/\s+/);
|
|
195
|
+
const command = args.join(" ");
|
|
196
|
+
return Number(uid) === process.getuid?.() && /workerd/.test(command) && command.includes("config.capnp");
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Whether `dir` is a directory mkdtemp made for this user: not a symlink,
|
|
204
|
+
* owned by us, and closed to everyone else. Anyone can create entries in
|
|
205
|
+
* tmpdir(), so nothing in a directory that fails this is trusted (#335).
|
|
206
|
+
* Windows has no uids; its tmpdir() is per-user.
|
|
207
|
+
*/
|
|
208
|
+
function isOwnPrivateDir(dir) {
|
|
209
|
+
try {
|
|
210
|
+
const stat = lstatSync(dir);
|
|
211
|
+
if (!stat.isDirectory())
|
|
212
|
+
return false;
|
|
213
|
+
if (process.getuid === undefined)
|
|
214
|
+
return true;
|
|
215
|
+
return stat.uid === process.getuid() && (stat.mode & 0o077) === 0;
|
|
193
216
|
}
|
|
194
217
|
catch {
|
|
195
218
|
return false;
|
|
@@ -210,12 +233,14 @@ export function sweepStaleWorkerdDirs(root = tmpdir()) {
|
|
|
210
233
|
}
|
|
211
234
|
for (const name of names) {
|
|
212
235
|
const dir = join(root, name);
|
|
236
|
+
if (!isOwnPrivateDir(dir))
|
|
237
|
+
continue;
|
|
213
238
|
let owner;
|
|
214
239
|
try {
|
|
215
240
|
owner = JSON.parse(readFileSync(join(dir, OWNER_FILE), "utf8"));
|
|
216
241
|
}
|
|
217
242
|
catch {
|
|
218
|
-
continue; //
|
|
243
|
+
continue; // From before owner files, or being set up.
|
|
219
244
|
}
|
|
220
245
|
if (typeof owner.host !== "number" || pidAlive(owner.host))
|
|
221
246
|
continue;
|
|
@@ -227,7 +252,12 @@ export function sweepStaleWorkerdDirs(root = tmpdir()) {
|
|
|
227
252
|
// Gone already.
|
|
228
253
|
}
|
|
229
254
|
}
|
|
230
|
-
|
|
255
|
+
try {
|
|
256
|
+
rmSync(dir, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// Cleanup is best effort; it must not stop this kernel starting.
|
|
260
|
+
}
|
|
231
261
|
}
|
|
232
262
|
}
|
|
233
263
|
/** The driver on a workerd of its own, behind a Unix socket. */
|
|
@@ -295,7 +325,7 @@ export class RawWorkerd {
|
|
|
295
325
|
}
|
|
296
326
|
})();
|
|
297
327
|
try {
|
|
298
|
-
const host = new RawWorkerd(child, await Promise.race([ready, exited]), compatibilityDate);
|
|
328
|
+
const host = new RawWorkerd(child, await withStartTimeout(Promise.race([ready, exited]), "workerd"), compatibilityDate);
|
|
299
329
|
starting = false;
|
|
300
330
|
early = "";
|
|
301
331
|
return host;
|
|
@@ -360,7 +390,7 @@ export class WorkerdTarget {
|
|
|
360
390
|
let notice;
|
|
361
391
|
if (project !== null) {
|
|
362
392
|
try {
|
|
363
|
-
host = await startMiniflare(project, cwd);
|
|
393
|
+
host = await startMiniflare(project, cwd, options.startTimeoutMs);
|
|
364
394
|
}
|
|
365
395
|
catch (err) {
|
|
366
396
|
// Like a broken deno.json (#13): say why in the first cell, and run
|
|
@@ -370,16 +400,18 @@ export class WorkerdTarget {
|
|
|
370
400
|
}
|
|
371
401
|
}
|
|
372
402
|
host ??= await RawWorkerd.start(cwd, options.executable);
|
|
373
|
-
return await WorkerdTarget.open(host, cwd, events, "workerd", notice);
|
|
403
|
+
return await WorkerdTarget.open(host, cwd, events, "workerd", { notice, startTimeoutMs: options.startTimeoutMs });
|
|
374
404
|
}
|
|
375
405
|
/**
|
|
376
406
|
* A target over a started host's session. `notice` (a project that
|
|
377
407
|
* failed to load) is replaced by the bindings when the host has a project.
|
|
378
408
|
*/
|
|
379
|
-
static async open(host, cwd, events, runtime,
|
|
409
|
+
static async open(host, cwd, events, runtime, options = {}) {
|
|
380
410
|
const target = new WorkerdTarget(host, cwd, events, runtime);
|
|
411
|
+
let notice = options.notice;
|
|
381
412
|
try {
|
|
382
|
-
|
|
413
|
+
// A driver that never answers is stopped (#336).
|
|
414
|
+
const { v8, bindings, worker } = await withStartTimeout(target.#request("info"), `the ${runtime} driver`, options.startTimeoutMs);
|
|
383
415
|
if (typeof v8 === "string" && v8)
|
|
384
416
|
target.versions.engine = `V8 ${v8}`;
|
|
385
417
|
if (host.project !== undefined) {
|
|
@@ -396,7 +428,15 @@ export class WorkerdTarget {
|
|
|
396
428
|
return target;
|
|
397
429
|
}
|
|
398
430
|
#receive(text) {
|
|
399
|
-
const msg =
|
|
431
|
+
const msg = parseJson(text);
|
|
432
|
+
if (msg === undefined) {
|
|
433
|
+
// The driver only sends JSON; a frame that isn't means the session is
|
|
434
|
+
// broken, which ends the target rather than the kernel (#337).
|
|
435
|
+
this.#stopNote = `[cdp-kernel] ${this.runtime} sent a message that isn't JSON, so it was stopped; ` +
|
|
436
|
+
"it restarts on the next cell, without the earlier cells' state";
|
|
437
|
+
this.kill();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
400
440
|
if (isRecord(msg) && msg.kind === "stream") {
|
|
401
441
|
this.#events.stream(msg.name === "stderr" ? "stderr" : "stdout", String(msg.text));
|
|
402
442
|
}
|
|
@@ -519,6 +559,10 @@ export class WorkerdTarget {
|
|
|
519
559
|
}
|
|
520
560
|
return { handled: reply.handled === true };
|
|
521
561
|
}
|
|
562
|
+
async format(value) {
|
|
563
|
+
const reply = await this.#request("format", { handle: value.objectId });
|
|
564
|
+
return parseBundle(reply.value);
|
|
565
|
+
}
|
|
522
566
|
async describeError(exception) {
|
|
523
567
|
if (exception.objectId === undefined)
|
|
524
568
|
return { name: "Error", message: exception.description ?? "", stack: "" };
|
|
@@ -24,4 +24,4 @@ export declare function bundleProject(main: string, define?: {
|
|
|
24
24
|
[name: string]: string;
|
|
25
25
|
}): Promise<string>;
|
|
26
26
|
/** Starts the driver on the project's Miniflare with its bindings. */
|
|
27
|
-
export declare function startMiniflare(configPath: string, cwd: string): Promise<WorkerHost>;
|
|
27
|
+
export declare function startMiniflare(configPath: string, cwd: string, startTimeoutMs?: number): Promise<WorkerHost>;
|
package/dist/workers-project.js
CHANGED
|
@@ -17,6 +17,7 @@ import { dirname, join } from "node:path";
|
|
|
17
17
|
import { build } from "esbuild";
|
|
18
18
|
import { childPids, reap } from "./children.js";
|
|
19
19
|
import { runtimeDir } from "./package.js";
|
|
20
|
+
import { withStartTimeout } from "./target-api.js";
|
|
20
21
|
import { cellMeta, WORKER_MODULES } from "./workerd-target.js";
|
|
21
22
|
export const WRANGLER_CONFIGS = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
|
|
22
23
|
/** The nearest wrangler config at or above `cwd`, or null. */
|
|
@@ -157,7 +158,7 @@ class MiniflareHost {
|
|
|
157
158
|
}
|
|
158
159
|
}
|
|
159
160
|
/** Starts the driver on the project's Miniflare with its bindings. */
|
|
160
|
-
export async function startMiniflare(configPath, cwd) {
|
|
161
|
+
export async function startMiniflare(configPath, cwd, startTimeoutMs) {
|
|
161
162
|
const { wrangler, miniflare, version } = loadTooling(configPath);
|
|
162
163
|
const projectDir = dirname(configPath);
|
|
163
164
|
const { workerOptions, main, define } = wrangler.unstable_getMiniflareWorkerOptions(configPath);
|
|
@@ -190,10 +191,10 @@ export async function startMiniflare(configPath, cwd) {
|
|
|
190
191
|
const before = new Set(childPids(process.pid, "workerd"));
|
|
191
192
|
const mf = new miniflare.Miniflare(v5 && miniflare.convertV4MiniflareOptions ? miniflare.convertV4MiniflareOptions(options) : options);
|
|
192
193
|
try {
|
|
193
|
-
await mf.ready;
|
|
194
194
|
// Miniflare listens on a loopback TCP port; the token is what keeps
|
|
195
195
|
// other local processes from the driver.
|
|
196
|
-
const
|
|
196
|
+
const connect = mf.ready.then(() => mf.dispatchFetch("http://cdpk/", { headers: { Upgrade: "websocket", "x-cdpk-token": token } }));
|
|
197
|
+
const res = await withStartTimeout(connect, "Miniflare", startTimeoutMs);
|
|
197
198
|
if (res.status !== 101 || res.webSocket === null)
|
|
198
199
|
throw new Error(`the driver answered ${res.status}: ${await res.text()}`);
|
|
199
200
|
const workerd = childPids(process.pid, "workerd").filter((pid) => !before.has(pid));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ConnectionInfo } from "./kernel.ts";
|
|
2
|
+
import type { ChannelName, KernelChannels, Received } from "./kernel-core.ts";
|
|
3
|
+
import type { OutgoingMessage } from "./message.ts";
|
|
4
|
+
export declare class ZmqChannels implements KernelChannels {
|
|
5
|
+
#private;
|
|
6
|
+
readonly ready: Promise<unknown>;
|
|
7
|
+
constructor(conn: ConnectionInfo, log: (text: string) => void);
|
|
8
|
+
recv(channel: ChannelName): Promise<Received | null>;
|
|
9
|
+
shellQueued(): number;
|
|
10
|
+
reply(channel: ChannelName, peer: string, msg: OutgoingMessage): void;
|
|
11
|
+
publish(msg: OutgoingMessage): void;
|
|
12
|
+
stdinPeers(): number;
|
|
13
|
+
sendStdin(msg: OutgoingMessage): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// KernelChannels over ZeroMQ: the connection file's five ports, with
|
|
2
|
+
// messages framed and signed as src/wire.ts does it.
|
|
3
|
+
import { decodeMessage, encodeMessage, verify } from "./wire.js";
|
|
4
|
+
import { listening, PubSocket, RouterSocket, startHeartbeat } from "./zmtp.js";
|
|
5
|
+
export class ZmqChannels {
|
|
6
|
+
ready;
|
|
7
|
+
#key;
|
|
8
|
+
#log;
|
|
9
|
+
#routers;
|
|
10
|
+
#iopub;
|
|
11
|
+
constructor(conn, log) {
|
|
12
|
+
this.#key = conn.key;
|
|
13
|
+
this.#log = log;
|
|
14
|
+
const heartbeat = startHeartbeat(conn.hb_port, conn.ip);
|
|
15
|
+
this.#routers = {
|
|
16
|
+
shell: new RouterSocket(conn.shell_port, conn.ip),
|
|
17
|
+
control: new RouterSocket(conn.control_port, conn.ip),
|
|
18
|
+
stdin: new RouterSocket(conn.stdin_port, conn.ip),
|
|
19
|
+
};
|
|
20
|
+
this.#iopub = new PubSocket(conn.iopub_port, conn.ip);
|
|
21
|
+
// Settles once all five ports are bound (#257).
|
|
22
|
+
this.ready = Promise.all([heartbeat, ...Object.values(this.#routers).map((r) => r.server), this.#iopub.server].map(listening));
|
|
23
|
+
}
|
|
24
|
+
async recv(channel) {
|
|
25
|
+
const { peer, frames } = await this.#routers[channel].recv();
|
|
26
|
+
try {
|
|
27
|
+
const msg = decodeMessage(frames);
|
|
28
|
+
// Unsigned or forged messages are dropped, as in Deno.
|
|
29
|
+
return verify(this.#key, msg.signedParts, msg.sig) ? { peer, msg } : null;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
this.#log(`error handling message: ${err instanceof Error ? err.stack : err}`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
shellQueued() {
|
|
37
|
+
return this.#routers.shell.incoming.size;
|
|
38
|
+
}
|
|
39
|
+
#encode(msg) {
|
|
40
|
+
return encodeMessage(msg.header.session, this.#key, msg.header.msg_type, msg.content, {
|
|
41
|
+
header: msg.header,
|
|
42
|
+
parentHeader: msg.parentHeader,
|
|
43
|
+
metadata: msg.metadata,
|
|
44
|
+
buffers: msg.buffers,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
reply(channel, peer, msg) {
|
|
48
|
+
this.#routers[channel].send(peer, this.#encode(msg));
|
|
49
|
+
}
|
|
50
|
+
publish(msg) {
|
|
51
|
+
this.#iopub.send(this.#encode(msg));
|
|
52
|
+
}
|
|
53
|
+
stdinPeers() {
|
|
54
|
+
return this.#routers.stdin.peers.size;
|
|
55
|
+
}
|
|
56
|
+
sendStdin(msg) {
|
|
57
|
+
this.#routers.stdin.sendAll(this.#encode(msg));
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tlockney/cdp-kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Deno's Jupyter kernel, extracted to run notebooks on Node, Deno, Bun, workerd and celld",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Thomas Lockney <thomas@lockney.net>",
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
"types": "./dist/globals.d.ts",
|
|
35
35
|
"default": "./dist/globals.js"
|
|
36
36
|
},
|
|
37
|
+
"./browser": {
|
|
38
|
+
"types": "./dist/browser/mod.d.ts",
|
|
39
|
+
"default": "./dist/browser/kernel.js"
|
|
40
|
+
},
|
|
41
|
+
"./browser/worker": "./dist/browser/worker.js",
|
|
37
42
|
"./globals/deno-compat": {
|
|
38
43
|
"types": "./dist/globals-deno-compat.d.ts",
|
|
39
44
|
"default": "./dist/globals-deno-compat.js"
|
|
@@ -49,11 +54,12 @@
|
|
|
49
54
|
"access": "public"
|
|
50
55
|
},
|
|
51
56
|
"scripts": {
|
|
52
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
57
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/build-browser.mjs dist/browser",
|
|
53
58
|
"check": "tsc -p tsconfig.json",
|
|
54
59
|
"docs:dev": "vitepress dev docs",
|
|
55
60
|
"docs:build": "vitepress build docs",
|
|
56
61
|
"docs:notebooks": "node docs/.vitepress/run-notebooks.ts",
|
|
62
|
+
"docs:debugger": "uv run --with jupyterlab --with playwright --with nbformat python test/debugger_screenshot.py",
|
|
57
63
|
"docs:widgets": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/widget_screenshots.py",
|
|
58
64
|
"install:node": "node src/main.ts install --runtime node",
|
|
59
65
|
"install:deno": "node src/main.ts install --runtime deno",
|
|
@@ -76,6 +82,7 @@
|
|
|
76
82
|
},
|
|
77
83
|
"devDependencies": {
|
|
78
84
|
"@types/node": "^24.19.2",
|
|
85
|
+
"medium-zoom": "^1.1.0",
|
|
79
86
|
"vitepress": "^1.6.4",
|
|
80
87
|
"workerd": "1.20261015.1",
|
|
81
88
|
"wrangler": "4.151.0"
|
package/runtime/bootstrap.mjs
CHANGED
|
@@ -215,6 +215,8 @@ globalThis.__cdpk = {
|
|
|
215
215
|
inspectPath: (path, detail) => inspectPath(path, { inspect: inspectPlain, detail }),
|
|
216
216
|
resolvePath,
|
|
217
217
|
broadcastResult: jupyter.broadcastResult,
|
|
218
|
+
// execute_request's user_expressions (#340).
|
|
219
|
+
formatJson: async (value) => JSON.stringify(await jupyter.namespace.format(value)),
|
|
218
220
|
// The debugger's rich variable viewer (#18), synchronously: it runs while
|
|
219
221
|
// the target is paused. A value whose $display is async shows as text.
|
|
220
222
|
richBundle(value) {
|