@tlockney/cdp-kernel 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ // @ts-self-types="./host.d.mts"
2
+ // jupyter.host() (#461): the client for a Ganymede server's host API, which
3
+ // lets a cell read, search and (when granted) run other notebooks. The
4
+ // server opens a comm to target `ganymede.host.v1` when the kernel starts,
5
+ // with its `hello` as the open's data. Calls go out as comm messages
6
+ // `{ id, method, params }`; the server replies `{ id, result }` or
7
+ // `{ id, error: { code, message } }` on a kernel subshell, so a cell that
8
+ // awaits a call gets its reply. The message format and error codes are
9
+ // ganymede-device's, so the two clients behave alike.
10
+ //
11
+ // Design: https://github.com/tlockney/ganymede/blob/main/docs/host-api.md
12
+
13
+ export const HOST_TARGET = "ganymede.host.v1";
14
+
15
+ const CODES = new Set(["unavailable", "denied", "cancelled", "timeout", "failed", "disconnected"]);
16
+
17
+ export class HostError extends Error {
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.name = "HostError";
21
+ this.code = code;
22
+ }
23
+
24
+ /** An abort becomes `cancelled`, a timeout signal's abort `timeout`, anything else `failed`. */
25
+ static from(err) {
26
+ if (err instanceof HostError) return err;
27
+ const message = typeof err?.message === "string" ? err.message : String(err);
28
+ if (err?.name === "AbortError") return new HostError("cancelled", message);
29
+ if (err?.name === "TimeoutError") return new HostError("timeout", message);
30
+ if (typeof err?.code === "string" && CODES.has(err.code)) return new HostError(err.code, message);
31
+ return new HostError("failed", message);
32
+ }
33
+ }
34
+
35
+ /** Why an aborted signal ended a call: `timeout` for a timeout signal, else `cancelled`. */
36
+ function abortError(reason) {
37
+ const message = typeof reason?.message === "string" ? reason.message : String(reason ?? "aborted");
38
+ return new HostError(reason?.name === "TimeoutError" ? "timeout" : "cancelled", message);
39
+ }
40
+
41
+ /** A server's `hello`, or null when `data` isn't one. */
42
+ function helloOf(data) {
43
+ if (data === null || typeof data !== "object" || typeof data.protocol !== "number") return null;
44
+ return {
45
+ protocol: data.protocol,
46
+ server: typeof data.server === "string" ? data.server : "",
47
+ notebook: typeof data.notebook === "string" ? data.notebook : null,
48
+ methods: Array.isArray(data.methods) ? data.methods.filter((m) => typeof m === "string") : [],
49
+ };
50
+ }
51
+
52
+ /**
53
+ * @param {import("./comm.d.mts").CommApi} comms
54
+ * @param {() => AbortSignal | undefined} [defaultSignal] the signal a call
55
+ * watches when it is given none: jupyter.signal.
56
+ */
57
+ export function createHost(comms, defaultSignal = () => undefined) {
58
+ /** The open host comm: { comm, hello, host, pending }, or null. */
59
+ let link = null;
60
+ /** Whoever waits for a hello: called with the link's Host, or null once there is no link. */
61
+ const waiters = new Set();
62
+ /**
63
+ * A comm this runtime opened went unanswered: no server. Remembered, so
64
+ * each `jupyter.host()` after the first answers null at once, until a
65
+ * server opens the comm itself.
66
+ */
67
+ let noServer = false;
68
+ let nextId = 0;
69
+
70
+ function settle() {
71
+ const host = link?.host ?? null;
72
+ if (host === null && link !== null) return;
73
+ for (const waiter of [...waiters]) waiter(host);
74
+ }
75
+
76
+ /** `ours`: this runtime opened the comm, not the server. */
77
+ function adopt(comm, data, ours = false) {
78
+ // A new comm replaces the old one: the server opens one per kernel start.
79
+ const old = link;
80
+ const l = { comm, host: null, pending: new Map(), ours };
81
+ if (!ours) noServer = false;
82
+ link = l;
83
+ if (old !== null && !old.comm.closed) old.comm.close();
84
+ const hello = helloOf(data);
85
+ if (hello !== null) l.host = makeHost(l, hello);
86
+ comm.onMsg(({ data }) => {
87
+ if (data.hello !== undefined) {
88
+ const hello = helloOf(data.hello);
89
+ if (hello !== null) {
90
+ l.host = makeHost(l, hello);
91
+ if (link === l) settle();
92
+ }
93
+ return;
94
+ }
95
+ const call = l.pending.get(data.id);
96
+ if (call === undefined) return;
97
+ if (data.error !== undefined) {
98
+ const { code, message } = data.error ?? {};
99
+ call.reject(new HostError(CODES.has(code) ? code : "failed", typeof message === "string" ? message : String(code)));
100
+ } else {
101
+ call.resolve(data.result);
102
+ }
103
+ });
104
+ comm.onClose(({ data }) => {
105
+ for (const call of l.pending.values()) {
106
+ call.reject(new HostError("disconnected", typeof data?.error === "string" ? `host comm closed: ${data.error}` : "host comm closed"));
107
+ }
108
+ l.pending.clear();
109
+ if (link === l) {
110
+ link = null;
111
+ // The frontend closed the comm this runtime opened: no server.
112
+ if (l.ours && l.host === null) noServer = true;
113
+ settle();
114
+ }
115
+ });
116
+ if (l.host !== null) settle();
117
+ }
118
+
119
+ function makeHost(l, hello) {
120
+ const methods = Object.freeze([...hello.methods]);
121
+ function call(method, params = {}, { signal = defaultSignal() } = {}) {
122
+ if (signal?.aborted) return Promise.reject(abortError(signal.reason));
123
+ if (l.comm.closed) return Promise.reject(new HostError("disconnected", "host comm closed"));
124
+ const id = nextId++;
125
+ return new Promise((resolve, reject) => {
126
+ const onAbort = () => {
127
+ l.pending.delete(id);
128
+ if (!l.comm.closed) l.comm.send({ id, cancel: true });
129
+ reject(abortError(signal.reason));
130
+ };
131
+ const done = () => signal?.removeEventListener("abort", onAbort);
132
+ const call = {
133
+ resolve: (value) => (l.pending.delete(id), done(), resolve(value)),
134
+ reject: (error) => (l.pending.delete(id), done(), reject(error)),
135
+ };
136
+ l.pending.set(id, call);
137
+ signal?.addEventListener("abort", onAbort, { once: true });
138
+ try {
139
+ l.comm.send({ id, method, params });
140
+ } catch (err) {
141
+ // Params that can't be sent, such as a BigInt: the server never
142
+ // got the call.
143
+ call.reject(new HostError("failed", `cannot send ${method}: ${err instanceof Error ? err.message : err}`));
144
+ }
145
+ });
146
+ }
147
+ const bind = (method) => (params, options) => call(method, params, options);
148
+ return Object.freeze({
149
+ protocol: hello.protocol,
150
+ server: hello.server,
151
+ notebook: hello.notebook,
152
+ methods,
153
+ has: (method) => methods.includes(method),
154
+ call,
155
+ notebooks: Object.freeze({
156
+ list: bind("notebooks.list"),
157
+ read: bind("notebooks.read"),
158
+ search: bind("notebooks.search"),
159
+ results: bind("notebooks.results"),
160
+ run: bind("notebooks.run"),
161
+ }),
162
+ self: Object.freeze({
163
+ add: bind("self.add"),
164
+ edit: bind("self.edit"),
165
+ delete: bind("self.delete"),
166
+ }),
167
+ });
168
+ }
169
+
170
+ comms.registerTarget(HOST_TARGET, (comm, msg) => adopt(comm, msg.data));
171
+
172
+ async function host({ timeout = 1000 } = {}) {
173
+ if (link?.host) return link.host;
174
+ // The wait for a server watches jupyter.signal, so an interrupt ends it.
175
+ const signal = defaultSignal();
176
+ if (signal?.aborted) throw abortError(signal.reason);
177
+ if (noServer) return null;
178
+ // No comm from the server (one whose comm closed, as when a runtime
179
+ // restarts in the same kernel): open it from here. A Ganymede server
180
+ // answers `{ hello }`; anything else closes it or stays silent.
181
+ if (link === null) adopt(comms.open(HOST_TARGET), null, true);
182
+ const mine = link;
183
+ return new Promise((resolve, reject) => {
184
+ let timer;
185
+ const onAbort = () => {
186
+ done();
187
+ reject(abortError(signal.reason));
188
+ };
189
+ const done = () => {
190
+ waiters.delete(waiter);
191
+ clearTimeout(timer);
192
+ signal?.removeEventListener("abort", onAbort);
193
+ };
194
+ const waiter = (host) => {
195
+ done();
196
+ resolve(host);
197
+ };
198
+ waiters.add(waiter);
199
+ signal?.addEventListener("abort", onAbort, { once: true });
200
+ // Each caller waits its own timeout; the last to give up on a comm
201
+ // this runtime opened closes it.
202
+ timer = setTimeout(() => {
203
+ done();
204
+ if (waiters.size === 0 && link === mine && mine.host === null && mine.ours) {
205
+ link = null;
206
+ noServer = true;
207
+ mine.comm.close();
208
+ }
209
+ resolve(null);
210
+ }, timeout);
211
+ });
212
+ }
213
+
214
+ return { host };
215
+ }
@@ -101,7 +101,10 @@ const jupyter = createJupyter({
101
101
  // A request can't block for an answer, so prompt() and confirm() behave
102
102
  // as when the frontend has no stdin.
103
103
  input: () => null,
104
+ // jupyter.params (#456): the CDPK_PARAMS binding, set when install() runs.
105
+ params: () => paramsText,
104
106
  });
107
+ let paramsText;
105
108
 
106
109
  // --- Modules ----------------------------------------------------------------
107
110
  const modules = new Map();
@@ -131,6 +134,7 @@ let handle = null;
131
134
 
132
135
  function install(env) {
133
136
  const meta = JSON.parse(env.META);
137
+ paramsText = typeof env.CDPK_PARAMS === "string" ? env.CDPK_PARAMS : undefined;
134
138
  globalThis.__cdpk = {
135
139
  import: cellImport,
136
140
  meta: { ...meta, resolve: (specifier) => new URL(specifier, meta.url).href },
@@ -164,7 +168,7 @@ function install(env) {
164
168
  globalThis.confirm = jupyter.confirm;
165
169
  // A cell has no fetch(request, env), so the Worker's bindings (#26) are a
166
170
  // global, without the kernel's own.
167
- const { UNSAFE, TOKEN, META, ...bindings } = env;
171
+ const { UNSAFE, TOKEN, META, CDPK_PARAMS, ...bindings } = env;
168
172
  globalThis.env = bindings;
169
173
  // workerd forbids eval without its UnsafeEval binding; celld (#25) allows
170
174
  // a plain global eval and has no such binding.