@marimo-team/frontend 0.25.3-dev10 → 0.25.3-dev13
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/assets/{CellStatus-nZpuDkm7.js → CellStatus-DVOHpdeJ.js} +1 -1
- package/dist/assets/{ConnectedDataExplorerComponent-t8fH_TNc.js → ConnectedDataExplorerComponent-TprnxZeJ.js} +1 -1
- package/dist/assets/{ErrorBoundary-B4n9EnHB.js → ErrorBoundary-CM13wrd8.js} +2 -2
- package/dist/assets/{JsonOutput-BxB0NL8w.js → JsonOutput-Cx1iyibB.js} +1 -1
- package/dist/assets/{LazyAnyLanguageCodeMirror-CKcCbG2G.js → LazyAnyLanguageCodeMirror-B4fCknq1.js} +2 -2
- package/dist/assets/{MarimoErrorOutput-BTy4MOzX.js → MarimoErrorOutput-s_5GOX8k.js} +1 -1
- package/dist/assets/{RenderHTML-9bMgbYmo.js → RenderHTML-DX6i99Op.js} +1 -1
- package/dist/assets/{RunButton-CFQUNN5c.js → RunButton-BKoh0zYG.js} +1 -1
- package/dist/assets/{add-cell-with-ai-f_MnLCwI.js → add-cell-with-ai-BpD_790A.js} +6 -6
- package/dist/assets/{add-connection-dialog-CoE_PhuD.js → add-connection-dialog-B1BXCjex.js} +2 -2
- package/dist/assets/{agent-panel-BWZz24jK.js → agent-panel-CDFUc1uu.js} +1 -1
- package/dist/assets/{ai-model-dropdown-zm-2fXzf.js → ai-model-dropdown-i1vfGeVi.js} +2 -2
- package/dist/assets/{any-language-editor-BLCAD3yx.js → any-language-editor-BdTWdMCI.js} +1 -1
- package/dist/assets/{app-config-button-BhU0ulYs.js → app-config-button-AdV-QDD7.js} +1 -1
- package/dist/assets/{blob-iOBwGaP9.js → blob-BG8yoQmx.js} +1 -1
- package/dist/assets/{cell-editor-CAj1UKxx.js → cell-editor-BKJ06H5F.js} +5 -5
- package/dist/assets/cell-link-8Auigi8P.js +1 -0
- package/dist/assets/{cells-rA_jsZCj.js → cells-C6YGYGdi.js} +56 -56
- package/dist/assets/{chat-display-BmEPmhDP.js → chat-display-DhgI2MS9.js} +1 -1
- package/dist/assets/{chat-panel-Ce2Xh7dM.js → chat-panel-Cau71Ygr.js} +1 -1
- package/dist/assets/{chat-ui-DTy98rP3.js → chat-ui-C5ql1Up-.js} +1 -1
- package/dist/assets/code-visibility-BJImdpDi.js +1 -0
- package/dist/assets/coerce-DWjFeXOY.js +1 -0
- package/dist/assets/{column-preview-DU6dT80y.js → column-preview-BZ_iNhtJ.js} +1 -1
- package/dist/assets/{command-palette-BMqFKXVr.js → command-palette-DDC3kBet.js} +1 -1
- package/dist/assets/{common-lisZ0l6a.js → common-Sl3uthjg.js} +1 -1
- package/dist/assets/{components-Dvd1GnD6.js → components-3cou6Mka.js} +1 -1
- package/dist/assets/{components-CP_UoFtT.js → components-CskjK6xZ.js} +1 -1
- package/dist/assets/{components-CXfO-F47.js → components-YBmtV0p6.js} +1 -1
- package/dist/assets/{config-VPSq73n8.js → config-CA1V-wjh.js} +1 -1
- package/dist/assets/{config-DJLAC21v.js → config-DRIpOKqh.js} +1 -1
- package/dist/assets/config-Dy28fDwn.js +1 -0
- package/dist/assets/{datasource-Dr1xNKUn.js → datasource-DfbNb83e.js} +1 -1
- package/dist/assets/{dependency-graph-panel-B0uP-_id.js → dependency-graph-panel-L5cBE5sl.js} +1 -1
- package/dist/assets/{documentation-panel-DVl_ebWy.js → documentation-panel-D7S8JIc2.js} +1 -1
- package/dist/assets/{download-Bx55LMki.js → download-C03FOElq.js} +1 -1
- package/dist/assets/{edit-page-DlfCLUaq.js → edit-page-D_Rg0gQO.js} +6 -6
- package/dist/assets/{error-banner-Cd69vvyX.js → error-banner-COZ7SfYV.js} +1 -1
- package/dist/assets/{error-entries-DOWpdGvI.js → error-entries-CLnPDv7b.js} +1 -1
- package/dist/assets/{error-panel-DpbNKSLs.js → error-panel-D2xrYLue.js} +1 -1
- package/dist/assets/{errors-DCrsa1nH.js → errors-CNsgAw6m.js} +1 -1
- package/dist/assets/{feedback-button-B99ThNJW.js → feedback-button-DzPunIqU.js} +2 -2
- package/dist/assets/feedback-button-Kla7i5RT.js +1 -0
- package/dist/assets/{file-explorer-panel-Bo9eN3rW.js → file-explorer-panel-Uk4_QIJQ.js} +1 -1
- package/dist/assets/{file-name-input-ChnBsDE_.js → file-name-input-B4fRaQkI.js} +1 -1
- package/dist/assets/{floating-outline-MYqQW4Vp.js → floating-outline-B5MaM1JS.js} +1 -1
- package/dist/assets/{gallery-page-DSL0J4md.js → gallery-page-BefvdsM7.js} +1 -1
- package/dist/assets/{glide-data-editor-B1MWlKeM.js → glide-data-editor-C3eaYlXg.js} +1 -1
- package/dist/assets/{home-page-KrhQ55Vg.js → home-page-hmvKlc1w.js} +1 -1
- package/dist/assets/{hooks-BIogTGCr.js → hooks-DzML9nez.js} +1 -1
- package/dist/assets/index-ptK1gxmo.js +30 -0
- package/dist/assets/{layout-DOpdUwym.js → layout-Ck1RoL1B.js} +5 -5
- package/dist/assets/{logs-panel-y0XnfulJ.js → logs-panel-BRytaWaO.js} +1 -1
- package/dist/assets/{markdown-renderer-Q5AhbRU3.js → markdown-renderer-DX0Xrrfy.js} +1 -1
- package/dist/assets/{mermaid-a3xYymTy.js → mermaid-C4EiVqcd.js} +1 -1
- package/dist/assets/{name-cell-input-BNOnF6-0.js → name-cell-input-DlpTvPLa.js} +1 -1
- package/dist/assets/{outline-panel-Be7stWwv.js → outline-panel-B2WIpz-1.js} +1 -1
- package/dist/assets/{packages-panel-ClxOAEGy.js → packages-panel-CI5ff9b-.js} +1 -1
- package/dist/assets/{panel-accordion-state-GeMEdCPw.js → panel-accordion-state-BqSH5ttk.js} +1 -1
- package/dist/assets/panels-BCcZ_yLK.js +107 -0
- package/dist/assets/{process-output-DNFKMODr.js → process-output-f3C3PD0Z.js} +1 -1
- package/dist/assets/{readonly-code-display-YULOXf4h.js → readonly-code-display-MnMEoYW5.js} +1 -1
- package/dist/assets/{readonly-python-code-CadgXgRu.js → readonly-python-code-ggs4hAzC.js} +1 -1
- package/dist/assets/{renderShortcut-BkVJQ_Zh.js → renderShortcut-C1JwYcqW.js} +1 -1
- package/dist/assets/{reveal-component-BVAR7ayZ.js → reveal-component-PiV1eQk9.js} +1 -1
- package/dist/assets/run-page-AfZhtEh1.js +1 -0
- package/dist/assets/{runtime-details-state-DuChzsA6.js → runtime-details-state-CoL-p0MC.js} +1 -1
- package/dist/assets/{schemas-Dv42wLFq.js → schemas-ChRqMOTL.js} +1 -1
- package/dist/assets/{scratchpad-panel-By-ybhjg.js → scratchpad-panel-D2rWg4k8.js} +1 -1
- package/dist/assets/{secrets-panel-Bctbtkrp.js → secrets-panel-D7KN4tVs.js} +1 -1
- package/dist/assets/{session-panel-BmW6PH4z.js → session-panel-CnANhcTo.js} +1 -1
- package/dist/assets/{snippets-panel-CQ1dn3lA.js → snippets-panel-ClGHtEKQ.js} +1 -1
- package/dist/assets/{state-z3dlofZB.js → state-CbjBtkdd.js} +3 -3
- package/dist/assets/{state-Bfh_2SLl.js → state-DtGB7xeJ.js} +1 -1
- package/dist/assets/{terminal-Ck_vK-34.js → terminal-wnSHYT8N.js} +1 -1
- package/dist/assets/{tracing-BARlJ_Dk.js → tracing-Dz2rYOFL.js} +1 -1
- package/dist/assets/{tracing-panel-dgnr2fIN.js → tracing-panel-DqQY9V91.js} +2 -2
- package/dist/assets/{tree-actions-DAd80MbP.js → tree-actions-DnUokmva.js} +1 -1
- package/dist/assets/{useAddCell-DGstJK0S.js → useAddCell-IN24thff.js} +1 -1
- package/dist/assets/{useBoolean-BDD6_xQQ.js → useBoolean-d0Tty94N.js} +1 -1
- package/dist/assets/{useCellActionButton-DY9RlJTb.js → useCellActionButton-DmaFZ4oF.js} +1 -1
- package/dist/assets/{useDeleteCell-DFBGlSQk.js → useDeleteCell-fQlM46Y2.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-DydxsE5d.js → useDependencyPanelTab-ImXYINm_.js} +1 -1
- package/dist/assets/{useInstallPackage-iX1eufdl.js → useInstallPackage-BW06eter.js} +1 -1
- package/dist/assets/{useNotebookActions-DfvB7RD_.js → useNotebookActions-DHt3s-ia.js} +1 -1
- package/dist/assets/{useRunCells-BFjFX3hz.js → useRunCells-DSJPXE-j.js} +1 -1
- package/dist/assets/{useSplitCell-Tqv5pPtL.js → useSplitCell-DQyMF5gd.js} +1 -1
- package/dist/assets/{useTheme-COhSHkCj.js → useTheme-n8Dj1zH7.js} +1 -1
- package/dist/assets/{vega-component-CnadTgoE.js → vega-component-IouMe13t.js} +1 -1
- package/dist/index.html +32 -32
- package/package.json +1 -1
- package/src/__tests__/mount.test.ts +20 -0
- package/src/core/edit-app.tsx +2 -2
- package/src/core/host/__tests__/events.test.ts +46 -0
- package/src/core/host/__tests__/notebook-client.test.ts +191 -0
- package/src/core/host/connection.ts +81 -0
- package/src/core/host/events.ts +60 -0
- package/src/core/host/notebook-client.ts +226 -0
- package/src/core/run-app.tsx +2 -2
- package/src/core/runtime/__tests__/runtime.test.ts +55 -11
- package/src/core/runtime/runtime.ts +14 -1
- package/src/core/runtime/types.ts +2 -0
- package/src/core/websocket/__tests__/{useMarimoKernelConnection.test.ts → connection-state.test.ts} +15 -17
- package/src/core/websocket/__tests__/{useWebSocket.test.ts → createConnectionTransport.test.ts} +1 -1
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +140 -0
- package/src/core/websocket/__tests__/{useMarimoKernelConnection.hook.test.tsx → useNotebookSession.hook.test.tsx} +227 -133
- package/src/core/websocket/connection-state.ts +70 -0
- package/src/core/websocket/transports/__tests__/sse.test.ts +1 -4
- package/src/core/websocket/transports/create.ts +31 -0
- package/src/core/websocket/transports/transport.ts +12 -23
- package/src/core/websocket/types.ts +9 -39
- package/src/core/websocket/useNotebookClient.ts +80 -0
- package/src/core/websocket/{useMarimoKernelConnection.tsx → useNotebookSession.tsx} +64 -231
- package/src/mount.tsx +4 -0
- package/dist/assets/cell-link-1CynTVGQ.js +0 -1
- package/dist/assets/code-visibility-B-_GvwR5.js +0 -1
- package/dist/assets/coerce-CpE6kUdX.js +0 -1
- package/dist/assets/config-DX8Y2WbJ.js +0 -1
- package/dist/assets/feedback-button-DlqZkJwz.js +0 -1
- package/dist/assets/index-B3fcrmuf.js +0 -30
- package/dist/assets/panels-DfCmzLtT.js +0 -4
- package/dist/assets/run-page-Cxu1yV8Q.js +0 -1
- package/src/core/websocket/useWebSocket.tsx +0 -88
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { Logger } from "@/utils/Logger";
|
|
4
|
+
import { type Atom, atom, type createStore } from "jotai/vanilla";
|
|
5
|
+
import {
|
|
6
|
+
type ConnectionStatus,
|
|
7
|
+
ConnectionState,
|
|
8
|
+
ConnectionClosedReason,
|
|
9
|
+
type CloseDecision,
|
|
10
|
+
type NotebookTransport,
|
|
11
|
+
TransportReadyState,
|
|
12
|
+
} from "./connection";
|
|
13
|
+
import { parseNotebookEvent } from "./events";
|
|
14
|
+
|
|
15
|
+
/** A connection to a notebook on a marimo host. */
|
|
16
|
+
export interface NotebookClient {
|
|
17
|
+
/** Transport state, independent of kernel readiness. */
|
|
18
|
+
readonly connectionAtom: Atom<ConnectionStatus>;
|
|
19
|
+
/** Attempt to restore a closed connection after checking host health. */
|
|
20
|
+
reconnect(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The transport and application callbacks needed to connect a notebook. */
|
|
24
|
+
export interface NotebookConnection {
|
|
25
|
+
/** Create a transport that reads the URL again on each connection attempt. */
|
|
26
|
+
createTransport: (url: () => string) => NotebookTransport;
|
|
27
|
+
/** The current stream URL, including authentication and subscription options. */
|
|
28
|
+
url: () => string;
|
|
29
|
+
/** Host events require an intact stream; legacy notifications are independent. */
|
|
30
|
+
messageFormat: "host" | "session";
|
|
31
|
+
/** Abort to close the transport and clear the client's state. */
|
|
32
|
+
signal: AbortSignal;
|
|
33
|
+
/** Wait until the host can accept the initial connection. */
|
|
34
|
+
waitUntilReady: () => Promise<void>;
|
|
35
|
+
/** Check whether the host is reachable before an explicit reconnect. */
|
|
36
|
+
checkHealth: () => Promise<boolean>;
|
|
37
|
+
/** Decode wire text without interpreting its event type. */
|
|
38
|
+
decodeJson: (message: string) => unknown;
|
|
39
|
+
/** Handle a native marimo notification. Its payload has not been validated. */
|
|
40
|
+
onNotification: (notification: unknown) => void;
|
|
41
|
+
/** Report a malformed message without coupling the client to editor UI. */
|
|
42
|
+
onMessageError: (error: unknown) => void;
|
|
43
|
+
/** Choose the connection status and retry policy after the transport closes. */
|
|
44
|
+
classifyClose: (event: CloseEvent) => CloseDecision;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Create a client without opening a connection. */
|
|
48
|
+
export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
49
|
+
const connectionAtom = atom<ConnectionStatus>({
|
|
50
|
+
state: ConnectionState.NOT_STARTED,
|
|
51
|
+
});
|
|
52
|
+
let reconnect: (() => Promise<void>) | undefined;
|
|
53
|
+
const client: NotebookClient = {
|
|
54
|
+
connectionAtom,
|
|
55
|
+
reconnect: async () => reconnect?.(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function connect({
|
|
59
|
+
createTransport,
|
|
60
|
+
url,
|
|
61
|
+
messageFormat,
|
|
62
|
+
signal,
|
|
63
|
+
waitUntilReady,
|
|
64
|
+
checkHealth,
|
|
65
|
+
onNotification,
|
|
66
|
+
onMessageError,
|
|
67
|
+
decodeJson,
|
|
68
|
+
classifyClose,
|
|
69
|
+
}: NotebookConnection) {
|
|
70
|
+
if (signal.aborted) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let shouldRetry = true;
|
|
74
|
+
let terminal = false;
|
|
75
|
+
let checkingHealth = false;
|
|
76
|
+
const transport = createTransport(url);
|
|
77
|
+
const setStatus = (status: ConnectionStatus) =>
|
|
78
|
+
store.set(connectionAtom, status);
|
|
79
|
+
const disconnected = {
|
|
80
|
+
state: ConnectionState.CLOSED,
|
|
81
|
+
code: ConnectionClosedReason.KERNEL_DISCONNECTED,
|
|
82
|
+
reason: "kernel not found",
|
|
83
|
+
} satisfies ConnectionStatus;
|
|
84
|
+
|
|
85
|
+
function retry(code?: number, reason?: string) {
|
|
86
|
+
if (!signal.aborted && !terminal && shouldRetry) {
|
|
87
|
+
shouldRetry = false;
|
|
88
|
+
transport.reconnect(code, reason);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onOpen() {
|
|
93
|
+
if (signal.aborted || terminal) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
shouldRetry = true;
|
|
97
|
+
setStatus({ state: ConnectionState.OPEN });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onClose(event: CloseEvent) {
|
|
101
|
+
if (signal.aborted || terminal) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
Logger.warn("Notebook connection closed", event.code, event.reason);
|
|
105
|
+
const decision = classifyClose(event);
|
|
106
|
+
setStatus(decision.status);
|
|
107
|
+
if (decision.kind === "terminal") {
|
|
108
|
+
terminal = true;
|
|
109
|
+
transport.close();
|
|
110
|
+
} else if (decision.kind === "retry") {
|
|
111
|
+
retry(event.code, event.reason);
|
|
112
|
+
} else {
|
|
113
|
+
terminal = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onError(event: Event) {
|
|
118
|
+
if (signal.aborted || terminal) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
Logger.warn("Notebook connection error", event);
|
|
122
|
+
setStatus(disconnected);
|
|
123
|
+
retry();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function onMessage(message: MessageEvent<string>) {
|
|
127
|
+
if (signal.aborted || terminal) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
let event;
|
|
131
|
+
try {
|
|
132
|
+
event = parseNotebookEvent(decodeJson(message.data));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
Logger.error("Failed to decode notebook message", error);
|
|
135
|
+
if (messageFormat === "session") {
|
|
136
|
+
onMessageError(error);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
terminal = true;
|
|
140
|
+
setStatus({
|
|
141
|
+
...disconnected,
|
|
142
|
+
reason: "Failed to decode notebook message",
|
|
143
|
+
});
|
|
144
|
+
transport.close();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (event.event === "marimo") {
|
|
148
|
+
onNotification(event.data.marimo);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
reconnect = async () => {
|
|
153
|
+
if (
|
|
154
|
+
signal.aborted ||
|
|
155
|
+
checkingHealth ||
|
|
156
|
+
transport.readyState === TransportReadyState.OPEN ||
|
|
157
|
+
transport.readyState === TransportReadyState.CONNECTING
|
|
158
|
+
) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
checkingHealth = true;
|
|
162
|
+
setStatus({ state: ConnectionState.CONNECTING });
|
|
163
|
+
try {
|
|
164
|
+
const healthy = await checkHealth();
|
|
165
|
+
if (signal.aborted) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!healthy) {
|
|
169
|
+
shouldRetry = false;
|
|
170
|
+
setStatus(disconnected);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
terminal = false;
|
|
174
|
+
shouldRetry = true;
|
|
175
|
+
transport.reconnect();
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!signal.aborted) {
|
|
178
|
+
Logger.error("Notebook health check failed", error);
|
|
179
|
+
shouldRetry = false;
|
|
180
|
+
setStatus(disconnected);
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
checkingHealth = false;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
signal.addEventListener(
|
|
188
|
+
"abort",
|
|
189
|
+
() => {
|
|
190
|
+
transport.removeEventListener("open", onOpen);
|
|
191
|
+
transport.removeEventListener("close", onClose);
|
|
192
|
+
transport.removeEventListener("error", onError);
|
|
193
|
+
transport.removeEventListener("message", onMessage);
|
|
194
|
+
transport.close();
|
|
195
|
+
reconnect = undefined;
|
|
196
|
+
setStatus({ state: ConnectionState.NOT_STARTED });
|
|
197
|
+
},
|
|
198
|
+
{ once: true },
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
transport.addEventListener("open", onOpen);
|
|
202
|
+
transport.addEventListener("close", onClose);
|
|
203
|
+
transport.addEventListener("error", onError);
|
|
204
|
+
transport.addEventListener("message", onMessage);
|
|
205
|
+
|
|
206
|
+
if (transport.readyState === TransportReadyState.CLOSED) {
|
|
207
|
+
setStatus({ state: ConnectionState.CONNECTING });
|
|
208
|
+
void waitUntilReady()
|
|
209
|
+
.then(() => {
|
|
210
|
+
if (!signal.aborted) {
|
|
211
|
+
transport.reconnect();
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
.catch((error) => {
|
|
215
|
+
if (!signal.aborted) {
|
|
216
|
+
Logger.error("Healthy connection never made", error);
|
|
217
|
+
terminal = true;
|
|
218
|
+
setStatus(disconnected);
|
|
219
|
+
transport.close();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { client, connect };
|
|
226
|
+
}
|
package/src/core/run-app.tsx
CHANGED
|
@@ -22,7 +22,7 @@ import { getSessionId } from "./kernel/session";
|
|
|
22
22
|
import { connectionAtom } from "./network/connection";
|
|
23
23
|
import { useRequestClient } from "./network/requests";
|
|
24
24
|
import { isAppConnecting } from "./websocket/connection-utils";
|
|
25
|
-
import {
|
|
25
|
+
import { useNotebookSession } from "./websocket/useNotebookSession";
|
|
26
26
|
|
|
27
27
|
interface AppProps {
|
|
28
28
|
appConfig: AppConfig;
|
|
@@ -51,7 +51,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig, hideHeader }) => {
|
|
|
51
51
|
};
|
|
52
52
|
}, []);
|
|
53
53
|
|
|
54
|
-
const { connection, reconnect } =
|
|
54
|
+
const { connection, reconnect } = useNotebookSession({
|
|
55
55
|
autoInstantiate: true,
|
|
56
56
|
setCells: setCells,
|
|
57
57
|
sessionId: getSessionId(),
|
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { Mocks } from "@/__mocks__/common";
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
generateSessionId,
|
|
7
|
+
isSessionId,
|
|
8
|
+
type SessionId,
|
|
9
|
+
} from "@/core/kernel/session";
|
|
6
10
|
import { Logger } from "@/utils/Logger";
|
|
7
11
|
import { RuntimeManager } from "../runtime";
|
|
8
12
|
import type { RuntimeConfig } from "../types";
|
|
9
13
|
|
|
10
14
|
// Mock the session module
|
|
11
|
-
vi.mock("@/core/kernel/session", () => ({
|
|
12
|
-
|
|
15
|
+
vi.mock("@/core/kernel/session", async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<typeof import("@/core/kernel/session")>()),
|
|
17
|
+
getSessionId: () => "test-session-id",
|
|
13
18
|
}));
|
|
14
19
|
|
|
15
20
|
vi.mock("@/utils/Logger", () => ({ Logger: Mocks.quietLogger() }));
|
|
@@ -51,6 +56,38 @@ describe("RuntimeManager", () => {
|
|
|
51
56
|
});
|
|
52
57
|
|
|
53
58
|
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: "marimo",
|
|
82
|
+
session_id: sessionId,
|
|
83
|
+
access_token: "test-token",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
} finally {
|
|
87
|
+
window.history.replaceState({}, "", original);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
54
91
|
it("should return WebSocket URL with session ID for https", () => {
|
|
55
92
|
const runtime = new RuntimeManager(mockConfig);
|
|
56
93
|
const sessionId = "1234" as SessionId;
|
|
@@ -59,6 +96,7 @@ describe("RuntimeManager", () => {
|
|
|
59
96
|
expect(url.protocol).toBe("wss:");
|
|
60
97
|
expect(url.hostname).toBe("example.com");
|
|
61
98
|
expect(url.pathname).toBe("/ws");
|
|
99
|
+
expect(url.searchParams.has("types")).toBe(false);
|
|
62
100
|
expect(url.searchParams.get("session_id")).toBe(sessionId);
|
|
63
101
|
});
|
|
64
102
|
|
|
@@ -219,14 +257,20 @@ describe("RuntimeManager", () => {
|
|
|
219
257
|
});
|
|
220
258
|
|
|
221
259
|
describe("getSseURL", () => {
|
|
222
|
-
it("
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
);
|
|
230
274
|
|
|
231
275
|
it("should never put the auth token in the URL, even cross-origin", () => {
|
|
232
276
|
// SSE requests can send headers, so auth travels in the
|
|
@@ -9,6 +9,8 @@ import { isStaticNotebook } from "../static/static-state";
|
|
|
9
9
|
import { isWasm } from "../wasm/utils";
|
|
10
10
|
import type { RuntimeConfig } from "./types";
|
|
11
11
|
|
|
12
|
+
const HOST_EVENT_TYPES = ["marimo"];
|
|
13
|
+
|
|
12
14
|
export class RuntimeManager {
|
|
13
15
|
private initialHealthyCheck = new Deferred<void>();
|
|
14
16
|
private config: RuntimeConfig;
|
|
@@ -154,7 +156,18 @@ export class RuntimeManager {
|
|
|
154
156
|
* The WebSocket URL of the runtime.
|
|
155
157
|
*/
|
|
156
158
|
public getWsURL(sessionId: SessionId): URL {
|
|
157
|
-
|
|
159
|
+
const searchParams = this.getSessionSearchParams(sessionId);
|
|
160
|
+
// TODO(MO-8039): Rediscover hosts and resolve notebooks on reconnect.
|
|
161
|
+
const notebookId = this.config.notebookId;
|
|
162
|
+
const path = notebookId
|
|
163
|
+
? `/api/marimo/v1/notebooks/${encodeURIComponent(notebookId)}/events/ws`
|
|
164
|
+
: "/ws";
|
|
165
|
+
const url = this.formatWsURL(path, searchParams);
|
|
166
|
+
if (notebookId) {
|
|
167
|
+
// TODO(MO-8034): Separate host options from notebook query params.
|
|
168
|
+
url.searchParams.set("types", HOST_EVENT_TYPES.join(","));
|
|
169
|
+
}
|
|
170
|
+
return url;
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
/**
|
package/src/core/websocket/__tests__/{useMarimoKernelConnection.test.ts → connection-state.test.ts}
RENAMED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { Logger } from "@/utils/Logger";
|
|
5
5
|
import { WebSocketClosedReason, ConnectionState } from "../types";
|
|
6
|
-
import { classifyCloseEvent } from "../
|
|
6
|
+
import { classifyCloseEvent } from "../connection-state";
|
|
7
7
|
|
|
8
8
|
function classify(reason: string | undefined) {
|
|
9
9
|
return classifyCloseEvent({ reason });
|
|
@@ -13,8 +13,10 @@ describe("classifyCloseEvent", () => {
|
|
|
13
13
|
describe("transient closes (default branch)", () => {
|
|
14
14
|
it("retries on empty/undefined reason", () => {
|
|
15
15
|
const decision = classify(undefined);
|
|
16
|
-
expect(decision
|
|
17
|
-
|
|
16
|
+
expect(decision).toEqual({
|
|
17
|
+
kind: "retry",
|
|
18
|
+
status: { state: ConnectionState.CONNECTING },
|
|
19
|
+
});
|
|
18
20
|
});
|
|
19
21
|
|
|
20
22
|
it("treats unknown reason strings as transient and logs a warning", () => {
|
|
@@ -40,29 +42,25 @@ describe("classifyCloseEvent", () => {
|
|
|
40
42
|
// terminal or a rejected client would reconnect forever.
|
|
41
43
|
"MARIMO_UNAUTHORIZED",
|
|
42
44
|
"MARIMO_KIOSK_NOT_ALLOWED",
|
|
43
|
-
])("%s → terminal with KERNEL_DISCONNECTED
|
|
45
|
+
])("%s → terminal with KERNEL_DISCONNECTED", (reason) => {
|
|
44
46
|
const decision = classify(reason);
|
|
45
|
-
expect(decision.kind).toBe("terminal");
|
|
46
|
-
expect(decision.status).toMatchObject({
|
|
47
|
-
state: ConnectionState.CLOSED,
|
|
48
|
-
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
|
|
49
|
-
});
|
|
50
47
|
expect(decision).toMatchObject({
|
|
51
48
|
kind: "terminal",
|
|
52
|
-
|
|
49
|
+
status: {
|
|
50
|
+
state: ConnectionState.CLOSED,
|
|
51
|
+
code: WebSocketClosedReason.KERNEL_DISCONNECTED,
|
|
52
|
+
},
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
it("MARIMO_KERNEL_STARTUP_ERROR → terminal
|
|
56
|
+
it("MARIMO_KERNEL_STARTUP_ERROR → terminal", () => {
|
|
57
57
|
const decision = classify("MARIMO_KERNEL_STARTUP_ERROR");
|
|
58
|
-
expect(decision.kind).toBe("terminal");
|
|
59
|
-
expect(decision.status).toMatchObject({
|
|
60
|
-
state: ConnectionState.CLOSED,
|
|
61
|
-
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
62
|
-
});
|
|
63
58
|
expect(decision).toMatchObject({
|
|
64
59
|
kind: "terminal",
|
|
65
|
-
|
|
60
|
+
status: {
|
|
61
|
+
state: ConnectionState.CLOSED,
|
|
62
|
+
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
63
|
+
},
|
|
66
64
|
});
|
|
67
65
|
});
|
|
68
66
|
});
|
package/src/core/websocket/__tests__/{useWebSocket.test.ts → createConnectionTransport.test.ts}
RENAMED
|
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
4
4
|
import { BasicTransport } from "../transports/basic";
|
|
5
5
|
import { SseTransport } from "../transports/sse";
|
|
6
6
|
import { WsTransport } from "../transports/ws";
|
|
7
|
-
import { createConnectionTransport } from "../
|
|
7
|
+
import { createConnectionTransport } from "../transports/create";
|
|
8
8
|
|
|
9
9
|
vi.mock("../../wasm/utils", () => ({
|
|
10
10
|
isWasm: () => false,
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
// @vitest-environment jsdom
|
|
3
|
+
|
|
4
|
+
import { act, renderHook } from "@testing-library/react";
|
|
5
|
+
import { createStore, Provider } from "jotai";
|
|
6
|
+
import type { PropsWithChildren } from "react";
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { ConnectionState } from "../types";
|
|
9
|
+
import { createConnectionTransport } from "../transports/create";
|
|
10
|
+
import type { IConnectionTransport } from "../transports/transport";
|
|
11
|
+
import { useNotebookClient } from "../useNotebookClient";
|
|
12
|
+
|
|
13
|
+
vi.mock("../transports/create", () => ({ createConnectionTransport: vi.fn() }));
|
|
14
|
+
|
|
15
|
+
function makeTransport() {
|
|
16
|
+
const events = new EventTarget();
|
|
17
|
+
const transport: IConnectionTransport = {
|
|
18
|
+
readyState: WebSocket.CLOSED,
|
|
19
|
+
reconnect: vi.fn(() => {
|
|
20
|
+
transport.readyState = WebSocket.OPEN;
|
|
21
|
+
events.dispatchEvent(new Event("open"));
|
|
22
|
+
}),
|
|
23
|
+
close: vi.fn(() => {
|
|
24
|
+
transport.readyState = WebSocket.CLOSED;
|
|
25
|
+
events.dispatchEvent(new CloseEvent("close"));
|
|
26
|
+
}),
|
|
27
|
+
send: vi.fn(),
|
|
28
|
+
addEventListener: events.addEventListener.bind(events),
|
|
29
|
+
removeEventListener: events.removeEventListener.bind(events),
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
transport,
|
|
33
|
+
notify: () =>
|
|
34
|
+
events.dispatchEvent(
|
|
35
|
+
new MessageEvent("message", {
|
|
36
|
+
data: JSON.stringify({
|
|
37
|
+
event: "marimo",
|
|
38
|
+
data: { marimo: { op: "reconnected" } },
|
|
39
|
+
}),
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setup(strict = false) {
|
|
46
|
+
const store = createStore();
|
|
47
|
+
const options = {
|
|
48
|
+
sessionId: "first",
|
|
49
|
+
static: false,
|
|
50
|
+
transportType: "websocket" as const,
|
|
51
|
+
messageFormat: "host" as const,
|
|
52
|
+
url: () => "ws://localhost/ws",
|
|
53
|
+
headers: () => ({}),
|
|
54
|
+
waitUntilReady: async () => undefined,
|
|
55
|
+
checkHealth: async () => true,
|
|
56
|
+
onNotification: vi.fn(),
|
|
57
|
+
onMessageError: vi.fn(),
|
|
58
|
+
};
|
|
59
|
+
const wrapper = ({ children }: PropsWithChildren) => (
|
|
60
|
+
<Provider store={store}>{children}</Provider>
|
|
61
|
+
);
|
|
62
|
+
const hook = renderHook((props) => useNotebookClient(props), {
|
|
63
|
+
initialProps: options,
|
|
64
|
+
reactStrictMode: strict,
|
|
65
|
+
wrapper,
|
|
66
|
+
});
|
|
67
|
+
return { ...hook, options, store };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.mocked(createConnectionTransport).mockReset();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("useNotebookClient", () => {
|
|
75
|
+
it("uses current callbacks and URL providers without reconnecting on a rerender", async () => {
|
|
76
|
+
const first = makeTransport();
|
|
77
|
+
vi.mocked(createConnectionTransport).mockReturnValue(first.transport);
|
|
78
|
+
const { rerender, options, unmount } = setup();
|
|
79
|
+
await act(async () => Promise.resolve());
|
|
80
|
+
const onNotification = vi.fn();
|
|
81
|
+
rerender({ ...options, onNotification, url: () => "ws://redirected/ws" });
|
|
82
|
+
act(() => first.notify());
|
|
83
|
+
expect(options.onNotification).not.toHaveBeenCalled();
|
|
84
|
+
expect(onNotification).toHaveBeenCalledExactlyOnceWith({
|
|
85
|
+
data: { op: "reconnected" },
|
|
86
|
+
});
|
|
87
|
+
expect(createConnectionTransport).toHaveBeenCalledOnce();
|
|
88
|
+
expect(vi.mocked(createConnectionTransport).mock.calls[0][0].url()).toBe(
|
|
89
|
+
"ws://redirected/ws",
|
|
90
|
+
);
|
|
91
|
+
unmount();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("replaces the transport when session identity changes and ignores old messages", async () => {
|
|
95
|
+
const first = makeTransport();
|
|
96
|
+
const second = makeTransport();
|
|
97
|
+
vi.mocked(createConnectionTransport)
|
|
98
|
+
.mockReturnValueOnce(first.transport)
|
|
99
|
+
.mockReturnValueOnce(second.transport);
|
|
100
|
+
const { rerender, options, unmount } = setup();
|
|
101
|
+
await act(async () => Promise.resolve());
|
|
102
|
+
rerender({ ...options, sessionId: "second" });
|
|
103
|
+
await act(async () => Promise.resolve());
|
|
104
|
+
expect(first.transport.close).toHaveBeenCalledOnce();
|
|
105
|
+
act(() => {
|
|
106
|
+
first.notify();
|
|
107
|
+
second.notify();
|
|
108
|
+
});
|
|
109
|
+
expect(options.onNotification).toHaveBeenCalledOnce();
|
|
110
|
+
unmount();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("cleans up every transport under Strict Mode and only connects the active lifetime", async () => {
|
|
114
|
+
const transports: ReturnType<typeof makeTransport>[] = [];
|
|
115
|
+
vi.mocked(createConnectionTransport).mockImplementation(() => {
|
|
116
|
+
const next = makeTransport();
|
|
117
|
+
transports.push(next);
|
|
118
|
+
return next.transport;
|
|
119
|
+
});
|
|
120
|
+
const { result, store, options, unmount } = setup(true);
|
|
121
|
+
await act(async () => Promise.resolve());
|
|
122
|
+
expect(transports).toHaveLength(2);
|
|
123
|
+
expect(transports[0].transport.reconnect).not.toHaveBeenCalled();
|
|
124
|
+
expect(transports[0].transport.close).toHaveBeenCalledOnce();
|
|
125
|
+
expect(transports[1].transport.reconnect).toHaveBeenCalledOnce();
|
|
126
|
+
expect(store.get(result.current.connectionAtom)).toEqual({
|
|
127
|
+
state: ConnectionState.OPEN,
|
|
128
|
+
});
|
|
129
|
+
act(() => {
|
|
130
|
+
for (const transport of transports) {
|
|
131
|
+
transport.notify();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
expect(options.onNotification).toHaveBeenCalledOnce();
|
|
135
|
+
unmount();
|
|
136
|
+
expect(transports[1].transport.close).toHaveBeenCalledOnce();
|
|
137
|
+
await result.current.reconnect();
|
|
138
|
+
expect(transports[1].transport.reconnect).toHaveBeenCalledOnce();
|
|
139
|
+
});
|
|
140
|
+
});
|