@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.
Files changed (171) hide show
  1. package/dist/{ConnectedDataExplorerComponent-Du3_nUzI.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
  2. package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
  3. package/dist/assets/{worker-DEDLIQQV.js → worker-DAWRHcPq.js} +2 -2
  4. package/dist/{chat-ui-BZxLHwyD.js → chat-ui-DinOvbWu.js} +3248 -3044
  5. package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
  6. package/dist/{code-visibility-C90Am97q.js → code-visibility-EvXXzjfW.js} +1169 -960
  7. package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
  8. package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
  9. package/dist/{formats-d6MhLuQ9.js → formats-Dzx4J_z1.js} +1 -1
  10. package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-CjTu7ukN.js} +2084 -1889
  11. package/dist/{html-to-image-CGp_08St.js → html-to-image-_wGfk8V-.js} +2389 -2322
  12. package/dist/{input-CbEz_aj_.js → input-DtsN7xm-.js} +1 -1
  13. package/dist/main.js +3630 -1801
  14. package/dist/{mermaid-CJW9vIyO.js → mermaid-BYqXy_NE.js} +2 -2
  15. package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
  16. package/dist/{process-output-R6JsYrv3.js → process-output-Mh4UrjwM.js} +1 -1
  17. package/dist/{reveal-component-C_u9FY4_.js → reveal-component-CKfV5wlk.js} +600 -596
  18. package/dist/{spec-Bv-XlYiv.js → spec-Cz-Bj1JI.js} +1 -1
  19. package/dist/style.css +1 -1
  20. package/dist/{toDate-D-l5s8nn.js → toDate-CWNNlFEX.js} +15 -7
  21. package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-KfHB8wQR.js} +1 -1
  22. package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
  23. package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-DegSo0lV.js} +1 -1
  24. package/dist/{useTheme-BrYvK-_A.js → useTheme-6eZ3GOTS.js} +31 -26
  25. package/dist/{vega-component-Pk6lyc_a.js → vega-component-DzyyM9fc.js} +5 -5
  26. package/package.json +4 -4
  27. package/src/__mocks__/requests.ts +1 -0
  28. package/src/__tests__/CellStatus.test.tsx +1 -2
  29. package/src/components/app-config/user-config-form.tsx +52 -0
  30. package/src/components/chat/acp/agent-panel.tsx +35 -1
  31. package/src/components/chat/chat-panel.tsx +68 -29
  32. package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
  33. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
  34. package/src/components/data-table/charts/__tests__/altair-generator.test.ts +24 -0
  35. package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
  36. package/src/components/data-table/charts/chart-spec/altair-generator.ts +4 -1
  37. package/src/components/data-table/charts/charts.tsx +23 -1
  38. package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
  39. package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
  40. package/src/components/debugger/debugger-code.tsx +7 -2
  41. package/src/components/editor/actions/useNotebookActions.tsx +2 -2
  42. package/src/components/editor/cell/CellStatus.tsx +1 -20
  43. package/src/components/editor/cell/PendingDeleteConfirmation.tsx +1 -1
  44. package/src/components/editor/cell/cell-context-menu.tsx +2 -2
  45. package/src/components/editor/chrome/panels/snippets-panel.tsx +3 -3
  46. package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
  47. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
  48. package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
  49. package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
  50. package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +79 -0
  51. package/src/components/editor/code/readonly-python-code.tsx +60 -33
  52. package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
  53. package/src/components/editor/errors/auto-fix.tsx +88 -34
  54. package/src/components/editor/errors/fix-mode.ts +1 -1
  55. package/src/components/editor/notebook-cell.tsx +7 -0
  56. package/src/components/editor/output/ImageOutput.tsx +20 -6
  57. package/src/components/editor/output/MarimoTracebackOutput.tsx +28 -2
  58. package/src/components/editor/output/__tests__/ImageOutput.test.tsx +53 -0
  59. package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
  60. package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
  61. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +7 -3
  62. package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
  63. package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
  64. package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
  65. package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
  66. package/src/components/slides/__tests__/slide.test.tsx +33 -0
  67. package/src/components/slides/minimap.tsx +7 -1
  68. package/src/components/slides/reveal-component.tsx +3 -0
  69. package/src/components/slides/slide-cell-view.tsx +10 -6
  70. package/src/components/slides/slide.tsx +16 -13
  71. package/src/components/tracing/tracing.tsx +2 -1
  72. package/src/core/ai/config.ts +2 -2
  73. package/src/core/ai/context/__tests__/registry.test.ts +133 -3
  74. package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
  75. package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
  76. package/src/core/ai/context/providers/datasource.ts +27 -2
  77. package/src/core/ai/context/providers/error.ts +226 -36
  78. package/src/core/ai/context/registry.ts +77 -43
  79. package/src/core/ai/state.ts +11 -0
  80. package/src/core/cells/__tests__/cell.test.ts +39 -0
  81. package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
  82. package/src/core/cells/cell.ts +5 -3
  83. package/src/core/cells/readonly-code-display.ts +35 -0
  84. package/src/core/codemirror/__tests__/setup.test.ts +33 -1
  85. package/src/core/codemirror/ai/resources.ts +15 -10
  86. package/src/core/codemirror/cells/__tests__/debugger-decorations.test.ts +185 -0
  87. package/src/core/codemirror/cells/__tests__/debugger-state.test.ts +224 -0
  88. package/src/core/codemirror/cells/__tests__/line-timing-decorations.test.ts +126 -0
  89. package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
  90. package/src/core/codemirror/cells/debugger-state.ts +133 -0
  91. package/src/core/codemirror/cells/extensions.ts +32 -4
  92. package/src/core/codemirror/cells/line-timing-decorations.ts +165 -0
  93. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +75 -3
  94. package/src/core/codemirror/go-to-definition/extension.ts +3 -3
  95. package/src/core/codemirror/go-to-definition/underline.ts +5 -14
  96. package/src/core/codemirror/go-to-definition/utils.ts +49 -1
  97. package/src/core/codemirror/utils.ts +15 -0
  98. package/src/core/config/__tests__/config-schema.test.ts +17 -0
  99. package/src/core/config/config-schema.ts +1 -0
  100. package/src/core/config/config.ts +10 -0
  101. package/src/core/config/feature-flag.tsx +4 -0
  102. package/src/core/edit-app.tsx +8 -5
  103. package/src/core/errors/errors.ts +2 -1
  104. package/src/core/islands/bootstrap.ts +4 -3
  105. package/src/core/islands/bridge.ts +1 -0
  106. package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
  107. package/src/core/lifecycle/render-policy.ts +125 -0
  108. package/src/core/mime.ts +11 -4
  109. package/src/core/network/__tests__/api.test.ts +17 -0
  110. package/src/core/network/__tests__/requests-toasting.test.tsx +46 -0
  111. package/src/core/network/api.ts +17 -6
  112. package/src/core/network/requests-lazy.ts +1 -0
  113. package/src/core/network/requests-network.ts +8 -0
  114. package/src/core/network/requests-static.ts +1 -0
  115. package/src/core/network/requests-toasting.tsx +10 -1
  116. package/src/core/network/types.ts +2 -0
  117. package/src/core/run-app.tsx +27 -23
  118. package/src/core/runtime/__tests__/adapter.test.ts +160 -0
  119. package/src/core/runtime/__tests__/runtime.test.ts +45 -0
  120. package/src/core/runtime/adapter.ts +182 -0
  121. package/src/core/runtime/runtime.ts +30 -16
  122. package/src/core/wasm/PyodideLoader.tsx +20 -62
  123. package/src/core/wasm/bridge.ts +13 -4
  124. package/src/core/wasm/state.ts +8 -19
  125. package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +4 -0
  126. package/src/core/websocket/__tests__/useWebSocket.test.ts +49 -0
  127. package/src/core/websocket/transports/__tests__/sse.test.ts +338 -0
  128. package/src/core/websocket/transports/sse.ts +308 -0
  129. package/src/core/websocket/useMarimoKernelConnection.tsx +58 -7
  130. package/src/core/websocket/useWebSocket.tsx +12 -2
  131. package/src/css/app/codemirror.css +14 -0
  132. package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
  133. package/src/hooks/useDelayElapsed.ts +27 -0
  134. package/src/mount.tsx +3 -3
  135. package/src/plugins/core/RenderHTML.tsx +35 -1
  136. package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
  137. package/src/plugins/impl/DataTablePlugin.tsx +1 -0
  138. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -254
  139. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +116 -174
  140. package/src/plugins/impl/anywidget/__tests__/host.test.ts +313 -0
  141. package/src/plugins/impl/anywidget/__tests__/model-proxy.test.ts +158 -0
  142. package/src/plugins/impl/anywidget/__tests__/model.test.ts +8 -177
  143. package/src/plugins/impl/anywidget/__tests__/registry.test.ts +708 -0
  144. package/src/plugins/impl/anywidget/__tests__/resolve-widget.test.ts +132 -0
  145. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +305 -133
  146. package/src/plugins/impl/anywidget/__tests__/widget-ref.test.ts +28 -0
  147. package/src/plugins/impl/anywidget/host.ts +54 -0
  148. package/src/plugins/impl/anywidget/model-proxy.ts +70 -0
  149. package/src/plugins/impl/anywidget/model.ts +59 -239
  150. package/src/plugins/impl/anywidget/registry.ts +268 -0
  151. package/src/plugins/impl/anywidget/resolve-widget.ts +138 -0
  152. package/src/plugins/impl/anywidget/runtime.ts +422 -0
  153. package/src/plugins/impl/anywidget/types.ts +14 -0
  154. package/src/plugins/impl/anywidget/widget-binding.ts +280 -119
  155. package/src/plugins/impl/anywidget/widget-ref.ts +29 -0
  156. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
  157. package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
  158. package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
  159. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +3 -2
  160. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +16 -13
  161. package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +1 -1
  162. package/src/utils/errors.ts +15 -0
  163. package/src/utils/time.ts +20 -0
  164. package/dist/assets/__vite-browser-external-DAm_YW43.js +0 -1
  165. package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
  166. package/src/components/editor/chrome/panels/__tests__/snippet-display.test.ts +0 -22
  167. package/src/components/editor/chrome/panels/snippet-display.ts +0 -27
  168. package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
  169. package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
  170. package/src/core/wasm/__tests__/state.test.ts +0 -124
  171. 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
+ }