@marimo-team/islands 0.23.14-dev6 → 0.23.14-dev61
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-Du3_nUzI.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
- package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
- package/dist/assets/{worker-DEDLIQQV.js → worker-DAWRHcPq.js} +2 -2
- package/dist/{chat-ui-BZxLHwyD.js → chat-ui-DinOvbWu.js} +3248 -3044
- package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
- package/dist/{code-visibility-C90Am97q.js → code-visibility-EvXXzjfW.js} +1169 -960
- package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
- package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
- package/dist/{formats-d6MhLuQ9.js → formats-Dzx4J_z1.js} +1 -1
- package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-CjTu7ukN.js} +2084 -1889
- package/dist/{html-to-image-CGp_08St.js → html-to-image-_wGfk8V-.js} +2389 -2322
- package/dist/{input-CbEz_aj_.js → input-DtsN7xm-.js} +1 -1
- package/dist/main.js +3630 -1801
- package/dist/{mermaid-CJW9vIyO.js → mermaid-BYqXy_NE.js} +2 -2
- package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
- package/dist/{process-output-R6JsYrv3.js → process-output-Mh4UrjwM.js} +1 -1
- package/dist/{reveal-component-C_u9FY4_.js → reveal-component-CKfV5wlk.js} +600 -596
- package/dist/{spec-Bv-XlYiv.js → spec-Cz-Bj1JI.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-D-l5s8nn.js → toDate-CWNNlFEX.js} +15 -7
- package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-KfHB8wQR.js} +1 -1
- package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
- package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-DegSo0lV.js} +1 -1
- package/dist/{useTheme-BrYvK-_A.js → useTheme-6eZ3GOTS.js} +31 -26
- package/dist/{vega-component-Pk6lyc_a.js → vega-component-DzyyM9fc.js} +5 -5
- package/package.json +4 -4
- package/src/__mocks__/requests.ts +1 -0
- package/src/__tests__/CellStatus.test.tsx +1 -2
- package/src/components/app-config/user-config-form.tsx +52 -0
- package/src/components/chat/acp/agent-panel.tsx +35 -1
- package/src/components/chat/chat-panel.tsx +68 -29
- package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
- package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
- package/src/components/data-table/charts/__tests__/altair-generator.test.ts +24 -0
- package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
- package/src/components/data-table/charts/chart-spec/altair-generator.ts +4 -1
- package/src/components/data-table/charts/charts.tsx +23 -1
- package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
- package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
- package/src/components/debugger/debugger-code.tsx +7 -2
- package/src/components/editor/actions/useNotebookActions.tsx +2 -2
- package/src/components/editor/cell/CellStatus.tsx +1 -20
- package/src/components/editor/cell/PendingDeleteConfirmation.tsx +1 -1
- package/src/components/editor/cell/cell-context-menu.tsx +2 -2
- package/src/components/editor/chrome/panels/snippets-panel.tsx +3 -3
- package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
- package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
- package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
- package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
- package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +79 -0
- package/src/components/editor/code/readonly-python-code.tsx +60 -33
- package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
- package/src/components/editor/errors/auto-fix.tsx +88 -34
- package/src/components/editor/errors/fix-mode.ts +1 -1
- package/src/components/editor/notebook-cell.tsx +7 -0
- package/src/components/editor/output/ImageOutput.tsx +20 -6
- package/src/components/editor/output/MarimoTracebackOutput.tsx +28 -2
- package/src/components/editor/output/__tests__/ImageOutput.test.tsx +53 -0
- package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
- package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +7 -3
- package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
- package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
- package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
- package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
- package/src/components/slides/__tests__/slide.test.tsx +33 -0
- package/src/components/slides/minimap.tsx +7 -1
- package/src/components/slides/reveal-component.tsx +3 -0
- package/src/components/slides/slide-cell-view.tsx +10 -6
- package/src/components/slides/slide.tsx +16 -13
- package/src/components/tracing/tracing.tsx +2 -1
- package/src/core/ai/config.ts +2 -2
- package/src/core/ai/context/__tests__/registry.test.ts +133 -3
- package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
- package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
- package/src/core/ai/context/providers/datasource.ts +27 -2
- package/src/core/ai/context/providers/error.ts +226 -36
- package/src/core/ai/context/registry.ts +77 -43
- package/src/core/ai/state.ts +11 -0
- package/src/core/cells/__tests__/cell.test.ts +39 -0
- package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
- package/src/core/cells/cell.ts +5 -3
- package/src/core/cells/readonly-code-display.ts +35 -0
- package/src/core/codemirror/__tests__/setup.test.ts +33 -1
- package/src/core/codemirror/ai/resources.ts +15 -10
- package/src/core/codemirror/cells/__tests__/debugger-decorations.test.ts +185 -0
- package/src/core/codemirror/cells/__tests__/debugger-state.test.ts +224 -0
- package/src/core/codemirror/cells/__tests__/line-timing-decorations.test.ts +126 -0
- package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
- package/src/core/codemirror/cells/debugger-state.ts +133 -0
- package/src/core/codemirror/cells/extensions.ts +32 -4
- package/src/core/codemirror/cells/line-timing-decorations.ts +165 -0
- package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +75 -3
- package/src/core/codemirror/go-to-definition/extension.ts +3 -3
- package/src/core/codemirror/go-to-definition/underline.ts +5 -14
- package/src/core/codemirror/go-to-definition/utils.ts +49 -1
- package/src/core/codemirror/utils.ts +15 -0
- package/src/core/config/__tests__/config-schema.test.ts +17 -0
- package/src/core/config/config-schema.ts +1 -0
- package/src/core/config/config.ts +10 -0
- package/src/core/config/feature-flag.tsx +4 -0
- package/src/core/edit-app.tsx +8 -5
- package/src/core/errors/errors.ts +2 -1
- package/src/core/islands/bootstrap.ts +4 -3
- package/src/core/islands/bridge.ts +1 -0
- package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
- package/src/core/lifecycle/render-policy.ts +125 -0
- package/src/core/mime.ts +11 -4
- package/src/core/network/__tests__/api.test.ts +17 -0
- package/src/core/network/__tests__/requests-toasting.test.tsx +46 -0
- package/src/core/network/api.ts +17 -6
- package/src/core/network/requests-lazy.ts +1 -0
- package/src/core/network/requests-network.ts +8 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +10 -1
- package/src/core/network/types.ts +2 -0
- package/src/core/run-app.tsx +27 -23
- package/src/core/runtime/__tests__/adapter.test.ts +160 -0
- package/src/core/runtime/__tests__/runtime.test.ts +45 -0
- package/src/core/runtime/adapter.ts +182 -0
- package/src/core/runtime/runtime.ts +30 -16
- package/src/core/wasm/PyodideLoader.tsx +20 -62
- package/src/core/wasm/bridge.ts +13 -4
- package/src/core/wasm/state.ts +8 -19
- package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +4 -0
- package/src/core/websocket/__tests__/useWebSocket.test.ts +49 -0
- package/src/core/websocket/transports/__tests__/sse.test.ts +338 -0
- package/src/core/websocket/transports/sse.ts +308 -0
- package/src/core/websocket/useMarimoKernelConnection.tsx +58 -7
- package/src/core/websocket/useWebSocket.tsx +12 -2
- package/src/css/app/codemirror.css +14 -0
- package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
- package/src/hooks/useDelayElapsed.ts +27 -0
- package/src/mount.tsx +3 -3
- package/src/plugins/core/RenderHTML.tsx +35 -1
- package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
- package/src/plugins/impl/DataTablePlugin.tsx +1 -0
- package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -254
- package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +116 -174
- package/src/plugins/impl/anywidget/__tests__/host.test.ts +313 -0
- package/src/plugins/impl/anywidget/__tests__/model-proxy.test.ts +158 -0
- package/src/plugins/impl/anywidget/__tests__/model.test.ts +8 -177
- package/src/plugins/impl/anywidget/__tests__/registry.test.ts +708 -0
- package/src/plugins/impl/anywidget/__tests__/resolve-widget.test.ts +132 -0
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +305 -133
- package/src/plugins/impl/anywidget/__tests__/widget-ref.test.ts +28 -0
- package/src/plugins/impl/anywidget/host.ts +54 -0
- package/src/plugins/impl/anywidget/model-proxy.ts +70 -0
- package/src/plugins/impl/anywidget/model.ts +59 -239
- package/src/plugins/impl/anywidget/registry.ts +268 -0
- package/src/plugins/impl/anywidget/resolve-widget.ts +138 -0
- package/src/plugins/impl/anywidget/runtime.ts +422 -0
- package/src/plugins/impl/anywidget/types.ts +14 -0
- package/src/plugins/impl/anywidget/widget-binding.ts +280 -119
- package/src/plugins/impl/anywidget/widget-ref.ts +29 -0
- package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
- package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
- package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +3 -2
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +16 -13
- package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +1 -1
- package/src/utils/errors.ts +15 -0
- package/src/utils/time.ts +20 -0
- package/dist/assets/__vite-browser-external-DAm_YW43.js +0 -1
- package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
- package/src/components/editor/chrome/panels/__tests__/snippet-display.test.ts +0 -22
- package/src/components/editor/chrome/panels/snippet-display.ts +0 -27
- package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
- package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
- package/src/core/wasm/__tests__/state.test.ts +0 -124
- package/src/plugins/impl/anywidget/schemas.ts +0 -32
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { SseTransport } from "../sse";
|
|
5
|
+
import { MAX_RETRIES, TRANSPORT_EXHAUSTED_REASON } from "../ws";
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
|
|
9
|
+
function createControllableStream() {
|
|
10
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
11
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
12
|
+
start(c) {
|
|
13
|
+
controller = c;
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
stream,
|
|
18
|
+
push: (text: string) => controller.enqueue(encoder.encode(text)),
|
|
19
|
+
end: () => controller.close(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sseResponse(body: ReadableStream<Uint8Array> | null, status = 200) {
|
|
24
|
+
return {
|
|
25
|
+
ok: status >= 200 && status < 300,
|
|
26
|
+
status,
|
|
27
|
+
body,
|
|
28
|
+
} as Response;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("SseTransport", () => {
|
|
32
|
+
let transport: SseTransport;
|
|
33
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
34
|
+
let opens: Event[];
|
|
35
|
+
let messages: MessageEvent[];
|
|
36
|
+
let closes: CloseEvent[];
|
|
37
|
+
let errors: Event[];
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
fetchMock = vi.fn();
|
|
41
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
42
|
+
transport = new SseTransport(() => "http://example.invalid/sse");
|
|
43
|
+
opens = [];
|
|
44
|
+
messages = [];
|
|
45
|
+
closes = [];
|
|
46
|
+
errors = [];
|
|
47
|
+
transport.addEventListener("open", (e) => opens.push(e));
|
|
48
|
+
transport.addEventListener("message", (e) => messages.push(e));
|
|
49
|
+
transport.addEventListener("close", (e) => closes.push(e));
|
|
50
|
+
transport.addEventListener("error", (e) => errors.push(e));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
transport.close();
|
|
55
|
+
vi.unstubAllGlobals();
|
|
56
|
+
vi.useRealTimers();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("starts closed and connects on reconnect()", async () => {
|
|
60
|
+
expect(transport.readyState).toBe(WebSocket.CLOSED);
|
|
61
|
+
const { stream } = createControllableStream();
|
|
62
|
+
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
63
|
+
|
|
64
|
+
transport.reconnect();
|
|
65
|
+
expect(transport.readyState).toBe(WebSocket.CONNECTING);
|
|
66
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
67
|
+
expect(transport.readyState).toBe(WebSocket.OPEN);
|
|
68
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
69
|
+
"http://example.invalid/sse",
|
|
70
|
+
expect.objectContaining({
|
|
71
|
+
headers: { Accept: "text/event-stream" },
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("dispatches message events, joining multi-line data", async () => {
|
|
77
|
+
const { stream, push } = createControllableStream();
|
|
78
|
+
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
79
|
+
transport.reconnect();
|
|
80
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
81
|
+
|
|
82
|
+
push('data: {"op": "alert"}\n\n');
|
|
83
|
+
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
84
|
+
expect(messages[0].data).toBe('{"op": "alert"}');
|
|
85
|
+
|
|
86
|
+
// Multi-line data and records split across chunks
|
|
87
|
+
push("data: line1\nda");
|
|
88
|
+
push("ta: line2\n\n");
|
|
89
|
+
await vi.waitFor(() => expect(messages).toHaveLength(2));
|
|
90
|
+
expect(messages[1].data).toBe("line1\nline2");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("ignores keep-alive comments", 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(": keep-alive\n\n");
|
|
100
|
+
push("data: after\n\n");
|
|
101
|
+
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
102
|
+
expect(messages[0].data).toBe("after");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("surfaces a server close event with its code and reason", async () => {
|
|
106
|
+
const { stream, push } = createControllableStream();
|
|
107
|
+
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
108
|
+
transport.reconnect();
|
|
109
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
110
|
+
|
|
111
|
+
// Stop retrying when the consumer treats the close as terminal,
|
|
112
|
+
// mirroring useMarimoKernelConnection's terminal path.
|
|
113
|
+
transport.addEventListener("close", () => transport.close());
|
|
114
|
+
push('event: close\ndata: {"code": 1000, "reason": "MARIMO_SHUTDOWN"}\n\n');
|
|
115
|
+
await vi.waitFor(() => expect(closes).toHaveLength(1));
|
|
116
|
+
expect(closes[0].code).toBe(1000);
|
|
117
|
+
expect(closes[0].reason).toBe("MARIMO_SHUTDOWN");
|
|
118
|
+
expect(closes[0].wasClean).toBe(true);
|
|
119
|
+
expect(transport.readyState).toBe(WebSocket.CLOSED);
|
|
120
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("emits an empty-reason close and retries when the stream ends", async () => {
|
|
124
|
+
vi.useFakeTimers();
|
|
125
|
+
const first = createControllableStream();
|
|
126
|
+
fetchMock.mockResolvedValueOnce(sseResponse(first.stream));
|
|
127
|
+
transport.reconnect();
|
|
128
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
129
|
+
|
|
130
|
+
first.end();
|
|
131
|
+
await vi.waitFor(() => expect(closes).toHaveLength(1));
|
|
132
|
+
expect(closes[0].reason).toBe("");
|
|
133
|
+
expect(closes[0].wasClean).toBe(false);
|
|
134
|
+
|
|
135
|
+
// A retry is scheduled and reconnects
|
|
136
|
+
const second = createControllableStream();
|
|
137
|
+
fetchMock.mockResolvedValueOnce(sseResponse(second.stream));
|
|
138
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
139
|
+
await vi.waitFor(() => expect(opens).toHaveLength(2));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("does not stack its own retry when the consumer reconnects in onClose", async () => {
|
|
143
|
+
vi.useFakeTimers();
|
|
144
|
+
const first = createControllableStream();
|
|
145
|
+
const second = createControllableStream();
|
|
146
|
+
fetchMock
|
|
147
|
+
.mockResolvedValueOnce(sseResponse(first.stream))
|
|
148
|
+
.mockResolvedValueOnce(sseResponse(second.stream));
|
|
149
|
+
// Mirror useMarimoKernelConnection's retry path: reconnect()
|
|
150
|
+
// synchronously from inside the close handler.
|
|
151
|
+
transport.addEventListener("close", () => transport.reconnect());
|
|
152
|
+
|
|
153
|
+
transport.reconnect();
|
|
154
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
155
|
+
|
|
156
|
+
first.end();
|
|
157
|
+
await vi.waitFor(() => expect(opens).toHaveLength(2));
|
|
158
|
+
|
|
159
|
+
// The superseded run must not schedule a second connection on top of
|
|
160
|
+
// the consumer's reconnect.
|
|
161
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
162
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
163
|
+
|
|
164
|
+
// The consumer-driven connection is healthy
|
|
165
|
+
second.push("data: alive\n\n");
|
|
166
|
+
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
167
|
+
expect(transport.readyState).toBe(WebSocket.OPEN);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("exhausts the retry budget when the server closes every connection", async () => {
|
|
171
|
+
vi.useFakeTimers();
|
|
172
|
+
// A server that accepts, immediately sends a close event, and never
|
|
173
|
+
// delivers a message must not be retried forever.
|
|
174
|
+
fetchMock.mockImplementation(() => {
|
|
175
|
+
const { stream, push } = createControllableStream();
|
|
176
|
+
push('event: close\ndata: {"code": 3000, "reason": "MARIMO_X"}\n\n');
|
|
177
|
+
return Promise.resolve(sseResponse(stream));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
transport.reconnect();
|
|
181
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
182
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
expect(closes.length).toBe(MAX_RETRIES);
|
|
186
|
+
expect(closes.at(-1)?.reason).toBe(TRANSPORT_EXHAUSTED_REASON);
|
|
187
|
+
const fetchCallsWhenExhausted = fetchMock.mock.calls.length;
|
|
188
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
189
|
+
expect(fetchMock.mock.calls.length).toBe(fetchCallsWhenExhausted);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("a delivered message resets the retry budget", async () => {
|
|
193
|
+
vi.useFakeTimers();
|
|
194
|
+
const first = createControllableStream();
|
|
195
|
+
fetchMock.mockResolvedValueOnce(sseResponse(first.stream));
|
|
196
|
+
transport.reconnect();
|
|
197
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
198
|
+
first.push("data: healthy\n\n");
|
|
199
|
+
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
200
|
+
|
|
201
|
+
first.end();
|
|
202
|
+
await vi.waitFor(() => expect(closes).toHaveLength(1));
|
|
203
|
+
// Budget was reset by the message, so this close is retry #1, not
|
|
204
|
+
// an exhaustion.
|
|
205
|
+
expect(closes[0].reason).toBe("");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("gives up with TRANSPORT_EXHAUSTED_REASON after MAX_RETRIES failures", async () => {
|
|
209
|
+
vi.useFakeTimers();
|
|
210
|
+
fetchMock.mockRejectedValue(new Error("connection refused"));
|
|
211
|
+
|
|
212
|
+
transport.reconnect();
|
|
213
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
214
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
expect(closes.length).toBe(MAX_RETRIES);
|
|
218
|
+
expect(closes.at(-1)?.reason).toBe(TRANSPORT_EXHAUSTED_REASON);
|
|
219
|
+
expect(errors.length).toBe(MAX_RETRIES);
|
|
220
|
+
const fetchCallsWhenExhausted = fetchMock.mock.calls.length;
|
|
221
|
+
|
|
222
|
+
// No further retries once exhausted
|
|
223
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
224
|
+
expect(fetchMock.mock.calls.length).toBe(fetchCallsWhenExhausted);
|
|
225
|
+
|
|
226
|
+
// A manual reconnect() resets the budget
|
|
227
|
+
const { stream } = createControllableStream();
|
|
228
|
+
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
229
|
+
transport.reconnect();
|
|
230
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("close() aborts the connection and cancels retries", async () => {
|
|
234
|
+
vi.useFakeTimers();
|
|
235
|
+
const { stream } = createControllableStream();
|
|
236
|
+
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
237
|
+
transport.reconnect();
|
|
238
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
239
|
+
|
|
240
|
+
transport.close();
|
|
241
|
+
expect(transport.readyState).toBe(WebSocket.CLOSED);
|
|
242
|
+
// No close event is dispatched for a user-initiated close, and no
|
|
243
|
+
// retry is scheduled.
|
|
244
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
245
|
+
expect(closes).toHaveLength(0);
|
|
246
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("treats a non-2xx response as a connection failure", async () => {
|
|
250
|
+
vi.useFakeTimers();
|
|
251
|
+
fetchMock.mockResolvedValueOnce(sseResponse(null, 500));
|
|
252
|
+
transport.reconnect();
|
|
253
|
+
|
|
254
|
+
await vi.waitFor(() => expect(closes).toHaveLength(1));
|
|
255
|
+
expect(opens).toHaveLength(0);
|
|
256
|
+
expect(errors).toHaveLength(1);
|
|
257
|
+
expect(closes[0].reason).toBe("");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("sends provided headers (auth) with the request", async () => {
|
|
261
|
+
const withHeaders = new SseTransport(
|
|
262
|
+
() => "http://example.invalid/sse",
|
|
263
|
+
() => ({ Authorization: "Bearer token123" }),
|
|
264
|
+
);
|
|
265
|
+
const { stream } = createControllableStream();
|
|
266
|
+
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
267
|
+
withHeaders.reconnect();
|
|
268
|
+
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
|
269
|
+
withHeaders.close();
|
|
270
|
+
|
|
271
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
272
|
+
"http://example.invalid/sse",
|
|
273
|
+
expect.objectContaining({
|
|
274
|
+
headers: {
|
|
275
|
+
Authorization: "Bearer token123",
|
|
276
|
+
Accept: "text/event-stream",
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("aborts a connection attempt that exceeds the timeout", async () => {
|
|
283
|
+
vi.useFakeTimers();
|
|
284
|
+
// fetch that only settles when aborted
|
|
285
|
+
fetchMock.mockImplementation(
|
|
286
|
+
(_url: string, init: RequestInit) =>
|
|
287
|
+
new Promise((_, reject) => {
|
|
288
|
+
init.signal?.addEventListener("abort", () =>
|
|
289
|
+
reject(new Error("aborted")),
|
|
290
|
+
);
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
transport.reconnect();
|
|
295
|
+
await vi.advanceTimersByTimeAsync(11_000);
|
|
296
|
+
expect(errors).toHaveLength(1);
|
|
297
|
+
expect(closes).toHaveLength(1);
|
|
298
|
+
expect(closes[0].reason).toBe("");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("reconnect() mid-stream supersedes the old connection silently", async () => {
|
|
302
|
+
const first = createControllableStream();
|
|
303
|
+
const second = createControllableStream();
|
|
304
|
+
fetchMock
|
|
305
|
+
.mockResolvedValueOnce(sseResponse(first.stream))
|
|
306
|
+
.mockResolvedValueOnce(sseResponse(second.stream));
|
|
307
|
+
|
|
308
|
+
transport.reconnect();
|
|
309
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
310
|
+
|
|
311
|
+
transport.reconnect();
|
|
312
|
+
await vi.waitFor(() => expect(opens).toHaveLength(2));
|
|
313
|
+
|
|
314
|
+
// The aborted first connection must not surface close/error events
|
|
315
|
+
second.push("data: alive\n\n");
|
|
316
|
+
await vi.waitFor(() => expect(messages).toHaveLength(1));
|
|
317
|
+
expect(closes).toHaveLength(0);
|
|
318
|
+
expect(errors).toHaveLength(0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("send() is a no-op", () => {
|
|
322
|
+
expect(() => transport.send("data")).not.toThrow();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("dedupes repeated addEventListener calls", async () => {
|
|
326
|
+
const { stream, push } = createControllableStream();
|
|
327
|
+
fetchMock.mockResolvedValueOnce(sseResponse(stream));
|
|
328
|
+
const onMessage = (e: MessageEvent) => messages.push(e);
|
|
329
|
+
transport.addEventListener("message", onMessage);
|
|
330
|
+
transport.addEventListener("message", onMessage);
|
|
331
|
+
transport.reconnect();
|
|
332
|
+
await vi.waitFor(() => expect(opens).toHaveLength(1));
|
|
333
|
+
|
|
334
|
+
push("data: once\n\n");
|
|
335
|
+
// The beforeEach listener plus the deduped listener = 2 events
|
|
336
|
+
await vi.waitFor(() => expect(messages).toHaveLength(2));
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { Logger } from "@/utils/Logger";
|
|
4
|
+
import {
|
|
5
|
+
type ConnectionEvent,
|
|
6
|
+
ConnectionSubscriptions,
|
|
7
|
+
type ConnectionTransportCallback,
|
|
8
|
+
type IConnectionTransport,
|
|
9
|
+
} from "./transport";
|
|
10
|
+
import { MAX_RETRIES, TRANSPORT_EXHAUSTED_REASON } from "./ws";
|
|
11
|
+
|
|
12
|
+
// Mirrors WsTransport/partysocket retry behavior.
|
|
13
|
+
const CONNECTION_TIMEOUT_MS = 10_000;
|
|
14
|
+
const MIN_RETRY_DELAY_MS = 1000;
|
|
15
|
+
const MAX_RETRY_DELAY_MS = 10_000;
|
|
16
|
+
const RETRY_GROWTH_FACTOR = 1.3;
|
|
17
|
+
|
|
18
|
+
// Stream ended without a server close event (transient disconnect); matches
|
|
19
|
+
// the WebSocket abnormal-closure code so close handling stays uniform.
|
|
20
|
+
const ABNORMAL_CLOSURE = 1006;
|
|
21
|
+
|
|
22
|
+
interface ServerClose {
|
|
23
|
+
code: number;
|
|
24
|
+
reason: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A kernel-connection transport over server-sent events, used when
|
|
29
|
+
* `server.transport` is `"sse"` (experimental).
|
|
30
|
+
*
|
|
31
|
+
* The kernel connection only carries messages server to client (control
|
|
32
|
+
* requests go over HTTP POST), so an SSE stream can fully replace the
|
|
33
|
+
* WebSocket. The server mirrors WebSocket close frames with a `close` SSE
|
|
34
|
+
* event carrying `{code, reason}`; a stream that ends without one is a
|
|
35
|
+
* transient disconnect.
|
|
36
|
+
*
|
|
37
|
+
* Uses `fetch` + stream parsing rather than `EventSource`: we need to
|
|
38
|
+
* surface server close reasons, send auth headers, and match partysocket's
|
|
39
|
+
* retry semantics (per-`reconnect()` retry budget, exhaustion surfaced via
|
|
40
|
+
* `TRANSPORT_EXHAUSTED_REASON`), none of which `EventSource` supports.
|
|
41
|
+
*
|
|
42
|
+
* Like `WsTransport`, starts closed; the first `reconnect()` connects.
|
|
43
|
+
* `close()` stops retrying and does not dispatch a close event.
|
|
44
|
+
*/
|
|
45
|
+
export class SseTransport implements IConnectionTransport {
|
|
46
|
+
private subscriptions = new ConnectionSubscriptions();
|
|
47
|
+
private urlProvider: () => string;
|
|
48
|
+
private headersProvider: () => Record<string, string>;
|
|
49
|
+
private abortController: AbortController | null = null;
|
|
50
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
51
|
+
private retryCount = 0;
|
|
52
|
+
private userClosed = false;
|
|
53
|
+
private state: WebSocket["readyState"] = WebSocket.CLOSED;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
urlProvider: () => string,
|
|
57
|
+
headersProvider: () => Record<string, string> = () => ({}),
|
|
58
|
+
) {
|
|
59
|
+
this.urlProvider = urlProvider;
|
|
60
|
+
this.headersProvider = headersProvider;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get readyState(): WebSocket["readyState"] {
|
|
64
|
+
return this.state;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
reconnect(_code?: number, _reason?: string): void {
|
|
68
|
+
this.userClosed = false;
|
|
69
|
+
this.retryCount = 0;
|
|
70
|
+
this.clearRetryTimer();
|
|
71
|
+
this.abortController?.abort();
|
|
72
|
+
this.connect();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(): void {
|
|
76
|
+
this.userClosed = true;
|
|
77
|
+
this.clearRetryTimer();
|
|
78
|
+
this.state = WebSocket.CLOSED;
|
|
79
|
+
this.abortController?.abort();
|
|
80
|
+
this.abortController = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
send(_data: string | ArrayBuffer | Blob | ArrayBufferView): void {
|
|
84
|
+
// The kernel connection is receive-only; all client requests go over
|
|
85
|
+
// HTTP POST endpoints.
|
|
86
|
+
Logger.warn("SseTransport does not support send(); dropping message");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
addEventListener<T extends ConnectionEvent>(
|
|
90
|
+
event: T,
|
|
91
|
+
callback: ConnectionTransportCallback<T>,
|
|
92
|
+
): void {
|
|
93
|
+
this.subscriptions.addSubscription(
|
|
94
|
+
event,
|
|
95
|
+
callback as ConnectionTransportCallback<ConnectionEvent>,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
removeEventListener<T extends ConnectionEvent>(
|
|
100
|
+
event: T,
|
|
101
|
+
callback: ConnectionTransportCallback<T>,
|
|
102
|
+
): void {
|
|
103
|
+
this.subscriptions.removeSubscription(
|
|
104
|
+
event,
|
|
105
|
+
callback as ConnectionTransportCallback<ConnectionEvent>,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private connect(): void {
|
|
110
|
+
this.state = WebSocket.CONNECTING;
|
|
111
|
+
void this.run();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async run(): Promise<void> {
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
this.abortController = controller;
|
|
117
|
+
// Long timeout — the server can become slow when many notebooks are open.
|
|
118
|
+
const connectTimeout = setTimeout(
|
|
119
|
+
() => controller.abort(),
|
|
120
|
+
CONNECTION_TIMEOUT_MS,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
let serverClose: ServerClose | null = null;
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(this.urlProvider(), {
|
|
126
|
+
headers: {
|
|
127
|
+
...this.headersProvider(),
|
|
128
|
+
Accept: "text/event-stream",
|
|
129
|
+
},
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
});
|
|
132
|
+
clearTimeout(connectTimeout);
|
|
133
|
+
if (!response.ok || response.body === null) {
|
|
134
|
+
throw new Error(`Unexpected SSE response: ${response.status}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.state = WebSocket.OPEN;
|
|
138
|
+
this.subscriptions.notify("open", new Event("open"));
|
|
139
|
+
|
|
140
|
+
serverClose = await this.readEvents(response.body);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
clearTimeout(connectTimeout);
|
|
143
|
+
if (this.isSuperseded(controller)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
Logger.warn("SSE connection failed", error);
|
|
147
|
+
this.subscriptions.notify("error", new Event("error"));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.isSuperseded(controller)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
controller.abort();
|
|
154
|
+
this.state = WebSocket.CLOSED;
|
|
155
|
+
|
|
156
|
+
// The retry budget counts consecutive attempts without a healthy
|
|
157
|
+
// stream (readEvents resets it on the first message), so a server
|
|
158
|
+
// that accepts and immediately closes still exhausts the budget.
|
|
159
|
+
this.retryCount += 1;
|
|
160
|
+
const exhausted = this.retryCount >= MAX_RETRIES;
|
|
161
|
+
this.subscriptions.notify(
|
|
162
|
+
"close",
|
|
163
|
+
new CloseEvent("close", {
|
|
164
|
+
code: serverClose?.code ?? ABNORMAL_CLOSURE,
|
|
165
|
+
// On exhaustion the reason is rewritten so the consumer stops
|
|
166
|
+
// retrying, matching WsTransport.
|
|
167
|
+
reason: exhausted
|
|
168
|
+
? TRANSPORT_EXHAUSTED_REASON
|
|
169
|
+
: (serverClose?.reason ?? ""),
|
|
170
|
+
wasClean: serverClose !== null,
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
// The consumer's close handler runs synchronously inside notify and
|
|
174
|
+
// may have called reconnect() (starting a fresh connection) or
|
|
175
|
+
// close(); re-check before scheduling our own retry so we never
|
|
176
|
+
// stack a second connection on top of theirs.
|
|
177
|
+
if (exhausted || this.isSuperseded(controller)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this.scheduleRetry();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Whether this run was replaced by a newer reconnect() or close(). */
|
|
184
|
+
private isSuperseded(controller: AbortController): boolean {
|
|
185
|
+
return this.userClosed || this.abortController !== controller;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private scheduleRetry(): void {
|
|
189
|
+
this.clearRetryTimer();
|
|
190
|
+
this.state = WebSocket.CONNECTING;
|
|
191
|
+
const delay = Math.min(
|
|
192
|
+
MIN_RETRY_DELAY_MS * RETRY_GROWTH_FACTOR ** this.retryCount,
|
|
193
|
+
MAX_RETRY_DELAY_MS,
|
|
194
|
+
);
|
|
195
|
+
this.retryTimer = setTimeout(() => {
|
|
196
|
+
this.retryTimer = null;
|
|
197
|
+
this.connect();
|
|
198
|
+
}, delay);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private clearRetryTimer(): void {
|
|
202
|
+
if (this.retryTimer !== null) {
|
|
203
|
+
clearTimeout(this.retryTimer);
|
|
204
|
+
this.retryTimer = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Read and dispatch SSE events until the stream ends.
|
|
210
|
+
*
|
|
211
|
+
* Returns the server close payload if a `close` event arrives, or null
|
|
212
|
+
* if the stream ends without one.
|
|
213
|
+
*/
|
|
214
|
+
private async readEvents(
|
|
215
|
+
body: ReadableStream<Uint8Array>,
|
|
216
|
+
): Promise<ServerClose | null> {
|
|
217
|
+
const reader = body.getReader();
|
|
218
|
+
const decoder = new TextDecoder();
|
|
219
|
+
let buffer = "";
|
|
220
|
+
let eventName: string | null = null;
|
|
221
|
+
let dataLines: string[] = [];
|
|
222
|
+
|
|
223
|
+
const dispatch = (): ServerClose | null => {
|
|
224
|
+
if (dataLines.length === 0) {
|
|
225
|
+
eventName = null;
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const data = dataLines.join("\n");
|
|
229
|
+
const name = eventName;
|
|
230
|
+
eventName = null;
|
|
231
|
+
dataLines = [];
|
|
232
|
+
if (name === "close") {
|
|
233
|
+
return parseServerClose(data);
|
|
234
|
+
}
|
|
235
|
+
// A message means the stream is healthy: reset the retry budget
|
|
236
|
+
// (rather than on open, so a server that accepts and immediately
|
|
237
|
+
// closes cannot retry forever).
|
|
238
|
+
this.retryCount = 0;
|
|
239
|
+
this.subscriptions.notify(
|
|
240
|
+
"message",
|
|
241
|
+
new MessageEvent("message", { data }),
|
|
242
|
+
);
|
|
243
|
+
return null;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
while (true) {
|
|
247
|
+
const { done, value } = await reader.read();
|
|
248
|
+
if (done) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
buffer += decoder.decode(value, { stream: true });
|
|
252
|
+
|
|
253
|
+
// Consume complete lines with a cursor; a single tail slice per
|
|
254
|
+
// read keeps parsing linear even for many buffered lines.
|
|
255
|
+
let cursor = 0;
|
|
256
|
+
let newlineIndex = buffer.indexOf("\n", cursor);
|
|
257
|
+
while (newlineIndex !== -1) {
|
|
258
|
+
let line = buffer.slice(cursor, newlineIndex);
|
|
259
|
+
cursor = newlineIndex + 1;
|
|
260
|
+
newlineIndex = buffer.indexOf("\n", cursor);
|
|
261
|
+
if (line.endsWith("\r")) {
|
|
262
|
+
line = line.slice(0, -1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (line === "") {
|
|
266
|
+
const serverClose = dispatch();
|
|
267
|
+
if (serverClose !== null) {
|
|
268
|
+
return serverClose;
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (line.startsWith(":")) {
|
|
273
|
+
// Comment (keep-alive)
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const colonIndex = line.indexOf(":");
|
|
278
|
+
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
|
|
279
|
+
let fieldValue = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
|
|
280
|
+
if (fieldValue.startsWith(" ")) {
|
|
281
|
+
fieldValue = fieldValue.slice(1);
|
|
282
|
+
}
|
|
283
|
+
if (field === "data") {
|
|
284
|
+
dataLines.push(fieldValue);
|
|
285
|
+
} else if (field === "event") {
|
|
286
|
+
eventName = fieldValue;
|
|
287
|
+
}
|
|
288
|
+
// `id` and `retry` fields are unused.
|
|
289
|
+
}
|
|
290
|
+
if (cursor > 0) {
|
|
291
|
+
buffer = buffer.slice(cursor);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseServerClose(data: string): ServerClose {
|
|
298
|
+
try {
|
|
299
|
+
const parsed = JSON.parse(data) as Partial<ServerClose>;
|
|
300
|
+
return {
|
|
301
|
+
code: typeof parsed.code === "number" ? parsed.code : 1000,
|
|
302
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : "",
|
|
303
|
+
};
|
|
304
|
+
} catch {
|
|
305
|
+
Logger.warn("Failed to parse SSE close event", data);
|
|
306
|
+
return { code: 1000, reason: "" };
|
|
307
|
+
}
|
|
308
|
+
}
|