@marimo-team/islands 0.25.3-dev125 → 0.25.3-dev127
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/dist/{ErrorBoundary-BccEkd6j.js → ErrorBoundary-CPEgy6hY.js} +1 -1
- package/dist/{chat-ui-DA8zEZ1I.js → chat-ui-k6_K3FK7.js} +4 -4
- package/dist/{code-visibility-DF85Rrl4.js → code-visibility-DA9jRfM0.js} +3 -3
- package/dist/{common-_eQpkBcI.js → common-uGnMtkcz.js} +5 -5
- package/dist/{data-grid-overlay-editor-B3ajKmHB.js → data-grid-overlay-editor-C2qbg6YG.js} +1 -1
- package/dist/{error-entries-g9iQiVIT.js → error-entries-BvD_zQ4j.js} +1 -1
- package/dist/{feedback-button-C6tIDIdd.js → feedback-button-CIiADQ2k.js} +3 -3
- package/dist/{formats-V8ifA0Bk.js → formats-B3UNwK0S.js} +1 -1
- package/dist/{glide-data-editor-YSVdw8s8.js → glide-data-editor-yXRV-D0b.js} +3 -3
- package/dist/{globals-BFqOJRmn.js → globals-DbERpwvN.js} +1 -1
- package/dist/{html-to-image-Bg5ZoSO-.js → html-to-image-DJ2kGp48.js} +2 -2
- package/dist/main.js +13 -13
- package/dist/{number-overlay-editor-b_1S9raa.js → number-overlay-editor-z12o1WAt.js} +1 -1
- package/dist/{process-output-BtVPCGeI.js → process-output-BcFHHtnm.js} +2 -2
- package/dist/{reveal-component-CRsrHrmb.js → reveal-component-VBjBT6SL.js} +4 -4
- package/dist/{toDate-DKH0_kMo.js → toDate-DfkyXuPi.js} +4 -0
- package/dist/{vega-component-DgytCKrL.js → vega-component-0hWVSBRc.js} +2 -2
- package/package.json +1 -1
- package/src/core/host/__tests__/notebook-client.test.ts +18 -6
- package/src/core/host/__tests__/static.test.ts +4 -2
- package/src/core/host/__tests__/wasm.test.ts +12 -10
- package/src/core/host/backend.ts +8 -3
- package/src/core/host/notebook-client.ts +15 -15
- package/src/core/host/static.ts +2 -2
- package/src/core/host/wasm.ts +4 -3
- package/src/core/runtime/__tests__/runtime.test.ts +17 -0
- package/src/core/runtime/runtime.ts +13 -0
- package/src/core/websocket/__tests__/backend.test.ts +111 -0
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +32 -27
- package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +7 -4
- package/src/core/websocket/backend.ts +68 -0
- package/src/core/websocket/transports/__tests__/notebook.test.ts +9 -56
- package/src/core/websocket/transports/notebook.ts +15 -38
- package/src/core/websocket/useNotebookClient.ts +6 -6
- package/src/core/websocket/useNotebookSession.tsx +3 -2
package/src/core/host/static.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function createStaticNotebookBackend(
|
|
|
51
51
|
message(frame);
|
|
52
52
|
}
|
|
53
53
|
}),
|
|
54
|
-
|
|
54
|
+
notebook: () => ({
|
|
55
55
|
edit: async (_changes, options) => {
|
|
56
56
|
options?.signal?.throwIfAborted();
|
|
57
57
|
return {
|
|
@@ -65,6 +65,6 @@ export function createStaticNotebookBackend(
|
|
|
65
65
|
},
|
|
66
66
|
};
|
|
67
67
|
},
|
|
68
|
-
},
|
|
68
|
+
}),
|
|
69
69
|
};
|
|
70
70
|
}
|
package/src/core/host/wasm.ts
CHANGED
|
@@ -39,6 +39,9 @@ function abortable<T>(
|
|
|
39
39
|
export function createWasmNotebookBackend(
|
|
40
40
|
bridge: WasmNotebookBridge,
|
|
41
41
|
): NotebookBackend {
|
|
42
|
+
const client = NotebookClient.unstable_over((call) => bridge.call(call), {
|
|
43
|
+
notebookId: bridge.notebookId,
|
|
44
|
+
});
|
|
42
45
|
return {
|
|
43
46
|
transport: async ({ cursor, signal }, { open, message }) => {
|
|
44
47
|
if (signal.aborted) {
|
|
@@ -85,8 +88,6 @@ export function createWasmNotebookBackend(
|
|
|
85
88
|
signal.removeEventListener("abort", abort);
|
|
86
89
|
}
|
|
87
90
|
},
|
|
88
|
-
|
|
89
|
-
notebookId: bridge.notebookId,
|
|
90
|
-
}),
|
|
91
|
+
notebook: () => client,
|
|
91
92
|
};
|
|
92
93
|
}
|
|
@@ -220,6 +220,23 @@ describe("RuntimeManager", () => {
|
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
describe("getNotebookURL", () => {
|
|
224
|
+
it("addresses the notebook below the host API without the auth token", () => {
|
|
225
|
+
const runtime = new RuntimeManager(
|
|
226
|
+
{
|
|
227
|
+
url: "https://sandbox.example.com/base",
|
|
228
|
+
lazy: true,
|
|
229
|
+
authToken: "my-secret-token",
|
|
230
|
+
},
|
|
231
|
+
true,
|
|
232
|
+
);
|
|
233
|
+
const url = runtime.getNotebookURL("n/1");
|
|
234
|
+
|
|
235
|
+
expect(url.pathname).toBe("/base/api/marimo/v1/notebooks/n%2F1");
|
|
236
|
+
expect(url.toString()).not.toContain("my-secret-token");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
223
240
|
describe("getSseURL", () => {
|
|
224
241
|
it("should never put the auth token in the URL, even cross-origin", () => {
|
|
225
242
|
// SSE requests can send headers, so auth travels in the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import type { NotebookId } from "@marimo-team/client/protocol";
|
|
3
4
|
import { Deferred } from "@/utils/Deferred";
|
|
4
5
|
import { Logger } from "@/utils/Logger";
|
|
5
6
|
import { KnownQueryParams } from "../constants";
|
|
@@ -171,6 +172,18 @@ export class RuntimeManager {
|
|
|
171
172
|
return url;
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The host API URL of a notebook, below which its operations are served.
|
|
177
|
+
* Requests send auth in headers (see `headers()`), never in the URL.
|
|
178
|
+
*/
|
|
179
|
+
public getNotebookURL(notebookId: NotebookId): URL {
|
|
180
|
+
const url = this.formatHttpURL({
|
|
181
|
+
path: `/api/marimo/v1/notebooks/${encodeURIComponent(notebookId)}`,
|
|
182
|
+
});
|
|
183
|
+
url.searchParams.delete(KnownQueryParams.accessToken);
|
|
184
|
+
return url;
|
|
185
|
+
}
|
|
186
|
+
|
|
174
187
|
/**
|
|
175
188
|
* The WebSocket Sync URL of the runtime, for real-time updates.
|
|
176
189
|
*/
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { NotebookEvents } from "@marimo-team/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createEditorBackend } from "../backend";
|
|
6
|
+
|
|
7
|
+
const wasm = vi.hoisted(() => ({ enabled: false, consumer: vi.fn() }));
|
|
8
|
+
vi.mock("../../wasm/utils", () => ({ isWasm: () => wasm.enabled }));
|
|
9
|
+
vi.mock("../../wasm/bridge", () => ({
|
|
10
|
+
PyodideBridge: { INSTANCE: { attachMessageConsumer: wasm.consumer } },
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
wasm.enabled = false;
|
|
15
|
+
wasm.consumer.mockReset();
|
|
16
|
+
vi.unstubAllGlobals();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("createEditorBackend", () => {
|
|
20
|
+
const options = {
|
|
21
|
+
static: false,
|
|
22
|
+
transportType: "websocket" as const,
|
|
23
|
+
url: () => "ws://localhost/ws",
|
|
24
|
+
headers: () => ({}),
|
|
25
|
+
notebookUrl: (id: string) =>
|
|
26
|
+
new URL(`http://localhost/api/marimo/v1/notebooks/${id}`),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
it.each([
|
|
30
|
+
["a static notebook", { ...options, static: true }, "idle"],
|
|
31
|
+
["WASM", options, "wasm"],
|
|
32
|
+
["SSE", { ...options, transportType: "sse" as const }, "fetch"],
|
|
33
|
+
["the default", options, "socket"],
|
|
34
|
+
] as const)("follows %s", async (_name, chosen, expected) => {
|
|
35
|
+
wasm.enabled = expected === "wasm";
|
|
36
|
+
const fetch = vi.fn(() => new Promise<Response>(() => undefined));
|
|
37
|
+
const socket = vi.fn();
|
|
38
|
+
vi.stubGlobal("fetch", fetch);
|
|
39
|
+
vi.stubGlobal(
|
|
40
|
+
"WebSocket",
|
|
41
|
+
class extends EventTarget {
|
|
42
|
+
public constructor(url: URL) {
|
|
43
|
+
super();
|
|
44
|
+
socket(url);
|
|
45
|
+
}
|
|
46
|
+
public close() {}
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
const events = new NotebookEvents(createEditorBackend(chosen).transport);
|
|
50
|
+
await vi.waitFor(() =>
|
|
51
|
+
expect(
|
|
52
|
+
{
|
|
53
|
+
idle:
|
|
54
|
+
events.status === "connected" && !wasm.consumer.mock.calls.length,
|
|
55
|
+
wasm: wasm.consumer.mock.calls.length === 1,
|
|
56
|
+
fetch: fetch.mock.calls.length === 1,
|
|
57
|
+
socket: socket.mock.calls.length === 1,
|
|
58
|
+
}[expected],
|
|
59
|
+
).toBe(true),
|
|
60
|
+
);
|
|
61
|
+
events.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("edits a reported notebook on its host with the session's headers", async () => {
|
|
65
|
+
const fetch = vi.fn(
|
|
66
|
+
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
|
67
|
+
new Response(
|
|
68
|
+
JSON.stringify({ type: "conflict", title: "Conflict", status: 409 }),
|
|
69
|
+
{
|
|
70
|
+
status: 409,
|
|
71
|
+
headers: { "Content-Type": "application/problem+json" },
|
|
72
|
+
},
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
vi.stubGlobal("fetch", fetch);
|
|
76
|
+
const backend = createEditorBackend({
|
|
77
|
+
...options,
|
|
78
|
+
headers: () => ({
|
|
79
|
+
Authorization: "Bearer editor-token",
|
|
80
|
+
"Marimo-Server-Token": "server-token",
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
const result = await backend
|
|
84
|
+
.notebook("n1")
|
|
85
|
+
.edit([], { idempotencyKey: "edit-1" });
|
|
86
|
+
expect(result).toMatchObject({ ok: false, status: 409 });
|
|
87
|
+
const [[input, init]] = fetch.mock.calls;
|
|
88
|
+
expect(String(input)).toBe(
|
|
89
|
+
"http://localhost/api/marimo/v1/notebooks/n1/edits",
|
|
90
|
+
);
|
|
91
|
+
const headers = new Headers(init?.headers);
|
|
92
|
+
expect(init?.method).toBe("POST");
|
|
93
|
+
expect(headers.get("Authorization")).toBe("Bearer editor-token");
|
|
94
|
+
expect(headers.get("Marimo-Server-Token")).toBe("server-token");
|
|
95
|
+
expect(headers.get("Idempotency-Key")).toBe("edit-1");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it.each([
|
|
99
|
+
["a static notebook", { ...options, static: true }, false],
|
|
100
|
+
["WASM", options, true],
|
|
101
|
+
] as const)("does not edit %s", async (_name, chosen, isWasm) => {
|
|
102
|
+
wasm.enabled = isWasm;
|
|
103
|
+
const fetch = vi.fn();
|
|
104
|
+
vi.stubGlobal("fetch", fetch);
|
|
105
|
+
const result = await createEditorBackend(chosen)
|
|
106
|
+
.notebook("n1")
|
|
107
|
+
.edit([], { idempotencyKey: "edit-1" });
|
|
108
|
+
expect(result).toMatchObject({ ok: false, status: 501 });
|
|
109
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -8,13 +8,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
8
8
|
import type { NotebookTransport } from "@marimo-team/client";
|
|
9
9
|
import { ConnectionState } from "../types";
|
|
10
10
|
import { Deferred } from "@/utils/Deferred";
|
|
11
|
-
import {
|
|
11
|
+
import type { NotebookBackend } from "../../host/backend";
|
|
12
|
+
import { createEditorBackend } from "../backend";
|
|
12
13
|
import { useNotebookClient } from "../useNotebookClient";
|
|
13
14
|
import { fixtureMessages } from "../../host/__tests__/fixtures";
|
|
14
15
|
import { agentAttachmentsAtom } from "../../attachments/state";
|
|
15
16
|
import { notebookClientAtom } from "../state";
|
|
16
17
|
|
|
17
|
-
vi.mock("../
|
|
18
|
+
vi.mock("../backend", () => ({ createEditorBackend: vi.fn() }));
|
|
19
|
+
|
|
20
|
+
/** A backend that follows `transport` and cannot edit. */
|
|
21
|
+
function backend(transport: NotebookTransport): NotebookBackend {
|
|
22
|
+
return { transport, notebook: () => ({ edit: vi.fn() }) };
|
|
23
|
+
}
|
|
18
24
|
|
|
19
25
|
/** A transport whose connections the test drives. */
|
|
20
26
|
function makeTransport() {
|
|
@@ -53,6 +59,7 @@ function setup(
|
|
|
53
59
|
transportType: "websocket" as const,
|
|
54
60
|
url: () => "ws://localhost/ws",
|
|
55
61
|
headers: () => ({}),
|
|
62
|
+
notebookUrl: (id: string) => new URL(`http://localhost/notebooks/${id}`),
|
|
56
63
|
waitUntilReady: async () => undefined,
|
|
57
64
|
checkHealth: async () => true,
|
|
58
65
|
onNotification: vi.fn(),
|
|
@@ -70,7 +77,7 @@ function setup(
|
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
beforeEach(() => {
|
|
73
|
-
vi.mocked(
|
|
80
|
+
vi.mocked(createEditorBackend).mockReset();
|
|
74
81
|
});
|
|
75
82
|
|
|
76
83
|
describe("useNotebookClient", () => {
|
|
@@ -78,16 +85,15 @@ describe("useNotebookClient", () => {
|
|
|
78
85
|
"rechecks health after failed startup (healthy: %s)",
|
|
79
86
|
async (healthy) => {
|
|
80
87
|
const fake = makeTransport();
|
|
81
|
-
vi.mocked(
|
|
82
|
-
(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
},
|
|
88
|
+
vi.mocked(createEditorBackend).mockImplementation(({ url }) =>
|
|
89
|
+
backend(async (attempt, handlers) => {
|
|
90
|
+
if (typeof url === "function") {
|
|
91
|
+
await url();
|
|
92
|
+
}
|
|
93
|
+
if (!attempt.signal.aborted) {
|
|
94
|
+
return fake.transport(attempt, handlers);
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
91
97
|
);
|
|
92
98
|
const waitUntilReady = vi
|
|
93
99
|
.fn()
|
|
@@ -125,14 +131,13 @@ describe("useNotebookClient", () => {
|
|
|
125
131
|
it("waits for initial readiness without reviving an unmounted connection", async () => {
|
|
126
132
|
const ready = new Deferred<void>();
|
|
127
133
|
const resolvedUrl = vi.fn();
|
|
128
|
-
vi.mocked(
|
|
129
|
-
(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
},
|
|
134
|
+
vi.mocked(createEditorBackend).mockImplementation(({ url }) =>
|
|
135
|
+
backend(async () => {
|
|
136
|
+
if (typeof url === "function") {
|
|
137
|
+
await url();
|
|
138
|
+
resolvedUrl();
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
136
141
|
);
|
|
137
142
|
const { result, store, unmount } = setup(false, {
|
|
138
143
|
waitUntilReady: () => ready.promise,
|
|
@@ -152,7 +157,7 @@ describe("useNotebookClient", () => {
|
|
|
152
157
|
|
|
153
158
|
it("exposes host presence to the UI without waiting for kernel-ready", async () => {
|
|
154
159
|
const fake = makeTransport();
|
|
155
|
-
vi.mocked(
|
|
160
|
+
vi.mocked(createEditorBackend).mockReturnValue(backend(fake.transport));
|
|
156
161
|
const { result, store, unmount } = setup();
|
|
157
162
|
await act(async () => Promise.resolve());
|
|
158
163
|
expect(store.get(notebookClientAtom)).toBe(result.current);
|
|
@@ -177,7 +182,7 @@ describe("useNotebookClient", () => {
|
|
|
177
182
|
|
|
178
183
|
it("uses current callbacks and URL providers without reconnecting on a rerender", async () => {
|
|
179
184
|
const fake = makeTransport();
|
|
180
|
-
vi.mocked(
|
|
185
|
+
vi.mocked(createEditorBackend).mockReturnValue(backend(fake.transport));
|
|
181
186
|
const { rerender, options, unmount } = setup();
|
|
182
187
|
await act(async () => Promise.resolve());
|
|
183
188
|
const onNotification = vi.fn();
|
|
@@ -187,8 +192,8 @@ describe("useNotebookClient", () => {
|
|
|
187
192
|
expect(onNotification).toHaveBeenCalledExactlyOnceWith({
|
|
188
193
|
data: { op: "reconnected" },
|
|
189
194
|
});
|
|
190
|
-
expect(
|
|
191
|
-
const [{ url }] = vi.mocked(
|
|
195
|
+
expect(createEditorBackend).toHaveBeenCalledOnce();
|
|
196
|
+
const [{ url }] = vi.mocked(createEditorBackend).mock.calls[0];
|
|
192
197
|
expect(String(await (url as () => Promise<string>)())).toBe(
|
|
193
198
|
"ws://redirected/ws",
|
|
194
199
|
);
|
|
@@ -197,10 +202,10 @@ describe("useNotebookClient", () => {
|
|
|
197
202
|
|
|
198
203
|
it("closes every stream under Strict Mode and only follows the active lifetime", async () => {
|
|
199
204
|
const fakes: ReturnType<typeof makeTransport>[] = [];
|
|
200
|
-
vi.mocked(
|
|
205
|
+
vi.mocked(createEditorBackend).mockImplementation(() => {
|
|
201
206
|
const next = makeTransport();
|
|
202
207
|
fakes.push(next);
|
|
203
|
-
return next.transport;
|
|
208
|
+
return backend(next.transport);
|
|
204
209
|
});
|
|
205
210
|
const { result, store, options, unmount } = setup(true);
|
|
206
211
|
await act(async () => Promise.resolve());
|
|
@@ -21,8 +21,8 @@ import {
|
|
|
21
21
|
vi,
|
|
22
22
|
} from "vitest";
|
|
23
23
|
|
|
24
|
-
vi.mock("../
|
|
25
|
-
|
|
24
|
+
vi.mock("../backend", () => ({
|
|
25
|
+
createEditorBackend: vi.fn(),
|
|
26
26
|
}));
|
|
27
27
|
|
|
28
28
|
vi.mock("@/core/runtime/config", async () => {
|
|
@@ -51,7 +51,7 @@ import { generateSessionId } from "../../kernel/session";
|
|
|
51
51
|
import { RuntimeManager } from "../../runtime/runtime";
|
|
52
52
|
import { WebSocketClosedReason, ConnectionState } from "../types";
|
|
53
53
|
import { classifyCloseEvent } from "../connection-state";
|
|
54
|
-
import {
|
|
54
|
+
import { createEditorBackend } from "../backend";
|
|
55
55
|
import { useNotebookSession } from "../useNotebookSession";
|
|
56
56
|
import { type NotebookTransport, StreamStopped } from "@marimo-team/client";
|
|
57
57
|
|
|
@@ -85,7 +85,10 @@ type FakeTransport = ReturnType<typeof makeTransport>;
|
|
|
85
85
|
|
|
86
86
|
/** Use a fake transport for the next session the hook follows. */
|
|
87
87
|
function withTransport(fake: FakeTransport = makeTransport()) {
|
|
88
|
-
vi.mocked(
|
|
88
|
+
vi.mocked(createEditorBackend).mockReturnValue({
|
|
89
|
+
transport: fake.transport,
|
|
90
|
+
notebook: () => ({ edit: vi.fn() }),
|
|
91
|
+
});
|
|
89
92
|
return fake;
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { NotebookClient, type StreamUrl } from "@marimo-team/client";
|
|
4
|
+
import type { NotebookId } from "@marimo-team/client/protocol";
|
|
5
|
+
import type { NotebookBackend } from "../host/backend";
|
|
6
|
+
import { PyodideBridge } from "../wasm/bridge";
|
|
7
|
+
import { isWasm } from "../wasm/utils";
|
|
8
|
+
import {
|
|
9
|
+
idle,
|
|
10
|
+
pyodide,
|
|
11
|
+
sessionSse,
|
|
12
|
+
websocket,
|
|
13
|
+
withHeaders,
|
|
14
|
+
} from "./transports/notebook";
|
|
15
|
+
|
|
16
|
+
/** How the editor follows its notebook. */
|
|
17
|
+
export interface EditorBackendOptions {
|
|
18
|
+
static: boolean;
|
|
19
|
+
transportType: "websocket" | "sse";
|
|
20
|
+
/** The session route, read before each connection. */
|
|
21
|
+
url: StreamUrl;
|
|
22
|
+
/** The runtime's headers, read before each request. */
|
|
23
|
+
headers: () => Record<string, string>;
|
|
24
|
+
/** The host URL of a notebook the stream reported. */
|
|
25
|
+
notebookUrl: (notebookId: NotebookId) => URL;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Commands for a notebook the editor cannot change through its backend. */
|
|
29
|
+
function uneditable(): ReturnType<NotebookBackend["notebook"]> {
|
|
30
|
+
return {
|
|
31
|
+
edit: async () => ({
|
|
32
|
+
ok: false,
|
|
33
|
+
status: 501,
|
|
34
|
+
problem: {
|
|
35
|
+
type: "tag:marimo.io,2026:host/unsupported-operation",
|
|
36
|
+
title: "Document editing is unavailable on this connection",
|
|
37
|
+
status: 501,
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Choose the backend for the editor's notebook. */
|
|
44
|
+
export function createEditorBackend(
|
|
45
|
+
options: EditorBackendOptions,
|
|
46
|
+
): NotebookBackend {
|
|
47
|
+
if (options.static) {
|
|
48
|
+
return { transport: idle, notebook: uneditable };
|
|
49
|
+
}
|
|
50
|
+
if (isWasm()) {
|
|
51
|
+
return {
|
|
52
|
+
transport: pyodide((consumer) =>
|
|
53
|
+
PyodideBridge.INSTANCE.attachMessageConsumer(consumer),
|
|
54
|
+
),
|
|
55
|
+
notebook: uneditable,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
transport:
|
|
60
|
+
options.transportType === "sse"
|
|
61
|
+
? sessionSse(options.url, options.headers)
|
|
62
|
+
: websocket(options.url),
|
|
63
|
+
notebook: (id) =>
|
|
64
|
+
new NotebookClient(options.notebookUrl(id), {
|
|
65
|
+
fetch: withHeaders(options.headers),
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -11,18 +11,7 @@ import { createNotebookClient } from "../../../host/notebook-client";
|
|
|
11
11
|
import { fixtureMessages } from "../../../host/__tests__/fixtures";
|
|
12
12
|
import { ConnectionState } from "../../../host/connection";
|
|
13
13
|
import { Deferred } from "@/utils/Deferred";
|
|
14
|
-
import {
|
|
15
|
-
createNotebookTransport,
|
|
16
|
-
pyodide,
|
|
17
|
-
sessionSse,
|
|
18
|
-
websocket,
|
|
19
|
-
} from "../notebook";
|
|
20
|
-
|
|
21
|
-
const wasm = vi.hoisted(() => ({ enabled: false, consumer: vi.fn() }));
|
|
22
|
-
vi.mock("../../../wasm/utils", () => ({ isWasm: () => wasm.enabled }));
|
|
23
|
-
vi.mock("../../../wasm/bridge", () => ({
|
|
24
|
-
PyodideBridge: { INSTANCE: { attachMessageConsumer: wasm.consumer } },
|
|
25
|
-
}));
|
|
14
|
+
import { pyodide, sessionSse, websocket } from "../notebook";
|
|
26
15
|
|
|
27
16
|
const encoder = new TextEncoder();
|
|
28
17
|
|
|
@@ -83,14 +72,14 @@ function network(transportType: "websocket" | "sse", url: StreamUrl) {
|
|
|
83
72
|
);
|
|
84
73
|
const count = () =>
|
|
85
74
|
transportType === "websocket" ? FakeSocket.opened.length : requests.length;
|
|
86
|
-
const transport =
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
});
|
|
75
|
+
const transport =
|
|
76
|
+
transportType === "websocket"
|
|
77
|
+
? websocket(url)
|
|
78
|
+
: sessionSse(url, () => ({}));
|
|
79
|
+
const backend = { transport, notebook: () => ({ edit: vi.fn() }) };
|
|
92
80
|
let index = 0;
|
|
93
81
|
return {
|
|
82
|
+
backend,
|
|
94
83
|
transport,
|
|
95
84
|
count,
|
|
96
85
|
opened: async () => {
|
|
@@ -126,8 +115,6 @@ function network(transportType: "websocket" | "sse", url: StreamUrl) {
|
|
|
126
115
|
|
|
127
116
|
afterEach(() => {
|
|
128
117
|
FakeSocket.opened.length = 0;
|
|
129
|
-
wasm.enabled = false;
|
|
130
|
-
wasm.consumer.mockReset();
|
|
131
118
|
vi.unstubAllGlobals();
|
|
132
119
|
vi.useRealTimers();
|
|
133
120
|
});
|
|
@@ -313,7 +300,7 @@ describe.each(["websocket", "sse"] as const)(
|
|
|
313
300
|
const lifetime = new AbortController();
|
|
314
301
|
onTestFinished(() => lifetime.abort());
|
|
315
302
|
connect({
|
|
316
|
-
|
|
303
|
+
backend: connection.backend,
|
|
317
304
|
signal: lifetime.signal,
|
|
318
305
|
checkHealth: async () => true,
|
|
319
306
|
onNotification: vi.fn(),
|
|
@@ -380,7 +367,7 @@ describe.each(["websocket", "sse"] as const)(
|
|
|
380
367
|
const lifetime = new AbortController();
|
|
381
368
|
onTestFinished(() => lifetime.abort());
|
|
382
369
|
connect({
|
|
383
|
-
|
|
370
|
+
backend: connection.backend,
|
|
384
371
|
signal: lifetime.signal,
|
|
385
372
|
checkHealth: async () => true,
|
|
386
373
|
onNotification: vi.fn(),
|
|
@@ -466,37 +453,3 @@ describe("pyodide", () => {
|
|
|
466
453
|
events.close();
|
|
467
454
|
});
|
|
468
455
|
});
|
|
469
|
-
|
|
470
|
-
describe("createNotebookTransport", () => {
|
|
471
|
-
const options = {
|
|
472
|
-
static: false,
|
|
473
|
-
transportType: "websocket" as const,
|
|
474
|
-
url: () => "ws://localhost/ws",
|
|
475
|
-
headers: () => ({}),
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
it.each([
|
|
479
|
-
["a static notebook", { ...options, static: true }, "idle"],
|
|
480
|
-
["WASM", options, "wasm"],
|
|
481
|
-
["SSE", { ...options, transportType: "sse" as const }, "fetch"],
|
|
482
|
-
["the default", options, "socket"],
|
|
483
|
-
] as const)("follows %s", async (_name, chosen, expected) => {
|
|
484
|
-
wasm.enabled = expected === "wasm";
|
|
485
|
-
const fetch = vi.fn(() => new Promise<Response>(() => undefined));
|
|
486
|
-
vi.stubGlobal("fetch", fetch);
|
|
487
|
-
vi.stubGlobal("WebSocket", FakeSocket);
|
|
488
|
-
const events = new NotebookEvents(createNotebookTransport(chosen));
|
|
489
|
-
await vi.waitFor(() =>
|
|
490
|
-
expect(
|
|
491
|
-
{
|
|
492
|
-
idle:
|
|
493
|
-
events.status === "connected" && !wasm.consumer.mock.calls.length,
|
|
494
|
-
wasm: wasm.consumer.mock.calls.length === 1,
|
|
495
|
-
fetch: fetch.mock.calls.length === 1,
|
|
496
|
-
socket: FakeSocket.opened.length === 1,
|
|
497
|
-
}[expected],
|
|
498
|
-
).toBe(true),
|
|
499
|
-
);
|
|
500
|
-
events.close();
|
|
501
|
-
});
|
|
502
|
-
});
|
|
@@ -7,8 +7,6 @@ import {
|
|
|
7
7
|
type StreamUrl,
|
|
8
8
|
} from "@marimo-team/client";
|
|
9
9
|
import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
|
|
10
|
-
import { PyodideBridge } from "../../wasm/bridge";
|
|
11
|
-
import { isWasm } from "../../wasm/utils";
|
|
12
10
|
import { classifyCloseEvent } from "../connection-state";
|
|
13
11
|
|
|
14
12
|
/** The server can be slow to accept when many notebooks are open. */
|
|
@@ -40,6 +38,19 @@ async function resolve(url: StreamUrl): Promise<URL | undefined> {
|
|
|
40
38
|
}
|
|
41
39
|
}
|
|
42
40
|
|
|
41
|
+
/** Send each request with the runtime's headers, read before each request. */
|
|
42
|
+
export function withHeaders(
|
|
43
|
+
headers: () => Record<string, string>,
|
|
44
|
+
): typeof globalThis.fetch {
|
|
45
|
+
return (input, init) => {
|
|
46
|
+
const sent = new Headers(init?.headers);
|
|
47
|
+
for (const [name, value] of Object.entries(headers())) {
|
|
48
|
+
sent.set(name, value);
|
|
49
|
+
}
|
|
50
|
+
return globalThis.fetch(input, { ...init, headers: sent });
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
/** A resume cursor and replica belong to the session that produced them. */
|
|
44
55
|
function withSessionIdentity(
|
|
45
56
|
url: StreamUrl,
|
|
@@ -131,13 +142,7 @@ export function sessionSse(
|
|
|
131
142
|
return withSessionIdentity(url, (target) =>
|
|
132
143
|
sse(target, {
|
|
133
144
|
parseData: jsonParseWithSpecialChar,
|
|
134
|
-
fetch: (
|
|
135
|
-
const sent = new Headers(init?.headers);
|
|
136
|
-
for (const [name, value] of Object.entries(headers())) {
|
|
137
|
-
sent.set(name, value);
|
|
138
|
-
}
|
|
139
|
-
return globalThis.fetch(input, { ...init, headers: sent });
|
|
140
|
-
},
|
|
145
|
+
fetch: withHeaders(headers),
|
|
141
146
|
stop: (frame) =>
|
|
142
147
|
frame.event === "close"
|
|
143
148
|
? stopOn(frame.data as { code?: number; reason?: string })
|
|
@@ -147,7 +152,7 @@ export function sessionSse(
|
|
|
147
152
|
}
|
|
148
153
|
|
|
149
154
|
/** A connection that opens and delivers nothing, for static notebooks. */
|
|
150
|
-
const idle: NotebookTransport = ({ signal }, { open }) =>
|
|
155
|
+
export const idle: NotebookTransport = ({ signal }, { open }) =>
|
|
151
156
|
new Promise((settle) => {
|
|
152
157
|
open();
|
|
153
158
|
signal.addEventListener("abort", () => settle(), { once: true });
|
|
@@ -183,31 +188,3 @@ export function pyodide(
|
|
|
183
188
|
signal.addEventListener("abort", () => settle(), { once: true });
|
|
184
189
|
});
|
|
185
190
|
}
|
|
186
|
-
|
|
187
|
-
/** How the editor follows its notebook. */
|
|
188
|
-
export interface NotebookTransportOptions {
|
|
189
|
-
static: boolean;
|
|
190
|
-
transportType: "websocket" | "sse";
|
|
191
|
-
/** The session route, read before each connection. */
|
|
192
|
-
url: StreamUrl;
|
|
193
|
-
/** The runtime's headers, read before each request. */
|
|
194
|
-
headers: () => Record<string, string>;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Choose the transport for the editor's notebook. */
|
|
198
|
-
export function createNotebookTransport(
|
|
199
|
-
options: NotebookTransportOptions,
|
|
200
|
-
): NotebookTransport {
|
|
201
|
-
if (options.static) {
|
|
202
|
-
return idle;
|
|
203
|
-
}
|
|
204
|
-
if (isWasm()) {
|
|
205
|
-
return pyodide((consumer) =>
|
|
206
|
-
PyodideBridge.INSTANCE.attachMessageConsumer(consumer),
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
if (options.transportType === "sse") {
|
|
210
|
-
return sessionSse(options.url, options.headers);
|
|
211
|
-
}
|
|
212
|
-
return websocket(options.url);
|
|
213
|
-
}
|
|
@@ -13,12 +13,9 @@ import {
|
|
|
13
13
|
type NotebookClient,
|
|
14
14
|
} from "../host/notebook-client";
|
|
15
15
|
import { notebookClientAtom } from "./state";
|
|
16
|
-
import {
|
|
17
|
-
createNotebookTransport,
|
|
18
|
-
type NotebookTransportOptions,
|
|
19
|
-
} from "./transports/notebook";
|
|
16
|
+
import { createEditorBackend, type EditorBackendOptions } from "./backend";
|
|
20
17
|
|
|
21
|
-
type Options = Omit<
|
|
18
|
+
type Options = Omit<EditorBackendOptions, "url"> & {
|
|
22
19
|
/** The session route, read before each connection. */
|
|
23
20
|
url: () => string;
|
|
24
21
|
/** Wait until the host can accept a connection. */
|
|
@@ -33,6 +30,7 @@ export function useNotebookClient(options: Options): NotebookClient {
|
|
|
33
30
|
const controller = useMemo(() => createNotebookClient(store), [store]);
|
|
34
31
|
const url = useEvent(options.url);
|
|
35
32
|
const headers = useEvent(options.headers);
|
|
33
|
+
const notebookUrl = useEvent(options.notebookUrl);
|
|
36
34
|
const waitUntilReady = useEvent(options.waitUntilReady);
|
|
37
35
|
const checkHealth = useEvent(options.checkHealth);
|
|
38
36
|
const onNotification = useEvent(options.onNotification);
|
|
@@ -42,7 +40,7 @@ export function useNotebookClient(options: Options): NotebookClient {
|
|
|
42
40
|
let needsInitialReadiness = true;
|
|
43
41
|
store.set(notebookClientAtom, controller.client);
|
|
44
42
|
controller.connect({
|
|
45
|
-
|
|
43
|
+
backend: createEditorBackend({
|
|
46
44
|
static: isStatic,
|
|
47
45
|
transportType,
|
|
48
46
|
url: async () => {
|
|
@@ -60,6 +58,7 @@ export function useNotebookClient(options: Options): NotebookClient {
|
|
|
60
58
|
return url();
|
|
61
59
|
},
|
|
62
60
|
headers,
|
|
61
|
+
notebookUrl,
|
|
63
62
|
}),
|
|
64
63
|
signal: lifetime.signal,
|
|
65
64
|
checkHealth,
|
|
@@ -80,6 +79,7 @@ export function useNotebookClient(options: Options): NotebookClient {
|
|
|
80
79
|
transportType,
|
|
81
80
|
url,
|
|
82
81
|
headers,
|
|
82
|
+
notebookUrl,
|
|
83
83
|
waitUntilReady,
|
|
84
84
|
checkHealth,
|
|
85
85
|
onNotification,
|