@marimo-team/frontend 0.25.3-dev80 → 0.25.3-dev82

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 (106) hide show
  1. package/dist/assets/{CellStatus-cD3S0wEA.js → CellStatus-DXroYLOP.js} +1 -1
  2. package/dist/assets/{ErrorBoundary-DJhsFCsX.js → ErrorBoundary-DaQ9THH_.js} +2 -2
  3. package/dist/assets/{JsonOutput-Pm6OCP3J.js → JsonOutput-VxzHyItt.js} +1 -1
  4. package/dist/assets/{MarimoErrorOutput-DlEjLCZ-.js → MarimoErrorOutput-D-hLaDA-.js} +1 -1
  5. package/dist/assets/{RenderHTML-CfQ6i3QV.js → RenderHTML-DjOEZL25.js} +1 -1
  6. package/dist/assets/{RunButton-DvuvkgrG.js → RunButton-CuhNTo5_.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-By51zSlC.js → add-cell-with-ai-BbVntTh7.js} +1 -1
  8. package/dist/assets/{add-connection-dialog-D7EYSraq.js → add-connection-dialog-Qf63aKV6.js} +1 -1
  9. package/dist/assets/{agent-panel-aCWa3Yai.js → agent-panel-DMOfF3qF.js} +1 -1
  10. package/dist/assets/{ai-model-dropdown-bwTRyFn4.js → ai-model-dropdown-B15l0R62.js} +2 -2
  11. package/dist/assets/{app-config-button-DGhsM0xw.js → app-config-button-BIMB8qjD.js} +1 -1
  12. package/dist/assets/{blob-e6VlOmQP.js → blob-De9GR2s6.js} +1 -1
  13. package/dist/assets/{cell-editor-M8dEnDzk.js → cell-editor-Di0cSFhC.js} +32 -11
  14. package/dist/assets/{cell-link-DFqfW-Ym.js → cell-link-DR3W2HC7.js} +1 -1
  15. package/dist/assets/{cells-T9_Y4Jlv.js → cells-styV7IX8.js} +6 -6
  16. package/dist/assets/{chat-display-DXn2Yrjr.js → chat-display-BzdYdN39.js} +1 -1
  17. package/dist/assets/{chat-panel-D3iUyZId.js → chat-panel-De3YzkSF.js} +1 -1
  18. package/dist/assets/{chat-ui-YuJtv3UJ.js → chat-ui-NTNapdtP.js} +1 -1
  19. package/dist/assets/{code-visibility-CV3S4xuf.js → code-visibility-csc3tgA0.js} +1 -1
  20. package/dist/assets/{column-preview-C6uXRJry.js → column-preview-DkH-oD8f.js} +1 -1
  21. package/dist/assets/{command-palette-CGNUQdgW.js → command-palette-Dg_xstl0.js} +1 -1
  22. package/dist/assets/{common-DeeLgjWQ.js → common-D7VAS6rd.js} +1 -1
  23. package/dist/assets/{components-DyAF3_Nf.js → components-CNmvC781.js} +1 -1
  24. package/dist/assets/config-BQT0uYUo.js +1 -0
  25. package/dist/assets/{config-CjQ4WJnJ.js → config-BbRbT9Ww.js} +1 -1
  26. package/dist/assets/{datasource-gqFgG9Qd.js → datasource-C3yfeDa2.js} +1 -1
  27. package/dist/assets/{dependency-graph-panel-B3T9kSTs.js → dependency-graph-panel-CjoA6L0w.js} +1 -1
  28. package/dist/assets/{documentation-panel-BBpGbT-f.js → documentation-panel-DHWFOiG1.js} +1 -1
  29. package/dist/assets/{download-C8Gw-D0Y.js → download-BrCpRZKN.js} +1 -1
  30. package/dist/assets/{edit-page-CBftLPze.js → edit-page-BPMo_x-T.js} +4 -4
  31. package/dist/assets/{error-entries-uyqOG5tg.js → error-entries-BkEAmmnD.js} +1 -1
  32. package/dist/assets/{error-panel-Cli5zPWi.js → error-panel-BhTfPA_i.js} +1 -1
  33. package/dist/assets/feedback-button-BAcClv7c.js +1 -0
  34. package/dist/assets/{feedback-button-CvrwBGr7.js → feedback-button-D9InK87G.js} +1 -1
  35. package/dist/assets/{file-explorer-panel-BSU5ultY.js → file-explorer-panel-BSd_ZbUC.js} +1 -1
  36. package/dist/assets/{file-name-input-CDrRlIiX.js → file-name-input-RHURglL_.js} +1 -1
  37. package/dist/assets/{floating-outline-GQD9km0W.js → floating-outline-Cm49rYYe.js} +1 -1
  38. package/dist/assets/{gallery-page-B-5H1fEt.js → gallery-page-DdMdOtdv.js} +1 -1
  39. package/dist/assets/{glide-data-editor-B1kpoetH.js → glide-data-editor-eiSlEUyR.js} +1 -1
  40. package/dist/assets/{home-page-3qsPTb4v.js → home-page-DwWvBB0R.js} +1 -1
  41. package/dist/assets/{hooks-BrFYiTJS.js → hooks-CHeUZHPp.js} +1 -1
  42. package/dist/assets/{index-D0IWODMU.js → index-DckSn2w6.js} +8 -8
  43. package/dist/assets/{layout-CGC8ajpe.js → layout-_lp06T0C.js} +3 -3
  44. package/dist/assets/{logs-panel-CdUc5vtA.js → logs-panel-CAAk72Gb.js} +1 -1
  45. package/dist/assets/{markdown-renderer-pKc_bFac.js → markdown-renderer-CAyCgogC.js} +1 -1
  46. package/dist/assets/{name-cell-input-csH3Cy54.js → name-cell-input-DA8hU9XT.js} +1 -1
  47. package/dist/assets/{outline-panel-DhsHYTuo.js → outline-panel-Cc5oQ4LL.js} +1 -1
  48. package/dist/assets/{packages-panel-BtkXVz3L.js → packages-panel-DTfKez6I.js} +1 -1
  49. package/dist/assets/{panel-accordion-state-Bco6nsUI.js → panel-accordion-state-BZHex38W.js} +1 -1
  50. package/dist/assets/panels-DU-SaRHy.js +105 -0
  51. package/dist/assets/{process-output-B2-bT4cq.js → process-output-CrDc_O1D.js} +1 -1
  52. package/dist/assets/{readonly-code-display-Dsp6vWPh.js → readonly-code-display-BR-_jUj8.js} +1 -1
  53. package/dist/assets/{readonly-python-code-B81AjzXM.js → readonly-python-code-D7jcy9ds.js} +1 -1
  54. package/dist/assets/{reveal-component-Cxo3hLGA.js → reveal-component-eLftBwOe.js} +1 -1
  55. package/dist/assets/{run-page-CLP1s8uX.js → run-page-mURyATDJ.js} +1 -1
  56. package/dist/assets/{runtime-details-state-CznIe40L.js → runtime-details-state-hb7A1jgR.js} +1 -1
  57. package/dist/assets/{scratchpad-panel-yHIW63VQ.js → scratchpad-panel-B_gEnoqj.js} +1 -1
  58. package/dist/assets/{session-panel-C7-wmZhc.js → session-panel-DmDjaPsN.js} +1 -1
  59. package/dist/assets/{snippets-panel-BARpOPyS.js → snippets-panel-o9KL5jMi.js} +1 -1
  60. package/dist/assets/state-BkAqJoW7.js +3 -0
  61. package/dist/assets/{state-Bye6rCo4.js → state-Os7LSUUZ.js} +1 -1
  62. package/dist/assets/{terminal-Bhcn_y1N.js → terminal-BI85khrS.js} +1 -1
  63. package/dist/assets/{tracing-DDohxo24.js → tracing-AsNGuDsh.js} +1 -1
  64. package/dist/assets/{tracing-panel-DYhGDAo5.js → tracing-panel-CBcmea6L.js} +2 -2
  65. package/dist/assets/{tree-actions-Cb-nUK1F.js → tree-actions-Cx5EDpA6.js} +1 -1
  66. package/dist/assets/{useAddCell-Bielffe0.js → useAddCell-uJJYtgwa.js} +1 -1
  67. package/dist/assets/{useBoolean-CMgj9hnq.js → useBoolean-8wlrTeus.js} +1 -1
  68. package/dist/assets/{useCellActionButton-DAcQhse_.js → useCellActionButton-CmQ-OcPa.js} +1 -1
  69. package/dist/assets/{useDeleteCell-BV4VemCv.js → useDeleteCell-CXgOCDqY.js} +1 -1
  70. package/dist/assets/{useDependencyPanelTab-PtXIVh53.js → useDependencyPanelTab-Ch6gm7uM.js} +1 -1
  71. package/dist/assets/{useInstallPackage-C7neMPwh.js → useInstallPackage-CYQoMdMQ.js} +1 -1
  72. package/dist/assets/{useNotebookActions-DZBB98fP.js → useNotebookActions-DGShHZ_1.js} +1 -1
  73. package/dist/assets/{useRunCells-BFeZMi7f.js → useRunCells-DAfDmOMC.js} +1 -1
  74. package/dist/assets/{useSplitCell-DllkNFke.js → useSplitCell-NLULgcq7.js} +1 -1
  75. package/dist/assets/{vega-component-D7JFthDD.js → vega-component-BKJU3uaB.js} +1 -1
  76. package/dist/index.html +24 -24
  77. package/package.json +1 -1
  78. package/src/core/host/AGENTS.md +6 -5
  79. package/src/core/host/__tests__/notebook-client.test.ts +368 -350
  80. package/src/core/host/connection.ts +1 -40
  81. package/src/core/host/notebook-client.ts +129 -186
  82. package/src/core/wasm/bridge.ts +0 -8
  83. package/src/core/websocket/__tests__/connection-state.test.ts +0 -12
  84. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +133 -59
  85. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +152 -250
  86. package/src/core/websocket/connection-state.ts +0 -10
  87. package/src/core/websocket/transports/__tests__/notebook.test.ts +502 -0
  88. package/src/core/websocket/transports/notebook.ts +213 -0
  89. package/src/core/websocket/useNotebookClient.ts +28 -17
  90. package/dist/assets/config-DzdxZb17.js +0 -1
  91. package/dist/assets/feedback-button-DknbRCN_.js +0 -1
  92. package/dist/assets/panels-D1i80TGn.js +0 -107
  93. package/dist/assets/state-BRYi026f.js +0 -3
  94. package/dist/assets/ws-BXWY541V.js +0 -22
  95. package/src/core/host/__tests__/events.test.ts +0 -49
  96. package/src/core/host/events.ts +0 -68
  97. package/src/core/websocket/__tests__/createConnectionTransport.test.ts +0 -49
  98. package/src/core/websocket/transports/__tests__/basic.test.ts +0 -275
  99. package/src/core/websocket/transports/__tests__/sse.test.ts +0 -352
  100. package/src/core/websocket/transports/__tests__/transport.test.ts +0 -308
  101. package/src/core/websocket/transports/__tests__/ws.test.ts +0 -125
  102. package/src/core/websocket/transports/basic.ts +0 -102
  103. package/src/core/websocket/transports/create.ts +0 -31
  104. package/src/core/websocket/transports/sse.ts +0 -320
  105. package/src/core/websocket/transports/transport.ts +0 -50
  106. package/src/core/websocket/transports/ws.ts +0 -98
@@ -1,125 +0,0 @@
1
- /* Copyright 2026 Marimo. All rights reserved. */
2
-
3
- import { beforeEach, describe, expect, it, vi } from "vitest";
4
- import { MAX_RETRIES, WsTransport, TRANSPORT_EXHAUSTED_REASON } from "../ws";
5
-
6
- let innerListeners: Record<string, ((e: unknown) => void)[]>;
7
-
8
- vi.mock("partysocket/ws", () => {
9
- class FakeReconnectingWebSocket {
10
- public retryCount = 0;
11
- public readyState = WebSocket.CONNECTING;
12
- public constructor() {
13
- innerListeners = { open: [], close: [], message: [], error: [] };
14
- }
15
- public addEventListener(event: string, cb: (e: unknown) => void) {
16
- innerListeners[event].push(cb);
17
- }
18
- public removeEventListener(event: string, cb: (e: unknown) => void) {
19
- innerListeners[event] = innerListeners[event].filter((c) => c !== cb);
20
- }
21
- public reconnect() {}
22
- public close() {}
23
- public send() {}
24
- }
25
- return { default: FakeReconnectingWebSocket };
26
- });
27
-
28
- interface FakeReconnectingWebSocket {
29
- retryCount: number;
30
- readyState: number;
31
- }
32
-
33
- function dispatchClose(reason = "") {
34
- const evt = new CloseEvent("close", { reason, code: 1006 });
35
- for (const cb of innerListeners.close) {
36
- cb(evt);
37
- }
38
- }
39
-
40
- describe("WsTransport", () => {
41
- let transport: WsTransport;
42
- let inner: FakeReconnectingWebSocket;
43
-
44
- beforeEach(() => {
45
- transport = new WsTransport(() => "ws://example.invalid/ws");
46
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
- inner = (transport as any).inner;
48
- });
49
-
50
- describe("close event reason", () => {
51
- it("forwards the original reason before retry budget is exhausted", () => {
52
- const seen: CloseEvent[] = [];
53
- transport.addEventListener("close", (e) => seen.push(e));
54
-
55
- inner.retryCount = MAX_RETRIES - 1;
56
- dispatchClose("");
57
-
58
- expect(seen).toHaveLength(1);
59
- expect(seen[0].reason).toBe("");
60
- });
61
-
62
- it("rewrites reason to MARIMO_TRANSPORT_EXHAUSTED on exhaustion", () => {
63
- const seen: CloseEvent[] = [];
64
- transport.addEventListener("close", (e) => seen.push(e));
65
-
66
- inner.retryCount = MAX_RETRIES;
67
- dispatchClose("");
68
-
69
- expect(seen).toHaveLength(1);
70
- expect(seen[0].reason).toBe(TRANSPORT_EXHAUSTED_REASON);
71
- });
72
-
73
- it("does not rewrite a server-sent reason on exhaustion", () => {
74
- // If partysocket happens to deliver a real MARIMO_* reason at the same
75
- // moment the retry budget exhausts, the exhausted-state rewrite still
76
- // wins because the wrapper keys off retryCount, not the original reason.
77
- // Document the behavior so a future change is deliberate.
78
- const seen: CloseEvent[] = [];
79
- transport.addEventListener("close", (e) => seen.push(e));
80
-
81
- inner.retryCount = MAX_RETRIES;
82
- dispatchClose("MARIMO_SHUTDOWN");
83
-
84
- expect(seen[0].reason).toBe(TRANSPORT_EXHAUSTED_REASON);
85
- });
86
- });
87
-
88
- describe("addEventListener dedupe", () => {
89
- it("does not double-fire when the same close listener is added twice", () => {
90
- const cb = vi.fn();
91
- transport.addEventListener("close", cb);
92
- transport.addEventListener("close", cb);
93
-
94
- dispatchClose("");
95
-
96
- expect(cb).toHaveBeenCalledTimes(1);
97
- });
98
-
99
- it("a single removeEventListener fully unregisters a duplicated add", () => {
100
- const cb = vi.fn();
101
- transport.addEventListener("close", cb);
102
- transport.addEventListener("close", cb);
103
- transport.removeEventListener("close", cb);
104
-
105
- dispatchClose("");
106
-
107
- expect(cb).not.toHaveBeenCalled();
108
- // Inner socket has no orphaned wrappers left.
109
- expect(innerListeners.close).toHaveLength(0);
110
- });
111
- });
112
-
113
- describe("removeEventListener", () => {
114
- it("unregisters the right wrapper for close listeners", () => {
115
- const cb = vi.fn();
116
- transport.addEventListener("close", cb);
117
- transport.removeEventListener("close", cb);
118
-
119
- inner.retryCount = MAX_RETRIES;
120
- dispatchClose("");
121
-
122
- expect(cb).not.toHaveBeenCalled();
123
- });
124
- });
125
- });
@@ -1,102 +0,0 @@
1
- /* Copyright 2026 Marimo. All rights reserved. */
2
-
3
- import {
4
- type ConnectionEvent,
5
- ConnectionSubscriptions,
6
- type ConnectionTransportCallback,
7
- type IConnectionTransport,
8
- } from "./transport";
9
-
10
- type MessageProducer = (callback: (message: MessageEvent) => void) => void;
11
-
12
- export class BasicTransport implements IConnectionTransport {
13
- private subscriptions = new ConnectionSubscriptions();
14
- private producer?: MessageProducer;
15
-
16
- public static withProducerCallback(
17
- producer: MessageProducer,
18
- ): IConnectionTransport {
19
- return new BasicTransport(producer);
20
- }
21
-
22
- public static empty(): IConnectionTransport {
23
- return new BasicTransport();
24
- }
25
-
26
- private constructor(producer?: MessageProducer) {
27
- this.producer = producer;
28
- }
29
-
30
- private startProducer() {
31
- if (this.producer) {
32
- this.producer((message) => {
33
- this.subscriptions.notify("message", message);
34
- });
35
- }
36
- }
37
-
38
- private connect(): Promise<void> {
39
- return new Promise<void>((resolve) => setTimeout(resolve, 0)).then(() => {
40
- this.subscriptions.notify("open", new Event("open"));
41
- });
42
- }
43
-
44
- public get readyState(): WebSocket["readyState"] {
45
- return WebSocket.OPEN;
46
- }
47
-
48
- public reconnect(
49
- _code?: number | undefined,
50
- _reason?: string | undefined,
51
- ): void {
52
- this.close();
53
- this.connect();
54
- return;
55
- }
56
-
57
- public close(): void {
58
- this.subscriptions.notify("close", new Event("close"));
59
- }
60
-
61
- public send(
62
- data: string | ArrayBuffer | Blob | ArrayBufferView,
63
- ): Promise<void> {
64
- this.subscriptions.notify(
65
- "message",
66
- new MessageEvent("message", {
67
- data,
68
- }),
69
- );
70
- return Promise.resolve();
71
- }
72
-
73
- public addEventListener<T extends ConnectionEvent>(
74
- event: T,
75
- callback: ConnectionTransportCallback<T>,
76
- ): void {
77
- this.subscriptions.addSubscription(
78
- event,
79
- callback as ConnectionTransportCallback<ConnectionEvent>,
80
- );
81
-
82
- // Call open right away
83
- if (event === "open") {
84
- (callback as ConnectionTransportCallback<"open">)(new Event("open"));
85
- }
86
-
87
- // Start the producer when we have one consumer
88
- if (event === "message") {
89
- this.startProducer();
90
- }
91
- }
92
-
93
- public removeEventListener<T extends ConnectionEvent>(
94
- event: T,
95
- callback: ConnectionTransportCallback<T>,
96
- ): void {
97
- this.subscriptions.removeSubscription(
98
- event,
99
- callback as ConnectionTransportCallback<ConnectionEvent>,
100
- );
101
- }
102
- }
@@ -1,31 +0,0 @@
1
- /* Copyright 2026 Marimo. All rights reserved. */
2
-
3
- import { createPyodideConnection } from "../../wasm/bridge";
4
- import { isWasm } from "../../wasm/utils";
5
- import { BasicTransport } from "./basic";
6
- import { SseTransport } from "./sse";
7
- import { WsTransport } from "./ws";
8
- import type { IConnectionTransport } from "./transport";
9
-
10
- export interface ConnectionTransportOptions {
11
- url: () => string;
12
- static: boolean;
13
- transportType: "websocket" | "sse";
14
- headers: () => Record<string, string>;
15
- }
16
-
17
- export function createConnectionTransport(
18
- options: ConnectionTransportOptions,
19
- ): IConnectionTransport {
20
- if (options.static) {
21
- return BasicTransport.empty();
22
- }
23
- if (isWasm()) {
24
- return createPyodideConnection();
25
- }
26
- // Read the URL lazily to follow runtime redirects.
27
- if (options.transportType === "sse") {
28
- return new SseTransport(options.url, options.headers);
29
- }
30
- return new WsTransport(options.url);
31
- }
@@ -1,320 +0,0 @@
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
- public constructor(
56
- urlProvider: () => string,
57
- headersProvider: () => Record<string, string> = () => ({}),
58
- ) {
59
- this.urlProvider = urlProvider;
60
- this.headersProvider = headersProvider;
61
- }
62
-
63
- public get readyState(): WebSocket["readyState"] {
64
- return this.state;
65
- }
66
-
67
- public 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
- public 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
- public 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
- public 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
- public 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 eventId: string | null = null;
222
- let dataLines: string[] = [];
223
-
224
- const dispatch = (): ServerClose | null => {
225
- if (dataLines.length === 0) {
226
- eventName = null;
227
- eventId = null;
228
- return null;
229
- }
230
- const data = dataLines.join("\n");
231
- const name = eventName;
232
- const id = eventId;
233
- eventName = null;
234
- eventId = null;
235
- dataLines = [];
236
- if (name === "close") {
237
- return parseServerClose(data);
238
- }
239
- // A message means the stream is healthy: reset the retry budget
240
- // (rather than on open, so a server that accepts and immediately
241
- // closes cannot retry forever).
242
- this.retryCount = 0;
243
- // Normalize SSE fields into the same envelope as host WebSockets.
244
- // Leave the payload text intact for the client's JSON decoder.
245
- const envelope =
246
- name === null
247
- ? data
248
- : `{"event":${JSON.stringify(name)},"data":${data}${id === null ? "" : `,"id":${JSON.stringify(id)}`}}`;
249
- this.subscriptions.notify(
250
- "message",
251
- new MessageEvent("message", { data: envelope }),
252
- );
253
- return null;
254
- };
255
-
256
- while (true) {
257
- const { done, value } = await reader.read();
258
- if (done) {
259
- return null;
260
- }
261
- buffer += decoder.decode(value, { stream: true });
262
-
263
- // Consume complete lines with a cursor; a single tail slice per
264
- // read keeps parsing linear even for many buffered lines.
265
- let cursor = 0;
266
- let newlineIndex = buffer.indexOf("\n", cursor);
267
- while (newlineIndex !== -1) {
268
- let line = buffer.slice(cursor, newlineIndex);
269
- cursor = newlineIndex + 1;
270
- newlineIndex = buffer.indexOf("\n", cursor);
271
- if (line.endsWith("\r")) {
272
- line = line.slice(0, -1);
273
- }
274
-
275
- if (line === "") {
276
- const serverClose = dispatch();
277
- if (serverClose !== null) {
278
- return serverClose;
279
- }
280
- continue;
281
- }
282
- if (line.startsWith(":")) {
283
- // Comment (keep-alive)
284
- continue;
285
- }
286
-
287
- const colonIndex = line.indexOf(":");
288
- const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
289
- let fieldValue = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
290
- if (fieldValue.startsWith(" ")) {
291
- fieldValue = fieldValue.slice(1);
292
- }
293
- if (field === "data") {
294
- dataLines.push(fieldValue);
295
- } else if (field === "event") {
296
- eventName = fieldValue;
297
- } else if (field === "id") {
298
- eventId = fieldValue;
299
- }
300
- // The host client owns the cursor; `retry` does not override our policy.
301
- }
302
- if (cursor > 0) {
303
- buffer = buffer.slice(cursor);
304
- }
305
- }
306
- }
307
- }
308
-
309
- function parseServerClose(data: string): ServerClose {
310
- try {
311
- const parsed = JSON.parse(data) as Partial<ServerClose>;
312
- return {
313
- code: typeof parsed.code === "number" ? parsed.code : 1000,
314
- reason: typeof parsed.reason === "string" ? parsed.reason : "",
315
- };
316
- } catch {
317
- Logger.warn("Failed to parse SSE close event", data);
318
- return { code: 1000, reason: "" };
319
- }
320
- }
@@ -1,50 +0,0 @@
1
- /* Copyright 2026 Marimo. All rights reserved. */
2
-
3
- import type {
4
- NotebookTransport,
5
- ConnectionEvent,
6
- ConnectionTransportCallback,
7
- IConnectionTransportMap,
8
- } from "../../host/connection";
9
- export type {
10
- ConnectionEvent,
11
- ConnectionTransportCallback,
12
- IConnectionTransportMap,
13
- } from "../../host/connection";
14
-
15
- export interface IConnectionTransport extends NotebookTransport {
16
- send(data: string | ArrayBuffer | Blob | ArrayBufferView<ArrayBuffer>): void;
17
- }
18
-
19
- export class ConnectionSubscriptions {
20
- private subscriptions = new Map<
21
- ConnectionEvent,
22
- Set<ConnectionTransportCallback<ConnectionEvent>>
23
- >();
24
-
25
- public addSubscription(
26
- event: ConnectionEvent,
27
- callback: ConnectionTransportCallback<ConnectionEvent>,
28
- ): void {
29
- if (!this.subscriptions.has(event)) {
30
- this.subscriptions.set(event, new Set());
31
- }
32
- this.subscriptions.get(event)?.add(callback);
33
- }
34
-
35
- public removeSubscription(
36
- event: ConnectionEvent,
37
- callback: ConnectionTransportCallback<ConnectionEvent>,
38
- ): void {
39
- this.subscriptions.get(event)?.delete(callback);
40
- }
41
-
42
- public notify(
43
- event: ConnectionEvent,
44
- data: IConnectionTransportMap[ConnectionEvent],
45
- ): void {
46
- for (const callback of this.subscriptions.get(event) ?? []) {
47
- callback(data);
48
- }
49
- }
50
- }