@tlockney/cdp-kernel 0.5.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.
Files changed (44) hide show
  1. package/README.md +27 -6
  2. package/dist/cdp.d.ts +2 -0
  3. package/dist/cdp.js +6 -1
  4. package/dist/celld-target.js +9 -12
  5. package/dist/children.d.ts +25 -3
  6. package/dist/children.js +84 -2
  7. package/dist/deno-types.d.ts +30 -0
  8. package/dist/deno-types.js +93 -0
  9. package/dist/kernel.d.ts +7 -1
  10. package/dist/kernel.js +187 -29
  11. package/dist/language-service-worker.d.ts +1 -0
  12. package/dist/language-service-worker.js +88 -0
  13. package/dist/language-service.d.ts +64 -0
  14. package/dist/language-service.js +128 -0
  15. package/dist/main.js +102 -8
  16. package/dist/mod.d.ts +1 -0
  17. package/dist/mod.js +1 -0
  18. package/dist/notebook-types.d.ts +62 -0
  19. package/dist/notebook-types.js +310 -0
  20. package/dist/package.d.ts +2 -2
  21. package/dist/package.js +2 -2
  22. package/dist/parent.d.ts +12 -0
  23. package/dist/parent.js +39 -0
  24. package/dist/port-holder.d.ts +13 -0
  25. package/dist/port-holder.js +105 -0
  26. package/dist/sourcemap.d.ts +7 -2
  27. package/dist/sourcemap.js +9 -3
  28. package/dist/strip-esbuild.d.ts +2 -0
  29. package/dist/strip-esbuild.js +44 -0
  30. package/dist/strip-sucrase.d.ts +2 -0
  31. package/dist/strip-sucrase.js +78 -0
  32. package/dist/target.d.ts +27 -3
  33. package/dist/target.js +193 -36
  34. package/dist/transform.d.ts +21 -1
  35. package/dist/transform.js +38 -42
  36. package/dist/workerd-target.js +1 -1
  37. package/dist/workers-project.js +13 -2
  38. package/dist/zmtp.d.ts +2 -0
  39. package/dist/zmtp.js +12 -0
  40. package/package.json +9 -4
  41. package/runtime/bootstrap.mjs +113 -33
  42. package/runtime/introspect.mjs +1 -1
  43. package/runtime/node-hooks.d.mts +6 -0
  44. package/runtime/node-hooks.mjs +11 -0
package/dist/kernel.js CHANGED
@@ -5,20 +5,25 @@
5
5
  // The thirteen op_jupyter_* calls into Deno's REPL isolate are replaced by
6
6
  // a Target driven over CDP (src/target.ts).
7
7
  import { Buffer } from "node:buffer";
8
+ import { existsSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
8
10
  import process from "node:process";
9
11
  import { randomUUID } from "node:crypto";
10
12
  import { decodeMessage, encodeMessage, makeHeader, PROTOCOL_VERSION, verify } from "./wire.js";
11
- import { PubSocket, RouterSocket, startHeartbeat } from "./zmtp.js";
13
+ import { listening, PubSocket, RouterSocket, startHeartbeat } from "./zmtp.js";
12
14
  import { Interrupted, Target } from "./target.js";
13
15
  import { BunTarget } from "./bun-target.js";
14
16
  import { Debugger } from "./debugger.js";
15
17
  import { WorkerdTarget } from "./workerd-target.js";
16
18
  import { launchCelld } from "./celld-target.js";
17
- import { CellSyntaxError, objectLiteralCandidate } from "./transform.js";
19
+ import { CellSyntaxError, loadTypeStripper, objectLiteralCandidate, } from "./transform.js";
18
20
  import { loadDenoProject } from "./deno-config.js";
19
21
  import { checkIsComplete, codePointToUtf16Index, exprAtCursor, inspectTarget, utf16ToCodePointIndex } from "./text.js";
20
22
  import { queryHistory } from "./history.js";
23
+ import { LanguageService } from "./language-service.js";
24
+ import { findWranglerConfig } from "./workers-project.js";
21
25
  import { VERSION } from "./package.js";
26
+ import { watchParent } from "./parent.js";
22
27
  export function parseConnectionInfo(json) {
23
28
  const raw = JSON.parse(json);
24
29
  if (typeof raw !== "object" || raw === null)
@@ -50,8 +55,11 @@ export function parseConnectionInfo(json) {
50
55
  hb_port: info.hb_port,
51
56
  };
52
57
  }
53
- /** How long interrupt_request may wait on the target before it's answered anyway (#121). */
54
- const INTERRUPT_REPLY_WAIT_MS = 5_000;
58
+ /**
59
+ * How long interrupt_request may wait on the target before it's answered
60
+ * anyway (#121). Under the 5 s clients allow control requests (#253).
61
+ */
62
+ const INTERRUPT_REPLY_WAIT_MS = 1_000;
55
63
  const NO_INPUT = { value: null, interrupted: false };
56
64
  /** Shell requests other than execute_request, and their reply types. */
57
65
  const SHELL_REPLIES = {
@@ -62,16 +70,73 @@ const SHELL_REPLIES = {
62
70
  history_request: "history_reply",
63
71
  comm_info_request: "comm_info_reply",
64
72
  };
73
+ /** The target Node's major version, for @types/node: the host's, which is usually the same binary. */
74
+ function nodeMajor() {
75
+ return process.versions.node?.split(".")[0] ?? "*";
76
+ }
77
+ /**
78
+ * Where the language service gets each target's globals and imports from
79
+ * (#273, #275). A `--deno-compat` Node target gets Node's types only: its
80
+ * `Deno` namespace is partial, and Deno's declarations would promise
81
+ * members it doesn't have.
82
+ */
83
+ function languageServiceOptions(runtime, notebookDir, project) {
84
+ const base = { notebookDir, npmCache: "npm", deno: null, denoTypes: false, typeFiles: [], typePackages: [] };
85
+ switch (runtime) {
86
+ case "node":
87
+ return { ...base, typePackages: [{ name: "@types/node", range: nodeMajor() }] };
88
+ case "deno":
89
+ return { ...base, npmCache: null, deno: { config: project?.configPath ?? null }, denoTypes: true };
90
+ case "bun":
91
+ return { ...base, npmCache: "bun", typePackages: [{ name: "@types/bun", range: "1" }] };
92
+ case "workerd":
93
+ case "celld": {
94
+ // A Workers project's own types (`wrangler types`) know its bindings.
95
+ const config = findWranglerConfig(notebookDir);
96
+ const generated = config === null ? null : join(dirname(config), "worker-configuration.d.ts");
97
+ return generated !== null && existsSync(generated)
98
+ ? { ...base, typeFiles: [generated] }
99
+ : { ...base, typePackages: [{ name: "@cloudflare/workers-types", range: "*" }] };
100
+ }
101
+ }
102
+ }
103
+ /** Runtime inspection text without its Type and Signature lines, which the declared type replaces. */
104
+ function withoutSignature(text) {
105
+ return text.split("\n").filter((line) => !/^(?:Type|Signature): /.test(line)).join("\n");
106
+ }
107
+ /** A TypeScript completion kind as a Jupyter completion type (JupyterLab picks its icon by these). */
108
+ function jupyterType(kind) {
109
+ switch (kind) {
110
+ case undefined:
111
+ return "<unknown>";
112
+ case "var":
113
+ case "let":
114
+ case "const":
115
+ case "local var":
116
+ case "parameter":
117
+ return "variable";
118
+ case "local function":
119
+ return "function";
120
+ case "type":
121
+ case "alias":
122
+ return "type";
123
+ default:
124
+ return kind;
125
+ }
126
+ }
65
127
  export class Kernel {
66
128
  #conn;
67
129
  #runtime;
68
130
  #denoCompat;
131
+ #typeStrip;
69
132
  // A Deno target in a Deno project uses its config (#13).
70
133
  #project;
71
134
  #projectWarning = null;
72
135
  #noticeShownFor = null;
73
136
  /** JupyterLab's visual debugger, for targets driven over CDP (#18). */
74
137
  #debugger;
138
+ /** Typed completion from TypeScript's language service (#273). */
139
+ #types;
75
140
  /** Cells run with store_history, for history_request (#10). */
76
141
  #history = [];
77
142
  #session = randomUUID();
@@ -79,6 +144,8 @@ export class Kernel {
79
144
  #control;
80
145
  #stdin;
81
146
  #iopub;
147
+ /** Settles once all five ports are bound (#257). */
148
+ #bound;
82
149
  #target = null;
83
150
  #targetStarting = null;
84
151
  #executionCount = 0;
@@ -95,6 +162,8 @@ export class Kernel {
95
162
  #input = null;
96
163
  /** An interrupt is waiting on the target. */
97
164
  #interrupting = false;
165
+ /** The shell message being handled, and since when. */
166
+ #handling = null;
98
167
  // Open comms, for comm_info_request: comm_id -> target_name (#9).
99
168
  #comms = new Map();
100
169
  #shuttingDown = false;
@@ -102,15 +171,21 @@ export class Kernel {
102
171
  this.#conn = conn;
103
172
  this.#runtime = runtime;
104
173
  this.#denoCompat = options.denoCompat ?? false;
105
- this.#project = runtime === "deno" ? this.#loadProject(options.notebookDir ?? process.cwd()) : null;
174
+ this.#typeStrip = options.typeStrip ?? "esbuild";
175
+ const notebookDir = options.notebookDir ?? process.cwd();
176
+ this.#project = runtime === "deno" ? this.#loadProject(notebookDir) : null;
177
+ this.#types = options.languageService === false
178
+ ? null
179
+ : new LanguageService(languageServiceOptions(runtime, notebookDir, this.#project), { log: (text) => this.#log(text) });
106
180
  this.#debugger = runtime === "node" || runtime === "deno"
107
181
  ? new Debugger((event) => this.#publish("debug_event", event), () => this.#transformOptions())
108
182
  : null;
109
- startHeartbeat(conn.hb_port, conn.ip);
183
+ const heartbeat = startHeartbeat(conn.hb_port, conn.ip);
110
184
  this.#shell = new RouterSocket(conn.shell_port, conn.ip);
111
185
  this.#control = new RouterSocket(conn.control_port, conn.ip);
112
186
  this.#stdin = new RouterSocket(conn.stdin_port, conn.ip);
113
187
  this.#iopub = new PubSocket(conn.iopub_port, conn.ip);
188
+ this.#bound = Promise.all([heartbeat, this.#shell.server, this.#control.server, this.#stdin.server, this.#iopub.server].map(listening));
114
189
  }
115
190
  async run() {
116
191
  // Start the runtime eagerly so the first cell doesn't pay for it.
@@ -124,6 +199,18 @@ export class Kernel {
124
199
  process.exit(code);
125
200
  });
126
201
  }
202
+ // A server killed before it could shut the kernel down (#254).
203
+ watchParent(() => {
204
+ this.#log(`parent ${process.env.JPY_PARENT_PID} exited; shutting down`);
205
+ this.#shuttingDown = true;
206
+ process.exit(1);
207
+ });
208
+ // A state dump on stderr, for a kernel that has stopped answering (#237).
209
+ if (process.platform !== "win32")
210
+ process.on("SIGUSR2", () => void this.#logState());
211
+ // Transforms are synchronous, so the stripper loads before any cell.
212
+ if (typeof this.#typeStrip === "string")
213
+ await loadTypeStripper(this.#typeStrip);
127
214
  await Promise.all([this.#loop(this.#shell, (m, p) => this.#handleShell(m, p)),
128
215
  this.#loop(this.#control, (m, p) => this.#handleControl(m, p)),
129
216
  this.#loop(this.#stdin, (m) => this.#handleStdin(m))]);
@@ -131,6 +218,21 @@ export class Kernel {
131
218
  #log(text) {
132
219
  process.stderr.write(`[cdp-kernel] ${text}\n`);
133
220
  }
221
+ async #logState() {
222
+ const handling = this.#handling;
223
+ const target = this.#target;
224
+ let targetState = target === null ? (this.#targetStarting === null ? null : "starting") : target.runtime;
225
+ if (target?.diagnose)
226
+ targetState = await target.diagnose().catch((err) => `diagnose failed: ${err}`);
227
+ this.#log(`state: ${JSON.stringify({
228
+ shell: handling && { msgType: handling.msgType, msgId: handling.msgId, ms: Date.now() - handling.since },
229
+ queued: this.#shell.incoming.size,
230
+ executing: this.#executing,
231
+ interrupting: this.#interrupting,
232
+ awaitingInput: this.#input !== null,
233
+ target: targetState,
234
+ })}`);
235
+ }
134
236
  #loadProject(notebookDir) {
135
237
  try {
136
238
  return loadDenoProject(notebookDir);
@@ -145,7 +247,7 @@ export class Kernel {
145
247
  }
146
248
  }
147
249
  #transformOptions() {
148
- return this.#project?.transform ?? {};
250
+ return { ...this.#project?.transform, typeStrip: this.#typeStrip };
149
251
  }
150
252
  // --- comms (#9) ------------------------------------------------------------
151
253
  #trackComm(msgType, content) {
@@ -225,7 +327,10 @@ export class Kernel {
225
327
  #ensureTarget() {
226
328
  if (this.#target)
227
329
  return Promise.resolve(this.#target);
228
- this.#targetStarting ??= this.#launchTarget().then(async (target) => {
330
+ // Not before the kernel's own ports are bound: a Deno runtime binds its
331
+ // inspector to port 0 within milliseconds of starting and can take a
332
+ // connection-file port the kernel is still binding (#257).
333
+ this.#targetStarting ??= this.#bound.then(() => this.#launchTarget()).then(async (target) => {
229
334
  // Before any cell runs on it, so breakpoints already apply.
230
335
  await this.#debugger?.attachTarget(target.debug ?? null);
231
336
  this.#target = target;
@@ -243,6 +348,8 @@ export class Kernel {
243
348
  message: (msg) => void this.#onTargetMessage(msg).catch((err) => this.#log(`error handling target message: ${err}`)),
244
349
  exit: (code, note) => {
245
350
  this.#target = null;
351
+ // What its cells declared died with it.
352
+ this.#types?.reset();
246
353
  // Its prompt, if any, died with it; a late reply is dropped.
247
354
  this.#input = null;
248
355
  void this.#debugger?.attachTarget(null);
@@ -320,11 +427,17 @@ export class Kernel {
320
427
  // Unsigned or forged messages are dropped, as in Deno.
321
428
  if (!verify(this.#conn.key, msg.signedParts, msg.sig))
322
429
  continue;
430
+ if (socket === this.#shell)
431
+ this.#handling = { msgType: msg.header.msg_type, msgId: msg.header.msg_id, since: Date.now() };
323
432
  await handle(msg, peer);
324
433
  }
325
434
  catch (err) {
326
435
  this.#log(`error handling message: ${err instanceof Error ? err.stack : err}`);
327
436
  }
437
+ finally {
438
+ if (socket === this.#shell)
439
+ this.#handling = null;
440
+ }
328
441
  }
329
442
  }
330
443
  #kernelInfo() {
@@ -343,7 +456,8 @@ export class Kernel {
343
456
  codemirror_mode: "typescript",
344
457
  nbconvert_exporter: "script",
345
458
  },
346
- banner: `cdp-kernel on ${this.#runtime} ${versions?.runtime ?? ""} (${versions?.engine || "?"})`,
459
+ // The runtime's versions are known once it is up.
460
+ banner: versions ? `cdp-kernel on ${this.#runtime} ${versions.runtime} (${versions.engine || "?"})` : `cdp-kernel on ${this.#runtime}`,
347
461
  help_links: [],
348
462
  debugger: this.#debugger !== null,
349
463
  // What current ipykernel advertises; JupyterLab itself goes by the
@@ -413,7 +527,8 @@ export class Kernel {
413
527
  try {
414
528
  switch (type) {
415
529
  case "kernel_info_request":
416
- await this.#ensureTarget().catch(() => null);
530
+ // Without waiting for the runtime: clients probe with kernel_info
531
+ // while it starts, and re-send until answered (#253).
417
532
  this.#reply(this.#shell, peer, msg, "kernel_info_reply", this.#kernelInfo());
418
533
  break;
419
534
  case "complete_request":
@@ -461,15 +576,24 @@ export class Kernel {
461
576
  this.#status("idle", msg.header);
462
577
  }
463
578
  }
464
- /** The tooltip (Shift-Tab) for the name at the cursor (#10). */
579
+ /**
580
+ * The tooltip (Shift-Tab) for the name at the cursor (#10): its declared
581
+ * type, signature and JSDoc when the language service knows them (#274),
582
+ * then what the runtime finds.
583
+ */
465
584
  async #inspect(msg) {
466
585
  const code = String(msg.content.code ?? "");
467
586
  const cursorCp = typeof msg.content.cursor_pos === "number"
468
587
  ? msg.content.cursor_pos
469
588
  : utf16ToCodePointIndex(code, code.length);
470
- const path = inspectTarget(code, codePointToUtf16Index(code, cursorCp));
589
+ const cursor = codePointToUtf16Index(code, cursorCp);
590
+ const path = inspectTarget(code, cursor);
471
591
  const detail = msg.content.detail_level === 1 ? 1 : 0;
472
- const text = path === null ? null : await (await this.#ensureTarget()).inspect(path, detail);
592
+ const [runtime, typed] = await Promise.all([
593
+ path === null ? null : (async () => (await this.#ensureTarget()).inspect(path, detail))(),
594
+ this.#types?.quickInfo(code, cursor) ?? null,
595
+ ]);
596
+ const text = typed === null ? runtime : runtime === null ? typed : `${typed}\n\n${withoutSignature(runtime)}`;
473
597
  return text === null
474
598
  ? { status: "ok", found: false, data: {}, metadata: {} }
475
599
  : { status: "ok", found: true, data: { "text/plain": text }, metadata: {} };
@@ -480,29 +604,59 @@ export class Kernel {
480
604
  ? msg.content.cursor_pos
481
605
  : utf16ToCodePointIndex(code, code.length);
482
606
  const cursor = codePointToUtf16Index(code, cursorCp);
607
+ // Both at once: the language service may not answer within its budget.
608
+ const [runtime, typed] = await Promise.all([
609
+ this.#runtimeCompletions(code, cursor),
610
+ this.#types?.complete(code, cursor) ?? null,
611
+ ]);
612
+ // Declared types first, then what only the runtime knows (a property
613
+ // added at runtime, a global defined by a library).
614
+ let matches = runtime.matches;
615
+ let start = runtime.start;
616
+ const kinds = new Map();
617
+ if (typed !== null && typed.start === runtime.start) {
618
+ for (const entry of typed.entries)
619
+ kinds.set(entry.name, entry.kind);
620
+ matches = [...kinds.keys(), ...runtime.matches.filter((name) => !kinds.has(name))];
621
+ }
622
+ else if (typed !== null && typed.entries.length > 0 && runtime.matches.length === 0) {
623
+ // An expression the runtime can't complete, e.g. inside a call.
624
+ for (const entry of typed.entries)
625
+ kinds.set(entry.name, entry.kind);
626
+ matches = [...kinds.keys()];
627
+ start = typed.start;
628
+ }
629
+ const cursorStart = utf16ToCodePointIndex(code, start);
630
+ return {
631
+ status: "ok",
632
+ matches,
633
+ cursor_start: cursorStart,
634
+ cursor_end: cursorCp,
635
+ metadata: kinds.size === 0 ? {} : {
636
+ // JupyterLab shows each match's type as an icon.
637
+ _jupyter_types_experimental: matches.map((text) => ({
638
+ start: cursorStart,
639
+ end: cursorCp,
640
+ text,
641
+ type: jupyterType(kinds.get(text)),
642
+ })),
643
+ },
644
+ };
645
+ }
646
+ /** Completion from the runtime's values: property names, globals, lexical names. */
647
+ async #runtimeCompletions(code, cursor) {
483
648
  const expr = exprAtCursor(code, cursor);
484
649
  const target = await this.#ensureTarget();
485
- let matches;
486
- let start;
487
650
  const dot = expr.lastIndexOf(".");
488
651
  if (dot !== -1) {
489
652
  const prefix = expr.slice(dot + 1);
490
- matches = (await target.propertyNames(expr.slice(0, dot)))
653
+ const matches = (await target.propertyNames(expr.slice(0, dot)))
491
654
  .filter((name) => name.startsWith(prefix) && !name.startsWith("Symbol("));
492
- start = cursor - prefix.length;
655
+ return { matches, start: cursor - prefix.length };
493
656
  }
494
- else {
495
- const names = new Set([...(await target.propertyNames("globalThis")), ...(await target.lexicalNames())]);
496
- matches = [...names].filter((name) => name.startsWith(expr) && !name.startsWith("__cdpk")).sort();
497
- start = cursor - expr.length;
498
- }
499
- return {
500
- status: "ok",
501
- matches,
502
- cursor_start: utf16ToCodePointIndex(code, start),
503
- cursor_end: cursorCp,
504
- metadata: {},
505
- };
657
+ const names = new Set([...(await target.propertyNames("globalThis")), ...(await target.lexicalNames())]);
658
+ const matches = [...names].filter((name) => name.startsWith(expr) && !name.startsWith("__cdpk")).sort();
659
+ return { matches, start: cursor - expr.length };
506
660
  }
507
661
  async #requestInput(prompt, password) {
508
662
  // The target asked while an interrupt was already waiting on it.
@@ -599,6 +753,8 @@ export class Kernel {
599
753
  this.#publish("stream", { name: "stderr", text: `${target.notice}\n` });
600
754
  }
601
755
  const result = await this.#evaluateCell(target, code);
756
+ // It ran, so its declarations exist, even if it threw part way.
757
+ this.#types?.addCell(code);
602
758
  if (result.exceptionDetails) {
603
759
  const exception = result.exceptionDetails.exception;
604
760
  const described = exception
@@ -632,6 +788,8 @@ export class Kernel {
632
788
  finally {
633
789
  this.#executing = false;
634
790
  }
791
+ // The result and error are shown: nothing refers to them any more (#286).
792
+ await this.#target?.releaseResults?.();
635
793
  if (error) {
636
794
  if (!silent && msg.content.stop_on_error !== false) {
637
795
  // Abort what was queued behind this cell (JupyterLab's Run All sends
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ // The worker thread that runs TypeScript's language service for the kernel
2
+ // (#273). It owns a NotebookTypes (src/notebook-types.ts) and answers the
3
+ // host's requests (src/language-service.ts) in order. Loading TypeScript
4
+ // and the first program take most of a second, which here doesn't hold up
5
+ // heartbeats, shell or control messages.
6
+ import { parentPort, workerData } from "node:worker_threads";
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import ts from "typescript";
10
+ import { ensureInstalledAsync } from "../runtime/node-hooks.mjs";
11
+ import { kernelGlobals, NotebookTypes } from "./notebook-types.js";
12
+ import { DenoResolver, denoTypes } from "./deno-types.js";
13
+ const options = workerData;
14
+ const types = new NotebookTypes(ts, {
15
+ notebookDir: options.notebookDir,
16
+ npmCache: options.npmCache,
17
+ deno: options.deno === null
18
+ ? undefined
19
+ : new DenoResolver({ config: options.deno.config, root: join(options.notebookDir, "__cdpk_remote__") }),
20
+ types: [],
21
+ typeFiles: options.typeFiles,
22
+ globals: kernelGlobals(options.display),
23
+ });
24
+ if (options.denoTypes) {
25
+ const declarations = denoTypes();
26
+ if (declarations !== null)
27
+ types.addVirtualTypeFile("__cdpk_deno__.d.ts", declarations);
28
+ else
29
+ process.stderr.write("[cdp-kernel] no Deno types for completion: `deno types` failed\n");
30
+ }
31
+ /** A package's declaration entry point: package.json "types"/"typings", else index.d.ts. */
32
+ function typesEntry(dir) {
33
+ try {
34
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
35
+ const entry = manifest.types ?? manifest.typings;
36
+ if (typeof entry === "string")
37
+ return join(dir, entry);
38
+ }
39
+ catch {
40
+ // No readable package.json: fall back to the convention.
41
+ }
42
+ return join(dir, "index.d.ts");
43
+ }
44
+ /**
45
+ * A type package for the target's globals (@types/node on Node, @types/bun,
46
+ * @cloudflare/workers-types): the
47
+ * notebook project's own when it has one, else installed once into the
48
+ * kernel's npm cache, in the background. Completion goes on without it
49
+ * meanwhile, and without it at all if the install fails (offline).
50
+ */
51
+ async function addTypePackage({ name, range }) {
52
+ const short = name.replace(/^@types\//, "");
53
+ if (types.resolvesTypes(short)) {
54
+ types.addTypeNames([short]);
55
+ return;
56
+ }
57
+ try {
58
+ const { dir, alias } = await ensureInstalledAsync({ name, range });
59
+ types.addTypeFiles([typesEntry(join(dir, "node_modules", alias))]);
60
+ }
61
+ catch (err) {
62
+ process.stderr.write(`[cdp-kernel] no ${name} for completion: ${err instanceof Error ? err.message : err}\n`);
63
+ }
64
+ }
65
+ for (const pkg of options.typePackages)
66
+ void addTypePackage(pkg);
67
+ parentPort?.on("message", (request) => {
68
+ let response;
69
+ switch (request.type) {
70
+ case "cell":
71
+ types.addCell(request.code);
72
+ return;
73
+ case "reset":
74
+ types.reset();
75
+ return;
76
+ case "complete":
77
+ case "quickInfo":
78
+ try {
79
+ response = request.type === "complete"
80
+ ? { id: request.id, result: types.complete(request.code, request.cursor) }
81
+ : { id: request.id, result: types.quickInfo(request.code, request.cursor) };
82
+ }
83
+ catch (err) {
84
+ response = { id: request.id, error: err instanceof Error ? err.stack ?? err.message : String(err) };
85
+ }
86
+ parentPort?.postMessage(response);
87
+ }
88
+ });
@@ -0,0 +1,64 @@
1
+ import type { CompletionResult } from "./notebook-types.ts";
2
+ export type { CompletionEntry, CompletionResult } from "./notebook-types.ts";
3
+ /** What the worker is started with. */
4
+ export interface WorkerOptions {
5
+ notebookDir: string;
6
+ /** See NotebookTypesOptions.npmCache. */
7
+ npmCache: "npm" | "bun" | null;
8
+ /** A Deno target: resolve imports with `deno info`, under this project config if any. */
9
+ deno: {
10
+ config: string | null;
11
+ } | null;
12
+ /** Add `deno types` (the Deno namespace) to the globals. */
13
+ denoTypes: boolean;
14
+ /** Declaration files for the target's globals, e.g. a Workers project's worker-configuration.d.ts. */
15
+ typeFiles: string[];
16
+ /** runtime/display.mjs, which types `jupyter`; null if not a local file. */
17
+ display: string | null;
18
+ /** Type packages for the target's globals, e.g. @types/node. */
19
+ typePackages: {
20
+ name: string;
21
+ range: string;
22
+ }[];
23
+ }
24
+ export type WorkerRequest = {
25
+ type: "cell";
26
+ code: string;
27
+ } | {
28
+ type: "reset";
29
+ } | {
30
+ type: "complete" | "quickInfo";
31
+ id: number;
32
+ code: string;
33
+ cursor: number;
34
+ };
35
+ export type WorkerResponse = {
36
+ id: number;
37
+ result: CompletionResult | string | null;
38
+ } | {
39
+ id: number;
40
+ error: string;
41
+ };
42
+ /**
43
+ * What to start the worker from. Deno's node:worker_threads takes only
44
+ * file: and data: URLs, so a kernel run from JSR (https:) starts a data:
45
+ * module that imports the worker.
46
+ */
47
+ export declare function workerEntry(url?: URL): URL;
48
+ export declare class LanguageService {
49
+ #private;
50
+ constructor(options: Omit<WorkerOptions, "display">, { budgetMs, log }?: {
51
+ budgetMs?: number;
52
+ log?: (text: string) => void;
53
+ });
54
+ /** A cell that ran (even if it threw): its declarations exist now. */
55
+ addCell(code: string): void;
56
+ /** The target restarted, so what the cells declared is gone. */
57
+ reset(): void;
58
+ /** Typed completions, or null if the service isn't ready within the budget. */
59
+ complete(code: string, cursor: number): Promise<CompletionResult | null>;
60
+ /** Type, signature and JSDoc for the name at the cursor, or null. */
61
+ quickInfo(code: string, cursor: number): Promise<string | null>;
62
+ /** Ends the worker; the next request starts another. */
63
+ close(): void;
64
+ }
@@ -0,0 +1,128 @@
1
+ // The kernel's side of typed completion and inspection (#273, #274): starts the language-service
2
+ // worker (src/language-service-worker.ts) on the first request, keeps it fed
3
+ // with executed cells, and gives each request a time budget, after which
4
+ // the kernel answers from the runtime alone. The worker is ended after a
5
+ // while without requests, which gives its memory back (about 150 MB).
6
+ import { Worker } from "node:worker_threads";
7
+ import { fileURLToPath } from "node:url";
8
+ /**
9
+ * How long a request waits for the worker. JupyterLab gives a kernel's
10
+ * completions a second, and the runtime's half runs alongside.
11
+ */
12
+ const BUDGET_MS = 500;
13
+ const IDLE_MS = 10 * 60 * 1000;
14
+ // Compiled to dist/*.js for the npm package, run as .ts from a checkout.
15
+ const WORKER_URL = new URL(import.meta.url.endsWith(".ts") ? "./language-service-worker.ts" : "./language-service-worker.js", import.meta.url);
16
+ const DISPLAY_URL = new URL("../runtime/display.mjs", import.meta.url);
17
+ /**
18
+ * What to start the worker from. Deno's node:worker_threads takes only
19
+ * file: and data: URLs, so a kernel run from JSR (https:) starts a data:
20
+ * module that imports the worker.
21
+ */
22
+ export function workerEntry(url = WORKER_URL) {
23
+ if (url.protocol === "file:")
24
+ return url;
25
+ return new URL(`data:text/javascript,${encodeURIComponent(`import ${JSON.stringify(url.href)};`)}`);
26
+ }
27
+ export class LanguageService {
28
+ #options;
29
+ #budgetMs;
30
+ #log;
31
+ /** Executed cells, replayed to a new worker. */
32
+ #cells = [];
33
+ #pending = new Map();
34
+ #worker = null;
35
+ #nextId = 1;
36
+ #idle = null;
37
+ /** The worker couldn't start: don't keep trying. */
38
+ #broken = false;
39
+ constructor(options, { budgetMs = BUDGET_MS, log = () => { } } = {}) {
40
+ this.#options = { ...options, display: DISPLAY_URL.protocol === "file:" ? fileURLToPath(DISPLAY_URL) : null };
41
+ this.#budgetMs = budgetMs;
42
+ this.#log = log;
43
+ }
44
+ /** A cell that ran (even if it threw): its declarations exist now. */
45
+ addCell(code) {
46
+ this.#cells.push(code);
47
+ this.#worker?.postMessage({ type: "cell", code });
48
+ }
49
+ /** The target restarted, so what the cells declared is gone. */
50
+ reset() {
51
+ this.#cells.length = 0;
52
+ this.#worker?.postMessage({ type: "reset" });
53
+ }
54
+ /** Typed completions, or null if the service isn't ready within the budget. */
55
+ async complete(code, cursor) {
56
+ return (await this.#request("complete", code, cursor));
57
+ }
58
+ /** Type, signature and JSDoc for the name at the cursor, or null. */
59
+ async quickInfo(code, cursor) {
60
+ return (await this.#request("quickInfo", code, cursor));
61
+ }
62
+ #request(type, code, cursor) {
63
+ const worker = this.#ensureWorker();
64
+ if (worker === null)
65
+ return Promise.resolve(null);
66
+ const id = this.#nextId++;
67
+ return new Promise((resolve) => {
68
+ const timer = setTimeout(() => settle(null), this.#budgetMs);
69
+ const settle = (response) => {
70
+ clearTimeout(timer);
71
+ this.#pending.delete(id);
72
+ if (response !== null && "error" in response)
73
+ this.#log(`${type} failed: ${response.error}`);
74
+ resolve(response !== null && "result" in response ? response.result : null);
75
+ };
76
+ this.#pending.set(id, settle);
77
+ worker.postMessage({ type, id, code, cursor });
78
+ });
79
+ }
80
+ /** Ends the worker; the next request starts another. */
81
+ close() {
82
+ if (this.#idle !== null)
83
+ clearTimeout(this.#idle);
84
+ this.#idle = null;
85
+ const worker = this.#worker;
86
+ this.#worker = null;
87
+ for (const settle of this.#pending.values())
88
+ settle(null);
89
+ void worker?.terminate();
90
+ }
91
+ #ensureWorker() {
92
+ if (this.#broken)
93
+ return null;
94
+ if (this.#idle !== null)
95
+ clearTimeout(this.#idle);
96
+ this.#idle = setTimeout(() => this.close(), IDLE_MS);
97
+ this.#idle.unref();
98
+ if (this.#worker !== null)
99
+ return this.#worker;
100
+ let worker;
101
+ try {
102
+ worker = new Worker(workerEntry(), { workerData: this.#options });
103
+ }
104
+ catch (err) {
105
+ this.#broken = true;
106
+ this.#log(`typed completion unavailable: ${err instanceof Error ? err.message : err}`);
107
+ return null;
108
+ }
109
+ // An idle worker mustn't keep the kernel alive.
110
+ worker.unref();
111
+ worker.on("message", (response) => this.#pending.get(response.id)?.(response));
112
+ worker.on("error", (err) => {
113
+ // E.g. typescript failed to load. Completion goes on from the runtime.
114
+ this.#broken = true;
115
+ this.#log(`typed completion unavailable: ${err instanceof Error ? err.message : err}`);
116
+ if (this.#worker === worker)
117
+ this.close();
118
+ });
119
+ worker.on("exit", () => {
120
+ if (this.#worker === worker)
121
+ this.close();
122
+ });
123
+ for (const code of this.#cells)
124
+ worker.postMessage({ type: "cell", code });
125
+ this.#worker = worker;
126
+ return worker;
127
+ }
128
+ }