@tlockney/cdp-kernel 0.4.0 → 0.6.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 +78 -478
- package/THIRD-PARTY-NOTICES +62 -0
- package/dist/bun-target.d.ts +2 -2
- package/dist/bun-target.js +30 -8
- package/dist/cdp.d.ts +8 -1
- package/dist/cdp.js +31 -11
- package/dist/celld-target.d.ts +1 -1
- package/dist/celld-target.js +54 -19
- package/dist/children.d.ts +29 -0
- package/dist/children.js +114 -0
- package/dist/debugger.d.ts +1 -1
- package/dist/debugger.js +21 -10
- package/dist/deno-types.d.ts +30 -0
- package/dist/deno-types.js +93 -0
- package/dist/globals.d.ts +3 -3
- package/dist/globals.js +3 -3
- package/dist/kernel.d.ts +7 -1
- package/dist/kernel.js +348 -68
- package/dist/language-service-worker.d.ts +1 -0
- package/dist/language-service-worker.js +88 -0
- package/dist/language-service.d.ts +64 -0
- package/dist/language-service.js +128 -0
- package/dist/main.js +102 -8
- package/dist/mod.d.ts +6 -3
- package/dist/mod.js +6 -3
- package/dist/notebook-types.d.ts +62 -0
- package/dist/notebook-types.js +310 -0
- package/dist/package.d.ts +2 -2
- package/dist/package.js +3 -3
- package/dist/parent.d.ts +12 -0
- package/dist/parent.js +39 -0
- package/dist/port-holder.d.ts +13 -0
- package/dist/port-holder.js +105 -0
- package/dist/sourcemap.d.ts +7 -2
- package/dist/sourcemap.js +9 -3
- package/dist/strip-esbuild.d.ts +2 -0
- package/dist/strip-esbuild.js +44 -0
- package/dist/strip-sucrase.d.ts +2 -0
- package/dist/strip-sucrase.js +78 -0
- package/dist/target.d.ts +58 -6
- package/dist/target.js +412 -54
- package/dist/text.d.ts +5 -1
- package/dist/text.js +55 -2
- package/dist/transform.d.ts +30 -1
- package/dist/transform.js +68 -44
- package/dist/wire.d.ts +8 -1
- package/dist/wire.js +3 -1
- package/dist/workerd-target.d.ts +7 -1
- package/dist/workerd-target.js +102 -8
- package/dist/workers-project.js +13 -2
- package/dist/ws-client.d.ts +5 -1
- package/dist/ws-client.js +46 -13
- package/dist/zmtp.d.ts +9 -1
- package/dist/zmtp.js +42 -1
- package/package.json +17 -6
- package/runtime/bootstrap.mjs +233 -42
- 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 +14 -1
- package/runtime/display.mjs +8 -1
- package/runtime/introspect.mjs +1 -1
- package/runtime/node-hooks.d.mts +48 -0
- package/runtime/node-hooks.mjs +257 -41
- package/runtime/workerd-worker.mjs +77 -6
package/runtime/node-hooks.mjs
CHANGED
|
@@ -9,15 +9,19 @@
|
|
|
9
9
|
// one package can coexist (Deno keeps a global cache the same way).
|
|
10
10
|
// - jsr:@scope/name[@range][/subpath] maps onto JSR's npm compatibility
|
|
11
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).
|
|
12
16
|
// Uses module.registerHooks (synchronous, in-thread; Node >= 23.5), so the
|
|
13
17
|
// install can run with spawnSync inside resolve.
|
|
14
18
|
|
|
15
19
|
// Not a named import: Bun, which loads this file for its npm:/jsr: helpers,
|
|
16
20
|
// has no registerHooks, and a missing named export fails at link time.
|
|
17
21
|
import module from "node:module";
|
|
18
|
-
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
19
23
|
import { createHash } from "node:crypto";
|
|
20
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
21
25
|
import { homedir } from "node:os";
|
|
22
26
|
import { dirname, join } from "node:path";
|
|
23
27
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -44,12 +48,19 @@ export function aliasFor(name, range) {
|
|
|
44
48
|
return `cdpk-${digest}`;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
49
62
|
function cacheDir(installer) {
|
|
50
|
-
const dir = installer
|
|
51
|
-
? process.env.CDPK_CACHE_DIR ?? join(homedir(), ".cache", "cdp-kernel", "npm")
|
|
52
|
-
: join(homedir(), ".cache", "cdp-kernel", installer);
|
|
63
|
+
const dir = cacheDirPath(installer);
|
|
53
64
|
if (!existsSync(join(dir, "package.json"))) {
|
|
54
65
|
mkdirSync(dir, { recursive: true });
|
|
55
66
|
writeFileSync(join(dir, "package.json"), '{ "private": true }\n');
|
|
@@ -94,32 +105,55 @@ export function findLockFile(dir) {
|
|
|
94
105
|
|
|
95
106
|
/** `{ version: 1, packages: { "name@range": "exact version" } }`; `{}` counts as empty. */
|
|
96
107
|
export function readLock(path) {
|
|
97
|
-
|
|
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
|
+
}
|
|
98
114
|
const packages = parsed && typeof parsed.packages === "object" && parsed.packages !== null ? parsed.packages : {};
|
|
99
115
|
return { version: 1, packages };
|
|
100
116
|
}
|
|
101
117
|
|
|
102
|
-
/**
|
|
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
|
+
*/
|
|
103
123
|
export function writeLock(path, lock) {
|
|
104
124
|
const packages = Object.fromEntries(Object.entries(lock.packages).sort(([a], [b]) => a.localeCompare(b)));
|
|
105
|
-
|
|
125
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
126
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, packages }, null, 2) + "\n");
|
|
127
|
+
renameSync(tmp, path);
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
// --- Serialized installs (#14) -------------------------------------------------
|
|
109
131
|
// Kernels share one cache per installer, and npm/bun both rewrite its
|
|
110
132
|
// 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
|
|
133
|
+
// mkdir creates atomically. A lock left by a process that died is taken over;
|
|
134
|
+
// one whose holder is alive is waited on.
|
|
112
135
|
|
|
113
136
|
const LOCK_DIR = ".install-lock";
|
|
114
|
-
|
|
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;
|
|
115
141
|
|
|
116
142
|
function sleep(ms) {
|
|
117
143
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
118
144
|
}
|
|
119
145
|
|
|
146
|
+
function holderPid(lockDir) {
|
|
147
|
+
try {
|
|
148
|
+
return Number(readFileSync(join(lockDir, "pid"), "utf8"));
|
|
149
|
+
} catch {
|
|
150
|
+
return NaN;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
120
154
|
function holderAlive(lockDir) {
|
|
121
155
|
try {
|
|
122
|
-
const pid =
|
|
156
|
+
const pid = holderPid(lockDir);
|
|
123
157
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
124
158
|
process.kill(pid, 0);
|
|
125
159
|
return true;
|
|
@@ -129,15 +163,13 @@ function holderAlive(lockDir) {
|
|
|
129
163
|
}
|
|
130
164
|
}
|
|
131
165
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
const lockDir = join(dir, LOCK_DIR);
|
|
135
|
-
const deadline = Date.now() + timeoutMs;
|
|
166
|
+
/** Takes the install lock at `lockDir` if it's free: false means wait and try again. */
|
|
167
|
+
function tryLock(lockDir, deadline) {
|
|
136
168
|
for (;;) {
|
|
137
169
|
try {
|
|
138
170
|
mkdirSync(lockDir);
|
|
139
171
|
writeFileSync(join(lockDir, "pid"), String(process.pid));
|
|
140
|
-
|
|
172
|
+
return true;
|
|
141
173
|
} catch (err) {
|
|
142
174
|
if (err?.code !== "EEXIST") throw err;
|
|
143
175
|
let age = 0;
|
|
@@ -146,19 +178,46 @@ export function withInstallLock(dir, fn, { timeoutMs = STALE_MS } = {}) {
|
|
|
146
178
|
} catch {
|
|
147
179
|
continue; // released meanwhile
|
|
148
180
|
}
|
|
149
|
-
// A holder that died
|
|
150
|
-
|
|
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) {
|
|
151
186
|
rmSync(lockDir, { recursive: true, force: true });
|
|
152
187
|
continue;
|
|
153
188
|
}
|
|
154
189
|
if (Date.now() > deadline) throw new Error(`timed out waiting for the install lock ${lockDir}`);
|
|
155
|
-
|
|
190
|
+
return false;
|
|
156
191
|
}
|
|
157
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);
|
|
158
205
|
try {
|
|
159
206
|
return fn();
|
|
160
207
|
} finally {
|
|
161
|
-
|
|
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);
|
|
162
221
|
}
|
|
163
222
|
}
|
|
164
223
|
|
|
@@ -170,8 +229,62 @@ function installedVersion(dir, alias) {
|
|
|
170
229
|
}
|
|
171
230
|
}
|
|
172
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
|
+
|
|
173
246
|
function spawnInstall(cmd, args, cwd, env = {}) {
|
|
174
|
-
|
|
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}`);
|
|
175
288
|
}
|
|
176
289
|
|
|
177
290
|
/**
|
|
@@ -182,32 +295,135 @@ function spawnInstall(cmd, args, cwd, env = {}) {
|
|
|
182
295
|
*/
|
|
183
296
|
export function ensureInstalled({ name, range }, installer = "npm", { lockFile = null, run = spawnInstall } = {}) {
|
|
184
297
|
const dir = cacheDir(installer);
|
|
185
|
-
const key = `${name}@${range}`;
|
|
186
298
|
return withInstallLock(dir, () => {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
}
|
|
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);
|
|
197
303
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 } = {}) {
|
|
311
|
+
const dir = cacheDir(installer);
|
|
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);
|
|
204
317
|
}
|
|
205
|
-
|
|
318
|
+
recordLock(dir, name, range, lockFile, request);
|
|
319
|
+
return { dir, alias: request.alias };
|
|
206
320
|
});
|
|
207
321
|
}
|
|
208
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Where `name@range` is installed in the installer's cache, or null if it
|
|
325
|
+
* isn't: nothing is installed. For type information (#273), which must not
|
|
326
|
+
* start downloads.
|
|
327
|
+
*/
|
|
328
|
+
export function installedPackageDir({ name, range }, installer = "npm", { lockFile = null } = {}) {
|
|
329
|
+
const dir = cacheDirPath(installer);
|
|
330
|
+
const { alias } = lockedRequest(name, range, lockFile);
|
|
331
|
+
return installedVersion(dir, alias) === null ? null : { dir, alias };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- Imported TypeScript (#73) --------------------------------------------------
|
|
335
|
+
// Node strips types in place, so line and column numbers stay those of the
|
|
336
|
+
// file, but it rejects TypeScript that needs code generation. Such a file
|
|
337
|
+
// is compiled with esbuild, as cells are, with an inline source map. The
|
|
338
|
+
// host passes its esbuild binary in CDPK_ESBUILD_BINARY: the runtime files
|
|
339
|
+
// may have been copied away from any node_modules (src/package.ts), and the
|
|
340
|
+
// esbuild binary, unlike its JavaScript API, works from anywhere.
|
|
341
|
+
|
|
342
|
+
// Both match a file: URL's path, so a query or fragment (`./x.ts?v=2`, the
|
|
343
|
+
// cache-busting idiom for a module being edited) doesn't hide the extension (#145).
|
|
344
|
+
const TS_FILE = /\.[cm]?ts$/;
|
|
345
|
+
// Node has no format for these extensions, so its loader is never asked.
|
|
346
|
+
const JSX_FILE = /\.([jt]sx)$/;
|
|
347
|
+
const TS_FORMATS = { "module-typescript": "module", "commonjs-typescript": "commonjs" };
|
|
348
|
+
|
|
349
|
+
function isUnsupportedSyntax(err) {
|
|
350
|
+
return err?.code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Node's strip-only transform, without its once-per-process ExperimentalWarning. */
|
|
354
|
+
function stripTypes(source) {
|
|
355
|
+
const emitWarning = process.emitWarning;
|
|
356
|
+
process.emitWarning = function (warning, ...rest) {
|
|
357
|
+
if (String(warning?.message ?? warning).includes("stripTypeScriptTypes")) return;
|
|
358
|
+
return emitWarning.call(this, warning, ...rest);
|
|
359
|
+
};
|
|
360
|
+
try {
|
|
361
|
+
return module.stripTypeScriptTypes(source, { mode: "strip" });
|
|
362
|
+
} finally {
|
|
363
|
+
process.emitWarning = emitWarning;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Compiles one TypeScript (or, with loader "tsx"/"jsx", JSX) file with the
|
|
369
|
+
* esbuild binary at `binary`, as cells are compiled. JSX takes esbuild's
|
|
370
|
+
* default, classic React.createElement, which is what cells use on Node
|
|
371
|
+
* (no project config there); a file's own @jsx/@jsxImportSource pragma wins.
|
|
372
|
+
*/
|
|
373
|
+
export function compileTypeScript(source, filename, binary, run = spawnSync, loader = "ts") {
|
|
374
|
+
const args = [
|
|
375
|
+
`--loader=${loader}`,
|
|
376
|
+
"--target=esnext",
|
|
377
|
+
"--sourcemap=inline",
|
|
378
|
+
`--sourcefile=${filename}`,
|
|
379
|
+
'--tsconfig-raw={"compilerOptions":{"verbatimModuleSyntax":true}}',
|
|
380
|
+
"--log-level=error",
|
|
381
|
+
"--color=false",
|
|
382
|
+
];
|
|
383
|
+
const result = run(binary, args, { input: source, encoding: "utf8", maxBuffer: 1 << 30 });
|
|
384
|
+
if (result.status !== 0) {
|
|
385
|
+
throw new SyntaxError(`${filename}: ${(result.stderr || String(result.error)).trim()}`);
|
|
386
|
+
}
|
|
387
|
+
return result.stdout;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The load hook: Node's result unless it would reject the file's TypeScript. */
|
|
391
|
+
export function loadTypeScript(url, context, nextLoad, binary) {
|
|
392
|
+
const path = url.startsWith("file:") ? new URL(url).pathname : "";
|
|
393
|
+
const jsx = JSX_FILE.exec(path);
|
|
394
|
+
if (binary && jsx) {
|
|
395
|
+
// Notebooks import ES modules, so it's compiled as one.
|
|
396
|
+
const filename = fileURLToPath(url);
|
|
397
|
+
const source = compileTypeScript(readFileSync(filename, "utf8"), filename, binary, spawnSync, jsx[1]);
|
|
398
|
+
return { format: "module", source, shortCircuit: true };
|
|
399
|
+
}
|
|
400
|
+
if (!binary || !TS_FILE.test(path)) return nextLoad(url, context);
|
|
401
|
+
let result;
|
|
402
|
+
try {
|
|
403
|
+
result = nextLoad(url, context);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
// A .ts file outside a package.json "type" is stripped while Node
|
|
406
|
+
// detects its format, so the rejection surfaces here. Notebooks import
|
|
407
|
+
// ES modules, so it's compiled as one.
|
|
408
|
+
if (!isUnsupportedSyntax(err)) throw err;
|
|
409
|
+
const filename = fileURLToPath(url);
|
|
410
|
+
return { format: "module", source: compileTypeScript(readFileSync(filename, "utf8"), filename, binary), shortCircuit: true };
|
|
411
|
+
}
|
|
412
|
+
const format = TS_FORMATS[result.format];
|
|
413
|
+
if (format === undefined || typeof module.stripTypeScriptTypes !== "function") return result;
|
|
414
|
+
const source = typeof result.source === "string" ? result.source : new TextDecoder().decode(result.source);
|
|
415
|
+
try {
|
|
416
|
+
return { ...result, format, source: stripTypes(source) };
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (!isUnsupportedSyntax(err)) throw err;
|
|
419
|
+
return { ...result, format, source: compileTypeScript(source, fileURLToPath(url), binary) };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
209
423
|
export function installNodeResolveHooks({ shimUrl, replUrl }) {
|
|
424
|
+
const esbuild = process.env.CDPK_ESBUILD_BINARY;
|
|
210
425
|
module.registerHooks({
|
|
426
|
+
load: (url, context, nextLoad) => loadTypeScript(url, context, nextLoad, esbuild),
|
|
211
427
|
resolve(specifier, context, nextResolve) {
|
|
212
428
|
let pkg = null;
|
|
213
429
|
if (specifier.startsWith("npm:")) pkg = parsePackageSpecifier(specifier.slice(4));
|
|
@@ -51,6 +51,44 @@ Object.assign(console, {
|
|
|
51
51
|
error: stream("stderr"),
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
// An error thrown later by a cell's timer or promise goes to stderr, as on
|
|
55
|
+
// the other targets (runtime/bootstrap.mjs). Workers has no global "error"
|
|
56
|
+
// event, so timer callbacks report their own (#148), and so do the
|
|
57
|
+
// promises they return: celld doesn't dispatch "unhandledrejection".
|
|
58
|
+
function reportUncaught(kind, err) {
|
|
59
|
+
emit({ kind: "stream", name: "stderr", text: `${kind}: ${err instanceof Error ? (err.stack ?? err.message) : inspect(err)}\n` });
|
|
60
|
+
}
|
|
61
|
+
// A rejection both runtimes' events report is shown once.
|
|
62
|
+
const reported = new WeakSet();
|
|
63
|
+
function reportRejection(reason) {
|
|
64
|
+
if (typeof reason === "object" && reason !== null) {
|
|
65
|
+
if (reported.has(reason)) return;
|
|
66
|
+
reported.add(reason);
|
|
67
|
+
}
|
|
68
|
+
reportUncaught("Uncaught (in promise)", reason);
|
|
69
|
+
}
|
|
70
|
+
globalThis.addEventListener("unhandledrejection", (event) => {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
reportRejection(event.reason);
|
|
73
|
+
});
|
|
74
|
+
globalThis.process?.on?.("unhandledRejection", reportRejection);
|
|
75
|
+
const reporting = (callback) =>
|
|
76
|
+
typeof callback !== "function" ? callback : function (...args) {
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = callback.apply(this, args);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
reportUncaught("Uncaught", err);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (typeof result?.then === "function") result.then(undefined, reportRejection);
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
for (const name of ["setTimeout", "setInterval", "queueMicrotask"]) {
|
|
88
|
+
const original = globalThis[name];
|
|
89
|
+
globalThis[name] = { [name]: (callback, ...rest) => original(reporting(callback), ...rest) }[name];
|
|
90
|
+
}
|
|
91
|
+
|
|
54
92
|
const jupyter = createJupyter({
|
|
55
93
|
inspect: (value) => inspect(value, { colors: true, depth: 4 }),
|
|
56
94
|
base64,
|
|
@@ -131,13 +169,23 @@ function install(env) {
|
|
|
131
169
|
|
|
132
170
|
let evaluate = null;
|
|
133
171
|
|
|
172
|
+
// Modules whose load rejected. Importing one again gives its error again,
|
|
173
|
+
// and the next eval reply names it, so the host bundles it afresh for the
|
|
174
|
+
// next import (#147), which replaces it here.
|
|
175
|
+
const rejected = new Set();
|
|
176
|
+
const unreported = new Set();
|
|
177
|
+
|
|
134
178
|
/** Evaluates the bundles a cell brings, once each. */
|
|
135
179
|
async function loadModules(bundles = {}) {
|
|
136
180
|
for (const [specifier, script] of Object.entries(bundles)) {
|
|
137
|
-
if (modules.has(specifier)) continue;
|
|
181
|
+
if (modules.has(specifier) && !rejected.has(specifier)) continue;
|
|
182
|
+
rejected.delete(specifier);
|
|
138
183
|
const loading = Promise.resolve().then(() => evaluate(script));
|
|
139
|
-
|
|
140
|
-
|
|
184
|
+
loading.catch(() => {
|
|
185
|
+
if (modules.get(specifier) !== loading) return;
|
|
186
|
+
rejected.add(specifier);
|
|
187
|
+
unreported.add(specifier);
|
|
188
|
+
});
|
|
141
189
|
modules.set(specifier, loading);
|
|
142
190
|
}
|
|
143
191
|
}
|
|
@@ -151,8 +199,31 @@ async function answer(req) {
|
|
|
151
199
|
worker: globalThis.worker !== undefined,
|
|
152
200
|
};
|
|
153
201
|
}
|
|
154
|
-
if (req.op
|
|
155
|
-
|
|
202
|
+
if (req.op !== "eval") return await handle(req);
|
|
203
|
+
await loadModules(req.modules);
|
|
204
|
+
const reply = await handle(req);
|
|
205
|
+
// After the cell, so a load that failed in it is counted.
|
|
206
|
+
const failedModules = [...unreported];
|
|
207
|
+
unreported.clear();
|
|
208
|
+
return { ...reply, failedModules };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const encoder = new TextEncoder();
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Compares in constant time: under celld and Miniflare the driver listens on
|
|
215
|
+
* loopback TCP, where any local user can try tokens. The length (fixed by
|
|
216
|
+
* the host) is not secret.
|
|
217
|
+
*/
|
|
218
|
+
function tokenMatches(token, expected) {
|
|
219
|
+
if (typeof token !== "string" || typeof expected !== "string") return false;
|
|
220
|
+
const a = encoder.encode(token);
|
|
221
|
+
const b = encoder.encode(expected);
|
|
222
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
223
|
+
if (typeof crypto.subtle.timingSafeEqual === "function") return crypto.subtle.timingSafeEqual(a, b);
|
|
224
|
+
let diff = 0;
|
|
225
|
+
for (let i = 0; i < a.byteLength; i++) diff |= a[i] ^ b[i];
|
|
226
|
+
return diff === 0;
|
|
156
227
|
}
|
|
157
228
|
|
|
158
229
|
export default {
|
|
@@ -160,7 +231,7 @@ export default {
|
|
|
160
231
|
// A header, or the query string for a WebSocket client that can't set
|
|
161
232
|
// headers (celld's session uses the host's built-in WebSocket).
|
|
162
233
|
const token = request.headers.get("x-cdpk-token") ?? new URL(request.url).searchParams.get("token");
|
|
163
|
-
if (token
|
|
234
|
+
if (!tokenMatches(token, env.TOKEN)) return new Response("forbidden", { status: 403 });
|
|
164
235
|
if (request.headers.get("upgrade") !== "websocket") return new Response("expected a WebSocket", { status: 426 });
|
|
165
236
|
evaluate ??= install(env);
|
|
166
237
|
|