@tlockney/cdp-kernel 0.3.0 → 0.5.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 +60 -404
- package/THIRD-PARTY-NOTICES +62 -0
- package/dist/bun-target.d.ts +4 -2
- package/dist/bun-target.js +35 -8
- package/dist/cdp.d.ts +6 -1
- package/dist/cdp.js +25 -10
- package/dist/celld-target.d.ts +1 -1
- package/dist/celld-target.js +46 -8
- package/dist/children.d.ts +7 -0
- package/dist/children.js +32 -0
- package/dist/debugger.d.ts +42 -0
- package/dist/debugger.js +545 -0
- package/dist/globals.d.ts +3 -3
- package/dist/globals.js +3 -3
- package/dist/history.d.ts +18 -0
- package/dist/history.js +45 -0
- package/dist/kernel.js +211 -32
- package/dist/main.js +3 -1
- package/dist/mod.d.ts +5 -3
- package/dist/mod.js +5 -3
- package/dist/murmur2.d.ts +2 -0
- package/dist/murmur2.js +41 -0
- package/dist/package.d.ts +16 -2
- package/dist/package.js +26 -3
- package/dist/sourcemap.d.ts +22 -0
- package/dist/sourcemap.js +86 -0
- package/dist/target.d.ts +39 -3
- package/dist/target.js +281 -30
- package/dist/text.d.ts +11 -1
- package/dist/text.js +72 -2
- package/dist/transform.d.ts +19 -0
- package/dist/transform.js +74 -13
- package/dist/wire.d.ts +8 -1
- package/dist/wire.js +3 -1
- package/dist/workerd-target.d.ts +9 -1
- package/dist/workerd-target.js +108 -9
- package/dist/ws-client.d.ts +5 -1
- package/dist/ws-client.js +46 -13
- package/dist/zmtp.d.ts +7 -1
- package/dist/zmtp.js +30 -1
- package/package.json +10 -4
- package/runtime/bootstrap.mjs +138 -12
- package/runtime/bun-channel.d.mts +3 -0
- package/runtime/bun-channel.mjs +2 -2
- package/runtime/comm.mjs +11 -1
- package/runtime/deno-compat.d.mts +2 -0
- package/runtime/deno-compat.mjs +17 -2
- package/runtime/display.d.mts +2 -0
- package/runtime/display.mjs +17 -2
- 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 +61 -1
- package/runtime/node-hooks.mjs +354 -21
- package/runtime/workerd-worker.mjs +82 -6
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
|
@@ -8,14 +8,74 @@ export function parsePackageSpecifier(spec: string): PackageSpecifier;
|
|
|
8
8
|
export function jsrToNpm(spec: string): PackageSpecifier;
|
|
9
9
|
export function aliasFor(name: string, range: string): string;
|
|
10
10
|
export type Installer = "npm" | "bun";
|
|
11
|
+
/** Where `installer` keeps its packages: $XDG_CACHE_HOME/cdp-kernel/<installer>, or CDPK_CACHE_DIR for npm. */
|
|
12
|
+
export function cacheDirPath(
|
|
13
|
+
installer: Installer,
|
|
14
|
+
env?: { [name: string]: string | undefined },
|
|
15
|
+
home?: string,
|
|
16
|
+
): string;
|
|
11
17
|
export function installCommand(
|
|
12
18
|
installer: Installer,
|
|
13
19
|
alias: string,
|
|
14
20
|
name: string,
|
|
15
21
|
range: string,
|
|
16
|
-
): { cmd: string; args: string[] };
|
|
22
|
+
): { cmd: string; args: string[]; env?: { [name: string]: string } };
|
|
23
|
+
/** How to spawn an install command; on Windows, npm runs through cmd.exe. */
|
|
24
|
+
export function spawnCommand(
|
|
25
|
+
cmd: string,
|
|
26
|
+
args: string[],
|
|
27
|
+
platform?: string,
|
|
28
|
+
): { cmd: string; args: string[]; shell: boolean };
|
|
29
|
+
/** Runs an install; `env` is added to the process environment. */
|
|
30
|
+
export type InstallRunner = (
|
|
31
|
+
cmd: string,
|
|
32
|
+
args: string[],
|
|
33
|
+
cwd: string,
|
|
34
|
+
env?: { [name: string]: string },
|
|
35
|
+
) => { status: number | null; stderr?: string; error?: Error };
|
|
17
36
|
export function ensureInstalled(
|
|
18
37
|
pkg: { name: string; range: string },
|
|
19
38
|
installer?: Installer,
|
|
39
|
+
options?: { lockFile?: string | null; run?: InstallRunner },
|
|
20
40
|
): { dir: string; alias: string };
|
|
41
|
+
/** ensureInstalled without blocking the event loop; `run` may return a promise. */
|
|
42
|
+
export function ensureInstalledAsync(
|
|
43
|
+
pkg: { name: string; range: string },
|
|
44
|
+
installer?: Installer,
|
|
45
|
+
options?: {
|
|
46
|
+
lockFile?: string | null;
|
|
47
|
+
run?: (...args: Parameters<InstallRunner>) => ReturnType<InstallRunner> | Promise<ReturnType<InstallRunner>>;
|
|
48
|
+
},
|
|
49
|
+
): Promise<{ dir: string; alias: string }>;
|
|
50
|
+
|
|
51
|
+
export const LOCK_FILE: string;
|
|
52
|
+
export interface Lock {
|
|
53
|
+
version: 1;
|
|
54
|
+
packages: { [nameAtRange: string]: string };
|
|
55
|
+
}
|
|
56
|
+
export function findLockFile(dir: string): string | null;
|
|
57
|
+
export function readLock(path: string): Lock;
|
|
58
|
+
export function writeLock(path: string, lock: Lock): void;
|
|
59
|
+
export function withInstallLock<T>(dir: string, fn: () => T, options?: { timeoutMs?: number }): T;
|
|
60
|
+
export function withInstallLockAsync<T>(dir: string, fn: () => T | Promise<T>, options?: { timeoutMs?: number }): Promise<T>;
|
|
61
|
+
/** Compiles one TypeScript or JSX file with the esbuild binary at `binary`; `run` is replaceable in tests. */
|
|
62
|
+
export function compileTypeScript(
|
|
63
|
+
source: string,
|
|
64
|
+
filename: string,
|
|
65
|
+
binary: string,
|
|
66
|
+
run?: (cmd: string, args: string[], options: object) => { status: number | null; stdout: string; stderr: string; error?: Error },
|
|
67
|
+
loader?: "ts" | "tsx" | "jsx",
|
|
68
|
+
): string;
|
|
69
|
+
export interface LoadResult {
|
|
70
|
+
format?: string | null;
|
|
71
|
+
source?: string | ArrayBuffer | ArrayBufferView | null;
|
|
72
|
+
shortCircuit?: boolean;
|
|
73
|
+
}
|
|
74
|
+
/** The load hook: Node's own result unless its type stripping would reject the file; .tsx/.jsx always compiled. */
|
|
75
|
+
export function loadTypeScript(
|
|
76
|
+
url: string,
|
|
77
|
+
context: object,
|
|
78
|
+
nextLoad: (url: string, context: object) => LoadResult,
|
|
79
|
+
binary: string | undefined,
|
|
80
|
+
): LoadResult;
|
|
21
81
|
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
|
|
@@ -8,18 +9,22 @@
|
|
|
8
9
|
// one package can coexist (Deno keeps a global cache the same way).
|
|
9
10
|
// - jsr:@scope/name[@range][/subpath] maps onto JSR's npm compatibility
|
|
10
11
|
// registry (npm.jsr.io) as @jsr/scope__name.
|
|
12
|
+
// - TypeScript files a cell imports load with Node's own type stripping
|
|
13
|
+
// where it can; syntax that needs code generation (enum, namespaces with
|
|
14
|
+
// values, parameter properties) is compiled by esbuild instead (#73).
|
|
15
|
+
// Node can't load JSX at all, so .tsx/.jsx files are always compiled (#91).
|
|
11
16
|
// Uses module.registerHooks (synchronous, in-thread; Node >= 23.5), so the
|
|
12
17
|
// install can run with spawnSync inside resolve.
|
|
13
18
|
|
|
14
19
|
// Not a named import: Bun, which loads this file for its npm:/jsr: helpers,
|
|
15
20
|
// has no registerHooks, and a missing named export fails at link time.
|
|
16
21
|
import module from "node:module";
|
|
17
|
-
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
18
23
|
import { createHash } from "node:crypto";
|
|
19
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
20
25
|
import { homedir } from "node:os";
|
|
21
|
-
import { join } from "node:path";
|
|
22
|
-
import { pathToFileURL } from "node:url";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
28
|
|
|
24
29
|
const SPEC_RE = /^((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(\/.*)?$/;
|
|
25
30
|
|
|
@@ -43,12 +48,19 @@ export function aliasFor(name, range) {
|
|
|
43
48
|
return `cdpk-${digest}`;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Each installer keeps its own cache, so npm's and Bun's lockfiles never
|
|
53
|
+
* meet. Both sit beside the runtime cache (cacheRoot in src/package.ts);
|
|
54
|
+
* CDPK_CACHE_DIR overrides npm's.
|
|
55
|
+
*/
|
|
56
|
+
export function cacheDirPath(installer, env = process.env, home = homedir()) {
|
|
57
|
+
if (installer === "npm" && env.CDPK_CACHE_DIR) return env.CDPK_CACHE_DIR;
|
|
58
|
+
return join(env.XDG_CACHE_HOME || join(home, ".cache"), "cdp-kernel", installer);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Both caches read the @jsr registry from .npmrc.
|
|
48
62
|
function cacheDir(installer) {
|
|
49
|
-
const dir = installer
|
|
50
|
-
? process.env.CDPK_CACHE_DIR ?? join(homedir(), ".cache", "cdp-kernel", "npm")
|
|
51
|
-
: join(homedir(), ".cache", "cdp-kernel", installer);
|
|
63
|
+
const dir = cacheDirPath(installer);
|
|
52
64
|
if (!existsSync(join(dir, "package.json"))) {
|
|
53
65
|
mkdirSync(dir, { recursive: true });
|
|
54
66
|
writeFileSync(join(dir, "package.json"), '{ "private": true }\n');
|
|
@@ -58,35 +70,356 @@ function cacheDir(installer) {
|
|
|
58
70
|
}
|
|
59
71
|
|
|
60
72
|
/** The command that installs name@range under `alias` with `installer`. */
|
|
73
|
+
// - Saved exactly (#40): npm records `npm:react@19.0.0` as `^19.0.0` by
|
|
74
|
+
// default, and every later install into the shared cache re-resolves the
|
|
75
|
+
// ranges, so pinned packages drifted (react 19.3.1 beside react-dom
|
|
76
|
+
// 19.0.0). With an exact save, a version stays put and a range is frozen
|
|
77
|
+
// at what it first resolved to. Bun already saves exactly; the flag says so.
|
|
78
|
+
// - JSR packages come as tarballs from npm.jsr.io, which npm 12 refuses as
|
|
79
|
+
// "remote" unless allowed (#39). `root` is the narrowest value that works
|
|
80
|
+
// (`true`, which looks right, is invalid and warns), and only the JSR
|
|
81
|
+
// install gets it.
|
|
61
82
|
export function installCommand(installer, alias, name, range) {
|
|
62
83
|
const spec = `${alias}@npm:${name}@${range}`;
|
|
63
84
|
// 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
|
-
|
|
85
|
+
if (installer === "bun") return { cmd: process.execPath, args: ["add", "--silent", "--exact", spec] };
|
|
86
|
+
const args = ["install", "--no-audit", "--no-fund", "--loglevel=error", "--save-exact", spec];
|
|
87
|
+
return name.startsWith("@jsr/") ? { cmd: "npm", args, env: { npm_config_allow_remote: "root" } } : { cmd: "npm", args };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Lockfile (#14) -----------------------------------------------------------
|
|
91
|
+
// A cdp-kernel.lock.json at or above the notebook pins each npm:/jsr:
|
|
92
|
+
// request to the exact version it first resolved to, so the same notebook
|
|
93
|
+
// gets the same packages on another machine. Opt-in: create the file (with
|
|
94
|
+
// `{}` in it) to start locking; the kernel then fills it in.
|
|
95
|
+
|
|
96
|
+
export const LOCK_FILE = "cdp-kernel.lock.json";
|
|
97
|
+
|
|
98
|
+
/** The nearest cdp-kernel.lock.json at or above `dir`, or null. */
|
|
99
|
+
export function findLockFile(dir) {
|
|
100
|
+
for (let at = dir;; at = dirname(at)) {
|
|
101
|
+
if (existsSync(join(at, LOCK_FILE))) return join(at, LOCK_FILE);
|
|
102
|
+
if (dirname(at) === at) return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `{ version: 1, packages: { "name@range": "exact version" } }`; `{}` counts as empty. */
|
|
107
|
+
export function readLock(path) {
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(readFileSync(path, "utf8") || "{}");
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new Error(`cannot read ${path}: ${err instanceof Error ? err.message : err}`);
|
|
113
|
+
}
|
|
114
|
+
const packages = parsed && typeof parsed.packages === "object" && parsed.packages !== null ? parsed.packages : {};
|
|
115
|
+
return { version: 1, packages };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sorted, so a lockfile's diff shows only what changed. Written to a
|
|
120
|
+
* temporary file and renamed into place, so a crash mid-write can't leave
|
|
121
|
+
* a truncated lockfile (#144).
|
|
122
|
+
*/
|
|
123
|
+
export function writeLock(path, lock) {
|
|
124
|
+
const packages = Object.fromEntries(Object.entries(lock.packages).sort(([a], [b]) => a.localeCompare(b)));
|
|
125
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
126
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, packages }, null, 2) + "\n");
|
|
127
|
+
renameSync(tmp, path);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Serialized installs (#14) -------------------------------------------------
|
|
131
|
+
// Kernels share one cache per installer, and npm/bun both rewrite its
|
|
132
|
+
// package.json and node_modules, so installs take a lock: a directory, which
|
|
133
|
+
// mkdir creates atomically. A lock left by a process that died is taken over;
|
|
134
|
+
// one whose holder is alive is waited on.
|
|
135
|
+
|
|
136
|
+
const LOCK_DIR = ".install-lock";
|
|
137
|
+
/** How long a waiter waits for a live holder before giving up. */
|
|
138
|
+
const WAIT_MS = 10 * 60 * 1000;
|
|
139
|
+
/** A lock this old is taken over even if its pid is alive: the pid was reused. */
|
|
140
|
+
const ABANDONED_MS = 60 * 60 * 1000;
|
|
141
|
+
|
|
142
|
+
function sleep(ms) {
|
|
143
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function holderPid(lockDir) {
|
|
147
|
+
try {
|
|
148
|
+
return Number(readFileSync(join(lockDir, "pid"), "utf8"));
|
|
149
|
+
} catch {
|
|
150
|
+
return NaN;
|
|
151
|
+
}
|
|
66
152
|
}
|
|
67
153
|
|
|
68
|
-
|
|
69
|
-
|
|
154
|
+
function holderAlive(lockDir) {
|
|
155
|
+
try {
|
|
156
|
+
const pid = holderPid(lockDir);
|
|
157
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
158
|
+
process.kill(pid, 0);
|
|
159
|
+
return true;
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// EPERM: alive, but someone else's.
|
|
162
|
+
return err?.code === "EPERM";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Takes the install lock at `lockDir` if it's free: false means wait and try again. */
|
|
167
|
+
function tryLock(lockDir, deadline) {
|
|
168
|
+
for (;;) {
|
|
169
|
+
try {
|
|
170
|
+
mkdirSync(lockDir);
|
|
171
|
+
writeFileSync(join(lockDir, "pid"), String(process.pid));
|
|
172
|
+
return true;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err?.code !== "EEXIST") throw err;
|
|
175
|
+
let age = 0;
|
|
176
|
+
try {
|
|
177
|
+
age = Date.now() - statSync(lockDir).mtimeMs;
|
|
178
|
+
} catch {
|
|
179
|
+
continue; // released meanwhile
|
|
180
|
+
}
|
|
181
|
+
// A holder that died. The grace period covers the gap between mkdir
|
|
182
|
+
// and writing the pid. A live holder is waited on rather than stolen
|
|
183
|
+
// from, which would run a second install alongside its own (#144),
|
|
184
|
+
// unless the lock is so old its pid must have been reused.
|
|
185
|
+
if ((age > 1000 && !holderAlive(lockDir)) || age > ABANDONED_MS) {
|
|
186
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for the install lock ${lockDir}`);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Removes the lock only if still ours: a waiter that took it over owns it now. */
|
|
196
|
+
function releaseLock(lockDir) {
|
|
197
|
+
if (holderPid(lockDir) === process.pid) rmSync(lockDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Runs `fn` holding the cache's install lock. Synchronous, like the resolve hook. */
|
|
201
|
+
export function withInstallLock(dir, fn, { timeoutMs = WAIT_MS } = {}) {
|
|
202
|
+
const lockDir = join(dir, LOCK_DIR);
|
|
203
|
+
const deadline = Date.now() + timeoutMs;
|
|
204
|
+
while (!tryLock(lockDir, deadline)) sleep(100);
|
|
205
|
+
try {
|
|
206
|
+
return fn();
|
|
207
|
+
} finally {
|
|
208
|
+
releaseLock(lockDir);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** withInstallLock for a caller that mustn't block its event loop while it waits. */
|
|
213
|
+
export async function withInstallLockAsync(dir, fn, { timeoutMs = WAIT_MS } = {}) {
|
|
214
|
+
const lockDir = join(dir, LOCK_DIR);
|
|
215
|
+
const deadline = Date.now() + timeoutMs;
|
|
216
|
+
while (!tryLock(lockDir, deadline)) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
217
|
+
try {
|
|
218
|
+
return await fn();
|
|
219
|
+
} finally {
|
|
220
|
+
releaseLock(lockDir);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function installedVersion(dir, alias) {
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(readFileSync(join(dir, "node_modules", alias, "package.json"), "utf8")).version ?? null;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* How to spawn an install command on `platform`. On Windows npm is npm.cmd,
|
|
234
|
+
* which Node runs only through a shell (#51). cmd.exe reads ^ < > | & as
|
|
235
|
+
* operators, and semver ranges use them, so each argument is double-quoted,
|
|
236
|
+
* where cmd.exe leaves them alone. A " or % would still be read, so they're
|
|
237
|
+
* refused; no package name or valid range contains either.
|
|
238
|
+
*/
|
|
239
|
+
export function spawnCommand(cmd, args, platform = process.platform) {
|
|
240
|
+
if (platform !== "win32" || cmd !== "npm") return { cmd, args, shell: false };
|
|
241
|
+
const bad = args.find((arg) => /["%\r\n]/.test(arg));
|
|
242
|
+
if (bad !== undefined) throw new TypeError(`can't pass ${JSON.stringify(bad)} to npm through cmd.exe`);
|
|
243
|
+
return { cmd, args: args.map((arg) => `"${arg}"`), shell: true };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function spawnInstall(cmd, args, cwd, env = {}) {
|
|
247
|
+
const spawned = spawnCommand(cmd, args);
|
|
248
|
+
return spawnSync(spawned.cmd, spawned.args, { cwd, encoding: "utf8", env: { ...process.env, ...env }, shell: spawned.shell });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** spawnInstall without blocking: the kernel host keeps answering while it runs (#149). */
|
|
252
|
+
function spawnInstallAsync(cmd, args, cwd, env = {}) {
|
|
253
|
+
const spawned = spawnCommand(cmd, args);
|
|
254
|
+
return new Promise((resolve) => {
|
|
255
|
+
let stderr = "";
|
|
256
|
+
const child = spawn(spawned.cmd, spawned.args, {
|
|
257
|
+
cwd,
|
|
258
|
+
env: { ...process.env, ...env },
|
|
259
|
+
shell: spawned.shell,
|
|
260
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
261
|
+
});
|
|
262
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
|
|
263
|
+
child.on("error", (error) => resolve({ status: null, stderr, error }));
|
|
264
|
+
child.on("close", (status) => resolve({ status, stderr }));
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The version to install for `name@range`: the locked one, if the lockfile has it. */
|
|
269
|
+
function lockedRequest(name, range, lockFile) {
|
|
270
|
+
const lock = lockFile ? readLock(lockFile) : null;
|
|
271
|
+
const locked = lock?.packages[`${name}@${range}`];
|
|
272
|
+
const version = typeof locked === "string" ? locked : range;
|
|
273
|
+
return { lock, locked: typeof locked === "string", version, alias: aliasFor(name, version) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Records an unlocked request's installed version in the lockfile. */
|
|
277
|
+
function recordLock(dir, name, range, lockFile, { lock, locked, alias }) {
|
|
278
|
+
if (!lock || locked) return;
|
|
279
|
+
const resolved = installedVersion(dir, alias);
|
|
280
|
+
if (resolved !== null) {
|
|
281
|
+
lock.packages[`${name}@${range}`] = resolved;
|
|
282
|
+
writeLock(lockFile, lock);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function checkInstall(result, installer, name, version) {
|
|
287
|
+
if (result.status !== 0) throw new Error(`${installer} install ${name}@${version} failed:\n${result.stderr || result.error}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Installs a package into the installer's cache unless it's there already.
|
|
292
|
+
* With a lockfile, a locked request installs its exact version, and an
|
|
293
|
+
* unlocked one is recorded once installed. `run` is the install command
|
|
294
|
+
* runner, replaceable in tests.
|
|
295
|
+
*/
|
|
296
|
+
export function ensureInstalled({ name, range }, installer = "npm", { lockFile = null, run = spawnInstall } = {}) {
|
|
297
|
+
const dir = cacheDir(installer);
|
|
298
|
+
return withInstallLock(dir, () => {
|
|
299
|
+
const request = lockedRequest(name, range, lockFile);
|
|
300
|
+
if (installedVersion(dir, request.alias) === null) {
|
|
301
|
+
const { cmd, args, env } = installCommand(installer, request.alias, name, request.version);
|
|
302
|
+
checkInstall(run(cmd, args, dir, env), installer, name, request.version);
|
|
303
|
+
}
|
|
304
|
+
recordLock(dir, name, range, lockFile, request);
|
|
305
|
+
return { dir, alias: request.alias };
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** ensureInstalled for the kernel host (workerd's bundler), which mustn't block (#149). */
|
|
310
|
+
export function ensureInstalledAsync({ name, range }, installer = "npm", { lockFile = null, run = spawnInstallAsync } = {}) {
|
|
70
311
|
const dir = cacheDir(installer);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
throw new Error(`${installer} install ${name}@${range} failed:\n${result.stderr || result.error}`);
|
|
312
|
+
return withInstallLockAsync(dir, async () => {
|
|
313
|
+
const request = lockedRequest(name, range, lockFile);
|
|
314
|
+
if (installedVersion(dir, request.alias) === null) {
|
|
315
|
+
const { cmd, args, env } = installCommand(installer, request.alias, name, request.version);
|
|
316
|
+
checkInstall(await run(cmd, args, dir, env), installer, name, request.version);
|
|
77
317
|
}
|
|
318
|
+
recordLock(dir, name, range, lockFile, request);
|
|
319
|
+
return { dir, alias: request.alias };
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- Imported TypeScript (#73) --------------------------------------------------
|
|
324
|
+
// Node strips types in place, so line and column numbers stay those of the
|
|
325
|
+
// file, but it rejects TypeScript that needs code generation. Such a file
|
|
326
|
+
// is compiled with esbuild, as cells are, with an inline source map. The
|
|
327
|
+
// host passes its esbuild binary in CDPK_ESBUILD_BINARY: the runtime files
|
|
328
|
+
// may have been copied away from any node_modules (src/package.ts), and the
|
|
329
|
+
// esbuild binary, unlike its JavaScript API, works from anywhere.
|
|
330
|
+
|
|
331
|
+
// Both match a file: URL's path, so a query or fragment (`./x.ts?v=2`, the
|
|
332
|
+
// cache-busting idiom for a module being edited) doesn't hide the extension (#145).
|
|
333
|
+
const TS_FILE = /\.[cm]?ts$/;
|
|
334
|
+
// Node has no format for these extensions, so its loader is never asked.
|
|
335
|
+
const JSX_FILE = /\.([jt]sx)$/;
|
|
336
|
+
const TS_FORMATS = { "module-typescript": "module", "commonjs-typescript": "commonjs" };
|
|
337
|
+
|
|
338
|
+
function isUnsupportedSyntax(err) {
|
|
339
|
+
return err?.code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Node's strip-only transform, without its once-per-process ExperimentalWarning. */
|
|
343
|
+
function stripTypes(source) {
|
|
344
|
+
const emitWarning = process.emitWarning;
|
|
345
|
+
process.emitWarning = function (warning, ...rest) {
|
|
346
|
+
if (String(warning?.message ?? warning).includes("stripTypeScriptTypes")) return;
|
|
347
|
+
return emitWarning.call(this, warning, ...rest);
|
|
348
|
+
};
|
|
349
|
+
try {
|
|
350
|
+
return module.stripTypeScriptTypes(source, { mode: "strip" });
|
|
351
|
+
} finally {
|
|
352
|
+
process.emitWarning = emitWarning;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Compiles one TypeScript (or, with loader "tsx"/"jsx", JSX) file with the
|
|
358
|
+
* esbuild binary at `binary`, as cells are compiled. JSX takes esbuild's
|
|
359
|
+
* default, classic React.createElement, which is what cells use on Node
|
|
360
|
+
* (no project config there); a file's own @jsx/@jsxImportSource pragma wins.
|
|
361
|
+
*/
|
|
362
|
+
export function compileTypeScript(source, filename, binary, run = spawnSync, loader = "ts") {
|
|
363
|
+
const args = [
|
|
364
|
+
`--loader=${loader}`,
|
|
365
|
+
"--target=esnext",
|
|
366
|
+
"--sourcemap=inline",
|
|
367
|
+
`--sourcefile=${filename}`,
|
|
368
|
+
'--tsconfig-raw={"compilerOptions":{"verbatimModuleSyntax":true}}',
|
|
369
|
+
"--log-level=error",
|
|
370
|
+
"--color=false",
|
|
371
|
+
];
|
|
372
|
+
const result = run(binary, args, { input: source, encoding: "utf8", maxBuffer: 1 << 30 });
|
|
373
|
+
if (result.status !== 0) {
|
|
374
|
+
throw new SyntaxError(`${filename}: ${(result.stderr || String(result.error)).trim()}`);
|
|
375
|
+
}
|
|
376
|
+
return result.stdout;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The load hook: Node's result unless it would reject the file's TypeScript. */
|
|
380
|
+
export function loadTypeScript(url, context, nextLoad, binary) {
|
|
381
|
+
const path = url.startsWith("file:") ? new URL(url).pathname : "";
|
|
382
|
+
const jsx = JSX_FILE.exec(path);
|
|
383
|
+
if (binary && jsx) {
|
|
384
|
+
// Notebooks import ES modules, so it's compiled as one.
|
|
385
|
+
const filename = fileURLToPath(url);
|
|
386
|
+
const source = compileTypeScript(readFileSync(filename, "utf8"), filename, binary, spawnSync, jsx[1]);
|
|
387
|
+
return { format: "module", source, shortCircuit: true };
|
|
388
|
+
}
|
|
389
|
+
if (!binary || !TS_FILE.test(path)) return nextLoad(url, context);
|
|
390
|
+
let result;
|
|
391
|
+
try {
|
|
392
|
+
result = nextLoad(url, context);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
// A .ts file outside a package.json "type" is stripped while Node
|
|
395
|
+
// detects its format, so the rejection surfaces here. Notebooks import
|
|
396
|
+
// ES modules, so it's compiled as one.
|
|
397
|
+
if (!isUnsupportedSyntax(err)) throw err;
|
|
398
|
+
const filename = fileURLToPath(url);
|
|
399
|
+
return { format: "module", source: compileTypeScript(readFileSync(filename, "utf8"), filename, binary), shortCircuit: true };
|
|
400
|
+
}
|
|
401
|
+
const format = TS_FORMATS[result.format];
|
|
402
|
+
if (format === undefined || typeof module.stripTypeScriptTypes !== "function") return result;
|
|
403
|
+
const source = typeof result.source === "string" ? result.source : new TextDecoder().decode(result.source);
|
|
404
|
+
try {
|
|
405
|
+
return { ...result, format, source: stripTypes(source) };
|
|
406
|
+
} catch (err) {
|
|
407
|
+
if (!isUnsupportedSyntax(err)) throw err;
|
|
408
|
+
return { ...result, format, source: compileTypeScript(source, fileURLToPath(url), binary) };
|
|
78
409
|
}
|
|
79
|
-
return { dir, alias };
|
|
80
410
|
}
|
|
81
411
|
|
|
82
412
|
export function installNodeResolveHooks({ shimUrl, replUrl }) {
|
|
413
|
+
const esbuild = process.env.CDPK_ESBUILD_BINARY;
|
|
83
414
|
module.registerHooks({
|
|
415
|
+
load: (url, context, nextLoad) => loadTypeScript(url, context, nextLoad, esbuild),
|
|
84
416
|
resolve(specifier, context, nextResolve) {
|
|
85
417
|
let pkg = null;
|
|
86
418
|
if (specifier.startsWith("npm:")) pkg = parsePackageSpecifier(specifier.slice(4));
|
|
87
419
|
else if (specifier.startsWith("jsr:")) pkg = jsrToNpm(specifier.slice(4));
|
|
88
420
|
if (pkg) {
|
|
89
|
-
const
|
|
421
|
+
const lockFile = findLockFile(fileURLToPath(new URL(".", replUrl)));
|
|
422
|
+
const { dir, alias } = ensureInstalled(pkg, "npm", { lockFile });
|
|
90
423
|
const parentURL = pathToFileURL(join(dir, "package.json")).href;
|
|
91
424
|
return nextResolve(alias + pkg.subpath, { ...context, parentURL });
|
|
92
425
|
}
|