@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/transform.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { transformSync } from "esbuild";
|
|
18
18
|
import { parse } from "acorn";
|
|
19
19
|
import { full, recursive } from "acorn-walk";
|
|
20
|
+
import { LineMap } from "./sourcemap.js";
|
|
20
21
|
export const SHIM = "__cdpk";
|
|
21
22
|
// Each cell first yields one macrotask. User code then runs as an ordinary
|
|
22
23
|
// task instead of nested inside the inspector's dispatch of Runtime.evaluate,
|
|
@@ -33,7 +34,8 @@ function jsxSettings(jsx) {
|
|
|
33
34
|
}
|
|
34
35
|
return { jsx: "transform", jsxFactory: jsx?.factory, jsxFragment: jsx?.fragment };
|
|
35
36
|
}
|
|
36
|
-
|
|
37
|
+
/** esbuild's output, with its source map's `mappings` when asked for. */
|
|
38
|
+
function stripTypesMapped(code, options, sourcemap) {
|
|
37
39
|
const compilerOptions = {
|
|
38
40
|
verbatimModuleSyntax: true,
|
|
39
41
|
...(options.experimentalDecorators ? { experimentalDecorators: true } : {}),
|
|
@@ -42,13 +44,19 @@ function stripTypes(code, options) {
|
|
|
42
44
|
let firstError;
|
|
43
45
|
for (const loader of ["tsx", "ts"]) {
|
|
44
46
|
try {
|
|
45
|
-
|
|
47
|
+
const result = transformSync(code, {
|
|
46
48
|
loader,
|
|
47
49
|
target: "esnext",
|
|
48
50
|
...jsxSettings(options.jsx),
|
|
49
51
|
tsconfigRaw: { compilerOptions },
|
|
50
52
|
logLevel: "silent",
|
|
51
|
-
|
|
53
|
+
...(sourcemap ? { sourcemap: "external" } : {}),
|
|
54
|
+
});
|
|
55
|
+
const map = sourcemap ? JSON.parse(result.map) : null;
|
|
56
|
+
const mappings = typeof map?.mappings === "string"
|
|
57
|
+
? map.mappings
|
|
58
|
+
: "";
|
|
59
|
+
return { code: result.code, mappings };
|
|
52
60
|
}
|
|
53
61
|
catch (err) {
|
|
54
62
|
firstError ??= err;
|
|
@@ -57,6 +65,9 @@ function stripTypes(code, options) {
|
|
|
57
65
|
const message = firstError instanceof Error ? firstError.message : String(firstError);
|
|
58
66
|
throw new CellSyntaxError(message.replace(/^Transform failed with \d+ errors?:\n/, ""));
|
|
59
67
|
}
|
|
68
|
+
function stripTypes(code, options) {
|
|
69
|
+
return stripTypesMapped(code, options, false).code;
|
|
70
|
+
}
|
|
60
71
|
function importedName(spec) {
|
|
61
72
|
const imported = spec.imported;
|
|
62
73
|
if (!imported)
|
|
@@ -140,12 +151,25 @@ export function rewriteModuleSyntax(js) {
|
|
|
140
151
|
// keyword edit doesn't overlap its arguments), so apply back to front.
|
|
141
152
|
return applyEdits(js, edits);
|
|
142
153
|
}
|
|
154
|
+
function newlines(text) {
|
|
155
|
+
let count = 0;
|
|
156
|
+
for (const char of text)
|
|
157
|
+
if (char === "\n")
|
|
158
|
+
count++;
|
|
159
|
+
return count;
|
|
160
|
+
}
|
|
143
161
|
function applyEdits(js, edits) {
|
|
144
162
|
// Back to front; at a shared start, a replacement goes before an insertion.
|
|
145
163
|
edits.sort((a, b) => b.start - a.start || b.end - a.end);
|
|
146
164
|
let out = js;
|
|
147
|
-
for (const edit of edits)
|
|
148
|
-
|
|
165
|
+
for (const edit of edits) {
|
|
166
|
+
// Keep every later line where it was, so a cell's lines still map to
|
|
167
|
+
// esbuild's (the debugger relies on it, #18): a multi-line import
|
|
168
|
+
// rewritten to one line is padded with the newlines it replaced.
|
|
169
|
+
const lost = newlines(js.slice(edit.start, edit.end)) - newlines(edit.text);
|
|
170
|
+
const text = lost > 0 ? edit.text + "\n".repeat(lost) : edit.text;
|
|
171
|
+
out = out.slice(0, edit.start) + text + out.slice(edit.end);
|
|
172
|
+
}
|
|
149
173
|
return out;
|
|
150
174
|
}
|
|
151
175
|
/** Adds the names a binding pattern declares. */
|
|
@@ -266,6 +290,15 @@ function hoistForEval(js) {
|
|
|
266
290
|
export function transformCell(code, options = {}) {
|
|
267
291
|
return PRELUDE + rewriteModuleSyntax(stripTypes(code, options));
|
|
268
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* transformCell, plus the map between the cell's lines and the script's,
|
|
295
|
+
* for the debugger (#18). The rewrite keeps lines (see applyEdits), so the
|
|
296
|
+
* script's lines are esbuild's, after the prelude's one.
|
|
297
|
+
*/
|
|
298
|
+
export function transformCellWithLines(code, options = {}) {
|
|
299
|
+
const stripped = stripTypesMapped(code, options, true);
|
|
300
|
+
return { script: PRELUDE + rewriteModuleSyntax(stripped.code), lines: new LineMap(stripped.mappings, newlines(PRELUDE)) };
|
|
301
|
+
}
|
|
269
302
|
/**
|
|
270
303
|
* Like transformCell, for a target that evaluates cells itself, as global
|
|
271
304
|
* code (an indirect eval), instead of through the inspector's replMode: see
|
package/dist/workerd-target.d.ts
CHANGED
|
@@ -115,6 +115,8 @@ export declare class WorkerdTarget implements KernelTarget {
|
|
|
115
115
|
stack: string;
|
|
116
116
|
}>;
|
|
117
117
|
propertyNames(expression: string): Promise<string[]>;
|
|
118
|
+
/** Resolved by property descriptor in the driver, so nothing runs (#10). */
|
|
119
|
+
inspect(path: string, detail: number): Promise<string | null>;
|
|
118
120
|
/** None: top-level declarations here are properties of globalThis. */
|
|
119
121
|
lexicalNames(): Promise<string[]>;
|
|
120
122
|
/** Never asked for: prompt() in workerd answers null without asking. */
|
package/dist/workerd-target.js
CHANGED
|
@@ -29,9 +29,9 @@ import { runtimeDir } from "./package.js";
|
|
|
29
29
|
import { Interrupted, isTargetMessage } from "./target.js";
|
|
30
30
|
import { connectUnixWebSocket } from "./ws-client.js";
|
|
31
31
|
import { findWranglerConfig, startMiniflare } from "./workers-project.js";
|
|
32
|
-
import { ensureInstalled, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
|
|
32
|
+
import { ensureInstalled, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
|
|
33
33
|
/** The driver Worker's modules; the first is its main module. */
|
|
34
|
-
export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "evaluator.mjs"];
|
|
34
|
+
export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "evaluator.mjs", "introspect.mjs"];
|
|
35
35
|
/** See BunTarget: how long an interrupted cell has to show it isn't busy. */
|
|
36
36
|
export const INTERRUPT_GRACE_MS = 500;
|
|
37
37
|
export function restartNote(runtime) {
|
|
@@ -121,7 +121,7 @@ export async function bundleModule(specifier, cwd) {
|
|
|
121
121
|
? jsrToNpm(specifier.slice(4))
|
|
122
122
|
: null;
|
|
123
123
|
if (pkg) {
|
|
124
|
-
const { dir, alias } = ensureInstalled(pkg);
|
|
124
|
+
const { dir, alias } = ensureInstalled(pkg, "npm", { lockFile: findLockFile(cwd) });
|
|
125
125
|
entry = alias + pkg.subpath;
|
|
126
126
|
resolveDir = dir;
|
|
127
127
|
}
|
|
@@ -435,6 +435,11 @@ export class WorkerdTarget {
|
|
|
435
435
|
const reply = await this.#request("props", { expr: expression }).catch(() => null);
|
|
436
436
|
return Array.isArray(reply?.names) ? reply.names.filter((n) => typeof n === "string") : [];
|
|
437
437
|
}
|
|
438
|
+
/** Resolved by property descriptor in the driver, so nothing runs (#10). */
|
|
439
|
+
async inspect(path, detail) {
|
|
440
|
+
const reply = await this.#request("inspect", { path, detail }).catch(() => null);
|
|
441
|
+
return typeof reply?.text === "string" ? reply.text : null;
|
|
442
|
+
}
|
|
438
443
|
/** None: top-level declarations here are properties of globalThis. */
|
|
439
444
|
lexicalNames() {
|
|
440
445
|
return Promise.resolve([]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tlockney/cdp-kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Deno's Jupyter kernel, extracted to run notebooks on any V8 runtime over the Chrome DevTools Protocol",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Thomas Lockney <thomas@lockney.net>",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"install:workerd": "node src/main.ts install --runtime workerd",
|
|
58
58
|
"install:celld": "node src/main.ts install --runtime celld",
|
|
59
59
|
"prepack": "npm run build",
|
|
60
|
-
"test": "node --test test/*.test.ts",
|
|
60
|
+
"test": "node --test --test-timeout=300000 test/*.test.ts",
|
|
61
61
|
"e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py",
|
|
62
62
|
"test:lab": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/lab_widgets.py"
|
|
63
63
|
},
|
package/runtime/bootstrap.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
14
|
import { createJupyter } from "./display.mjs";
|
|
15
|
+
import { describeValue, inspectPath, resolvePath } from "./introspect.mjs";
|
|
15
16
|
|
|
16
17
|
const isDeno = typeof globalThis.Deno?.version?.deno === "string";
|
|
17
18
|
const isBun = typeof globalThis.Bun?.version === "string";
|
|
@@ -26,7 +27,7 @@ const cwdUrl = pathToFileURL(cwd.endsWith("/") ? cwd : cwd + "/");
|
|
|
26
27
|
// Stand-in referrer for cell code, like Deno's ./$deno$jupyter_repl.mts.
|
|
27
28
|
const replUrl = new URL("$cdpk$repl.mjs", cwdUrl);
|
|
28
29
|
|
|
29
|
-
const { inspect, readFile, writeOut, writeErr, readStdinLine } = await platform();
|
|
30
|
+
const { inspect, inspectPlain, readFile, writeOut, writeErr, readStdinLine } = await platform();
|
|
30
31
|
|
|
31
32
|
function base64(bytes) {
|
|
32
33
|
let binary = "";
|
|
@@ -122,7 +123,7 @@ function bunResolve(specifier) {
|
|
|
122
123
|
if (specifier.startsWith("npm:")) pkg = packages.parsePackageSpecifier(specifier.slice(4));
|
|
123
124
|
else if (specifier.startsWith("jsr:")) pkg = packages.jsrToNpm(specifier.slice(4));
|
|
124
125
|
if (pkg) {
|
|
125
|
-
const { dir, alias } = packages.ensureInstalled(pkg, "bun");
|
|
126
|
+
const { dir, alias } = packages.ensureInstalled(pkg, "bun", { lockFile: packages.findLockFile(cwd) });
|
|
126
127
|
return Bun.resolveSync(alias + pkg.subpath, dir);
|
|
127
128
|
}
|
|
128
129
|
// Relative, absolute, or a URL scheme such as node:, bun: or file:.
|
|
@@ -148,7 +149,19 @@ globalThis.__cdpk = {
|
|
|
148
149
|
resolve: (specifier) => new URL(specifier, replUrl).href,
|
|
149
150
|
},
|
|
150
151
|
barrier,
|
|
152
|
+
// inspect_request (#10). CDP targets find the value with
|
|
153
|
+
// throwOnSideEffect and describe it here; Bun resolves the path here
|
|
154
|
+
// too, by descriptor, so nothing runs.
|
|
155
|
+
describeForInspect: (value, detail) => describeValue(value, { inspect: inspectPlain, detail }),
|
|
156
|
+
inspectPath: (path, detail) => inspectPath(path, { inspect: inspectPlain, detail }),
|
|
157
|
+
resolvePath,
|
|
151
158
|
broadcastResult: jupyter.broadcastResult,
|
|
159
|
+
// The debugger's rich variable viewer (#18), synchronously: it runs while
|
|
160
|
+
// the target is paused. A value whose $display is async shows as text.
|
|
161
|
+
richBundle(value) {
|
|
162
|
+
const bundle = jupyter.formatNow(value);
|
|
163
|
+
return JSON.stringify(typeof bundle?.then === "function" ? { "text/plain": inspectPlain(value) } : bundle);
|
|
164
|
+
},
|
|
152
165
|
// Frontend comm messages (#9), from the host's shell channel. Handlers
|
|
153
166
|
// run after one macrotask, like cells, so interrupt can reach them.
|
|
154
167
|
async commDispatch(msgType, content, metadata, buffers64) {
|
|
@@ -222,6 +235,7 @@ async function platform() {
|
|
|
222
235
|
const stdinBuf = new Uint8Array(1);
|
|
223
236
|
return {
|
|
224
237
|
inspect: (v) => Deno.inspect(v, { colors: true, depth: 4 }),
|
|
238
|
+
inspectPlain: (v) => Deno.inspect(v, { colors: false, depth: 2 }),
|
|
225
239
|
readFile: (p) => Deno.readFileSync(p),
|
|
226
240
|
writeOut: (s) => Deno.stdout.writeSync(ENC.encode(s)),
|
|
227
241
|
writeErr: (s) => Deno.stderr.writeSync(ENC.encode(s)),
|
|
@@ -233,6 +247,7 @@ async function platform() {
|
|
|
233
247
|
const buf = Buffer.alloc(1);
|
|
234
248
|
return {
|
|
235
249
|
inspect: (v) => util.inspect(v, { colors: true, depth: 4 }),
|
|
250
|
+
inspectPlain: (v) => util.inspect(v, { colors: false, depth: 2 }),
|
|
236
251
|
readFile: (p) => fs.readFileSync(p),
|
|
237
252
|
writeOut: (s) => process.stdout.write(s),
|
|
238
253
|
writeErr: (s) => process.stderr.write(s),
|
package/runtime/deno-compat.mjs
CHANGED
|
@@ -161,7 +161,9 @@ export function createDenoShim({ jupyter }) {
|
|
|
161
161
|
rename: async((from, to) => fsp.rename(toPath(from), toPath(to))),
|
|
162
162
|
renameSync: sync((from, to) => fs.renameSync(toPath(from), toPath(to))),
|
|
163
163
|
realPath: async((p) => fsp.realpath(toPath(p))),
|
|
164
|
-
|
|
164
|
+
// The native call, like fsp.realpath above: the JavaScript one leaves
|
|
165
|
+
// Windows 8.3 names (RUNNER~1) unexpanded, so the two disagreed (#32).
|
|
166
|
+
realPathSync: sync((p) => fs.realpathSync.native(toPath(p))),
|
|
165
167
|
makeTempDir: async((options) =>
|
|
166
168
|
fsp.mkdtemp(path.join(options?.dir ?? os.tmpdir(), options?.prefix ?? "deno-"))
|
|
167
169
|
),
|
package/runtime/display.d.mts
CHANGED
|
@@ -47,6 +47,8 @@ export interface JupyterNamespace {
|
|
|
47
47
|
export interface Jupyter {
|
|
48
48
|
namespace: JupyterNamespace;
|
|
49
49
|
broadcastResult(executionCount: number, result: unknown): Promise<void>;
|
|
50
|
+
/** format without awaiting; a $display method's promise is returned as is. */
|
|
51
|
+
formatNow(obj: unknown): unknown;
|
|
50
52
|
confirm(message?: string): boolean;
|
|
51
53
|
prompt(message?: string, defaultValue?: string, options?: { password?: boolean }): string | null;
|
|
52
54
|
/** Routes a frontend comm message into the kernel's comms. */
|
package/runtime/display.mjs
CHANGED
|
@@ -146,7 +146,14 @@ function isMediaBundle(obj) {
|
|
|
146
146
|
*/
|
|
147
147
|
export function createJupyter(host) {
|
|
148
148
|
async function format(obj) {
|
|
149
|
-
|
|
149
|
+
return await formatNow(obj);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// format without awaiting: the debugger's variable viewer formats values
|
|
153
|
+
// while the target is paused, when no promise settles (#18). A $display
|
|
154
|
+
// method's promise is the caller's to handle.
|
|
155
|
+
function formatNow(obj) {
|
|
156
|
+
if (hasDisplaySymbol(obj)) return obj[$display]();
|
|
150
157
|
if (typeof obj !== "object" || obj === null) return { "text/plain": host.inspect(obj) };
|
|
151
158
|
if (isCanvasLike(obj)) {
|
|
152
159
|
const [prefix, data] = obj.toDataURL().split(",");
|
|
@@ -240,6 +247,7 @@ export function createJupyter(host) {
|
|
|
240
247
|
widget: createWidgetFactory(comms.api, $display),
|
|
241
248
|
},
|
|
242
249
|
broadcastResult,
|
|
250
|
+
formatNow,
|
|
243
251
|
confirm,
|
|
244
252
|
prompt,
|
|
245
253
|
commDispatch: comms.dispatch,
|
package/runtime/evaluator.d.mts
CHANGED
|
@@ -4,6 +4,10 @@ export interface EvaluatorShim {
|
|
|
4
4
|
describeError(value: unknown): string;
|
|
5
5
|
commDispatch(msgType: string, content: object, metadata: object, buffers64: string[]): Promise<unknown>;
|
|
6
6
|
barrier(): number;
|
|
7
|
+
/** inspect_request's text for a dotted path, or null (#10). */
|
|
8
|
+
inspectPath(path: string, detail: number): string | null;
|
|
9
|
+
/** Resolves a dotted path without running getters; see introspect.mjs. */
|
|
10
|
+
resolvePath?(path: string): import("./introspect.mjs").Resolved;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
/** A request from the host: an `op` plus its parameters. */
|
package/runtime/evaluator.mjs
CHANGED
|
@@ -66,6 +66,12 @@ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
|
|
|
66
66
|
return { error: JSON.parse(shim.describeError(err)) };
|
|
67
67
|
}
|
|
68
68
|
case "props": {
|
|
69
|
+
// Completion's expression is a dotted path; resolved by descriptor
|
|
70
|
+
// where the shim can, so completing `obj.` never runs a getter (#10).
|
|
71
|
+
if (typeof shim.resolvePath === "function") {
|
|
72
|
+
const resolved = shim.resolvePath(request.expr);
|
|
73
|
+
return { names: resolved.found && !resolved.accessor ? propertyNames(resolved.value) : [] };
|
|
74
|
+
}
|
|
69
75
|
let value;
|
|
70
76
|
try {
|
|
71
77
|
value = evaluate(`(${request.expr})`);
|
|
@@ -74,6 +80,8 @@ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
|
|
|
74
80
|
}
|
|
75
81
|
return { names: propertyNames(value) };
|
|
76
82
|
}
|
|
83
|
+
case "inspect":
|
|
84
|
+
return { text: shim.inspectPath(request.path, request.detail ?? 0) };
|
|
77
85
|
case "barrier":
|
|
78
86
|
return { value: shim.barrier() };
|
|
79
87
|
default:
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type Resolved =
|
|
2
|
+
| { found: false }
|
|
3
|
+
| { found: true; accessor: true; name: string }
|
|
4
|
+
| { found: true; value: unknown; accessor?: undefined };
|
|
5
|
+
|
|
6
|
+
export function resolvePath(path: string, root?: object): Resolved;
|
|
7
|
+
// deno-lint-ignore ban-types -- classes and functions alike
|
|
8
|
+
export function signatureOf(fn: Function): string;
|
|
9
|
+
export function describeValue(value: unknown, options: { inspect: (value: unknown) => string; detail?: number }): string;
|
|
10
|
+
export function inspectPath(
|
|
11
|
+
path: string,
|
|
12
|
+
options: { inspect: (value: unknown) => string; detail?: number; root?: object },
|
|
13
|
+
): string | null;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// What inspect_request (#10) shows for a value: the text of a JupyterLab
|
|
2
|
+
// tooltip (Shift-Tab) or inspector panel. Shared by every target; the host
|
|
3
|
+
// decides how the value is found (src/target.ts: CDP's throwOnSideEffect;
|
|
4
|
+
// in-process targets: resolvePath, below).
|
|
5
|
+
//
|
|
6
|
+
// No imports: this file must load in every target. `inspect` (util.inspect
|
|
7
|
+
// or Deno.inspect) is passed in.
|
|
8
|
+
|
|
9
|
+
const PREVIEW_CHARS = 1500;
|
|
10
|
+
const SOURCE_CHARS = 4000;
|
|
11
|
+
const MAX_PROPERTIES = 40;
|
|
12
|
+
|
|
13
|
+
const IDENTIFIER_PATH = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Looks up a dotted path such as "a.b.c" from globalThis, by property
|
|
17
|
+
* descriptor, so nothing runs: a getter along the way is reported rather
|
|
18
|
+
* than called. In-process targets (Bun, workerd, celld) have no way to
|
|
19
|
+
* evaluate without side effects, so inspection and completion use this.
|
|
20
|
+
*/
|
|
21
|
+
export function resolvePath(path, root = globalThis) {
|
|
22
|
+
if (!IDENTIFIER_PATH.test(path)) return { found: false };
|
|
23
|
+
let value = root;
|
|
24
|
+
for (const name of path.split(".")) {
|
|
25
|
+
if (value === null || value === undefined) return { found: false };
|
|
26
|
+
let descriptor;
|
|
27
|
+
for (let o = Object(value); o !== null && descriptor === undefined; o = Object.getPrototypeOf(o)) {
|
|
28
|
+
descriptor = Object.getOwnPropertyDescriptor(o, name);
|
|
29
|
+
}
|
|
30
|
+
if (descriptor === undefined) return { found: false };
|
|
31
|
+
if (!("value" in descriptor)) return { found: true, accessor: true, name };
|
|
32
|
+
value = descriptor.value;
|
|
33
|
+
}
|
|
34
|
+
return { found: true, value };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function truncate(text, limit) {
|
|
38
|
+
return text.length > limit ? `${text.slice(0, limit)}… (${text.length - limit} more characters)` : text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** "function add(a, b) {...}" -> "add(a, b)"; "class K {...}" -> "class K". */
|
|
42
|
+
export function signatureOf(fn) {
|
|
43
|
+
let source;
|
|
44
|
+
try {
|
|
45
|
+
source = Function.prototype.toString.call(fn);
|
|
46
|
+
} catch {
|
|
47
|
+
return `${fn.name || "anonymous"}(…)`;
|
|
48
|
+
}
|
|
49
|
+
if (/^class\b/.test(source)) return `class ${fn.name || "(anonymous)"}`;
|
|
50
|
+
const params = /^[^(]*\(([^)]*)\)/.exec(source)?.[1];
|
|
51
|
+
const native = /\{\s*\[native code\]\s*\}$/.test(source);
|
|
52
|
+
// One line, however the parameters were spread over the source.
|
|
53
|
+
const written = (params ?? "").replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim();
|
|
54
|
+
const args = native ? Array.from({ length: fn.length }, (_, i) => `arg${i}`).join(", ") : written;
|
|
55
|
+
const arrow = !/^(?:async\s+)?function\b/.test(source) && /=>/.test(source.split("{")[0] ?? "");
|
|
56
|
+
const name = fn.name || (arrow ? "(arrow function)" : "anonymous");
|
|
57
|
+
const prefix = /^async\b/.test(source) ? "async " : "";
|
|
58
|
+
return `${prefix}${name}(${args})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function typeOf(value) {
|
|
62
|
+
if (value === null) return "null";
|
|
63
|
+
if (typeof value !== "object" && typeof value !== "function") return typeof value;
|
|
64
|
+
const name = Object.getPrototypeOf(value)?.constructor?.name;
|
|
65
|
+
if (typeof value === "function") return /^class\b/.test(safeSource(value)) ? "class" : "function";
|
|
66
|
+
return name && name !== "Object" ? `object (${name})` : "object";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safeSource(fn) {
|
|
70
|
+
try {
|
|
71
|
+
return Function.prototype.toString.call(fn);
|
|
72
|
+
} catch {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function propertyNames(value) {
|
|
78
|
+
if ((typeof value !== "object" && typeof value !== "function") || value === null) return [];
|
|
79
|
+
try {
|
|
80
|
+
// A function's own internals aren't worth listing; older V8 (Node 24)
|
|
81
|
+
// also gives sloppy-mode functions own `arguments` and `caller`.
|
|
82
|
+
const internal = ["length", "name", "prototype", "arguments", "caller"];
|
|
83
|
+
return Object.getOwnPropertyNames(value).filter((n) => !(typeof value === "function" && internal.includes(n)));
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The tooltip text for a value. `detail` 1 (Shift-Tab twice, or the
|
|
91
|
+
* inspector panel) adds a function's source.
|
|
92
|
+
*/
|
|
93
|
+
export function describeValue(value, { inspect, detail = 0 }) {
|
|
94
|
+
const lines = [`Type: ${typeOf(value)}`];
|
|
95
|
+
if (typeof value === "function") lines.push(`Signature: ${signatureOf(value)}`);
|
|
96
|
+
let preview;
|
|
97
|
+
try {
|
|
98
|
+
preview = inspect(value);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
preview = `(inspect failed: ${err instanceof Error ? err.message : String(err)})`;
|
|
101
|
+
}
|
|
102
|
+
lines.push(`Value: ${truncate(preview, PREVIEW_CHARS)}`);
|
|
103
|
+
const names = propertyNames(value);
|
|
104
|
+
if (names.length > 0) {
|
|
105
|
+
const shown = names.slice(0, MAX_PROPERTIES).join(", ");
|
|
106
|
+
lines.push(`Properties: ${shown}${names.length > MAX_PROPERTIES ? `, … (${names.length - MAX_PROPERTIES} more)` : ""}`);
|
|
107
|
+
}
|
|
108
|
+
if (detail > 0 && typeof value === "function") lines.push(`Source:\n${truncate(safeSource(value), SOURCE_CHARS)}`);
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** inspect_request for in-process targets: resolvePath, then describeValue. */
|
|
113
|
+
export function inspectPath(path, { inspect, detail = 0, root = globalThis }) {
|
|
114
|
+
const resolved = resolvePath(path, root);
|
|
115
|
+
if (!resolved.found) return null;
|
|
116
|
+
if (resolved.accessor) {
|
|
117
|
+
return `Type: accessor property\n${resolved.name} has a getter, which isn't called for inspection: it could have side effects.`;
|
|
118
|
+
}
|
|
119
|
+
return describeValue(resolved.value, { inspect, detail });
|
|
120
|
+
}
|
package/runtime/node-hooks.d.mts
CHANGED
|
@@ -13,9 +13,27 @@ export function installCommand(
|
|
|
13
13
|
alias: string,
|
|
14
14
|
name: string,
|
|
15
15
|
range: string,
|
|
16
|
-
): { cmd: string; args: string[] };
|
|
16
|
+
): { cmd: string; args: string[]; env?: { [name: string]: string } };
|
|
17
|
+
/** Runs an install; `env` is added to the process environment. */
|
|
18
|
+
export type InstallRunner = (
|
|
19
|
+
cmd: string,
|
|
20
|
+
args: string[],
|
|
21
|
+
cwd: string,
|
|
22
|
+
env?: { [name: string]: string },
|
|
23
|
+
) => { status: number | null; stderr?: string; error?: Error };
|
|
17
24
|
export function ensureInstalled(
|
|
18
25
|
pkg: { name: string; range: string },
|
|
19
26
|
installer?: Installer,
|
|
27
|
+
options?: { lockFile?: string | null; run?: InstallRunner },
|
|
20
28
|
): { dir: string; alias: string };
|
|
29
|
+
|
|
30
|
+
export const LOCK_FILE: string;
|
|
31
|
+
export interface Lock {
|
|
32
|
+
version: 1;
|
|
33
|
+
packages: { [nameAtRange: string]: string };
|
|
34
|
+
}
|
|
35
|
+
export function findLockFile(dir: string): string | null;
|
|
36
|
+
export function readLock(path: string): Lock;
|
|
37
|
+
export function writeLock(path: string, lock: Lock): void;
|
|
38
|
+
export function withInstallLock<T>(dir: string, fn: () => T, options?: { timeoutMs?: number }): T;
|
|
21
39
|
export function installNodeResolveHooks(urls: { shimUrl: string; replUrl: string }): void;
|
package/runtime/node-hooks.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-self-types="./node-hooks.d.mts"
|
|
1
2
|
// Node-only module resolution for cell code: the part of Deno's module
|
|
2
3
|
// loader a notebook leans on most.
|
|
3
4
|
// - Imports issued by cell code arrive with the bootstrap module as their
|
|
@@ -16,10 +17,10 @@
|
|
|
16
17
|
import module from "node:module";
|
|
17
18
|
import { spawnSync } from "node:child_process";
|
|
18
19
|
import { createHash } from "node:crypto";
|
|
19
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
20
21
|
import { homedir } from "node:os";
|
|
21
|
-
import { join } from "node:path";
|
|
22
|
-
import { pathToFileURL } from "node:url";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
24
|
|
|
24
25
|
const SPEC_RE = /^((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(\/.*)?$/;
|
|
25
26
|
|
|
@@ -58,25 +59,151 @@ function cacheDir(installer) {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/** The command that installs name@range under `alias` with `installer`. */
|
|
62
|
+
// - Saved exactly (#40): npm records `npm:react@19.0.0` as `^19.0.0` by
|
|
63
|
+
// default, and every later install into the shared cache re-resolves the
|
|
64
|
+
// ranges, so pinned packages drifted (react 19.3.1 beside react-dom
|
|
65
|
+
// 19.0.0). With an exact save, a version stays put and a range is frozen
|
|
66
|
+
// at what it first resolved to. Bun already saves exactly; the flag says so.
|
|
67
|
+
// - JSR packages come as tarballs from npm.jsr.io, which npm 12 refuses as
|
|
68
|
+
// "remote" unless allowed (#39). `root` is the narrowest value that works
|
|
69
|
+
// (`true`, which looks right, is invalid and warns), and only the JSR
|
|
70
|
+
// install gets it.
|
|
61
71
|
export function installCommand(installer, alias, name, range) {
|
|
62
72
|
const spec = `${alias}@npm:${name}@${range}`;
|
|
63
73
|
// The Bun target runs this inside Bun, so it uses its own binary.
|
|
64
|
-
if (installer === "bun") return { cmd: process.execPath, args: ["add", "--silent", spec] };
|
|
65
|
-
|
|
74
|
+
if (installer === "bun") return { cmd: process.execPath, args: ["add", "--silent", "--exact", spec] };
|
|
75
|
+
const args = ["install", "--no-audit", "--no-fund", "--loglevel=error", "--save-exact", spec];
|
|
76
|
+
return name.startsWith("@jsr/") ? { cmd: "npm", args, env: { npm_config_allow_remote: "root" } } : { cmd: "npm", args };
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
// --- Lockfile (#14) -----------------------------------------------------------
|
|
80
|
+
// A cdp-kernel.lock.json at or above the notebook pins each npm:/jsr:
|
|
81
|
+
// request to the exact version it first resolved to, so the same notebook
|
|
82
|
+
// gets the same packages on another machine. Opt-in: create the file (with
|
|
83
|
+
// `{}` in it) to start locking; the kernel then fills it in.
|
|
84
|
+
|
|
85
|
+
export const LOCK_FILE = "cdp-kernel.lock.json";
|
|
86
|
+
|
|
87
|
+
/** The nearest cdp-kernel.lock.json at or above `dir`, or null. */
|
|
88
|
+
export function findLockFile(dir) {
|
|
89
|
+
for (let at = dir;; at = dirname(at)) {
|
|
90
|
+
if (existsSync(join(at, LOCK_FILE))) return join(at, LOCK_FILE);
|
|
91
|
+
if (dirname(at) === at) return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `{ version: 1, packages: { "name@range": "exact version" } }`; `{}` counts as empty. */
|
|
96
|
+
export function readLock(path) {
|
|
97
|
+
const parsed = JSON.parse(readFileSync(path, "utf8") || "{}");
|
|
98
|
+
const packages = parsed && typeof parsed.packages === "object" && parsed.packages !== null ? parsed.packages : {};
|
|
99
|
+
return { version: 1, packages };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Sorted, so a lockfile's diff shows only what changed. */
|
|
103
|
+
export function writeLock(path, lock) {
|
|
104
|
+
const packages = Object.fromEntries(Object.entries(lock.packages).sort(([a], [b]) => a.localeCompare(b)));
|
|
105
|
+
writeFileSync(path, JSON.stringify({ version: 1, packages }, null, 2) + "\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Serialized installs (#14) -------------------------------------------------
|
|
109
|
+
// Kernels share one cache per installer, and npm/bun both rewrite its
|
|
110
|
+
// package.json and node_modules, so installs take a lock: a directory, which
|
|
111
|
+
// mkdir creates atomically. A lock left by a process that died is taken over.
|
|
112
|
+
|
|
113
|
+
const LOCK_DIR = ".install-lock";
|
|
114
|
+
const STALE_MS = 10 * 60 * 1000;
|
|
115
|
+
|
|
116
|
+
function sleep(ms) {
|
|
117
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function holderAlive(lockDir) {
|
|
121
|
+
try {
|
|
122
|
+
const pid = Number(readFileSync(join(lockDir, "pid"), "utf8"));
|
|
123
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
124
|
+
process.kill(pid, 0);
|
|
125
|
+
return true;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// EPERM: alive, but someone else's.
|
|
128
|
+
return err?.code === "EPERM";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Runs `fn` holding the cache's install lock. Synchronous, like the resolve hook. */
|
|
133
|
+
export function withInstallLock(dir, fn, { timeoutMs = STALE_MS } = {}) {
|
|
134
|
+
const lockDir = join(dir, LOCK_DIR);
|
|
135
|
+
const deadline = Date.now() + timeoutMs;
|
|
136
|
+
for (;;) {
|
|
137
|
+
try {
|
|
138
|
+
mkdirSync(lockDir);
|
|
139
|
+
writeFileSync(join(lockDir, "pid"), String(process.pid));
|
|
140
|
+
break;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err?.code !== "EEXIST") throw err;
|
|
143
|
+
let age = 0;
|
|
144
|
+
try {
|
|
145
|
+
age = Date.now() - statSync(lockDir).mtimeMs;
|
|
146
|
+
} catch {
|
|
147
|
+
continue; // released meanwhile
|
|
148
|
+
}
|
|
149
|
+
// A holder that died, or one stuck for longer than any install takes.
|
|
150
|
+
if ((age > 1000 && !holderAlive(lockDir)) || age > STALE_MS) {
|
|
151
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for the install lock ${lockDir}`);
|
|
155
|
+
sleep(100);
|
|
77
156
|
}
|
|
78
157
|
}
|
|
79
|
-
|
|
158
|
+
try {
|
|
159
|
+
return fn();
|
|
160
|
+
} finally {
|
|
161
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function installedVersion(dir, alias) {
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(readFileSync(join(dir, "node_modules", alias, "package.json"), "utf8")).version ?? null;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function spawnInstall(cmd, args, cwd, env = {}) {
|
|
174
|
+
return spawnSync(cmd, args, { cwd, encoding: "utf8", env: { ...process.env, ...env } });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Installs a package into the installer's cache unless it's there already.
|
|
179
|
+
* With a lockfile, a locked request installs its exact version, and an
|
|
180
|
+
* unlocked one is recorded once installed. `run` is the install command
|
|
181
|
+
* runner, replaceable in tests.
|
|
182
|
+
*/
|
|
183
|
+
export function ensureInstalled({ name, range }, installer = "npm", { lockFile = null, run = spawnInstall } = {}) {
|
|
184
|
+
const dir = cacheDir(installer);
|
|
185
|
+
const key = `${name}@${range}`;
|
|
186
|
+
return withInstallLock(dir, () => {
|
|
187
|
+
const lock = lockFile ? readLock(lockFile) : null;
|
|
188
|
+
const locked = lock?.packages[key];
|
|
189
|
+
const version = typeof locked === "string" ? locked : range;
|
|
190
|
+
const alias = aliasFor(name, version);
|
|
191
|
+
if (installedVersion(dir, alias) === null) {
|
|
192
|
+
const { cmd, args, env } = installCommand(installer, alias, name, version);
|
|
193
|
+
const result = run(cmd, args, dir, env);
|
|
194
|
+
if (result.status !== 0) {
|
|
195
|
+
throw new Error(`${installer} install ${name}@${version} failed:\n${result.stderr || result.error}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (lock && typeof locked !== "string") {
|
|
199
|
+
const resolved = installedVersion(dir, alias);
|
|
200
|
+
if (resolved !== null) {
|
|
201
|
+
lock.packages[key] = resolved;
|
|
202
|
+
writeLock(lockFile, lock);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { dir, alias };
|
|
206
|
+
});
|
|
80
207
|
}
|
|
81
208
|
|
|
82
209
|
export function installNodeResolveHooks({ shimUrl, replUrl }) {
|
|
@@ -86,7 +213,8 @@ export function installNodeResolveHooks({ shimUrl, replUrl }) {
|
|
|
86
213
|
if (specifier.startsWith("npm:")) pkg = parsePackageSpecifier(specifier.slice(4));
|
|
87
214
|
else if (specifier.startsWith("jsr:")) pkg = jsrToNpm(specifier.slice(4));
|
|
88
215
|
if (pkg) {
|
|
89
|
-
const
|
|
216
|
+
const lockFile = findLockFile(fileURLToPath(new URL(".", replUrl)));
|
|
217
|
+
const { dir, alias } = ensureInstalled(pkg, "npm", { lockFile });
|
|
90
218
|
const parentURL = pathToFileURL(join(dir, "package.json")).href;
|
|
91
219
|
return nextResolve(alias + pkg.subpath, { ...context, parentURL });
|
|
92
220
|
}
|