@tlockney/cdp-kernel 0.3.0 → 0.4.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 +93 -16
- package/dist/bun-target.d.ts +2 -0
- package/dist/bun-target.js +5 -0
- package/dist/debugger.d.ts +42 -0
- package/dist/debugger.js +534 -0
- package/dist/history.d.ts +18 -0
- package/dist/history.js +45 -0
- package/dist/kernel.js +65 -8
- package/dist/main.js +3 -1
- package/dist/murmur2.d.ts +2 -0
- package/dist/murmur2.js +41 -0
- package/dist/package.d.ts +16 -2
- package/dist/package.js +25 -2
- package/dist/sourcemap.d.ts +22 -0
- package/dist/sourcemap.js +86 -0
- package/dist/target.d.ts +8 -0
- package/dist/target.js +51 -1
- package/dist/text.d.ts +6 -0
- package/dist/text.js +17 -0
- package/dist/transform.d.ts +10 -0
- package/dist/transform.js +38 -5
- package/dist/workerd-target.d.ts +2 -0
- package/dist/workerd-target.js +8 -3
- package/package.json +2 -2
- package/runtime/bootstrap.mjs +17 -2
- package/runtime/deno-compat.mjs +3 -1
- package/runtime/display.d.mts +2 -0
- package/runtime/display.mjs +9 -1
- package/runtime/evaluator.d.mts +4 -0
- package/runtime/evaluator.mjs +8 -0
- package/runtime/introspect.d.mts +13 -0
- package/runtime/introspect.mjs +120 -0
- package/runtime/node-hooks.d.mts +19 -1
- package/runtime/node-hooks.mjs +144 -16
- package/runtime/workerd-worker.mjs +5 -0
package/dist/debugger.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// The Jupyter debugger protocol (#18): JupyterLab's visual debugger sends
|
|
2
|
+
// DAP requests as debug_request messages and listens for DAP events on
|
|
3
|
+
// debug_event. For CDP targets (Node, Deno) this translates them to V8's
|
|
4
|
+
// Debugger domain on the target's existing session.
|
|
5
|
+
//
|
|
6
|
+
// Cells are named as JupyterLab names them, tmpFilePrefix + murmur2(code,
|
|
7
|
+
// hashSeed) + tmpFileSuffix (src/murmur2.ts), and evaluated with that name
|
|
8
|
+
// as their //# sourceURL, so breakpoints bind and frames point at the cell.
|
|
9
|
+
// Lines go through the cell's source map (src/sourcemap.ts), since esbuild
|
|
10
|
+
// rewrites TypeScript.
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join, sep } from "node:path";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { murmur2 } from "./murmur2.js";
|
|
17
|
+
import { transformCellWithLines } from "./transform.js";
|
|
18
|
+
/** ipykernel's seed, so a kernel switch doesn't move JupyterLab's breakpoints. */
|
|
19
|
+
export const HASH_SEED = 0xc70f6907;
|
|
20
|
+
export const THREAD_ID = 1;
|
|
21
|
+
const EXCEPTION_FILTERS = [
|
|
22
|
+
{ filter: "caught", label: "Caught Exceptions", default: false },
|
|
23
|
+
{ filter: "uncaught", label: "Uncaught Exceptions", default: false },
|
|
24
|
+
];
|
|
25
|
+
/** Bindings the kernel itself installs, hidden from the variable views. */
|
|
26
|
+
const KERNEL_GLOBALS = new Set(["__cdpk", "__cdpk_send", "jupyter", "prompt", "confirm"]);
|
|
27
|
+
const SCOPE_NAMES = {
|
|
28
|
+
local: "Locals",
|
|
29
|
+
block: "Block",
|
|
30
|
+
closure: "Closure",
|
|
31
|
+
catch: "Catch",
|
|
32
|
+
with: "With",
|
|
33
|
+
script: "Script",
|
|
34
|
+
module: "Module",
|
|
35
|
+
eval: "Eval",
|
|
36
|
+
global: "Globals",
|
|
37
|
+
};
|
|
38
|
+
function isRecord(value) {
|
|
39
|
+
return typeof value === "object" && value !== null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* CDPK_DEBUG_TRACE=1 logs every debug request, reply and event to stderr
|
|
43
|
+
* (the Jupyter server's log), for diagnosing a frontend.
|
|
44
|
+
*/
|
|
45
|
+
function trace(kind, ...parts) {
|
|
46
|
+
if (process.env.CDPK_DEBUG_TRACE !== "1")
|
|
47
|
+
return;
|
|
48
|
+
const text = parts.map((p) => JSON.stringify(p)).join(" -> ");
|
|
49
|
+
process.stderr.write(`[cdp-kernel debugger] ${kind} ${text.length > 600 ? `${text.slice(0, 600)}…` : text}\n`);
|
|
50
|
+
}
|
|
51
|
+
function isRemoteObject(value) {
|
|
52
|
+
return isRecord(value) && typeof value.type === "string";
|
|
53
|
+
}
|
|
54
|
+
/** A DAP value string for a CDP RemoteObject. */
|
|
55
|
+
export function describeRemote(object) {
|
|
56
|
+
if (object.type === "string")
|
|
57
|
+
return JSON.stringify(object.value);
|
|
58
|
+
if (object.unserializableValue !== undefined)
|
|
59
|
+
return object.unserializableValue;
|
|
60
|
+
if (object.description !== undefined)
|
|
61
|
+
return object.description;
|
|
62
|
+
if (object.type === "undefined")
|
|
63
|
+
return "undefined";
|
|
64
|
+
return JSON.stringify(object.value) ?? String(object.value);
|
|
65
|
+
}
|
|
66
|
+
function typeOf(object) {
|
|
67
|
+
return object.className ?? object.subtype ?? object.type;
|
|
68
|
+
}
|
|
69
|
+
export class Debugger {
|
|
70
|
+
prefix;
|
|
71
|
+
suffix = ".ts";
|
|
72
|
+
#channel = null;
|
|
73
|
+
#started = false;
|
|
74
|
+
#seq = 0;
|
|
75
|
+
/** Breakpoint lines (1-based, as the frontend set them) by cell path. */
|
|
76
|
+
#breakpoints = new Map();
|
|
77
|
+
/** The CDP breakpoint ids set for a path, to replace them. */
|
|
78
|
+
#cdpBreakpoints = new Map();
|
|
79
|
+
/** Each cell's code and line map, by path. */
|
|
80
|
+
#cells = new Map();
|
|
81
|
+
#scripts = new Map(); // scriptId -> url
|
|
82
|
+
#frames = [];
|
|
83
|
+
#stopped = false;
|
|
84
|
+
#refs = new Map();
|
|
85
|
+
#nextRef = 1;
|
|
86
|
+
#pauseOnExceptions = "none";
|
|
87
|
+
/** Called at the next pause; see #unlessPaused. */
|
|
88
|
+
#pauseWaiters = [];
|
|
89
|
+
#emit;
|
|
90
|
+
#transformOptions;
|
|
91
|
+
constructor(emit, transformOptions = () => ({})) {
|
|
92
|
+
this.#emit = emit;
|
|
93
|
+
this.#transformOptions = transformOptions;
|
|
94
|
+
this.prefix = join(tmpdir(), `cdp-kernel_${process.pid}`) + sep;
|
|
95
|
+
}
|
|
96
|
+
get started() {
|
|
97
|
+
return this.#started && this.#channel !== null;
|
|
98
|
+
}
|
|
99
|
+
get stopped() {
|
|
100
|
+
return this.#stopped;
|
|
101
|
+
}
|
|
102
|
+
/** A cell's path, as JupyterLab computes it for the same code. */
|
|
103
|
+
pathFor(code) {
|
|
104
|
+
return `${this.prefix}${murmur2(code, HASH_SEED)}${this.suffix}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The target to debug: called with each target the kernel launches. A
|
|
108
|
+
* restarted target gets the session's breakpoints again.
|
|
109
|
+
*/
|
|
110
|
+
async attachTarget(channel) {
|
|
111
|
+
this.#channel = channel;
|
|
112
|
+
this.#scripts.clear();
|
|
113
|
+
this.#resetStop();
|
|
114
|
+
if (!channel)
|
|
115
|
+
return;
|
|
116
|
+
channel.on("Debugger.scriptParsed", (p) => {
|
|
117
|
+
if (typeof p.scriptId === "string" && typeof p.url === "string")
|
|
118
|
+
this.#scripts.set(p.scriptId, p.url);
|
|
119
|
+
});
|
|
120
|
+
channel.on("Debugger.paused", (p) => this.#onPaused(p));
|
|
121
|
+
channel.on("Debugger.resumed", () => {
|
|
122
|
+
if (!this.#stopped)
|
|
123
|
+
return;
|
|
124
|
+
this.#resetStop();
|
|
125
|
+
this.#event("continued", { threadId: THREAD_ID, allThreadsContinued: true });
|
|
126
|
+
});
|
|
127
|
+
if (this.#started)
|
|
128
|
+
await this.#enable();
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The script for a cell while debugging: transformed with its line map
|
|
132
|
+
* kept, and named for its path. `source` is what actually runs (the cell,
|
|
133
|
+
* or the cell wrapped as an object literal); the path comes from `code`,
|
|
134
|
+
* the cell as the frontend sent it.
|
|
135
|
+
*/
|
|
136
|
+
cellScript(code, source) {
|
|
137
|
+
const path = this.pathFor(code);
|
|
138
|
+
const { script, lines } = transformCellWithLines(source, this.#transformOptions());
|
|
139
|
+
this.#cells.set(path, { code, lines });
|
|
140
|
+
this.#writeCell(path, code);
|
|
141
|
+
return `${script}\n//# sourceURL=${path}`;
|
|
142
|
+
}
|
|
143
|
+
async handle(request) {
|
|
144
|
+
const command = String(request.command ?? "");
|
|
145
|
+
const args = isRecord(request.arguments) ? request.arguments : {};
|
|
146
|
+
let response;
|
|
147
|
+
try {
|
|
148
|
+
const body = await this.#dispatch(command, args);
|
|
149
|
+
response = { type: "response", seq: ++this.#seq, request_seq: request.seq, command, success: true, body };
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
153
|
+
response = { type: "response", seq: ++this.#seq, request_seq: request.seq, command, success: false, message, body: {} };
|
|
154
|
+
}
|
|
155
|
+
trace("request", { command, arguments: args }, response);
|
|
156
|
+
return response;
|
|
157
|
+
}
|
|
158
|
+
// --- requests -------------------------------------------------------------
|
|
159
|
+
async #dispatch(command, args) {
|
|
160
|
+
switch (command) {
|
|
161
|
+
case "debugInfo":
|
|
162
|
+
return this.#debugInfo();
|
|
163
|
+
case "initialize":
|
|
164
|
+
return {
|
|
165
|
+
supportsConfigurationDoneRequest: true,
|
|
166
|
+
supportsEvaluateForHovers: true,
|
|
167
|
+
supportsSetVariable: false,
|
|
168
|
+
supportsModulesRequest: false,
|
|
169
|
+
exceptionBreakpointFilters: EXCEPTION_FILTERS,
|
|
170
|
+
};
|
|
171
|
+
case "attach":
|
|
172
|
+
this.#started = true;
|
|
173
|
+
if (this.#channel)
|
|
174
|
+
await this.#enable();
|
|
175
|
+
this.#event("initialized", {});
|
|
176
|
+
return {};
|
|
177
|
+
case "configurationDone":
|
|
178
|
+
return {};
|
|
179
|
+
case "disconnect":
|
|
180
|
+
await this.#disable();
|
|
181
|
+
return {};
|
|
182
|
+
case "dumpCell": {
|
|
183
|
+
const code = String(args.code ?? "");
|
|
184
|
+
const path = this.pathFor(code);
|
|
185
|
+
if (!this.#cells.has(path))
|
|
186
|
+
this.#cells.set(path, { code, lines: null });
|
|
187
|
+
this.#writeCell(path, code);
|
|
188
|
+
return { sourcePath: path };
|
|
189
|
+
}
|
|
190
|
+
case "setBreakpoints":
|
|
191
|
+
return await this.#setBreakpoints(args);
|
|
192
|
+
case "setExceptionBreakpoints": {
|
|
193
|
+
const filters = Array.isArray(args.filters) ? args.filters.map(String) : [];
|
|
194
|
+
this.#pauseOnExceptions = filters.includes("caught") ? "all" : filters.includes("uncaught") ? "uncaught" : "none";
|
|
195
|
+
if (this.started)
|
|
196
|
+
await this.#channel?.send("Debugger.setPauseOnExceptions", { state: this.#pauseOnExceptions });
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
case "threads":
|
|
200
|
+
return { threads: [{ id: THREAD_ID, name: "Main Thread" }] };
|
|
201
|
+
// JupyterLab asks while restoring state, whatever the capability says,
|
|
202
|
+
// and treats a failure as fatal: its debugger button stays disabled.
|
|
203
|
+
case "modules":
|
|
204
|
+
return { modules: [], totalModules: 0 };
|
|
205
|
+
case "stackTrace":
|
|
206
|
+
return this.#stackTrace();
|
|
207
|
+
case "scopes":
|
|
208
|
+
return this.#scopes(Number(args.frameId));
|
|
209
|
+
case "variables":
|
|
210
|
+
return await this.#variables(Number(args.variablesReference));
|
|
211
|
+
case "inspectVariables":
|
|
212
|
+
return { variables: await this.#globals() };
|
|
213
|
+
case "richInspectVariables":
|
|
214
|
+
return await this.#richInspect(String(args.variableName ?? ""), args.frameId);
|
|
215
|
+
case "evaluate":
|
|
216
|
+
return await this.#evaluate(String(args.expression ?? ""), args.frameId);
|
|
217
|
+
case "source":
|
|
218
|
+
return this.#source(args);
|
|
219
|
+
case "continue":
|
|
220
|
+
await this.#command("Debugger.resume");
|
|
221
|
+
return { allThreadsContinued: true };
|
|
222
|
+
case "next":
|
|
223
|
+
await this.#command("Debugger.stepOver");
|
|
224
|
+
return {};
|
|
225
|
+
case "stepIn":
|
|
226
|
+
await this.#command("Debugger.stepInto");
|
|
227
|
+
return {};
|
|
228
|
+
case "stepOut":
|
|
229
|
+
await this.#command("Debugger.stepOut");
|
|
230
|
+
return {};
|
|
231
|
+
case "pause":
|
|
232
|
+
await this.#command("Debugger.pause");
|
|
233
|
+
return {};
|
|
234
|
+
default:
|
|
235
|
+
throw new Error(`unsupported debug command: ${command}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
#debugInfo() {
|
|
239
|
+
return {
|
|
240
|
+
isStarted: this.#started,
|
|
241
|
+
hashMethod: "Murmur2",
|
|
242
|
+
hashSeed: HASH_SEED,
|
|
243
|
+
tmpFilePrefix: this.prefix,
|
|
244
|
+
tmpFileSuffix: this.suffix,
|
|
245
|
+
breakpoints: [...this.#breakpoints].map(([source, lines]) => ({ source, breakpoints: lines.map((line) => ({ line })) })),
|
|
246
|
+
stoppedThreads: this.#stopped ? [THREAD_ID] : [],
|
|
247
|
+
richRendering: true,
|
|
248
|
+
exceptionPaths: ["JavaScript Exceptions"],
|
|
249
|
+
copyToGlobals: false,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
async #enable() {
|
|
253
|
+
const channel = this.#channel;
|
|
254
|
+
if (!channel)
|
|
255
|
+
return;
|
|
256
|
+
await channel.send("Debugger.enable");
|
|
257
|
+
await channel.send("Debugger.setPauseOnExceptions", { state: this.#pauseOnExceptions });
|
|
258
|
+
this.#cdpBreakpoints.clear();
|
|
259
|
+
for (const path of this.#breakpoints.keys())
|
|
260
|
+
await this.#applyBreakpoints(path);
|
|
261
|
+
}
|
|
262
|
+
async #disable() {
|
|
263
|
+
const channel = this.#channel;
|
|
264
|
+
this.#started = false;
|
|
265
|
+
this.#breakpoints.clear();
|
|
266
|
+
if (channel) {
|
|
267
|
+
for (const ids of this.#cdpBreakpoints.values()) {
|
|
268
|
+
for (const breakpointId of ids)
|
|
269
|
+
await channel.send("Debugger.removeBreakpoint", { breakpointId }).catch(() => { });
|
|
270
|
+
}
|
|
271
|
+
if (this.#stopped)
|
|
272
|
+
await channel.send("Debugger.resume").catch(() => { });
|
|
273
|
+
await channel.send("Debugger.disable").catch(() => { });
|
|
274
|
+
}
|
|
275
|
+
this.#cdpBreakpoints.clear();
|
|
276
|
+
this.#resetStop();
|
|
277
|
+
}
|
|
278
|
+
async #setBreakpoints(args) {
|
|
279
|
+
const source = isRecord(args.source) ? args.source : {};
|
|
280
|
+
const path = String(source.path ?? "");
|
|
281
|
+
const requested = Array.isArray(args.breakpoints) ? args.breakpoints : [];
|
|
282
|
+
const lines = requested.map((b) => Number(isRecord(b) ? b.line : NaN)).filter((n) => Number.isInteger(n) && n > 0);
|
|
283
|
+
if (lines.length > 0)
|
|
284
|
+
this.#breakpoints.set(path, lines);
|
|
285
|
+
else
|
|
286
|
+
this.#breakpoints.delete(path);
|
|
287
|
+
if (this.started)
|
|
288
|
+
await this.#applyBreakpoints(path);
|
|
289
|
+
return { breakpoints: lines.map((line) => ({ verified: true, line, source: { path } })) };
|
|
290
|
+
}
|
|
291
|
+
/** The cell's line map, from its code if it hasn't run yet. */
|
|
292
|
+
#linesFor(path) {
|
|
293
|
+
const cell = this.#cells.get(path);
|
|
294
|
+
if (!cell)
|
|
295
|
+
return null;
|
|
296
|
+
if (cell.lines === null) {
|
|
297
|
+
try {
|
|
298
|
+
cell.lines = transformCellWithLines(cell.code, this.#transformOptions()).lines;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return null; // a syntax error: the cell won't run anyway
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return cell.lines;
|
|
305
|
+
}
|
|
306
|
+
async #applyBreakpoints(path) {
|
|
307
|
+
const channel = this.#channel;
|
|
308
|
+
if (!channel)
|
|
309
|
+
return;
|
|
310
|
+
for (const breakpointId of this.#cdpBreakpoints.get(path) ?? []) {
|
|
311
|
+
await channel.send("Debugger.removeBreakpoint", { breakpointId }).catch(() => { });
|
|
312
|
+
}
|
|
313
|
+
const ids = [];
|
|
314
|
+
const lines = this.#linesFor(path);
|
|
315
|
+
for (const line of this.#breakpoints.get(path) ?? []) {
|
|
316
|
+
// A cell not seen yet keeps its line numbers.
|
|
317
|
+
const lineNumber = lines ? lines.toGenerated(line - 1) : line - 1;
|
|
318
|
+
if (lineNumber === null)
|
|
319
|
+
continue;
|
|
320
|
+
const res = await channel.send("Debugger.setBreakpointByUrl", { url: path, lineNumber });
|
|
321
|
+
if (typeof res.breakpointId === "string")
|
|
322
|
+
ids.push(res.breakpointId);
|
|
323
|
+
}
|
|
324
|
+
this.#cdpBreakpoints.set(path, ids);
|
|
325
|
+
}
|
|
326
|
+
async #command(method) {
|
|
327
|
+
if (!this.#channel)
|
|
328
|
+
throw new Error("no target to debug");
|
|
329
|
+
await this.#channel.send(method);
|
|
330
|
+
}
|
|
331
|
+
// --- stopped state ----------------------------------------------------------
|
|
332
|
+
#onPaused(params) {
|
|
333
|
+
this.#frames = Array.isArray(params.callFrames) ? params.callFrames : [];
|
|
334
|
+
this.#stopped = true;
|
|
335
|
+
for (const notify of this.#pauseWaiters.splice(0))
|
|
336
|
+
notify();
|
|
337
|
+
this.#refs.clear();
|
|
338
|
+
const hit = Array.isArray(params.hitBreakpoints) && params.hitBreakpoints.length > 0;
|
|
339
|
+
const reason = hit
|
|
340
|
+
? "breakpoint"
|
|
341
|
+
: params.reason === "exception" || params.reason === "promiseRejection"
|
|
342
|
+
? "exception"
|
|
343
|
+
: params.reason === "other"
|
|
344
|
+
? "step"
|
|
345
|
+
: String(params.reason ?? "pause");
|
|
346
|
+
this.#event("stopped", { reason, threadId: THREAD_ID, allThreadsStopped: true });
|
|
347
|
+
}
|
|
348
|
+
#resetStop() {
|
|
349
|
+
this.#stopped = false;
|
|
350
|
+
this.#frames = [];
|
|
351
|
+
this.#refs.clear();
|
|
352
|
+
}
|
|
353
|
+
/** Cells, and the user's own files; not the runtime's or the kernel's. */
|
|
354
|
+
#userFrame(url) {
|
|
355
|
+
if (url.startsWith(this.prefix))
|
|
356
|
+
return true;
|
|
357
|
+
if (!url.startsWith("file:"))
|
|
358
|
+
return false;
|
|
359
|
+
return !/\/runtime\/[^/]+\.mjs$/.test(url) && !url.includes("/node_modules/");
|
|
360
|
+
}
|
|
361
|
+
#stackTrace() {
|
|
362
|
+
const stackFrames = this.#frames.flatMap((frame, id) => {
|
|
363
|
+
const url = this.#scripts.get(frame.location.scriptId) ?? "";
|
|
364
|
+
if (!this.#userFrame(url))
|
|
365
|
+
return [];
|
|
366
|
+
const lines = url.startsWith(this.prefix) ? this.#linesFor(url) : null;
|
|
367
|
+
const line = (lines ? lines.toOriginal(frame.location.lineNumber) : frame.location.lineNumber) + 1;
|
|
368
|
+
const path = url.startsWith("file:") ? fileURLToPath(url) : url;
|
|
369
|
+
return [{ id, name: frame.functionName || "<cell>", line, column: (frame.location.columnNumber ?? 0) + 1, source: { path } }];
|
|
370
|
+
});
|
|
371
|
+
return { stackFrames, totalFrames: stackFrames.length };
|
|
372
|
+
}
|
|
373
|
+
#ref(objectId, globals = false) {
|
|
374
|
+
const ref = this.#nextRef++;
|
|
375
|
+
this.#refs.set(ref, { objectId, globals });
|
|
376
|
+
return ref;
|
|
377
|
+
}
|
|
378
|
+
#scopes(frameId) {
|
|
379
|
+
const frame = this.#frames[frameId];
|
|
380
|
+
if (!frame)
|
|
381
|
+
throw new Error(`no frame ${frameId}`);
|
|
382
|
+
const scopes = frame.scopeChain.filter((s) => s.object.objectId).map((s) => ({
|
|
383
|
+
name: SCOPE_NAMES[s.type] ?? s.type,
|
|
384
|
+
variablesReference: this.#ref(s.object.objectId ?? "", s.type === "global"),
|
|
385
|
+
expensive: s.type === "global",
|
|
386
|
+
}));
|
|
387
|
+
return { scopes };
|
|
388
|
+
}
|
|
389
|
+
#variable(name, value) {
|
|
390
|
+
// Objects and functions can be expanded; primitives can't.
|
|
391
|
+
const expandable = value.objectId !== undefined && (value.type === "object" || value.type === "function");
|
|
392
|
+
return {
|
|
393
|
+
name,
|
|
394
|
+
value: describeRemote(value),
|
|
395
|
+
type: typeOf(value),
|
|
396
|
+
variablesReference: expandable ? this.#ref(value.objectId ?? "") : 0,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
async #variables(reference) {
|
|
400
|
+
const entry = this.#refs.get(reference);
|
|
401
|
+
if (!entry || !this.#channel)
|
|
402
|
+
return { variables: [] };
|
|
403
|
+
const res = await this.#channel.send("Runtime.getProperties", { objectId: entry.objectId, ownProperties: true });
|
|
404
|
+
const props = Array.isArray(res.result) ? res.result : [];
|
|
405
|
+
const variables = props
|
|
406
|
+
.filter((p) => p.value !== undefined)
|
|
407
|
+
// The global object's own properties are mostly the runtime's builtins.
|
|
408
|
+
.filter((p) => !entry.globals || (p.enumerable && !this.#channel?.baselineGlobals.has(p.name)))
|
|
409
|
+
.filter((p) => !KERNEL_GLOBALS.has(p.name) && !p.name.startsWith("__cdpk"))
|
|
410
|
+
.map((p) => this.#variable(p.name, p.value));
|
|
411
|
+
return { variables };
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* inspectVariables: what the cells have defined. Their top-level
|
|
415
|
+
* declarations (replMode keeps them in the global lexical scope) and the
|
|
416
|
+
* globals they added, each read with V8's side-effect check, so a getter
|
|
417
|
+
* never runs (JupyterLab asks during every cell).
|
|
418
|
+
*/
|
|
419
|
+
/**
|
|
420
|
+
* A CDP call's result, or null if the target pauses first: a global
|
|
421
|
+
* evaluation sent just before a breakpoint hits doesn't finish while the
|
|
422
|
+
* target is paused, and the kernel's control requests are answered one at
|
|
423
|
+
* a time, so it would hold up the stackTrace JupyterLab sends next.
|
|
424
|
+
*/
|
|
425
|
+
#unlessPaused(call) {
|
|
426
|
+
if (this.#stopped)
|
|
427
|
+
return Promise.resolve(null);
|
|
428
|
+
const paused = new Promise((resolve) => this.#pauseWaiters.push(() => resolve(null)));
|
|
429
|
+
call.catch(() => { }); // settles after the pause, unwatched
|
|
430
|
+
return Promise.race([call, paused]);
|
|
431
|
+
}
|
|
432
|
+
async #globals() {
|
|
433
|
+
const channel = this.#channel;
|
|
434
|
+
// While paused, the frontend shows the frame's scopes instead.
|
|
435
|
+
if (!channel || this.#stopped)
|
|
436
|
+
return [];
|
|
437
|
+
const lexical = await this.#unlessPaused(channel.send("Runtime.globalLexicalScopeNames", { executionContextId: channel.contextId }));
|
|
438
|
+
const keys = await this.#unlessPaused(channel.send("Runtime.evaluate", {
|
|
439
|
+
expression: "Object.keys(globalThis)",
|
|
440
|
+
contextId: channel.contextId,
|
|
441
|
+
returnByValue: true,
|
|
442
|
+
throwOnSideEffect: true,
|
|
443
|
+
}));
|
|
444
|
+
if (lexical === null || keys === null)
|
|
445
|
+
return [];
|
|
446
|
+
const added = Array.isArray(keys.result?.value)
|
|
447
|
+
? keys.result.value.filter((n) => !channel.baselineGlobals.has(n))
|
|
448
|
+
: [];
|
|
449
|
+
const names = [...new Set([...(Array.isArray(lexical.names) ? lexical.names.map(String) : []), ...added])]
|
|
450
|
+
.filter((n) => !KERNEL_GLOBALS.has(n) && !n.startsWith("__cdpk"));
|
|
451
|
+
const variables = [];
|
|
452
|
+
for (const name of names) {
|
|
453
|
+
const res = await this.#unlessPaused(channel.send("Runtime.evaluate", {
|
|
454
|
+
expression: name,
|
|
455
|
+
contextId: channel.contextId,
|
|
456
|
+
replMode: true,
|
|
457
|
+
throwOnSideEffect: true,
|
|
458
|
+
}));
|
|
459
|
+
if (res === null)
|
|
460
|
+
return [];
|
|
461
|
+
if (!res.exceptionDetails && isRemoteObject(res.result))
|
|
462
|
+
variables.push(this.#variable(name, res.result));
|
|
463
|
+
}
|
|
464
|
+
return variables;
|
|
465
|
+
}
|
|
466
|
+
/** An expression's value: in a paused frame, or globally (side-effect free). */
|
|
467
|
+
async #valueOf(expression, frameId, pure) {
|
|
468
|
+
const channel = this.#channel;
|
|
469
|
+
if (!channel)
|
|
470
|
+
throw new Error("no target to debug");
|
|
471
|
+
// While paused, only a frame can evaluate: use the top one if none was named.
|
|
472
|
+
const frame = typeof frameId === "number" ? this.#frames[frameId] : this.#stopped ? this.#frames[0] : undefined;
|
|
473
|
+
const res = frame
|
|
474
|
+
? await channel.send("Debugger.evaluateOnCallFrame", { callFrameId: frame.callFrameId, expression, throwOnSideEffect: pure })
|
|
475
|
+
: await channel.send("Runtime.evaluate", { expression, contextId: channel.contextId, replMode: true, throwOnSideEffect: pure });
|
|
476
|
+
if (isRecord(res.exceptionDetails)) {
|
|
477
|
+
const exception = res.exceptionDetails.exception;
|
|
478
|
+
throw new Error(exception ? describeRemote(exception) : String(res.exceptionDetails.text ?? "evaluation failed"));
|
|
479
|
+
}
|
|
480
|
+
if (!isRemoteObject(res.result))
|
|
481
|
+
throw new Error("the target returned no value");
|
|
482
|
+
return res.result;
|
|
483
|
+
}
|
|
484
|
+
async #evaluate(expression, frameId) {
|
|
485
|
+
const value = await this.#valueOf(expression, frameId, false);
|
|
486
|
+
const variable = this.#variable(expression, value);
|
|
487
|
+
return { result: variable.value, type: variable.type, variablesReference: variable.variablesReference };
|
|
488
|
+
}
|
|
489
|
+
/** The rich variable viewer: the value's MIME bundle, as a cell would display it. */
|
|
490
|
+
async #richInspect(name, frameId) {
|
|
491
|
+
const channel = this.#channel;
|
|
492
|
+
if (!channel)
|
|
493
|
+
throw new Error("no target to debug");
|
|
494
|
+
const value = await this.#valueOf(name, frameId, true);
|
|
495
|
+
const arg = value.objectId ? { objectId: value.objectId } : { value: value.value };
|
|
496
|
+
// Synchronous: while the target is paused, no promise would settle.
|
|
497
|
+
const res = await channel.send("Runtime.callFunctionOn", {
|
|
498
|
+
functionDeclaration: "function (v) { return globalThis.__cdpk.richBundle(v); }",
|
|
499
|
+
arguments: [arg],
|
|
500
|
+
executionContextId: channel.contextId,
|
|
501
|
+
returnByValue: true,
|
|
502
|
+
});
|
|
503
|
+
const text = res.result?.value;
|
|
504
|
+
const data = typeof text === "string" ? JSON.parse(text) : {};
|
|
505
|
+
return { data: isRecord(data) ? data : {}, metadata: {} };
|
|
506
|
+
}
|
|
507
|
+
#source(args) {
|
|
508
|
+
const source = isRecord(args.source) ? args.source : {};
|
|
509
|
+
const path = String(source.path ?? "");
|
|
510
|
+
const cell = this.#cells.get(path);
|
|
511
|
+
if (cell)
|
|
512
|
+
return { content: cell.code };
|
|
513
|
+
try {
|
|
514
|
+
return { content: readFileSync(path, "utf8") };
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
throw new Error("source unavailable");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
#writeCell(path, code) {
|
|
521
|
+
try {
|
|
522
|
+
mkdirSync(this.prefix, { recursive: true });
|
|
523
|
+
writeFileSync(path, code);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// The frontend has the cell itself; the file is a convenience.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
#event(event, body) {
|
|
530
|
+
const message = { type: "event", seq: ++this.#seq, event, body };
|
|
531
|
+
trace("event", message);
|
|
532
|
+
this.#emit(message);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface HistoryEntry {
|
|
2
|
+
line: number;
|
|
3
|
+
input: string;
|
|
4
|
+
}
|
|
5
|
+
/** [session, line, input], as history_reply carries it without output. */
|
|
6
|
+
export type HistoryItem = [number, number, string];
|
|
7
|
+
export declare const SESSION = 1;
|
|
8
|
+
/**
|
|
9
|
+
* Answers a history_request's content:
|
|
10
|
+
* tail: the last `n` entries;
|
|
11
|
+
* range: lines `start` to `stop` (exclusive) of `session` (1, 0 or -0
|
|
12
|
+
* mean this one), all lines when `stop` is missing;
|
|
13
|
+
* search: entries matching the glob `pattern`, newest `n` when `n` is
|
|
14
|
+
* set, optionally `unique` by input.
|
|
15
|
+
*/
|
|
16
|
+
export declare function queryHistory(entries: readonly HistoryEntry[], content: {
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}): HistoryItem[];
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// history_request (#10): the cells this kernel has run, for the frontend's
|
|
2
|
+
// history (e.g. arrow-up recall in a console). In memory, for one kernel
|
|
3
|
+
// session, which is session 1; earlier sessions aren't kept.
|
|
4
|
+
export const SESSION = 1;
|
|
5
|
+
/** Shell-style glob ("*" and "?") to a whole-string RegExp. */
|
|
6
|
+
function globToRegExp(pattern) {
|
|
7
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*").replace(/\?/g, "[\\s\\S]");
|
|
8
|
+
return new RegExp(`^${escaped}$`);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Answers a history_request's content:
|
|
12
|
+
* tail: the last `n` entries;
|
|
13
|
+
* range: lines `start` to `stop` (exclusive) of `session` (1, 0 or -0
|
|
14
|
+
* mean this one), all lines when `stop` is missing;
|
|
15
|
+
* search: entries matching the glob `pattern`, newest `n` when `n` is
|
|
16
|
+
* set, optionally `unique` by input.
|
|
17
|
+
*/
|
|
18
|
+
export function queryHistory(entries, content) {
|
|
19
|
+
const items = entries.map((e) => [SESSION, e.line, e.input]);
|
|
20
|
+
const n = typeof content.n === "number" && content.n > 0 ? content.n : undefined;
|
|
21
|
+
switch (content.hist_access_type) {
|
|
22
|
+
case "tail":
|
|
23
|
+
return n === undefined ? items : items.slice(-n);
|
|
24
|
+
case "range": {
|
|
25
|
+
const session = typeof content.session === "number" ? content.session : SESSION;
|
|
26
|
+
if (session !== SESSION && session !== 0)
|
|
27
|
+
return [];
|
|
28
|
+
const start = typeof content.start === "number" ? content.start : 0;
|
|
29
|
+
const stop = typeof content.stop === "number" ? content.stop : Infinity;
|
|
30
|
+
return items.filter(([, line]) => line >= start && line < stop);
|
|
31
|
+
}
|
|
32
|
+
case "search": {
|
|
33
|
+
const pattern = globToRegExp(typeof content.pattern === "string" ? content.pattern : "*");
|
|
34
|
+
let found = items.filter(([, , input]) => pattern.test(input));
|
|
35
|
+
if (content.unique === true) {
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
// Newest occurrence of each input, in order.
|
|
38
|
+
found = found.reverse().filter(([, , input]) => !seen.has(input) && seen.add(input) !== undefined).reverse();
|
|
39
|
+
}
|
|
40
|
+
return n === undefined ? found : found.slice(-n);
|
|
41
|
+
}
|
|
42
|
+
default:
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|