@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.
@@ -0,0 +1,84 @@
1
+ import { type CdpParams, type EvaluateResult, type RemoteObject } from "./cdp.ts";
2
+ export type RuntimeName = "node" | "deno";
3
+ /** `bootstrap` is the local path of runtime/bootstrap.mjs (see runtimeDir). */
4
+ export declare function launchCommand(runtime: RuntimeName, bootstrap: string, executable?: string): {
5
+ cmd: string;
6
+ args: string[];
7
+ };
8
+ /** Messages the bootstrap sends through the CDP binding. */
9
+ export type TargetMessage = {
10
+ kind: "iopub";
11
+ msg_type: string;
12
+ content: CdpParams;
13
+ metadata: CdpParams;
14
+ buffers: string[];
15
+ barrier: number;
16
+ } | {
17
+ kind: "input_request";
18
+ prompt: string;
19
+ password: boolean;
20
+ barrier: number;
21
+ };
22
+ /**
23
+ * Splits one output pipe into text and barrier markers, and releases them
24
+ * in order. A gated marker (one that announces a binding message) pauses
25
+ * the pipe until `gate(n)` resolves, i.e. until that message has been
26
+ * delivered, so later output can't overtake it. A possible partial marker
27
+ * at the end of a chunk is held back until the next chunk decides it.
28
+ */
29
+ export declare class PipeDemux {
30
+ #private;
31
+ readonly onText: (text: string) => void;
32
+ readonly gate: (n: number) => Promise<void>;
33
+ constructor(onText: (text: string) => void, gate?: (n: number) => Promise<void>);
34
+ push(chunk: string): void;
35
+ waitFor(n: number): Promise<void>;
36
+ /** Release everything (the process died). */
37
+ close(): void;
38
+ }
39
+ export interface TargetEvents {
40
+ stream(name: "stdout" | "stderr", text: string): void;
41
+ message(msg: TargetMessage): void;
42
+ exit(code: number | null): void;
43
+ }
44
+ export declare class Interrupted extends Error {
45
+ name: string;
46
+ }
47
+ export declare class Target {
48
+ #private;
49
+ readonly runtime: RuntimeName;
50
+ readonly versions: {
51
+ runtime: string;
52
+ v8: string;
53
+ };
54
+ private constructor();
55
+ static launch(runtime: RuntimeName, events: TargetEvents, options?: {
56
+ cwd?: string;
57
+ executable?: string;
58
+ denoCompat?: boolean;
59
+ }): Promise<Target>;
60
+ /** Resolves once all output and messages sent so far have been delivered. */
61
+ flush(): Promise<void>;
62
+ /** Runs transformed cell code. Rejects with Interrupted after interrupt(). */
63
+ evaluate(script: string): Promise<EvaluateResult>;
64
+ /**
65
+ * Deno calls IsolateHandle::terminate_execution from another thread. The
66
+ * CDP equivalent is Runtime.terminateExecution, sent on a second session
67
+ * because the shell session is the one blocked on the running evaluate.
68
+ * Terminated code never settles the awaited promise, so the pending
69
+ * evaluate is abandoned here and the session stays usable.
70
+ */
71
+ interrupt(): Promise<void>;
72
+ callFunctionOn(functionDeclaration: string, args: CdpParams[]): Promise<CdpParams>;
73
+ /** The broadcastResult step of Deno's execute flow (jupyter/mod.rs). */
74
+ broadcastResult(executionCount: number, result: RemoteObject): Promise<void>;
75
+ describeError(exception: RemoteObject): Promise<{
76
+ name: string;
77
+ message: string;
78
+ stack: string;
79
+ }>;
80
+ propertyNames(expression: string): Promise<string[]>;
81
+ lexicalNames(): Promise<string[]>;
82
+ replyInput(value: string | null): void;
83
+ kill(): void;
84
+ }
package/dist/target.js ADDED
@@ -0,0 +1,350 @@
1
+ // A target runtime driven over CDP: the stand-in for Deno's REPL isolate and
2
+ // the ops in cli/ops/jupyter.rs. Any runtime whose --inspect speaks CDP and
3
+ // can load runtime/bootstrap.mjs works; Node and Deno are wired up here.
4
+ import process from "node:process";
5
+ import { spawn } from "node:child_process";
6
+ import { join } from "node:path";
7
+ import { asEvaluateResult, CdpAbandoned, CdpSession } from "./cdp.js";
8
+ import { SHIM } from "./transform.js";
9
+ import { hostRuntime, runtimeDir } from "./package.js";
10
+ const BINDING = "__cdpk_send";
11
+ const MARKER_RE = /\x1b\[cdpk:(\d+)(m?)\]\x1b/;
12
+ const MAX_MARKER_LEN = 24;
13
+ // Lines the runtimes print to stderr on their own: inspector notices, and
14
+ // Deno's npm/jsr "Download https://..." progress (which `deno jupyter`
15
+ // sends to the server log rather than the notebook).
16
+ const INSPECTOR_CHATTER = /^(?:\x1b\[[0-9;]*m)*(Debugger listening on |Debugger attached\.|For help, see: |Visit chrome:\/\/inspect|Waiting for the debugger to disconnect|Download(?:\x1b\[[0-9;]*m)* https?:\/\/).*\n/gm;
17
+ /** `bootstrap` is the local path of runtime/bootstrap.mjs (see runtimeDir). */
18
+ export function launchCommand(runtime, bootstrap, executable) {
19
+ const inspect = "--inspect=127.0.0.1:0";
20
+ if (runtime === "deno") {
21
+ // Deno discovers config from the main module's location, which would
22
+ // put npm: imports in BYONM mode against this package's own
23
+ // package.json. `none` gives the global-cache behaviour `deno jupyter`
24
+ // has in a plain directory; --no-lock stops it writing a deno.lock
25
+ // into this package.
26
+ return {
27
+ cmd: executable ?? "deno",
28
+ args: ["run", "--allow-all", "--node-modules-dir=none", "--no-lock", inspect, bootstrap],
29
+ };
30
+ }
31
+ // Under a Deno host, process.execPath is deno; find node on PATH instead.
32
+ const node = hostRuntime() === "node" ? process.execPath : "node";
33
+ return { cmd: executable ?? node, args: [inspect, bootstrap] };
34
+ }
35
+ function isTargetMessage(value) {
36
+ if (typeof value !== "object" || value === null)
37
+ return false;
38
+ const kind = value.kind;
39
+ return (kind === "iopub" || kind === "input_request") &&
40
+ typeof value.barrier === "number";
41
+ }
42
+ const MARKER_PREFIX = "\x1b[cdpk:";
43
+ function isPartialMarker(tail) {
44
+ if (tail.length >= MAX_MARKER_LEN)
45
+ return false;
46
+ return MARKER_PREFIX.startsWith(tail) || /^\x1b\[cdpk:\d*m?\]?$/.test(tail);
47
+ }
48
+ /**
49
+ * Splits one output pipe into text and barrier markers, and releases them
50
+ * in order. A gated marker (one that announces a binding message) pauses
51
+ * the pipe until `gate(n)` resolves, i.e. until that message has been
52
+ * delivered, so later output can't overtake it. A possible partial marker
53
+ * at the end of a chunk is held back until the next chunk decides it.
54
+ */
55
+ export class PipeDemux {
56
+ #buffer = "";
57
+ #items = [];
58
+ #pumping = false;
59
+ #closed = false;
60
+ #reached = 0;
61
+ #waiters = [];
62
+ onText;
63
+ gate;
64
+ constructor(onText, gate = () => Promise.resolve()) {
65
+ this.onText = onText;
66
+ this.gate = gate;
67
+ }
68
+ push(chunk) {
69
+ this.#buffer += chunk;
70
+ for (;;) {
71
+ const m = MARKER_RE.exec(this.#buffer);
72
+ if (!m)
73
+ break;
74
+ if (m.index > 0)
75
+ this.#items.push({ text: this.#buffer.slice(0, m.index) });
76
+ this.#items.push({ barrier: Number(m[1]), gated: m[2] === "m" });
77
+ this.#buffer = this.#buffer.slice(m.index + m[0].length);
78
+ }
79
+ // Hold back a tail that could still grow into a marker, including one
80
+ // cut inside the "\x1b[cdpk:" prefix itself.
81
+ const esc = this.#buffer.lastIndexOf("\x1b");
82
+ const tail = esc === -1 ? "" : this.#buffer.slice(esc);
83
+ const holdFrom = esc !== -1 && isPartialMarker(tail) ? esc : this.#buffer.length;
84
+ if (holdFrom > 0) {
85
+ this.#items.push({ text: this.#buffer.slice(0, holdFrom) });
86
+ this.#buffer = this.#buffer.slice(holdFrom);
87
+ }
88
+ void this.#pump();
89
+ }
90
+ async #pump() {
91
+ if (this.#pumping)
92
+ return;
93
+ this.#pumping = true;
94
+ try {
95
+ for (let item = this.#items.shift(); item; item = this.#items.shift()) {
96
+ if ("text" in item) {
97
+ this.onText(item.text);
98
+ continue;
99
+ }
100
+ this.#reach(item.barrier);
101
+ if (item.gated && !this.#closed)
102
+ await this.gate(item.barrier);
103
+ }
104
+ }
105
+ finally {
106
+ this.#pumping = false;
107
+ }
108
+ }
109
+ #reach(n) {
110
+ this.#reached = Math.max(this.#reached, n);
111
+ this.#waiters = this.#waiters.filter((w) => {
112
+ if (w.n > this.#reached)
113
+ return true;
114
+ w.resolve();
115
+ return false;
116
+ });
117
+ }
118
+ waitFor(n) {
119
+ if (n <= this.#reached)
120
+ return Promise.resolve();
121
+ return new Promise((resolve) => this.#waiters.push({ n, resolve }));
122
+ }
123
+ /** Release everything (the process died). */
124
+ close() {
125
+ this.#closed = true;
126
+ if (this.#buffer)
127
+ this.#items.push({ text: this.#buffer });
128
+ this.#buffer = "";
129
+ this.#items.push({ barrier: Number.MAX_SAFE_INTEGER, gated: false });
130
+ void this.#pump();
131
+ }
132
+ }
133
+ /** Promises keyed by barrier number, resolvable before or after first use. */
134
+ class Gates {
135
+ #map = new Map();
136
+ #entry(n) {
137
+ let entry = this.#map.get(n);
138
+ if (!entry) {
139
+ let resolve = () => { };
140
+ const promise = new Promise((r) => (resolve = r));
141
+ entry = { promise, resolve };
142
+ this.#map.set(n, entry);
143
+ }
144
+ return entry;
145
+ }
146
+ wait(n) {
147
+ return this.#entry(n).promise;
148
+ }
149
+ open(n) {
150
+ this.#entry(n).resolve();
151
+ }
152
+ openAll() {
153
+ for (const entry of this.#map.values())
154
+ entry.resolve();
155
+ this.#map.clear();
156
+ }
157
+ }
158
+ export class Interrupted extends Error {
159
+ name = "Interrupted";
160
+ }
161
+ export class Target {
162
+ runtime;
163
+ #child;
164
+ #shell;
165
+ #control;
166
+ #stdout;
167
+ #stderr;
168
+ #gates;
169
+ #contextId;
170
+ #messages = Promise.resolve();
171
+ #interrupted = false;
172
+ versions;
173
+ constructor(runtime, child, shell, control, stdout, stderr, gates, contextId, versions) {
174
+ this.runtime = runtime;
175
+ this.#child = child;
176
+ this.#shell = shell;
177
+ this.#control = control;
178
+ this.#stdout = stdout;
179
+ this.#stderr = stderr;
180
+ this.#gates = gates;
181
+ this.#contextId = contextId;
182
+ this.versions = versions;
183
+ }
184
+ static async launch(runtime, events, options = {}) {
185
+ const bootstrap = join(await runtimeDir(new URL("../runtime/", import.meta.url).href), "bootstrap.mjs");
186
+ const { cmd, args } = launchCommand(runtime, bootstrap, options.executable);
187
+ const env = { ...process.env, FORCE_COLOR: "1" };
188
+ if (options.denoCompat)
189
+ env.CDPK_DENO_COMPAT = "1";
190
+ delete env.NO_COLOR;
191
+ const child = spawn(cmd, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"], env });
192
+ child.stdout.setEncoding("utf8");
193
+ child.stderr.setEncoding("utf8");
194
+ const gates = new Gates();
195
+ const stdout = new PipeDemux((text) => events.stream("stdout", text), (n) => gates.wait(n));
196
+ const stderr = new PipeDemux((text) => events.stream("stderr", text), (n) => gates.wait(n));
197
+ const pushStderr = (chunk) => {
198
+ const text = chunk.replace(INSPECTOR_CHATTER, "");
199
+ if (text)
200
+ stderr.push(text);
201
+ };
202
+ // Until the inspector URL appears, stderr carries the runtime's banner.
203
+ const wsUrl = await new Promise((resolve, reject) => {
204
+ let banner = "";
205
+ const onData = (chunk) => {
206
+ banner += chunk;
207
+ const m = /ws:\/\/\S+/.exec(banner);
208
+ if (!m)
209
+ return;
210
+ child.stderr.off("data", onData);
211
+ pushStderr(banner.slice(m.index + m[0].length).replace(/^.*\n?/, ""));
212
+ child.stderr.on("data", pushStderr);
213
+ resolve(m[0]);
214
+ };
215
+ child.stderr.on("data", onData);
216
+ child.once("error", reject);
217
+ child.once("exit", (code) => reject(new Error(`${cmd} exited (${code}) before its inspector came up:\n${banner}`)));
218
+ });
219
+ child.stdout.on("data", (c) => stdout.push(c));
220
+ child.once("exit", (code) => {
221
+ gates.openAll();
222
+ stdout.close();
223
+ stderr.close();
224
+ events.exit(code);
225
+ });
226
+ const shell = await CdpSession.connect(wsUrl);
227
+ const control = await CdpSession.connect(wsUrl);
228
+ const contextReady = new Promise((resolve) => {
229
+ shell.on("Runtime.executionContextCreated", (params) => {
230
+ const context = params.context;
231
+ if (context.auxData?.isDefault !== false)
232
+ resolve(context.id);
233
+ });
234
+ });
235
+ await shell.send("Runtime.enable");
236
+ await shell.send("Runtime.addBinding", { name: BINDING });
237
+ const contextId = await contextReady;
238
+ const target = new Target(runtime, child, shell, control, stdout, stderr, gates, contextId, { runtime: "", v8: "" });
239
+ shell.on("Runtime.bindingCalled", (params) => {
240
+ if (params.name !== BINDING || typeof params.payload !== "string")
241
+ return;
242
+ const msg = JSON.parse(params.payload);
243
+ if (isTargetMessage(msg))
244
+ target.#deliver(msg, events);
245
+ });
246
+ // Wait for the bootstrap's top-level await to finish installing the shim.
247
+ for (let i = 0; i < 200; i++) {
248
+ const probe = await target.#evaluateRaw(`typeof globalThis.${SHIM}?.tick === "function"`);
249
+ if (probe.result.value === true)
250
+ break;
251
+ await new Promise((r) => setTimeout(r, 25));
252
+ }
253
+ const versions = await target.#evaluateRaw(`JSON.stringify(typeof Deno !== "undefined" ? { runtime: Deno.version.deno, v8: Deno.version.v8 } : { runtime: process.versions.node, v8: process.versions.v8 })`);
254
+ Object.assign(target.versions, JSON.parse(String(versions.result.value)));
255
+ return target;
256
+ }
257
+ // Binding messages wait until both pipes have caught up to their marker,
258
+ // and are delivered strictly in order.
259
+ #deliver(msg, events) {
260
+ this.#messages = this.#messages.then(async () => {
261
+ await Promise.all([this.#stdout.waitFor(msg.barrier), this.#stderr.waitFor(msg.barrier)]);
262
+ events.message(msg);
263
+ this.#gates.open(msg.barrier);
264
+ });
265
+ }
266
+ /** Resolves once all output and messages sent so far have been delivered. */
267
+ async flush() {
268
+ const n = await this.#evaluateRaw(`${SHIM}.barrier()`);
269
+ const barrier = Number(n.result.value);
270
+ await Promise.all([this.#stdout.waitFor(barrier), this.#stderr.waitFor(barrier)]);
271
+ await this.#messages;
272
+ }
273
+ #evaluateRaw(expression, session = this.#shell) {
274
+ return session
275
+ .send("Runtime.evaluate", { expression, contextId: this.#contextId, replMode: true, awaitPromise: true })
276
+ .then(asEvaluateResult);
277
+ }
278
+ /** Runs transformed cell code. Rejects with Interrupted after interrupt(). */
279
+ async evaluate(script) {
280
+ this.#interrupted = false;
281
+ try {
282
+ return await this.#evaluateRaw(script);
283
+ }
284
+ catch (err) {
285
+ if (err instanceof CdpAbandoned || this.#interrupted)
286
+ throw new Interrupted("Execution interrupted");
287
+ throw err;
288
+ }
289
+ }
290
+ /**
291
+ * Deno calls IsolateHandle::terminate_execution from another thread. The
292
+ * CDP equivalent is Runtime.terminateExecution, sent on a second session
293
+ * because the shell session is the one blocked on the running evaluate.
294
+ * Terminated code never settles the awaited promise, so the pending
295
+ * evaluate is abandoned here and the session stays usable.
296
+ */
297
+ async interrupt() {
298
+ this.#interrupted = true;
299
+ await this.#control.send("Runtime.terminateExecution");
300
+ this.#shell.abandonPending();
301
+ }
302
+ callFunctionOn(functionDeclaration, args) {
303
+ return this.#shell.send("Runtime.callFunctionOn", {
304
+ functionDeclaration,
305
+ arguments: args,
306
+ executionContextId: this.#contextId,
307
+ awaitPromise: true,
308
+ returnByValue: true,
309
+ });
310
+ }
311
+ /** The broadcastResult step of Deno's execute flow (jupyter/mod.rs). */
312
+ async broadcastResult(executionCount, result) {
313
+ const arg = result.objectId
314
+ ? { objectId: result.objectId }
315
+ : result.unserializableValue !== undefined
316
+ ? { unserializableValue: result.unserializableValue }
317
+ : { value: result.value };
318
+ await this.callFunctionOn(`function (n, r) { return globalThis.${SHIM}.broadcastResult(n, r); }`, [
319
+ { value: executionCount },
320
+ arg,
321
+ ]);
322
+ }
323
+ async describeError(exception) {
324
+ const arg = exception.objectId ? { objectId: exception.objectId } : { value: exception.value };
325
+ const res = await this.callFunctionOn(`function (e) { return globalThis.${SHIM}.describeError(e); }`, [arg]);
326
+ const value = res.result?.value;
327
+ return JSON.parse(typeof value === "string" ? value : '{"name":"Error","message":"","stack":""}');
328
+ }
329
+ async propertyNames(expression) {
330
+ const evaluated = await this.#evaluateRaw(`(${expression})`).catch(() => null);
331
+ const objectId = evaluated?.result.objectId;
332
+ if (!objectId || evaluated?.exceptionDetails)
333
+ return [];
334
+ const props = await this.#shell.send("Runtime.getProperties", { objectId, nonIndexedPropertiesOnly: true });
335
+ const list = props.result;
336
+ return (list ?? []).map((p) => p.name);
337
+ }
338
+ async lexicalNames() {
339
+ const res = await this.#shell.send("Runtime.globalLexicalScopeNames", { executionContextId: this.#contextId });
340
+ return res.names ?? [];
341
+ }
342
+ replyInput(value) {
343
+ this.#child.stdin.write(JSON.stringify({ value }) + "\n");
344
+ }
345
+ kill() {
346
+ this.#shell.close();
347
+ this.#control.close();
348
+ this.#child.kill();
349
+ }
350
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type IsCompleteReply = {
2
+ status: "complete";
3
+ } | {
4
+ status: "incomplete";
5
+ indent: string;
6
+ } | {
7
+ status: "invalid";
8
+ };
9
+ /** Bracket/quote/comment scanner deciding whether a cell is finished. */
10
+ export declare function checkIsComplete(code: string): IsCompleteReply;
11
+ /** The dotted expression ending at the cursor, e.g. "Math.fl". */
12
+ export declare function exprAtCursor(code: string, cursor: number): string;
13
+ export declare function codePointToUtf16Index(str: string, cpOffset: number): number;
14
+ export declare function utf16ToCodePointIndex(str: string, utf16Offset: number): number;
package/dist/text.js ADDED
@@ -0,0 +1,69 @@
1
+ // Text helpers for is_complete and complete requests.
2
+ //
3
+ // Ported from Deno's cli/js/jupyter_kernel.js (v2.9.6), which is
4
+ // Copyright 2018-2026 the Deno authors, MIT license.
5
+ /** Bracket/quote/comment scanner deciding whether a cell is finished. */
6
+ export function checkIsComplete(code) {
7
+ const stack = [];
8
+ const closers = { ")": "(", "]": "[", "}": "{" };
9
+ let i = 0;
10
+ while (i < code.length) {
11
+ const ch = code[i];
12
+ if (ch === "/" && code[i + 1] === "/") {
13
+ while (i < code.length && code[i] !== "\n")
14
+ i++;
15
+ continue;
16
+ }
17
+ if (ch === "/" && code[i + 1] === "*") {
18
+ const end = code.indexOf("*/", i + 2);
19
+ if (end === -1)
20
+ return { status: "incomplete", indent: "" };
21
+ i = end + 2;
22
+ continue;
23
+ }
24
+ if (ch === "'" || ch === '"' || ch === "`") {
25
+ i++;
26
+ let closed = false;
27
+ while (i < code.length) {
28
+ if (code[i] === "\\" && ch !== "`") {
29
+ i += 2;
30
+ continue;
31
+ }
32
+ if (code[i++] === ch) {
33
+ closed = true;
34
+ break;
35
+ }
36
+ }
37
+ if (!closed)
38
+ return { status: "incomplete", indent: "" };
39
+ continue;
40
+ }
41
+ if (ch === "(" || ch === "[" || ch === "{")
42
+ stack.push(ch);
43
+ else if (ch in closers && stack.pop() !== closers[ch])
44
+ return { status: "invalid" };
45
+ i++;
46
+ }
47
+ return stack.length === 0 ? { status: "complete" } : { status: "incomplete", indent: " " };
48
+ }
49
+ /** The dotted expression ending at the cursor, e.g. "Math.fl". */
50
+ export function exprAtCursor(code, cursor) {
51
+ const m = /[\w$.]+$/.exec(code.slice(0, cursor));
52
+ return m ? m[0] : "";
53
+ }
54
+ // Jupyter measures cursor_pos in Unicode codepoints; JS strings index UTF-16
55
+ // code units (denoland/deno#22771).
56
+ export function codePointToUtf16Index(str, cpOffset) {
57
+ let utf16 = 0;
58
+ for (let cp = 0; cp < cpOffset && utf16 < str.length; cp++) {
59
+ utf16 += (str.codePointAt(utf16) ?? 0) > 0xffff ? 2 : 1;
60
+ }
61
+ return utf16;
62
+ }
63
+ export function utf16ToCodePointIndex(str, utf16Offset) {
64
+ let cp = 0;
65
+ for (let utf16 = 0; utf16 < utf16Offset && utf16 < str.length; cp++) {
66
+ utf16 += (str.codePointAt(utf16) ?? 0) > 0xffff ? 2 : 1;
67
+ }
68
+ return cp;
69
+ }
@@ -0,0 +1,13 @@
1
+ export declare const SHIM = "__cdpk";
2
+ export declare class CellSyntaxError extends Error {
3
+ name: string;
4
+ }
5
+ /** Rewrites module-only syntax in plain JavaScript into script syntax. */
6
+ export declare function rewriteModuleSyntax(js: string): string;
7
+ export declare function transformCell(code: string): string;
8
+ /**
9
+ * Deno treats a cell like `{ "a": 1 }` as an object literal rather than a
10
+ * block, as browser consoles do. Returns the wrapped form to try first, or
11
+ * null when no wrapping applies.
12
+ */
13
+ export declare function objectLiteralCandidate(code: string): string | null;
@@ -0,0 +1,145 @@
1
+ // Turns a notebook cell (TypeScript/TSX, ES module syntax) into a script that
2
+ // CDP's Runtime.evaluate can run with replMode: true.
3
+ //
4
+ // Deno does this with deno_ast (swc) and `var_decl_imports: true`
5
+ // (cli/tools/repl/session.rs, evaluate_ts_expression). Here esbuild does the
6
+ // TypeScript/JSX part and acorn finds the module syntax that a script can't
7
+ // contain:
8
+ // import x, { y as z } from "m" -> var __cdpk_m0 = await __cdpk.import("m");
9
+ // var x = __cdpk_m0.default, z = __cdpk_m0.y;
10
+ // import("m") -> __cdpk.import("m")
11
+ // import.meta -> __cdpk.meta
12
+ // export const a = 1 -> const a = 1
13
+ // `__cdpk` is installed in the target by runtime/bootstrap.mjs. Routing
14
+ // import() through it matters: code evaluated over the inspector has no
15
+ // module referrer, so Node rejects a bare import() ("A dynamic import
16
+ // callback was not specified").
17
+ import { transformSync } from "esbuild";
18
+ import { parse } from "acorn";
19
+ import { full } from "acorn-walk";
20
+ export const SHIM = "__cdpk";
21
+ // Each cell first yields one macrotask. User code then runs as an ordinary
22
+ // task instead of nested inside the inspector's dispatch of Runtime.evaluate,
23
+ // which is what lets a Runtime.terminateExecution from the control session
24
+ // interrupt a busy loop. (Within that dispatch the inspector processes no
25
+ // other messages, so an interrupt would never be seen.)
26
+ const PRELUDE = `'use strict'; await ${SHIM}.tick();\n`;
27
+ export class CellSyntaxError extends Error {
28
+ name = "SyntaxError";
29
+ }
30
+ function stripTypes(code) {
31
+ // Like Deno: prefer TSX, fall back to TS for `<T>x` assertions.
32
+ let firstError;
33
+ for (const loader of ["tsx", "ts"]) {
34
+ try {
35
+ return transformSync(code, {
36
+ loader,
37
+ target: "esnext",
38
+ jsx: "transform",
39
+ tsconfigRaw: { compilerOptions: { verbatimModuleSyntax: true } },
40
+ logLevel: "silent",
41
+ }).code;
42
+ }
43
+ catch (err) {
44
+ firstError ??= err;
45
+ }
46
+ }
47
+ const message = firstError instanceof Error ? firstError.message : String(firstError);
48
+ throw new CellSyntaxError(message.replace(/^Transform failed with \d+ errors?:\n/, ""));
49
+ }
50
+ function importedName(spec) {
51
+ const imported = spec.imported;
52
+ if (!imported)
53
+ return "default";
54
+ return imported.type === "Literal" ? String(imported.value) : String(imported.name);
55
+ }
56
+ function rewriteImport(code, node, index) {
57
+ const source = JSON.stringify(node.source.value);
58
+ const attrs = node.attributes && node.attributes.length > 0
59
+ ? `, { with: { ${node.attributes.map((a) => code.slice(a.start, a.end)).join(", ")} } }`
60
+ : "";
61
+ const load = `await ${SHIM}.import(${source}${attrs})`;
62
+ if (node.specifiers.length === 0)
63
+ return `${load};`;
64
+ const ns = `${SHIM}_m${index}`;
65
+ const bindings = node.specifiers.map((spec) => {
66
+ const local = String(spec.local.name);
67
+ if (spec.type === "ImportNamespaceSpecifier")
68
+ return `${local} = ${ns}`;
69
+ return `${local} = ${ns}[${JSON.stringify(importedName(spec))}]`;
70
+ });
71
+ return `var ${ns} = ${load}; var ${bindings.join(", ")};`;
72
+ }
73
+ /** Rewrites module-only syntax in plain JavaScript into script syntax. */
74
+ export function rewriteModuleSyntax(js) {
75
+ let program;
76
+ try {
77
+ program = parse(js, {
78
+ ecmaVersion: "latest",
79
+ sourceType: "module",
80
+ allowAwaitOutsideFunction: true,
81
+ allowReturnOutsideFunction: false,
82
+ });
83
+ }
84
+ catch (err) {
85
+ throw new CellSyntaxError(err instanceof Error ? err.message : String(err));
86
+ }
87
+ const edits = [];
88
+ let importIndex = 0;
89
+ full(program, (node) => {
90
+ switch (node.type) {
91
+ case "ImportDeclaration": {
92
+ const decl = node;
93
+ edits.push({ start: decl.start, end: decl.end, text: rewriteImport(js, decl, importIndex++) });
94
+ break;
95
+ }
96
+ case "ImportExpression":
97
+ // Replace just the `import` keyword; arguments stay as written.
98
+ edits.push({ start: node.start, end: node.start + "import".length, text: `${SHIM}.import` });
99
+ break;
100
+ case "MetaProperty":
101
+ if (js.slice(node.start, node.end).startsWith("import")) {
102
+ edits.push({ start: node.start, end: node.end, text: `${SHIM}.meta` });
103
+ }
104
+ break;
105
+ case "ExportNamedDeclaration": {
106
+ const decl = node;
107
+ if (decl.declaration) {
108
+ edits.push({ start: decl.start, end: decl.declaration.start, text: "" });
109
+ }
110
+ else if (!decl.source) {
111
+ edits.push({ start: decl.start, end: decl.end, text: "" }); // export { a, b }
112
+ }
113
+ else {
114
+ throw new CellSyntaxError("re-exports (export ... from) are not supported in a cell");
115
+ }
116
+ break;
117
+ }
118
+ case "ExportDefaultDeclaration": {
119
+ const decl = node;
120
+ edits.push({ start: decl.start, end: decl.declaration.start, text: "" });
121
+ break;
122
+ }
123
+ case "ExportAllDeclaration":
124
+ throw new CellSyntaxError("re-exports (export * from) are not supported in a cell");
125
+ }
126
+ });
127
+ // Nested edits can't occur (imports/exports are top level, import()'s
128
+ // keyword edit doesn't overlap its arguments), so apply back to front.
129
+ edits.sort((a, b) => b.start - a.start);
130
+ let out = js;
131
+ for (const edit of edits)
132
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
133
+ return out;
134
+ }
135
+ export function transformCell(code) {
136
+ return PRELUDE + rewriteModuleSyntax(stripTypes(code));
137
+ }
138
+ /**
139
+ * Deno treats a cell like `{ "a": 1 }` as an object literal rather than a
140
+ * block, as browser consoles do. Returns the wrapped form to try first, or
141
+ * null when no wrapping applies.
142
+ */
143
+ export function objectLiteralCandidate(code) {
144
+ return code.trimStart().startsWith("{") && !code.trimEnd().endsWith(";") ? `(${code})` : null;
145
+ }