@tlockney/cdp-kernel 0.8.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.
- package/README.md +4 -2
- package/dist/browser/kernel.js +592 -426
- package/dist/browser/kernel.js.map +3 -3
- package/dist/browser/target.d.ts +7 -0
- package/dist/browser/target.js +2 -1
- package/dist/browser/worker.js +224 -6
- package/dist/browser/worker.js.map +4 -4
- package/dist/celld-target.d.ts +2 -1
- package/dist/celld-target.js +5 -3
- package/dist/globals.d.ts +35 -4
- package/dist/kernel-core.js +11 -0
- package/dist/package.d.ts +3 -3
- package/dist/package.js +3 -2
- package/dist/transform.d.ts +10 -1
- package/dist/transform.js +20 -2
- package/dist/workerd-target.js +6 -2
- package/dist/workers-project.js +1 -1
- package/package.json +3 -3
- package/runtime/bootstrap.mjs +2 -0
- package/runtime/browser-worker.mjs +5 -1
- package/runtime/comm.d.mts +87 -1
- package/runtime/display.d.mts +315 -6
- package/runtime/display.mjs +63 -0
- package/runtime/host.d.mts +246 -0
- package/runtime/host.mjs +215 -0
- package/runtime/widget.d.mts +205 -6
- package/runtime/workerd-worker.mjs +5 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { CommApi } from "./comm.d.mts";
|
|
2
|
+
|
|
3
|
+
/** The target name of the comm a Ganymede server opens to the kernel. */
|
|
4
|
+
export const HOST_TARGET: "ganymede.host.v1";
|
|
5
|
+
|
|
6
|
+
/** Why a host call failed. The codes are ganymede-device's. */
|
|
7
|
+
export type HostErrorCode = "unavailable" | "denied" | "cancelled" | "timeout" | "failed" | "disconnected";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A failed host call. Branch on `code` (or `name === "HostError"`): the
|
|
11
|
+
* class isn't a global, so `instanceof` isn't available in a cell.
|
|
12
|
+
*
|
|
13
|
+
* A failed host call: `unavailable` (no such method), `denied` (not
|
|
14
|
+
* granted to this notebook), `cancelled` (its signal aborted), `timeout`,
|
|
15
|
+
* `failed` (the server couldn't do it), `disconnected` (the host comm
|
|
16
|
+
* closed, such as on a server restart).
|
|
17
|
+
*/
|
|
18
|
+
export class HostError extends Error {
|
|
19
|
+
readonly code: HostErrorCode;
|
|
20
|
+
constructor(code: HostErrorCode, message: string);
|
|
21
|
+
static from(err: unknown): HostError;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Options for a host call. */
|
|
25
|
+
export interface HostCallOptions {
|
|
26
|
+
/** Aborting it cancels the call. Default `jupyter.signal`. */
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Parameters and results are JSON; the host API's design note lists each method's. */
|
|
31
|
+
export type HostParams = { [key: string]: unknown };
|
|
32
|
+
|
|
33
|
+
/** An entry `notebooks.list` returns: a folder, or a notebook this notebook may read. */
|
|
34
|
+
export interface NotebookEntry {
|
|
35
|
+
path: string;
|
|
36
|
+
type: "directory" | "notebook";
|
|
37
|
+
/** Its first heading, for a notebook that has one. */
|
|
38
|
+
title?: string | null;
|
|
39
|
+
/** Its kernelspec name. */
|
|
40
|
+
kernel?: string | null;
|
|
41
|
+
/** ISO 8601. */
|
|
42
|
+
modified: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A cell `notebooks.search` matched. */
|
|
46
|
+
export interface NotebookHit {
|
|
47
|
+
path: string;
|
|
48
|
+
title: string | null;
|
|
49
|
+
/** The cell's position in the notebook. */
|
|
50
|
+
index: number;
|
|
51
|
+
id: string | null;
|
|
52
|
+
cell_type: "code" | "markdown" | "raw";
|
|
53
|
+
/** Where the first word matched. */
|
|
54
|
+
in: "source" | "output";
|
|
55
|
+
excerpt: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What `notebooks.search` returns. */
|
|
59
|
+
export interface NotebookSearch {
|
|
60
|
+
hits: NotebookHit[];
|
|
61
|
+
/** Matching cells, beyond `limit` too. */
|
|
62
|
+
total: number;
|
|
63
|
+
truncated: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The Ganymede server that runs this kernel, from `jupyter.host()`. */
|
|
67
|
+
export interface Host {
|
|
68
|
+
/** The host API's protocol version. */
|
|
69
|
+
readonly protocol: number;
|
|
70
|
+
/** The server's version. */
|
|
71
|
+
readonly server: string;
|
|
72
|
+
/** This notebook's path under the server's root, or null when the kernel has none. */
|
|
73
|
+
readonly notebook: string | null;
|
|
74
|
+
/** The methods this notebook may call. */
|
|
75
|
+
readonly methods: readonly string[];
|
|
76
|
+
/**
|
|
77
|
+
* Whether this notebook may call `method`.
|
|
78
|
+
*
|
|
79
|
+
* @param method The method, such as `"notebooks.run"`.
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* const host = await jupyter.host();
|
|
83
|
+
* host?.has("notebooks.run"); // false unless the notebook is granted runs
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
has(method: string): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Call a method by name. The shortcuts under `notebooks` and `self` call
|
|
89
|
+
* this.
|
|
90
|
+
*
|
|
91
|
+
* @param method The method, such as `"notebooks.read"`.
|
|
92
|
+
* @param params Its parameters, a JSON object. Default `{}`.
|
|
93
|
+
* @param options `signal` cancels the call.
|
|
94
|
+
* @returns What the server answered, as JSON.
|
|
95
|
+
* @throws {HostError} The call failed; `code` says why.
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* const host = await jupyter.host();
|
|
99
|
+
* const { notebook } = await host!.call("notebooks.read", { path: "journal/a.ipynb" });
|
|
100
|
+
* notebook.cells.length;
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
call(method: string, params?: HostParams, options?: HostCallOptions): Promise<any>;
|
|
104
|
+
/** Other notebooks under the server's root, as far as this notebook's grant reaches. */
|
|
105
|
+
readonly notebooks: HostNotebooks;
|
|
106
|
+
/** This notebook's own cells. Not on a server yet: they reject `unavailable`. */
|
|
107
|
+
readonly self: HostSelf;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `host.notebooks`: other notebooks under the server's root. */
|
|
111
|
+
export interface HostNotebooks {
|
|
112
|
+
/**
|
|
113
|
+
* The notebooks this notebook may read, and the folders, in a folder.
|
|
114
|
+
*
|
|
115
|
+
* @param params `dir`: the folder, relative to the root. Default the root.
|
|
116
|
+
* @param options `signal` cancels the call.
|
|
117
|
+
* @returns One entry per notebook or folder.
|
|
118
|
+
* @throws {HostError} `denied` when the grant doesn't reach `dir`.
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* const host = await jupyter.host();
|
|
122
|
+
* const entries = await host!.notebooks.list({ dir: "journal" });
|
|
123
|
+
* entries.filter((e) => e.type === "notebook").map((e) => e.title ?? e.path);
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
list(params?: { dir?: string }, options?: HostCallOptions): Promise<NotebookEntry[]>;
|
|
127
|
+
/**
|
|
128
|
+
* A notebook as nbformat JSON (from its open document when someone has
|
|
129
|
+
* it open), and each cell's source hash by cell id.
|
|
130
|
+
*
|
|
131
|
+
* @param params `path`: the notebook, relative to the root.
|
|
132
|
+
* @param options `signal` cancels the call.
|
|
133
|
+
* @returns The notebook and its hashes.
|
|
134
|
+
* @throws {HostError} `denied` when the grant doesn't reach `path`.
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* const host = await jupyter.host();
|
|
138
|
+
* const { notebook } = await host!.notebooks.read({ path: "journal/a.ipynb" });
|
|
139
|
+
* notebook.cells.length;
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
read(params: { path: string }, options?: HostCallOptions): Promise<{ notebook: any; hashes: { [cellId: string]: string } }>;
|
|
143
|
+
/**
|
|
144
|
+
* Cells that hold every word of `query`, in the notebooks this notebook
|
|
145
|
+
* may read: in sources, and in outputs too with `outputs: true`.
|
|
146
|
+
*
|
|
147
|
+
* @param params `query`, and optionally `dir`, `outputs`, `cell_type`
|
|
148
|
+
* (`code`, `markdown` or `raw`) and `limit` (default 20, at most 200).
|
|
149
|
+
* @param options `signal` cancels the call.
|
|
150
|
+
* @returns The matches, how many there are in all, and whether `limit` cut them.
|
|
151
|
+
* @example
|
|
152
|
+
* ```ts
|
|
153
|
+
* const host = await jupyter.host();
|
|
154
|
+
* const { hits } = await host!.notebooks.search({ query: "weekday drop", dir: "journal" });
|
|
155
|
+
* hits.map((h) => `${h.path}: ${h.excerpt}`);
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
search(
|
|
159
|
+
params: { query: string; dir?: string; outputs?: boolean; cell_type?: "code" | "markdown" | "raw"; limit?: number },
|
|
160
|
+
options?: HostCallOptions,
|
|
161
|
+
): Promise<NotebookSearch>;
|
|
162
|
+
/**
|
|
163
|
+
* A notebook's named results. Not on a server yet: rejects `unavailable`.
|
|
164
|
+
*
|
|
165
|
+
* @param params `path`: the notebook.
|
|
166
|
+
* @param options `signal` cancels the call.
|
|
167
|
+
* @example
|
|
168
|
+
* ```ts
|
|
169
|
+
* const host = await jupyter.host();
|
|
170
|
+
* const results = await host!.notebooks.results({ path: "journal/fremont.ipynb" });
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
results(params: { path: string }, options?: HostCallOptions): Promise<any>;
|
|
174
|
+
/**
|
|
175
|
+
* Run a notebook with parameters and return its results. Not on a
|
|
176
|
+
* server yet: rejects `unavailable`.
|
|
177
|
+
*
|
|
178
|
+
* @param params `path`: the notebook; `params`: its parameters.
|
|
179
|
+
* @param options `signal` cancels the run.
|
|
180
|
+
* @example
|
|
181
|
+
* ```ts
|
|
182
|
+
* const host = await jupyter.host();
|
|
183
|
+
* const run = await host!.notebooks.run({ path: "journal/fremont.ipynb", params: { counter: "2nd Ave" } });
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
run(params: { path: string; params?: HostParams }, options?: HostCallOptions): Promise<any>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `host.self`: this notebook's own cells. Not on a server yet: each rejects `unavailable`. */
|
|
190
|
+
export interface HostSelf {
|
|
191
|
+
/**
|
|
192
|
+
* Add a cell to this notebook.
|
|
193
|
+
*
|
|
194
|
+
* @param params The cell, as the host API's design note describes.
|
|
195
|
+
* @param options `signal` cancels the call.
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* const host = await jupyter.host();
|
|
199
|
+
* await host!.self.add({ cell_type: "markdown", source: "## Index" });
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
add(params?: HostParams, options?: HostCallOptions): Promise<any>;
|
|
203
|
+
/**
|
|
204
|
+
* Change a cell's source, given the hash of the source it replaces.
|
|
205
|
+
*
|
|
206
|
+
* @param params The cell id, its new source and the old source's hash.
|
|
207
|
+
* @param options `signal` cancels the call.
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* const host = await jupyter.host();
|
|
211
|
+
* const { notebook, hashes } = await host!.notebooks.read({ path: host!.notebook! });
|
|
212
|
+
* const id = notebook.cells[0].id;
|
|
213
|
+
* await host!.self.edit({ id, source: "# New title", source_hash: hashes[id] });
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
edit(params?: HostParams, options?: HostCallOptions): Promise<any>;
|
|
217
|
+
/**
|
|
218
|
+
* Delete a cell, given its source's hash.
|
|
219
|
+
*
|
|
220
|
+
* @param params The cell id and its source's hash.
|
|
221
|
+
* @param options `signal` cancels the call.
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* const host = await jupyter.host();
|
|
225
|
+
* const { notebook, hashes } = await host!.notebooks.read({ path: host!.notebook! });
|
|
226
|
+
* const id = notebook.cells.at(-1).id;
|
|
227
|
+
* await host!.self.delete({ id, source_hash: hashes[id] });
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
delete(params?: HostParams, options?: HostCallOptions): Promise<any>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Options for `jupyter.host()`. */
|
|
234
|
+
export interface HostOptions {
|
|
235
|
+
/**
|
|
236
|
+
* How long to wait for a server that hasn't opened the comm, in ms.
|
|
237
|
+
* Default 1000. Once a wait goes unanswered, later calls answer `null`
|
|
238
|
+
* at once.
|
|
239
|
+
*/
|
|
240
|
+
timeout?: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function createHost(
|
|
244
|
+
comms: CommApi,
|
|
245
|
+
defaultSignal?: () => AbortSignal | undefined,
|
|
246
|
+
): { host(options?: HostOptions): Promise<Host | null> };
|
package/runtime/host.mjs
ADDED
|
@@ -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
|
+
}
|
package/runtime/widget.d.mts
CHANGED
|
@@ -5,31 +5,230 @@ export const WIDGET_PROTOCOL_VERSION: "2.1.0";
|
|
|
5
5
|
|
|
6
6
|
export type WidgetState = { [key: string]: unknown };
|
|
7
7
|
|
|
8
|
+
/** Options for `jupyter.widget`. */
|
|
8
9
|
export interface WidgetOptions {
|
|
9
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* The anywidget view module, as source:
|
|
12
|
+
* `export default { render({ model, el }) { ... } }`. A `${...}` inside
|
|
13
|
+
* a template literal here is filled in by the kernel, not the browser:
|
|
14
|
+
* escape it as `\${...}`.
|
|
15
|
+
*/
|
|
10
16
|
esm: string;
|
|
11
|
-
/**
|
|
17
|
+
/** CSS for the view. */
|
|
12
18
|
css?: string;
|
|
13
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* The initial synced state. `Uint8Array` and `ArrayBuffer` values travel
|
|
21
|
+
* as binary buffers. Default `{}`.
|
|
22
|
+
*/
|
|
14
23
|
state?: WidgetState;
|
|
15
24
|
}
|
|
16
25
|
|
|
17
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* An anywidget model, from `jupyter.widget`, synced with its frontend
|
|
28
|
+
* views. Display it to show a view; display it again for a second view of
|
|
29
|
+
* the same model.
|
|
30
|
+
*/
|
|
18
31
|
export interface Widget {
|
|
19
32
|
/** The model id (the comm id). */
|
|
20
33
|
readonly id: string;
|
|
34
|
+
/** Whether the model has been closed. */
|
|
21
35
|
readonly closed: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The current value of a state key.
|
|
38
|
+
*
|
|
39
|
+
* @param key The state key.
|
|
40
|
+
* @returns Its value, or `undefined`.
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* const w = jupyter.widget({ esm: "export default {}", state: { count: 0 } });
|
|
44
|
+
* w.get("count"); // 0
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
22
47
|
get(key: string): unknown;
|
|
23
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Change one state key, or several at once, and sync the change to every
|
|
50
|
+
* view. Fires this model's own `change` listeners too. After `close` it
|
|
51
|
+
* changes the local state only.
|
|
52
|
+
*
|
|
53
|
+
* @param key The state key.
|
|
54
|
+
* @param value Its new value.
|
|
55
|
+
* @param changes Several keys and their new values, instead of `key` and
|
|
56
|
+
* `value`.
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* const w = jupyter.widget({ esm: "export default {}", state: { count: 0, label: "" } });
|
|
60
|
+
* w.set("count", 1);
|
|
61
|
+
* w.set({ count: 2, label: "two" });
|
|
62
|
+
* ```
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* const progress = jupyter.widget({
|
|
66
|
+
* esm: `export default {
|
|
67
|
+
* render({ model, el }) {
|
|
68
|
+
* el.innerHTML =
|
|
69
|
+
* '<div class="progress-track"><div class="progress-fill"></div></div>' +
|
|
70
|
+
* '<div class="progress-label"></div>';
|
|
71
|
+
* const fill = el.querySelector(".progress-fill");
|
|
72
|
+
* const label = el.querySelector(".progress-label");
|
|
73
|
+
* const update = () => {
|
|
74
|
+
* const percent = Math.round(model.get("value") * 100);
|
|
75
|
+
* fill.style.width = percent + "%";
|
|
76
|
+
* label.textContent = model.get("message") + " (" + percent + "%)";
|
|
77
|
+
* };
|
|
78
|
+
* model.on("change:value", update);
|
|
79
|
+
* model.on("change:message", update);
|
|
80
|
+
* update();
|
|
81
|
+
* },
|
|
82
|
+
* };`,
|
|
83
|
+
* css: `
|
|
84
|
+
* .progress-track { width: 320px; height: 10px; border-radius: 5px; background: #e5e7eb; overflow: hidden; }
|
|
85
|
+
* .progress-fill { height: 100%; background: #2e6e64; transition: width 0.2s; }
|
|
86
|
+
* .progress-label { margin-top: 6px; font: 13px system-ui, sans-serif; color: #444; }
|
|
87
|
+
* `,
|
|
88
|
+
* state: { value: 0, message: "Starting" },
|
|
89
|
+
* });
|
|
90
|
+
* await jupyter.display(progress);
|
|
91
|
+
*
|
|
92
|
+
* const files = ["january.csv", "february.csv", "march.csv", "april.csv", "may.csv"];
|
|
93
|
+
* for (const [i, file] of files.entries()) {
|
|
94
|
+
* progress.set({ message: "Processing " + file, value: i / files.length });
|
|
95
|
+
* await new Promise((resolve) => setTimeout(resolve, 600)); // stand-in for real work
|
|
96
|
+
* }
|
|
97
|
+
* progress.set({ message: "Done", value: 1 });
|
|
98
|
+
* ```
|
|
99
|
+
* 
|
|
100
|
+
*/
|
|
24
101
|
set(key: string, value: unknown): void;
|
|
25
102
|
set(changes: WidgetState): void;
|
|
103
|
+
/**
|
|
104
|
+
* Listen for state changes or custom messages from the views.
|
|
105
|
+
* `change:key` gets the new and previous value; `change` gets all the
|
|
106
|
+
* changed keys; `msg:custom` gets a view's `model.send` content.
|
|
107
|
+
*
|
|
108
|
+
* @param event `change:<key>`, `change` or `msg:custom`.
|
|
109
|
+
* @param listener Called for each event; may be async.
|
|
110
|
+
* @example
|
|
111
|
+
* ```ts
|
|
112
|
+
* const w = jupyter.widget({ esm: "export default {}", state: { count: 0 } });
|
|
113
|
+
* w.on("change:count", (value, previous) => console.log(previous, "->", value));
|
|
114
|
+
* w.on("msg:custom", (content) => console.log("view sent", content));
|
|
115
|
+
* ```
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* const temperature = jupyter.widget({
|
|
119
|
+
* esm: `export default {
|
|
120
|
+
* render({ model, el }) {
|
|
121
|
+
* const slider = document.createElement("input");
|
|
122
|
+
* slider.type = "range";
|
|
123
|
+
* slider.min = "-20";
|
|
124
|
+
* slider.max = "40";
|
|
125
|
+
* const label = document.createElement("span");
|
|
126
|
+
* const update = () => {
|
|
127
|
+
* slider.value = String(model.get("celsius"));
|
|
128
|
+
* label.textContent = " " + model.get("celsius") + " °C is " + model.get("fahrenheit") + " °F";
|
|
129
|
+
* };
|
|
130
|
+
* slider.oninput = () => {
|
|
131
|
+
* model.set("celsius", slider.valueAsNumber);
|
|
132
|
+
* model.save_changes();
|
|
133
|
+
* };
|
|
134
|
+
* model.on("change:celsius", update);
|
|
135
|
+
* model.on("change:fahrenheit", update);
|
|
136
|
+
* update();
|
|
137
|
+
* el.append(slider, label);
|
|
138
|
+
* },
|
|
139
|
+
* };`,
|
|
140
|
+
* state: { celsius: 20, fahrenheit: 68 },
|
|
141
|
+
* });
|
|
142
|
+
*
|
|
143
|
+
* temperature.on("change:celsius", (celsius) => {
|
|
144
|
+
* temperature.set("fahrenheit", Math.round((Number(celsius) * 9) / 5 + 32));
|
|
145
|
+
* });
|
|
146
|
+
*
|
|
147
|
+
* temperature;
|
|
148
|
+
* ```
|
|
149
|
+
* 
|
|
150
|
+
*/
|
|
26
151
|
on(event: `change:${string}`, listener: (value: unknown, previous: unknown) => void | Promise<void>): void;
|
|
27
152
|
on(event: "change", listener: (changes: WidgetState) => void | Promise<void>): void;
|
|
28
153
|
on(event: "msg:custom", listener: (content: unknown, buffers: Uint8Array[]) => void | Promise<void>): void;
|
|
154
|
+
/**
|
|
155
|
+
* Remove a listener added with `on`.
|
|
156
|
+
*
|
|
157
|
+
* @param event The event it was added for.
|
|
158
|
+
* @param listener The same function.
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* const w = jupyter.widget({ esm: "export default {}", state: { count: 0 } });
|
|
162
|
+
* const log = (value: unknown) => console.log(value);
|
|
163
|
+
* w.on("change:count", log);
|
|
164
|
+
* w.off("change:count", log);
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
29
167
|
off(event: string, listener: (...args: never[]) => unknown): void;
|
|
30
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Send a custom message to every view's `model.on("msg:custom", ...)`.
|
|
170
|
+
*
|
|
171
|
+
* @param content JSON content.
|
|
172
|
+
* @param buffers Binary buffers. Default `[]`.
|
|
173
|
+
* @example
|
|
174
|
+
* ```ts
|
|
175
|
+
* const w = jupyter.widget({ esm: "export default {}" });
|
|
176
|
+
* w.send({ type: "reset" });
|
|
177
|
+
* ```
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* const dice = jupyter.widget({
|
|
181
|
+
* esm: `export default {
|
|
182
|
+
* render({ model, el }) {
|
|
183
|
+
* const button = document.createElement("button");
|
|
184
|
+
* button.textContent = "Roll 3 dice";
|
|
185
|
+
* button.onclick = () => model.send({ count: 3 });
|
|
186
|
+
* const result = document.createElement("span");
|
|
187
|
+
* result.style.marginLeft = "12px";
|
|
188
|
+
* result.style.fontSize = "24px";
|
|
189
|
+
* model.on("msg:custom", (message) => {
|
|
190
|
+
* result.textContent = message.faces + " (total " + message.total + ")";
|
|
191
|
+
* });
|
|
192
|
+
* el.append(button, result);
|
|
193
|
+
* },
|
|
194
|
+
* };`,
|
|
195
|
+
* });
|
|
196
|
+
*
|
|
197
|
+
* dice.on("msg:custom", (content) => {
|
|
198
|
+
* const { count } = content as { count: number };
|
|
199
|
+
* const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * 6));
|
|
200
|
+
* dice.send({
|
|
201
|
+
* faces: rolls.map((n) => "⚀⚁⚂⚃⚄⚅"[n - 1]).join(" "),
|
|
202
|
+
* total: rolls.reduce((a, b) => a + b, 0),
|
|
203
|
+
* });
|
|
204
|
+
* });
|
|
205
|
+
*
|
|
206
|
+
* dice;
|
|
207
|
+
* ```
|
|
208
|
+
* 
|
|
209
|
+
*/
|
|
31
210
|
send(content: unknown, buffers?: Uint8Array[]): void;
|
|
211
|
+
/**
|
|
212
|
+
* Close the model. Its views stop syncing.
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* const w = jupyter.widget({ esm: "export default {}" });
|
|
217
|
+
* w.close();
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
32
220
|
close(): void;
|
|
221
|
+
/**
|
|
222
|
+
* The full state, as the frontend sees it: anywidget's model keys
|
|
223
|
+
* (`_model_name`, `_view_name` and the rest) and the widget's own.
|
|
224
|
+
*
|
|
225
|
+
* @returns Every state key and its value.
|
|
226
|
+
* @example
|
|
227
|
+
* ```ts
|
|
228
|
+
* const w = jupyter.widget({ esm: "export default {}", state: { count: 0 } });
|
|
229
|
+
* w.fullState(); // { _model_name: "AnyModel", _model_module: "anywidget", ..., count: 0 }
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
33
232
|
fullState(): WidgetState;
|
|
34
233
|
/** Displaying a widget shows a view of it (application/vnd.jupyter.widget-view+json). */
|
|
35
234
|
[$display](): MediaBundle;
|