@marimo-team/islands 0.25.3-dev48 → 0.25.3-dev51
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/{ConnectedDataExplorerComponent-COS3f8io.js → ConnectedDataExplorerComponent-Da5OoEUL.js} +9 -9
- package/dist/{ErrorBoundary-DHfwqB0K.js → ErrorBoundary-CbxCC2kk.js} +8 -8
- package/dist/{abnfDiagram-VCTEODGH-DQ4bKxYa.js → abnfDiagram-VCTEODGH-Bz3mrprX.js} +2 -2
- package/dist/{any-language-editor-DQItQSua.js → any-language-editor-k2B8YI3Y.js} +2 -2
- package/dist/{architectureDiagram-5GKGNRK7-BA7-SI7Q.js → architectureDiagram-5GKGNRK7-Cs5ujb9K.js} +1 -1
- package/dist/{chat-ui-DlUp75HS.js → chat-ui-C1Gjgokv.js} +44 -44
- package/dist/{check-C209OH-W.js → check-P35dc2dH.js} +1 -1
- package/dist/{chunk-IMKFNOWR-DPY3kqOV.js → chunk-IMKFNOWR-CcTSaxZt.js} +1 -1
- package/dist/{chunk-SHT3W25Y-C14Ikodf.js → chunk-SHT3W25Y-DcJcjOaO.js} +1 -1
- package/dist/{chunk-SVP7TREG-BKu0HO4I.js → chunk-SVP7TREG-BVtDTjaF.js} +1 -1
- package/dist/{chunk-TICWLB2K-iJDDWGtE.js → chunk-TICWLB2K-BkDFkh8q.js} +1 -1
- package/dist/{classDiagram-ZZMXUADV-HuJXQI0F.js → classDiagram-ZZMXUADV-CfAt7HT5.js} +2 -2
- package/dist/{classDiagram-v2-VYDZK3BY-CpmmpTbC.js → classDiagram-v2-VYDZK3BY-CGEjNYa2.js} +2 -2
- package/dist/{code-visibility-5llx6KCK.js → code-visibility-DoZ9lxzx.js} +5 -5
- package/dist/{common-DBC1NAar.js → common-D1PacyXO.js} +75 -75
- package/dist/{copy-icon-L-mFk59e.js → copy-icon-CPoxUG7G.js} +2 -2
- package/dist/{cynefinDiagram-5FMLGOSQ-uh0puAW2.js → cynefinDiagram-5FMLGOSQ-BW6NmfJi.js} +1 -1
- package/dist/{dagre-GXQ25YYZ-4LBeAAMz.js → dagre-GXQ25YYZ-CWg3CTuF.js} +1 -1
- package/dist/{data-grid-overlay-editor-D01HzlXg.js → data-grid-overlay-editor-B50UpkXd.js} +1 -1
- package/dist/{diagram-S7CK7UJ4-BXPmZ2sX.js → diagram-S7CK7UJ4-BPxtoS-z.js} +1 -1
- package/dist/{diagram-UQ7AKVKN--Xz9UNpC.js → diagram-UQ7AKVKN-DA-vJdFL.js} +1 -1
- package/dist/{diagram-VX7I27RA-rt8VIRNT.js → diagram-VX7I27RA-D6bkR84S.js} +1 -1
- package/dist/{diagram-Z3DM3KII-Cs1GqfDD.js → diagram-Z3DM3KII-m60g4cAT.js} +1 -1
- package/dist/{dialog-CDbPtWUD.js → dialog-C1PcpCqX.js} +24 -24
- package/dist/{dropdown-menu-ugNoZmKx.js → dropdown-menu-MG5hiCIa.js} +793 -793
- package/dist/{ebnfDiagram-PWID7BFC-CgTsgbmZ.js → ebnfDiagram-PWID7BFC-CK99sho2.js} +2 -2
- package/dist/{erDiagram-RLTQ6QDP-CFoADGzT.js → erDiagram-RLTQ6QDP-Bryq0tdE.js} +1 -1
- package/dist/{error-banner-DMIrHA-R.js → error-banner-Sl2FlSJ4.js} +16 -16
- package/dist/{error-entries-D3ouOLDe.js → error-entries-CMNtA8NK.js} +25 -25
- package/dist/{feedback-button-gTC25Y-p.js → feedback-button-DqPqwryB.js} +8 -8
- package/dist/{flowDiagram-HODETNUW-BM2lvKuH.js → flowDiagram-HODETNUW-D-3aRpfS.js} +2 -2
- package/dist/{formats-C24TvExI.js → formats-6pLWWrY2.js} +1 -1
- package/dist/{glide-data-editor-BXXphavz.js → glide-data-editor-BxMhUd3K.js} +10 -10
- package/dist/{glide-utils-Q2ZsuA3h.js → glide-utils-Bt8_pBa2.js} +2 -2
- package/dist/{globals-BcxqBRmX.js → globals-DTJa_doJ.js} +3 -3
- package/dist/{html-to-image-VZvb968z.js → html-to-image-By72ALyI.js} +44 -44
- package/dist/{infoDiagram-27XIBGKW-Crwjiywp.js → infoDiagram-27XIBGKW-s1C1kgfU.js} +1 -1
- package/dist/{ishikawaDiagram-5VMMS53U-D4US2ShV.js → ishikawaDiagram-5VMMS53U-CCsRgolY.js} +1 -1
- package/dist/{kanban-definition-UXKFOSKX-Bk097CUX.js → kanban-definition-UXKFOSKX-BrmxDlcc.js} +1 -1
- package/dist/{label-CuEjBqYP.js → label-BRnUC0NX.js} +1 -1
- package/dist/main.js +614 -614
- package/dist/{mermaid-6EkeBlhr.js → mermaid-DKI0869I.js} +35 -35
- package/dist/{mindmap-definition-YA3MSWOX-DmORzfXQ.js → mindmap-definition-YA3MSWOX-641xJysF.js} +1 -1
- package/dist/{number-overlay-editor-CaspWg5p.js → number-overlay-editor-Z_H6x5b-.js} +1 -1
- package/dist/{pegDiagram-XKGWAZYB-DT5TL02S.js → pegDiagram-XKGWAZYB-keVUAUeS.js} +2 -2
- package/dist/{pieDiagram-E7YTZNPT-B1NwGcuH.js → pieDiagram-E7YTZNPT-DHktXPO_.js} +1 -1
- package/dist/{process-output-CCPgTShO.js → process-output-D-OMW5sV.js} +4 -4
- package/dist/{railroadDiagram-O6MQD6OU-BxoycdEu.js → railroadDiagram-O6MQD6OU-BVEF4OV_.js} +2 -2
- package/dist/{requirementDiagram-BXWQKSXE-DHq_eRrm.js → requirementDiagram-BXWQKSXE-CWo-UAKV.js} +1 -1
- package/dist/{reveal-component-CWk_paD2.js → reveal-component-tAB5K3QE.js} +9 -9
- package/dist/{select-D2iqeJYk.js → select-B0LRDH0g.js} +199 -199
- package/dist/{sequenceDiagram-WJ2MYXX4-Ba11BAOu.js → sequenceDiagram-WJ2MYXX4-CHQvLnq4.js} +1 -1
- package/dist/{spec-BCCcvWPS.js → spec-C-LRKGtT.js} +4 -4
- package/dist/{stateDiagram-D77RDMKH-OHQbvkbP.js → stateDiagram-D77RDMKH-BOJZgbUJ.js} +2 -2
- package/dist/{stateDiagram-v2-MP3YSRHH-BlIquvFJ.js → stateDiagram-v2-MP3YSRHH-C53XXdYH.js} +2 -2
- package/dist/{swimlanes-42K2YHIH-DSlitS4s.js → swimlanes-42K2YHIH-D2o1gOtw.js} +1 -1
- package/dist/{swimlanesDiagram-VR7AAH4N-ClwuzFNO.js → swimlanesDiagram-VR7AAH4N-BHFzN1Wk.js} +2 -2
- package/dist/{timeline-definition-24CTP7MA-hAzc5VkT.js → timeline-definition-24CTP7MA-rv7QUa46.js} +1 -1
- package/dist/toDate-CjPuEPou.js +705 -0
- package/dist/{toast-DxVfYlCp.js → toast-CFWpihJU.js} +221 -221
- package/dist/{tooltip-BCntm-jf.js → tooltip-CKO1pHJo.js} +51 -51
- package/dist/{use-restore-focus-BhXhsNLV.js → use-restore-focus-DBCITLeF.js} +1 -1
- package/dist/{useAsyncData-BbuzBAUp.js → useAsyncData-ChgNtdmE.js} +1 -1
- package/dist/{useDateFormatter-6AHbIR-J.js → useDateFormatter-c_yJtkAe.js} +2 -2
- package/dist/{useDeepCompareMemoize-da5A3c-Y.js → useDeepCompareMemoize-Bet7kSsI.js} +1 -1
- package/dist/{useLifecycle-C3nT1kwI.js → useLifecycle-VVmz_24o.js} +2 -2
- package/dist/{useTheme-BC8Lof0B.js → useTheme-DympCsQg.js} +83 -83
- package/dist/{vega-component-CF9IzE2r.js → vega-component-DnfQeoyt.js} +7 -7
- package/dist/{vennDiagram-4TSXK5OY-CINKaJgr.js → vennDiagram-4TSXK5OY-D36Zk7t-.js} +1 -1
- package/dist/{wardleyDiagram-VM6X3IG4-KG2tlXg_.js → wardleyDiagram-VM6X3IG4-BLBolvyQ.js} +1 -1
- package/dist/{xychartDiagram-S5SC5T6Z-XIH5cwgI.js → xychartDiagram-S5SC5T6Z-CfH57l0v.js} +1 -1
- package/package.json +1 -1
- package/src/__tests__/mount.test.ts +0 -20
- package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +2 -2
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +2 -2
- package/src/core/host/__tests__/notebook-client.test.ts +66 -3
- package/src/core/host/events.ts +1 -10
- package/src/core/host/notebook-client.ts +11 -18
- package/src/core/runtime/__tests__/runtime.test.ts +2 -53
- package/src/core/runtime/runtime.ts +1 -14
- package/src/core/runtime/types.ts +0 -2
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +2 -23
- package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +17 -25
- package/src/core/websocket/transports/__tests__/sse.test.ts +17 -0
- package/src/core/websocket/transports/sse.ts +14 -2
- package/src/core/websocket/useNotebookClient.ts +1 -11
- package/src/core/websocket/useNotebookSession.tsx +1 -6
- package/src/mount.tsx +0 -4
- package/dist/toDate-C0jaysq2.js +0 -1184
|
@@ -6,7 +6,6 @@ import { initialLayoutState, layoutStateAtom } from "@/core/layout/state";
|
|
|
6
6
|
import { kioskModeAtom } from "@/core/mode";
|
|
7
7
|
import { connectionAtom } from "@/core/network/connection";
|
|
8
8
|
import { store } from "@/core/state/jotai";
|
|
9
|
-
import { runtimeConfigAtom } from "@/core/runtime/config";
|
|
10
9
|
import { isStaticNotebook } from "@/core/static/static-state";
|
|
11
10
|
import { ConnectionState } from "@/core/websocket/types";
|
|
12
11
|
import { pairConfigAtom } from "@/core/config/pair";
|
|
@@ -102,25 +101,6 @@ describe("mount", () => {
|
|
|
102
101
|
});
|
|
103
102
|
|
|
104
103
|
describe("connection state initialization", () => {
|
|
105
|
-
it("passes the host notebook identity to the local runtime", () => {
|
|
106
|
-
mount({ ...baseOptions, notebookId: "nb_local" }, mockElement);
|
|
107
|
-
expect(store.get(runtimeConfigAtom).notebookId).toBe("nb_local");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("uses a remote runtime's own notebook identity", () => {
|
|
111
|
-
mount(
|
|
112
|
-
{
|
|
113
|
-
...baseOptions,
|
|
114
|
-
notebookId: "nb_local",
|
|
115
|
-
runtimeConfig: [
|
|
116
|
-
{ url: "https://remote.example", notebookId: "nb_remote" },
|
|
117
|
-
],
|
|
118
|
-
},
|
|
119
|
-
mockElement,
|
|
120
|
-
);
|
|
121
|
-
expect(store.get(runtimeConfigAtom).notebookId).toBe("nb_remote");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
104
|
it("should set connection to CONNECTING when runtimeConfig has lazy=false", () => {
|
|
125
105
|
mount(
|
|
126
106
|
{
|
|
@@ -16,6 +16,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|
|
16
16
|
import { toast } from "@/components/ui/use-toast";
|
|
17
17
|
import type { Attachment } from "@/core/attachments/state";
|
|
18
18
|
import { pairConfigAtom } from "@/core/config/pair";
|
|
19
|
+
import { createNotebookClient } from "@/core/host/notebook-client";
|
|
19
20
|
import {
|
|
20
21
|
type StableSessionId,
|
|
21
22
|
stableSessionIdAtom,
|
|
@@ -45,10 +46,9 @@ function renderStatus(attachments: Attachment[] = [AGENT], preview = true) {
|
|
|
45
46
|
store.set(connectionAtom, { state: ConnectionState.OPEN });
|
|
46
47
|
store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
|
|
47
48
|
store.set(notebookClientAtom, {
|
|
49
|
+
...createNotebookClient(store).client,
|
|
48
50
|
connectionAtom: atom({ state: ConnectionState.OPEN }),
|
|
49
|
-
documentAtom: atom(null),
|
|
50
51
|
attachmentsAtom,
|
|
51
|
-
reconnect: async () => undefined,
|
|
52
52
|
});
|
|
53
53
|
store.set(attachmentsAtom, attachments);
|
|
54
54
|
store.set(stableSessionIdAtom, "stable/test" as StableSessionId);
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "@/core/cells/error-report";
|
|
26
26
|
import { pairConfigAtom } from "@/core/config/pair";
|
|
27
27
|
import { notebookStateFromSession } from "@/core/cells/session";
|
|
28
|
+
import { createNotebookClient } from "@/core/host/notebook-client";
|
|
28
29
|
import type { MarimoError, OutputMessage } from "@/core/kernel/messages";
|
|
29
30
|
import {
|
|
30
31
|
type StableSessionId,
|
|
@@ -97,10 +98,9 @@ function renderButton({
|
|
|
97
98
|
store.set(connectionAtom, { state: ConnectionState.OPEN });
|
|
98
99
|
store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
|
|
99
100
|
store.set(notebookClientAtom, {
|
|
101
|
+
...createNotebookClient(store).client,
|
|
100
102
|
connectionAtom: atom({ state: ConnectionState.OPEN }),
|
|
101
|
-
documentAtom: atom(null),
|
|
102
103
|
attachmentsAtom,
|
|
103
|
-
reconnect: async () => undefined,
|
|
104
104
|
});
|
|
105
105
|
store.set(attachmentsAtom, attachments);
|
|
106
106
|
store.set(stableSessionIdAtom, stableId);
|
|
@@ -49,12 +49,10 @@ function setup(url: () => string = () => "ws://localhost/ws") {
|
|
|
49
49
|
return transport;
|
|
50
50
|
},
|
|
51
51
|
url,
|
|
52
|
-
messageFormat: "host",
|
|
53
52
|
signal,
|
|
54
53
|
waitUntilReady: () => healthy.promise,
|
|
55
54
|
checkHealth,
|
|
56
55
|
onNotification: (notification) => onNotification({ data: notification }),
|
|
57
|
-
onMessageError: vi.fn(),
|
|
58
56
|
decodeJson: jsonParseWithSpecialChar,
|
|
59
57
|
classifyClose: classifyCloseEvent,
|
|
60
58
|
});
|
|
@@ -76,6 +74,71 @@ function setup(url: () => string = () => "ws://localhost/ws") {
|
|
|
76
74
|
}
|
|
77
75
|
|
|
78
76
|
describe("NotebookClient lifetime", () => {
|
|
77
|
+
it("exposes identity and capabilities only after the notebook is ready", async () => {
|
|
78
|
+
const { connect, healthy, events, receive, client, store } = setup();
|
|
79
|
+
const lifetime = new AbortController();
|
|
80
|
+
connect(lifetime.signal);
|
|
81
|
+
healthy.resolve();
|
|
82
|
+
await healthy.promise;
|
|
83
|
+
events.dispatchEvent(new Event("open"));
|
|
84
|
+
const notebook = {
|
|
85
|
+
id: "nb/with space",
|
|
86
|
+
project_id: "p1",
|
|
87
|
+
path: null,
|
|
88
|
+
name: "Notebook",
|
|
89
|
+
operations: ["notebook.edit"],
|
|
90
|
+
updated_at: null,
|
|
91
|
+
};
|
|
92
|
+
receive({ event: "notebook", data: { notebook } });
|
|
93
|
+
expect(store.get(client.notebookAtom)).toBeNull();
|
|
94
|
+
receive({ event: "ready", id: "1", data: {} });
|
|
95
|
+
expect(store.get(client.notebookAtom)).toEqual(notebook);
|
|
96
|
+
receive({
|
|
97
|
+
event: "notebook",
|
|
98
|
+
id: "2",
|
|
99
|
+
data: { notebook: { ...notebook, operations: [] } },
|
|
100
|
+
});
|
|
101
|
+
expect(store.get(client.notebookAtom)?.operations).toEqual([]);
|
|
102
|
+
receive({ event: "notebook.removed", id: "3", data: { id: notebook.id } });
|
|
103
|
+
expect(store.get(client.notebookAtom)).toBeNull();
|
|
104
|
+
lifetime.abort();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rebuilds the replica after a reconnect reset selects a new notebook", async () => {
|
|
108
|
+
const notebookId = "n2";
|
|
109
|
+
const { connect, events, healthy, urls, receive, client, store } = setup(
|
|
110
|
+
() => "ws://localhost/ws?session_id=visitor",
|
|
111
|
+
);
|
|
112
|
+
const lifetime = new AbortController();
|
|
113
|
+
connect(lifetime.signal);
|
|
114
|
+
healthy.resolve();
|
|
115
|
+
await healthy.promise;
|
|
116
|
+
events.dispatchEvent(new Event("open"));
|
|
117
|
+
fixtureMessages("notebook-events.sse").forEach(receive);
|
|
118
|
+
expect(store.get(client.documentAtom)?.revision.notebook_id).toBe("n1");
|
|
119
|
+
|
|
120
|
+
events.dispatchEvent(new CloseEvent("close"));
|
|
121
|
+
expect(urls.at(-1)?.searchParams.get("cursor")).toBe("97");
|
|
122
|
+
expect(store.get(client.documentAtom)?.revision.notebook_id).toBe("n1");
|
|
123
|
+
expect(store.get(client.notebookAtom)).toBeNull();
|
|
124
|
+
events.dispatchEvent(new Event("open"));
|
|
125
|
+
receive({ event: "reset", data: {} });
|
|
126
|
+
expect(store.get(client.documentAtom)).toBeNull();
|
|
127
|
+
for (const event of fixtureMessages("notebook-events.sse")) {
|
|
128
|
+
receive(
|
|
129
|
+
JSON.parse(
|
|
130
|
+
JSON.stringify(event).replaceAll('"n1"', JSON.stringify(notebookId)),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
expect(store.get(client.documentAtom)?.revision.notebook_id).toBe(
|
|
135
|
+
notebookId,
|
|
136
|
+
);
|
|
137
|
+
expect(store.get(client.connectionAtom).state).toBe(ConnectionState.OPEN);
|
|
138
|
+
expect(store.get(client.notebookAtom)?.id).toBe(notebookId);
|
|
139
|
+
lifetime.abort();
|
|
140
|
+
});
|
|
141
|
+
|
|
79
142
|
it.each(["{broken", JSON.stringify({ event: "ready", data: {} })])(
|
|
80
143
|
"stops consuming a malformed host stream: %s",
|
|
81
144
|
(data) => {
|
|
@@ -423,7 +486,7 @@ describe("NotebookClient attachments", () => {
|
|
|
423
486
|
},
|
|
424
487
|
);
|
|
425
488
|
|
|
426
|
-
it("keeps
|
|
489
|
+
it("keeps host resources empty for notification-only WASM connections", async () => {
|
|
427
490
|
const { client, store, receive, lifetime } = await open();
|
|
428
491
|
receive({ data: { op: "kernel-ready" } });
|
|
429
492
|
receive({ data: { op: "reconnected" } });
|
package/src/core/host/events.ts
CHANGED
|
@@ -10,15 +10,6 @@ import * as Protocol from "@marimo-team/client/protocol";
|
|
|
10
10
|
* and cursor so clients can skip them without losing their place in the stream.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
/** Event kinds requested by the client; removals and controls are implicit. */
|
|
14
|
-
export const NOTEBOOK_EVENT_TYPES = [
|
|
15
|
-
"notebook",
|
|
16
|
-
"document",
|
|
17
|
-
"cell",
|
|
18
|
-
"attachment",
|
|
19
|
-
"marimo",
|
|
20
|
-
] as const satisfies readonly (Protocol.ObjectType | "marimo")[];
|
|
21
|
-
|
|
22
13
|
const envelope = z.object({
|
|
23
14
|
event: z.string().min(1),
|
|
24
15
|
data: z.unknown(),
|
|
@@ -54,7 +45,7 @@ export type NotebookEvent =
|
|
|
54
45
|
const sessionEnvelope = z.object({ data: z.unknown() });
|
|
55
46
|
|
|
56
47
|
/**
|
|
57
|
-
* Decode a host event or
|
|
48
|
+
* Decode a host event or an in-process WASM `{ data: notification }` message.
|
|
58
49
|
*
|
|
59
50
|
* Throws if the envelope or a known event is malformed. Native notification
|
|
60
51
|
* payloads are passed through for the session handler to interpret.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { Logger } from "@/utils/Logger";
|
|
4
|
-
import type { Attachment } from "@marimo-team/client/protocol";
|
|
4
|
+
import type { Attachment, NotebookOutput } from "@marimo-team/client/protocol";
|
|
5
5
|
import { createReducer } from "@marimo-team/client/reducer";
|
|
6
6
|
import { type Atom, atom, type createStore } from "jotai/vanilla";
|
|
7
7
|
import {
|
|
@@ -27,6 +27,8 @@ const UNCURSORED = new Set<NotebookEvent["event"]>([
|
|
|
27
27
|
export interface NotebookClient {
|
|
28
28
|
/** Client connection status, independent of application session readiness. */
|
|
29
29
|
readonly connectionAtom: Atom<ConnectionStatus>;
|
|
30
|
+
/** Identity and capabilities, available while the stream is connected and ready. */
|
|
31
|
+
readonly notebookAtom: Atom<NotebookOutput | null>;
|
|
30
32
|
/**
|
|
31
33
|
* The most recently confirmed document.
|
|
32
34
|
*
|
|
@@ -47,8 +49,6 @@ export interface NotebookConnection {
|
|
|
47
49
|
createTransport: (url: () => string) => NotebookTransport;
|
|
48
50
|
/** The current stream URL, including authentication and subscription options. */
|
|
49
51
|
url: () => string;
|
|
50
|
-
/** Host events require an intact stream; legacy notifications are independent. */
|
|
51
|
-
messageFormat: "host" | "session";
|
|
52
52
|
/** Abort to close the transport and clear the client's state. */
|
|
53
53
|
signal: AbortSignal;
|
|
54
54
|
/** Wait until the host can accept the initial connection. */
|
|
@@ -59,8 +59,6 @@ export interface NotebookConnection {
|
|
|
59
59
|
decodeJson: (message: string) => unknown;
|
|
60
60
|
/** Handle a native marimo notification. Its payload has not been validated. */
|
|
61
61
|
onNotification: (notification: unknown) => void;
|
|
62
|
-
/** Report a malformed message without coupling the client to editor UI. */
|
|
63
|
-
onMessageError: (error: unknown) => void;
|
|
64
62
|
/** Choose the connection status and retry policy after the transport closes. */
|
|
65
63
|
classifyClose: (event: CloseEvent) => CloseDecision;
|
|
66
64
|
}
|
|
@@ -75,6 +73,11 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
75
73
|
const streamAtom = atom(reducer.initial);
|
|
76
74
|
// A resumed stream is live, but presence waits for this connection's ready.
|
|
77
75
|
const readyAtom = atom(false);
|
|
76
|
+
const notebookAtom = atom((get) =>
|
|
77
|
+
get(readyAtom) && get(connectionAtom).state === ConnectionState.OPEN
|
|
78
|
+
? get(streamAtom).notebook
|
|
79
|
+
: null,
|
|
80
|
+
);
|
|
78
81
|
const documentAtom = atom(
|
|
79
82
|
(get) => get(streamAtom).resources.document.current,
|
|
80
83
|
);
|
|
@@ -86,6 +89,7 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
86
89
|
let reconnect: (() => Promise<void>) | undefined;
|
|
87
90
|
const client: NotebookClient = {
|
|
88
91
|
connectionAtom,
|
|
92
|
+
notebookAtom,
|
|
89
93
|
documentAtom,
|
|
90
94
|
attachmentsAtom,
|
|
91
95
|
reconnect: async () => reconnect?.(),
|
|
@@ -94,12 +98,10 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
94
98
|
function connect({
|
|
95
99
|
createTransport,
|
|
96
100
|
url,
|
|
97
|
-
messageFormat,
|
|
98
101
|
signal,
|
|
99
102
|
waitUntilReady,
|
|
100
103
|
checkHealth,
|
|
101
104
|
onNotification,
|
|
102
|
-
onMessageError,
|
|
103
105
|
decodeJson,
|
|
104
106
|
classifyClose,
|
|
105
107
|
}: NotebookConnection) {
|
|
@@ -113,9 +115,6 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
113
115
|
// The id of the last live event. Null before `ready` and after `reset`.
|
|
114
116
|
let cursor: string | null = null;
|
|
115
117
|
const transport = createTransport(() => {
|
|
116
|
-
if (messageFormat === "session") {
|
|
117
|
-
return url();
|
|
118
|
-
}
|
|
119
118
|
const next = new URL(url());
|
|
120
119
|
next.searchParams.delete("cursor");
|
|
121
120
|
if (signal.aborted) {
|
|
@@ -131,11 +130,9 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
131
130
|
// A live stream keeps its state and resumes from the cursor. An
|
|
132
131
|
// incomplete snapshot starts over.
|
|
133
132
|
const state =
|
|
134
|
-
key !== streamKey
|
|
133
|
+
key !== streamKey || current.phase === "snapshot"
|
|
135
134
|
? reducer.initial
|
|
136
|
-
: current
|
|
137
|
-
? { ...reducer.initial, notebookId: current.notebookId }
|
|
138
|
-
: current;
|
|
135
|
+
: current;
|
|
139
136
|
if (state.phase === "snapshot") {
|
|
140
137
|
cursor = null;
|
|
141
138
|
}
|
|
@@ -226,10 +223,6 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
|
226
223
|
}
|
|
227
224
|
} catch (error) {
|
|
228
225
|
Logger.error("Failed to decode notebook message", error);
|
|
229
|
-
if (messageFormat === "session") {
|
|
230
|
-
onMessageError(error);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
226
|
store.set(streamAtom, reducer.initial);
|
|
234
227
|
cursor = null;
|
|
235
228
|
terminal = true;
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { Mocks } from "@/__mocks__/common";
|
|
5
|
-
import {
|
|
6
|
-
generateSessionId,
|
|
7
|
-
isSessionId,
|
|
8
|
-
type SessionId,
|
|
9
|
-
} from "@/core/kernel/session";
|
|
5
|
+
import type { SessionId } from "@/core/kernel/session";
|
|
10
6
|
import { Logger } from "@/utils/Logger";
|
|
11
7
|
import { RuntimeManager } from "../runtime";
|
|
12
8
|
import type { RuntimeConfig } from "../types";
|
|
@@ -56,38 +52,6 @@ describe("RuntimeManager", () => {
|
|
|
56
52
|
});
|
|
57
53
|
|
|
58
54
|
describe("getWsURL", () => {
|
|
59
|
-
it("uses host notebook events with the existing session and auth parameters", () => {
|
|
60
|
-
const original = window.location.href;
|
|
61
|
-
window.history.replaceState({}, "", "/?types=run");
|
|
62
|
-
try {
|
|
63
|
-
const runtime = new RuntimeManager({
|
|
64
|
-
...mockConfig,
|
|
65
|
-
url: "https://example.com/nested?file=notebook.py",
|
|
66
|
-
notebookId: "nb_123",
|
|
67
|
-
});
|
|
68
|
-
const sessionId = "s_123456";
|
|
69
|
-
if (!isSessionId(sessionId)) {
|
|
70
|
-
throw new Error("Invalid test session ID");
|
|
71
|
-
}
|
|
72
|
-
const url = runtime.getWsURL(sessionId);
|
|
73
|
-
expect({
|
|
74
|
-
endpoint: `${url.origin}${url.pathname}`,
|
|
75
|
-
params: Object.fromEntries(url.searchParams),
|
|
76
|
-
}).toEqual({
|
|
77
|
-
endpoint:
|
|
78
|
-
"wss://example.com/nested/api/marimo/v1/notebooks/nb_123/events/ws",
|
|
79
|
-
params: {
|
|
80
|
-
file: "notebook.py",
|
|
81
|
-
types: "notebook,document,cell,attachment,marimo",
|
|
82
|
-
session_id: sessionId,
|
|
83
|
-
access_token: "test-token",
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
} finally {
|
|
87
|
-
window.history.replaceState({}, "", original);
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
55
|
it("should return WebSocket URL with session ID for https", () => {
|
|
92
56
|
const runtime = new RuntimeManager(mockConfig);
|
|
93
57
|
const sessionId = "1234" as SessionId;
|
|
@@ -257,21 +221,6 @@ describe("RuntimeManager", () => {
|
|
|
257
221
|
});
|
|
258
222
|
|
|
259
223
|
describe("getSseURL", () => {
|
|
260
|
-
it.each([undefined, "nb_123"])(
|
|
261
|
-
"uses the legacy session stream when notebookId is %s",
|
|
262
|
-
(notebookId) => {
|
|
263
|
-
const runtime = new RuntimeManager({
|
|
264
|
-
...mockConfig,
|
|
265
|
-
url: "https://example.com/nested?file=notebook.py",
|
|
266
|
-
notebookId,
|
|
267
|
-
});
|
|
268
|
-
const sessionId = generateSessionId();
|
|
269
|
-
expect(runtime.getSseURL(sessionId).toString()).toBe(
|
|
270
|
-
`https://example.com/nested/sse?file=notebook.py&session_id=${sessionId}`,
|
|
271
|
-
);
|
|
272
|
-
},
|
|
273
|
-
);
|
|
274
|
-
|
|
275
224
|
it("should never put the auth token in the URL, even cross-origin", () => {
|
|
276
225
|
// SSE requests can send headers, so auth travels in the
|
|
277
226
|
// Authorization header; a token in the URL would leak into
|
|
@@ -781,7 +730,7 @@ describe("RuntimeManager", () => {
|
|
|
781
730
|
);
|
|
782
731
|
});
|
|
783
732
|
|
|
784
|
-
it("
|
|
733
|
+
it("warns when creating WebSocket URLs from blob URLs", () => {
|
|
785
734
|
const runtime = new RuntimeManager({
|
|
786
735
|
url: "blob:https://example.com/12345678-1234-1234-1234-123456789abc",
|
|
787
736
|
lazy: true,
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { Deferred } from "@/utils/Deferred";
|
|
4
4
|
import { Logger } from "@/utils/Logger";
|
|
5
5
|
import { KnownQueryParams } from "../constants";
|
|
6
|
-
import { NOTEBOOK_EVENT_TYPES } from "../host/events";
|
|
7
6
|
import { isIslands } from "../islands/utils";
|
|
8
7
|
import { getSessionId, type SessionId } from "../kernel/session";
|
|
9
8
|
import { isStaticNotebook } from "../static/static-state";
|
|
@@ -155,17 +154,7 @@ export class RuntimeManager {
|
|
|
155
154
|
*/
|
|
156
155
|
public getWsURL(sessionId: SessionId): URL {
|
|
157
156
|
const searchParams = this.getSessionSearchParams(sessionId);
|
|
158
|
-
|
|
159
|
-
const notebookId = this.config.notebookId;
|
|
160
|
-
const path = notebookId
|
|
161
|
-
? `/api/marimo/v1/notebooks/${encodeURIComponent(notebookId)}/events/ws`
|
|
162
|
-
: "/ws";
|
|
163
|
-
const url = this.formatWsURL(path, searchParams);
|
|
164
|
-
if (notebookId) {
|
|
165
|
-
// TODO(MO-8034): Separate host options from notebook query params.
|
|
166
|
-
url.searchParams.set("types", NOTEBOOK_EVENT_TYPES.join(","));
|
|
167
|
-
}
|
|
168
|
-
return url;
|
|
157
|
+
return this.formatWsURL("/ws", searchParams);
|
|
169
158
|
}
|
|
170
159
|
|
|
171
160
|
/**
|
|
@@ -175,8 +164,6 @@ export class RuntimeManager {
|
|
|
175
164
|
* (see `headers()`) instead of the URL.
|
|
176
165
|
*/
|
|
177
166
|
public getSseURL(sessionId: SessionId): URL {
|
|
178
|
-
// TODO: Migrate editor SSE to the host event stream. Until then, host
|
|
179
|
-
// state such as attachments is unavailable on this legacy connection.
|
|
180
167
|
const url = this.formatHttpURL({
|
|
181
168
|
path: "/sse",
|
|
182
169
|
searchParams: this.getSessionSearchParams(sessionId),
|
|
@@ -15,11 +15,12 @@ import { notebookClientAtom } from "../state";
|
|
|
15
15
|
|
|
16
16
|
vi.mock("../transports/create", () => ({ createConnectionTransport: vi.fn() }));
|
|
17
17
|
|
|
18
|
-
function makeTransport() {
|
|
18
|
+
function makeTransport(url?: () => string) {
|
|
19
19
|
const events = new EventTarget();
|
|
20
20
|
const transport: IConnectionTransport = {
|
|
21
21
|
readyState: WebSocket.CLOSED,
|
|
22
22
|
reconnect: vi.fn(() => {
|
|
23
|
+
url?.();
|
|
23
24
|
transport.readyState = WebSocket.OPEN;
|
|
24
25
|
events.dispatchEvent(new Event("open"));
|
|
25
26
|
}),
|
|
@@ -52,16 +53,13 @@ function makeTransport() {
|
|
|
52
53
|
function setup(strict = false) {
|
|
53
54
|
const store = createStore();
|
|
54
55
|
const options = {
|
|
55
|
-
sessionId: "first",
|
|
56
56
|
static: false,
|
|
57
57
|
transportType: "websocket" as const,
|
|
58
|
-
messageFormat: "host" as const,
|
|
59
58
|
url: () => "ws://localhost/ws",
|
|
60
59
|
headers: () => ({}),
|
|
61
60
|
waitUntilReady: async () => undefined,
|
|
62
61
|
checkHealth: async () => true,
|
|
63
62
|
onNotification: vi.fn(),
|
|
64
|
-
onMessageError: vi.fn(),
|
|
65
63
|
};
|
|
66
64
|
const wrapper = ({ children }: PropsWithChildren) => (
|
|
67
65
|
<Provider store={store}>{children}</Provider>
|
|
@@ -121,25 +119,6 @@ describe("useNotebookClient", () => {
|
|
|
121
119
|
unmount();
|
|
122
120
|
});
|
|
123
121
|
|
|
124
|
-
it("replaces the transport when session identity changes and ignores old messages", async () => {
|
|
125
|
-
const first = makeTransport();
|
|
126
|
-
const second = makeTransport();
|
|
127
|
-
vi.mocked(createConnectionTransport)
|
|
128
|
-
.mockReturnValueOnce(first.transport)
|
|
129
|
-
.mockReturnValueOnce(second.transport);
|
|
130
|
-
const { rerender, options, unmount } = setup();
|
|
131
|
-
await act(async () => Promise.resolve());
|
|
132
|
-
rerender({ ...options, sessionId: "second" });
|
|
133
|
-
await act(async () => Promise.resolve());
|
|
134
|
-
expect(first.transport.close).toHaveBeenCalledOnce();
|
|
135
|
-
act(() => {
|
|
136
|
-
first.notify();
|
|
137
|
-
second.notify();
|
|
138
|
-
});
|
|
139
|
-
expect(options.onNotification).toHaveBeenCalledOnce();
|
|
140
|
-
unmount();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
122
|
it("cleans up every transport under Strict Mode and only connects the active lifetime", async () => {
|
|
144
123
|
const transports: ReturnType<typeof makeTransport>[] = [];
|
|
145
124
|
vi.mocked(createConnectionTransport).mockImplementation(() => {
|
|
@@ -36,7 +36,6 @@ vi.mock("@/core/runtime/config", async () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
import { MockNotebook } from "@/__mocks__/notebook";
|
|
39
|
-
import * as Toast from "@/components/ui/use-toast";
|
|
40
39
|
import { Logger } from "@/utils/Logger";
|
|
41
40
|
import { Deferred } from "@/utils/Deferred";
|
|
42
41
|
import { cellId } from "@/__tests__/branded";
|
|
@@ -235,36 +234,29 @@ describe("useNotebookSession messages", () => {
|
|
|
235
234
|
}
|
|
236
235
|
|
|
237
236
|
it.each(["{broken", "null"])(
|
|
238
|
-
"
|
|
237
|
+
"closes a malformed stream and ignores subsequent notifications: %s",
|
|
239
238
|
(data) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
expect(store.get(connectionAtom)).toEqual({
|
|
254
|
-
state: ConnectionState.OPEN,
|
|
255
|
-
});
|
|
256
|
-
receive({
|
|
257
|
-
data: {
|
|
239
|
+
receive({ event: "marimo", data: { marimo: { op: "reconnected" } } });
|
|
240
|
+
act(() =>
|
|
241
|
+
getTransportHandlers().onMessage(new MessageEvent("message", { data })),
|
|
242
|
+
);
|
|
243
|
+
expect(store.get(connectionAtom)).toEqual({
|
|
244
|
+
state: ConnectionState.CLOSED,
|
|
245
|
+
code: "KERNEL_DISCONNECTED",
|
|
246
|
+
reason: "Failed to decode notebook message",
|
|
247
|
+
});
|
|
248
|
+
receive({
|
|
249
|
+
event: "marimo",
|
|
250
|
+
data: {
|
|
251
|
+
marimo: {
|
|
258
252
|
op: "completed-run",
|
|
259
253
|
run_id: "r1",
|
|
260
254
|
outcome: "succeeded",
|
|
261
255
|
error: null,
|
|
262
256
|
},
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
toast.mockRestore();
|
|
267
|
-
}
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
expect(store.get(initialRunCompletedAtom)).toBe(false);
|
|
268
260
|
},
|
|
269
261
|
);
|
|
270
262
|
|
|
@@ -90,6 +90,23 @@ describe("SseTransport", () => {
|
|
|
90
90
|
expect(messages[1].data).toBe("line1\nline2");
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
it("preserves host event names and cursors without carrying IDs into snapshots", async () => {
|
|
94
|
+
const { stream, push } = createControllableStream();
|
|
95
|
+
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
96
|
+
transport.reconnect();
|
|
97
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
98
|
+
|
|
99
|
+
push("id: cursor-1\nevent: rea");
|
|
100
|
+
push("dy\ndata: {}\n\n: keep-alive\n\nevent: reset\ndata: {}\n\n");
|
|
101
|
+
push('event: marimo\ndata: {"marimo":\ndata: {"op":"reconnected"}}\n\n');
|
|
102
|
+
await vi.waitFor(() => expect(messages).toHaveLength(3));
|
|
103
|
+
expect(messages.map((message) => JSON.parse(message.data))).toEqual([
|
|
104
|
+
{ event: "ready", id: "cursor-1", data: {} },
|
|
105
|
+
{ event: "reset", data: {} },
|
|
106
|
+
{ event: "marimo", data: { marimo: { op: "reconnected" } } },
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
93
110
|
it("ignores keep-alive comments", async () => {
|
|
94
111
|
const { stream, push } = createControllableStream();
|
|
95
112
|
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
@@ -218,16 +218,20 @@ export class SseTransport implements IConnectionTransport {
|
|
|
218
218
|
const decoder = new TextDecoder();
|
|
219
219
|
let buffer = "";
|
|
220
220
|
let eventName: string | null = null;
|
|
221
|
+
let eventId: string | null = null;
|
|
221
222
|
let dataLines: string[] = [];
|
|
222
223
|
|
|
223
224
|
const dispatch = (): ServerClose | null => {
|
|
224
225
|
if (dataLines.length === 0) {
|
|
225
226
|
eventName = null;
|
|
227
|
+
eventId = null;
|
|
226
228
|
return null;
|
|
227
229
|
}
|
|
228
230
|
const data = dataLines.join("\n");
|
|
229
231
|
const name = eventName;
|
|
232
|
+
const id = eventId;
|
|
230
233
|
eventName = null;
|
|
234
|
+
eventId = null;
|
|
231
235
|
dataLines = [];
|
|
232
236
|
if (name === "close") {
|
|
233
237
|
return parseServerClose(data);
|
|
@@ -236,9 +240,15 @@ export class SseTransport implements IConnectionTransport {
|
|
|
236
240
|
// (rather than on open, so a server that accepts and immediately
|
|
237
241
|
// closes cannot retry forever).
|
|
238
242
|
this.retryCount = 0;
|
|
243
|
+
// Normalize SSE fields into the same envelope as host WebSockets.
|
|
244
|
+
// Leave the payload text intact for the client's JSON decoder.
|
|
245
|
+
const envelope =
|
|
246
|
+
name === null
|
|
247
|
+
? data
|
|
248
|
+
: `{"event":${JSON.stringify(name)},"data":${data}${id === null ? "" : `,"id":${JSON.stringify(id)}`}}`;
|
|
239
249
|
this.subscriptions.notify(
|
|
240
250
|
"message",
|
|
241
|
-
new MessageEvent("message", { data }),
|
|
251
|
+
new MessageEvent("message", { data: envelope }),
|
|
242
252
|
);
|
|
243
253
|
return null;
|
|
244
254
|
};
|
|
@@ -284,8 +294,10 @@ export class SseTransport implements IConnectionTransport {
|
|
|
284
294
|
dataLines.push(fieldValue);
|
|
285
295
|
} else if (field === "event") {
|
|
286
296
|
eventName = fieldValue;
|
|
297
|
+
} else if (field === "id") {
|
|
298
|
+
eventId = fieldValue;
|
|
287
299
|
}
|
|
288
|
-
//
|
|
300
|
+
// The host client owns the cursor; `retry` does not override our policy.
|
|
289
301
|
}
|
|
290
302
|
if (cursor > 0) {
|
|
291
303
|
buffer = buffer.slice(cursor);
|