@marimo-team/frontend 0.25.3-dev125 → 0.25.3-dev129

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 (169) hide show
  1. package/dist/assets/{CellStatus-_Aw9_foI.js → CellStatus-6xDEj3Hs.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-BRgqbAsc.js → ConnectedDataExplorerComponent-DCcLAktu.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-DxSzjvbx.js → ErrorBoundary-98Xo04DS.js} +2 -2
  4. package/dist/assets/{JsonOutput-B7pGj79O.js → JsonOutput-V47aiTIK.js} +5 -5
  5. package/dist/assets/{LazyAnyLanguageCodeMirror-BPYE76jR.js → LazyAnyLanguageCodeMirror-DoxWX_Wp.js} +2 -2
  6. package/dist/assets/{MarimoErrorOutput-Bzo6Bkp5.js → MarimoErrorOutput-DeWZNBgL.js} +1 -1
  7. package/dist/assets/{RenderHTML-D6AC0IhC.js → RenderHTML-Bjvcs70-.js} +1 -1
  8. package/dist/assets/{RunButton-BjYsPiKa.js → RunButton-CnvvXD9d.js} +1 -1
  9. package/dist/assets/{add-cell-with-ai-CFrR1iW4.js → add-cell-with-ai-BbuqzITi.js} +1 -1
  10. package/dist/assets/{add-connection-dialog-CyNAB_W2.js → add-connection-dialog-DIC7owvZ.js} +1 -1
  11. package/dist/assets/{agent-panel-VOrccb5q.js → agent-panel-D8hO2P1x.js} +1 -1
  12. package/dist/assets/{ai-model-dropdown-Bfxv_zvu.js → ai-model-dropdown-DgmiG_5j.js} +1 -1
  13. package/dist/assets/alert-dialog-8YiYLrfr.js +1 -0
  14. package/dist/assets/{any-language-editor-DUMe1UCk.js → any-language-editor-BWmz0P_C.js} +1 -1
  15. package/dist/assets/{app-config-button-BRcjv8A_.js → app-config-button-UZHv1Iim.js} +1 -1
  16. package/dist/assets/{blob-DTYOX-m5.js → blob-BPknzzkY.js} +1 -1
  17. package/dist/assets/{button-D8gVKwe-.js → button-Cl7haVNh.js} +1 -1
  18. package/dist/assets/{cache-panel-BV-c7ed0.js → cache-panel-DuBsA6oO.js} +1 -1
  19. package/dist/assets/{capabilities-Pq1YNPlK.js → capabilities-DcB-Dd0T.js} +1 -1
  20. package/dist/assets/{cell-editor-eKgYK8Bd.js → cell-editor-CM-K1_wq.js} +1 -1
  21. package/dist/assets/{cell-link-D3rkqgbU.js → cell-link-CAjuFcLK.js} +1 -1
  22. package/dist/assets/cells-BypTg035.js +220 -0
  23. package/dist/assets/{chat-display-BrT4VJJK.js → chat-display-BRLNOoTF.js} +1 -1
  24. package/dist/assets/chat-panel-D02Rk9we.js +4 -0
  25. package/dist/assets/{chat-ui-SAdJxg-k.js → chat-ui-DFN_RwxF.js} +1 -1
  26. package/dist/assets/circle-B0vOU-Dh.js +1 -0
  27. package/dist/assets/{code-visibility-DYDagVy7.js → code-visibility-D1Wf7-vD.js} +1 -1
  28. package/dist/assets/{column-preview-yI7-INkZ.js → column-preview-85ORMhfB.js} +1 -1
  29. package/dist/assets/{command-palette-DF-B_YpW.js → command-palette-CtyjjdsA.js} +1 -1
  30. package/dist/assets/{common-DgXKwblm.js → common-J265II7i.js} +1 -1
  31. package/dist/assets/{components-DCo7mAWO.js → components-9p_w2C6e.js} +1 -1
  32. package/dist/assets/{components-CBE8cOLK.js → components-B6gDDccL.js} +1 -1
  33. package/dist/assets/{components-CuMhY4Sf.js → components-Bd3Dpi92.js} +1 -1
  34. package/dist/assets/{config-CD1IuZFJ.js → config-7IjqNuVp.js} +1 -1
  35. package/dist/assets/config-BC32AAdw.js +1 -0
  36. package/dist/assets/{config-DpY86_Yq.js → config-CXnC3MRv.js} +1 -1
  37. package/dist/assets/{copy-KlhazlhB.js → copy-74EjuZso.js} +1 -1
  38. package/dist/assets/{copy-icon-8-a5vovc.js → copy-icon-Craaocbu.js} +1 -1
  39. package/dist/assets/{createReducer-CaI_Oa0v.js → createReducer-B04CIO-e.js} +1 -1
  40. package/dist/assets/{datasource-D9JMcEAP.js → datasource-Dy4rzzSP.js} +1 -1
  41. package/dist/assets/{dates-CQMwFko2.js → dates-CaPZVGtm.js} +1 -1
  42. package/dist/assets/{dependency-graph-panel-BVJWPCvw.js → dependency-graph-panel-ByGFFT4E.js} +1 -1
  43. package/dist/assets/dist-gaK_BThd.js +1 -0
  44. package/dist/assets/{documentation-panel--wI4XHso.js → documentation-panel-LxuJCY3M.js} +1 -1
  45. package/dist/assets/download-BsBJWH_j.js +9 -0
  46. package/dist/assets/dropdown-menu-Cwi17BoX.js +1 -0
  47. package/dist/assets/{edit-page-Uj9YSRMX.js → edit-page--d6X-OO9.js} +4 -4
  48. package/dist/assets/{error-banner-B5kzVf_0.js → error-banner-m3mastc-.js} +1 -1
  49. package/dist/assets/{error-entries-D00nWhWE.js → error-entries-0wTSSuK9.js} +1 -1
  50. package/dist/assets/{error-panel-CcN_1PWf.js → error-panel-ZNdbE3Fo.js} +1 -1
  51. package/dist/assets/{es-N9SHpmG7.js → es-Rg27gAEy.js} +1 -1
  52. package/dist/assets/feedback-button-Bt89tGG2.js +1 -0
  53. package/dist/assets/{feedback-button-ByGgqUsX.js → feedback-button-C6asPmsh.js} +1 -1
  54. package/dist/assets/{file-explorer-panel-eTEqtMPW.js → file-explorer-panel-0fUP_ptL.js} +1 -1
  55. package/dist/assets/{file-name-input-D5SfTNWh.js → file-name-input-B8lzI5iS.js} +1 -1
  56. package/dist/assets/{floating-outline-B-k3P_AO.js → floating-outline-CRpmOv32.js} +1 -1
  57. package/dist/assets/{formats-zylfwejq.js → formats-CYyvbiZA.js} +1 -1
  58. package/dist/assets/{formatting-uom1es4b.js → formatting-DW2u7HXI.js} +1 -1
  59. package/dist/assets/fullscreen-D-ldV-19.js +41 -0
  60. package/dist/assets/{gallery-page-BzbMhDHi.js → gallery-page-ClR8U-km.js} +1 -1
  61. package/dist/assets/{glide-data-editor-BMqhUrHI.js → glide-data-editor-B6QQBQwS.js} +1 -1
  62. package/dist/assets/{glide-utils-HFqhvWHI.js → glide-utils-DsTeJ7WA.js} +1 -1
  63. package/dist/assets/{home-page-DJcYWiga.js → home-page-CJnqDcT9.js} +1 -1
  64. package/dist/assets/{hooks-CGLb_CCz.js → hooks-CCek4i9f.js} +1 -1
  65. package/dist/assets/{html-to-image-D_0rd3_B.js → html-to-image-BW0fcUS8.js} +1 -1
  66. package/dist/assets/{index-bSIORW11.js → index-B5pWkt7a.js} +18 -18
  67. package/dist/assets/{input-DmHMEZGh.js → input-DLA5Dhn3.js} +2 -2
  68. package/dist/assets/{kiosk-mode-DFS6HopF.js → kiosk-mode-Cbxffiz9.js} +1 -1
  69. package/dist/assets/{layout-C2XnLAfO.js → layout-66Urh4Zg.js} +3 -3
  70. package/dist/assets/links-UwsB2RA1.js +1 -0
  71. package/dist/assets/{logs-panel-B3T1GFBk.js → logs-panel-DFhkIcge.js} +1 -1
  72. package/dist/assets/{markdown-renderer-BhiTvxtL.js → markdown-renderer-mYYQYD2m.js} +1 -1
  73. package/dist/assets/menu-items-yD7IV3V4.js +1 -0
  74. package/dist/assets/{mermaid-BKXlWk4F.js → mermaid-DmekSy8J.js} +1 -1
  75. package/dist/assets/{name-cell-input-CgAObR6N.js → name-cell-input-Crcu3i2z.js} +1 -1
  76. package/dist/assets/{numbers-CsZVw3Dc.js → numbers-DJK15_4S.js} +1 -1
  77. package/dist/assets/{outline-panel-BhBb7Vqw.js → outline-panel-BFIPK0H_.js} +1 -1
  78. package/dist/assets/{packages-panel-Bt0xNpFl.js → packages-panel-B4KtjgkF.js} +1 -1
  79. package/dist/assets/{panel-accordion-state-ZsUdpAYn.js → panel-accordion-state-D6KtwSF8.js} +1 -1
  80. package/dist/assets/panels-DiwGKPzK.js +1 -0
  81. package/dist/assets/process-output-DDvMd6Jh.js +1 -0
  82. package/dist/assets/{readonly-code-display-TvOC7ESE.js → readonly-code-display-VDqYn2d0.js} +1 -1
  83. package/dist/assets/{readonly-python-code-Cqkfdeyo.js → readonly-python-code-CZUKLXJ4.js} +1 -1
  84. package/dist/assets/{renderShortcut-rUwGqKgC.js → renderShortcut-Bxv9ZLnD.js} +1 -1
  85. package/dist/assets/{reveal-component-CwE6U9oJ.js → reveal-component-BMmDU1uN.js} +1 -1
  86. package/dist/assets/{run-page-CtonWTtJ.js → run-page-BmOPPh2Y.js} +1 -1
  87. package/dist/assets/{runs-C49hleBy.js → runs-C8uOPNAW.js} +1 -1
  88. package/dist/assets/{runtime-details-state-CKqJSfSR.js → runtime-details-state-CipqynUD.js} +1 -1
  89. package/dist/assets/{scratchpad-panel-CwVBedfZ.js → scratchpad-panel-Drr_kuwb.js} +1 -1
  90. package/dist/assets/{scroll-to-bottom-button-CIkON2WJ.js → scroll-to-bottom-button-CidjyqiH.js} +1 -1
  91. package/dist/assets/{secrets-panel-bcfEKxRx.js → secrets-panel-D-W1PL76.js} +1 -1
  92. package/dist/assets/select-CgWQvW1F.js +1 -0
  93. package/dist/assets/{session-CMLIzZKw.js → session-BHgGkjDm.js} +1 -1
  94. package/dist/assets/{session-panel-CdHdKUCF.js → session-panel-W7JOcog_.js} +1 -1
  95. package/dist/assets/{snippets-panel-DYLWOLYw.js → snippets-panel-cUPEdm66.js} +1 -1
  96. package/dist/assets/{spec-DlH4Mp7d.js → spec-DFtwOyXT.js} +1 -1
  97. package/dist/assets/{state-DgECPVFs.js → state-BLGvzMB6.js} +3 -3
  98. package/dist/assets/{state-B_MCKzBL.js → state-BYETGwDZ.js} +1 -1
  99. package/dist/assets/{state-3mcyTS9q.js → state-CZu2E2Lb.js} +1 -1
  100. package/dist/assets/{state-C5WAeuTu.js → state-ClBFtZxn.js} +1 -1
  101. package/dist/assets/{state-Cv2JW3l9.js → state-CrLrURYG.js} +1 -1
  102. package/dist/assets/{state-DZN9vZ6z.js → state-QfGh3m2G.js} +1 -1
  103. package/dist/assets/{strings-C25fhMR_.js → strings-3lNX212T.js} +1 -1
  104. package/dist/assets/{swiper-component-CRGRy5_O.js → swiper-component-BwGv-Pu1.js} +1 -1
  105. package/dist/assets/switch-D2gdC_ep.js +1 -0
  106. package/dist/assets/tabs-BLczoY3k.js +1 -0
  107. package/dist/assets/{terminal-Ca-kubKb.js → terminal-DLgGyFIP.js} +1 -1
  108. package/dist/assets/toast-trUlkvx3.js +1 -0
  109. package/dist/assets/tooltip-B5lAA_jn.js +1 -0
  110. package/dist/assets/{tooltip-Brya4Ghn.js → tooltip-BiQdpXA0.js} +1 -1
  111. package/dist/assets/{tracing-B0J2ctTm.js → tracing-BNFynaTu.js} +1 -1
  112. package/dist/assets/{tracing-panel-C1OCpoeE.js → tracing-panel-CsHuzRGP.js} +2 -2
  113. package/dist/assets/{tree-actions-sjBxIglc.js → tree-actions-BQw_utcj.js} +1 -1
  114. package/dist/assets/use-restore-focus-DMSzeuUr.js +1 -0
  115. package/dist/assets/{useAddCell-CaXnFGki.js → useAddCell-_b8InOZC.js} +1 -1
  116. package/dist/assets/{useBoolean-Dd2nWXFJ.js → useBoolean-BkeUcn1n.js} +1 -1
  117. package/dist/assets/{useCellActionButton-CJ90CMEj.js → useCellActionButton-BbgMzBdk.js} +1 -1
  118. package/dist/assets/{useDeleteCell-D9N85822.js → useDeleteCell-DhoFbEkU.js} +1 -1
  119. package/dist/assets/{useDependencyPanelTab-CzAE0cm-.js → useDependencyPanelTab-COTULhoH.js} +1 -1
  120. package/dist/assets/{useEventListener-CjDeLqFC.js → useEventListener-DPOTkksd.js} +1 -1
  121. package/dist/assets/{useIframeCapabilities-Czr9e4uG.js → useIframeCapabilities-sHeZt_EA.js} +1 -1
  122. package/dist/assets/{useInstallPackage-wcDq9hYT.js → useInstallPackage-BfNKN33w.js} +1 -1
  123. package/dist/assets/{useInterval-DJdQmXnq.js → useInterval-BcdwHHB5.js} +1 -1
  124. package/dist/assets/{useNotebookActions-CAMEQ38y.js → useNotebookActions-xnA_l3nY.js} +1 -1
  125. package/dist/assets/{useRunCells-DGakx8hj.js → useRunCells-VBCe6CLn.js} +1 -1
  126. package/dist/assets/{useSplitCell-AK5BKPGM.js → useSplitCell-AvSpVAmq.js} +1 -1
  127. package/dist/assets/{useTheme-BAA27lh7.js → useTheme-C2dnh2vM.js} +1 -1
  128. package/dist/assets/{vega-component-BeyxAJad.js → vega-component-DJf6ho22.js} +1 -1
  129. package/dist/assets/{write-secret-modal-BEx-EUlh.js → write-secret-modal-CBi2vNK_.js} +1 -1
  130. package/dist/index.html +62 -62
  131. package/package.json +3 -3
  132. package/src/core/host/__tests__/notebook-client.test.ts +18 -6
  133. package/src/core/host/__tests__/static.test.ts +4 -2
  134. package/src/core/host/__tests__/wasm.test.ts +12 -10
  135. package/src/core/host/backend.ts +8 -3
  136. package/src/core/host/notebook-client.ts +15 -15
  137. package/src/core/host/static.ts +2 -2
  138. package/src/core/host/wasm.ts +4 -3
  139. package/src/core/runtime/__tests__/runtime.test.ts +17 -0
  140. package/src/core/runtime/runtime.ts +13 -0
  141. package/src/core/websocket/__tests__/backend.test.ts +111 -0
  142. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +32 -27
  143. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +7 -4
  144. package/src/core/websocket/backend.ts +68 -0
  145. package/src/core/websocket/transports/__tests__/notebook.test.ts +9 -56
  146. package/src/core/websocket/transports/notebook.ts +15 -38
  147. package/src/core/websocket/useNotebookClient.ts +6 -6
  148. package/src/core/websocket/useNotebookSession.tsx +3 -2
  149. package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +66 -66
  150. package/dist/assets/alert-dialog-C-sWmKva.js +0 -1
  151. package/dist/assets/cells-2HETnlvE.js +0 -220
  152. package/dist/assets/chat-panel-qpPNEwxV.js +0 -4
  153. package/dist/assets/circle-B6D_6D8w.js +0 -1
  154. package/dist/assets/config-DMz7oN0r.js +0 -1
  155. package/dist/assets/dist-jlhMTKo_.js +0 -1
  156. package/dist/assets/download-Dod42wfD.js +0 -9
  157. package/dist/assets/dropdown-menu-DS6umAnH.js +0 -1
  158. package/dist/assets/feedback-button-tbWwJ_t6.js +0 -1
  159. package/dist/assets/fullscreen-BWTs8pYZ.js +0 -41
  160. package/dist/assets/links-DX2w97N9.js +0 -1
  161. package/dist/assets/menu-items-BgUyNUpu.js +0 -1
  162. package/dist/assets/panels-DJghvIKo.js +0 -1
  163. package/dist/assets/process-output-CeOMwE17.js +0 -1
  164. package/dist/assets/select-3CVGlYrC.js +0 -1
  165. package/dist/assets/switch-DYjBfpNJ.js +0 -1
  166. package/dist/assets/tabs-CY8E4r9U.js +0 -1
  167. package/dist/assets/toast-Gzxi-le_.js +0 -1
  168. package/dist/assets/tooltip-CkGm8X7m.js +0 -1
  169. package/dist/assets/use-restore-focus-DdS7Tl0F.js +0 -1
@@ -0,0 +1,111 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { NotebookEvents } from "@marimo-team/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { createEditorBackend } from "../backend";
6
+
7
+ const wasm = vi.hoisted(() => ({ enabled: false, consumer: vi.fn() }));
8
+ vi.mock("../../wasm/utils", () => ({ isWasm: () => wasm.enabled }));
9
+ vi.mock("../../wasm/bridge", () => ({
10
+ PyodideBridge: { INSTANCE: { attachMessageConsumer: wasm.consumer } },
11
+ }));
12
+
13
+ afterEach(() => {
14
+ wasm.enabled = false;
15
+ wasm.consumer.mockReset();
16
+ vi.unstubAllGlobals();
17
+ });
18
+
19
+ describe("createEditorBackend", () => {
20
+ const options = {
21
+ static: false,
22
+ transportType: "websocket" as const,
23
+ url: () => "ws://localhost/ws",
24
+ headers: () => ({}),
25
+ notebookUrl: (id: string) =>
26
+ new URL(`http://localhost/api/marimo/v1/notebooks/${id}`),
27
+ };
28
+
29
+ it.each([
30
+ ["a static notebook", { ...options, static: true }, "idle"],
31
+ ["WASM", options, "wasm"],
32
+ ["SSE", { ...options, transportType: "sse" as const }, "fetch"],
33
+ ["the default", options, "socket"],
34
+ ] as const)("follows %s", async (_name, chosen, expected) => {
35
+ wasm.enabled = expected === "wasm";
36
+ const fetch = vi.fn(() => new Promise<Response>(() => undefined));
37
+ const socket = vi.fn();
38
+ vi.stubGlobal("fetch", fetch);
39
+ vi.stubGlobal(
40
+ "WebSocket",
41
+ class extends EventTarget {
42
+ public constructor(url: URL) {
43
+ super();
44
+ socket(url);
45
+ }
46
+ public close() {}
47
+ },
48
+ );
49
+ const events = new NotebookEvents(createEditorBackend(chosen).transport);
50
+ await vi.waitFor(() =>
51
+ expect(
52
+ {
53
+ idle:
54
+ events.status === "connected" && !wasm.consumer.mock.calls.length,
55
+ wasm: wasm.consumer.mock.calls.length === 1,
56
+ fetch: fetch.mock.calls.length === 1,
57
+ socket: socket.mock.calls.length === 1,
58
+ }[expected],
59
+ ).toBe(true),
60
+ );
61
+ events.close();
62
+ });
63
+
64
+ it("edits a reported notebook on its host with the session's headers", async () => {
65
+ const fetch = vi.fn(
66
+ async (_input: RequestInfo | URL, _init?: RequestInit) =>
67
+ new Response(
68
+ JSON.stringify({ type: "conflict", title: "Conflict", status: 409 }),
69
+ {
70
+ status: 409,
71
+ headers: { "Content-Type": "application/problem+json" },
72
+ },
73
+ ),
74
+ );
75
+ vi.stubGlobal("fetch", fetch);
76
+ const backend = createEditorBackend({
77
+ ...options,
78
+ headers: () => ({
79
+ Authorization: "Bearer editor-token",
80
+ "Marimo-Server-Token": "server-token",
81
+ }),
82
+ });
83
+ const result = await backend
84
+ .notebook("n1")
85
+ .edit([], { idempotencyKey: "edit-1" });
86
+ expect(result).toMatchObject({ ok: false, status: 409 });
87
+ const [[input, init]] = fetch.mock.calls;
88
+ expect(String(input)).toBe(
89
+ "http://localhost/api/marimo/v1/notebooks/n1/edits",
90
+ );
91
+ const headers = new Headers(init?.headers);
92
+ expect(init?.method).toBe("POST");
93
+ expect(headers.get("Authorization")).toBe("Bearer editor-token");
94
+ expect(headers.get("Marimo-Server-Token")).toBe("server-token");
95
+ expect(headers.get("Idempotency-Key")).toBe("edit-1");
96
+ });
97
+
98
+ it.each([
99
+ ["a static notebook", { ...options, static: true }, false],
100
+ ["WASM", options, true],
101
+ ] as const)("does not edit %s", async (_name, chosen, isWasm) => {
102
+ wasm.enabled = isWasm;
103
+ const fetch = vi.fn();
104
+ vi.stubGlobal("fetch", fetch);
105
+ const result = await createEditorBackend(chosen)
106
+ .notebook("n1")
107
+ .edit([], { idempotencyKey: "edit-1" });
108
+ expect(result).toMatchObject({ ok: false, status: 501 });
109
+ expect(fetch).not.toHaveBeenCalled();
110
+ });
111
+ });
@@ -8,13 +8,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
8
8
  import type { NotebookTransport } from "@marimo-team/client";
9
9
  import { ConnectionState } from "../types";
10
10
  import { Deferred } from "@/utils/Deferred";
11
- import { createNotebookTransport } from "../transports/notebook";
11
+ import type { NotebookBackend } from "../../host/backend";
12
+ import { createEditorBackend } from "../backend";
12
13
  import { useNotebookClient } from "../useNotebookClient";
13
14
  import { fixtureMessages } from "../../host/__tests__/fixtures";
14
15
  import { agentAttachmentsAtom } from "../../attachments/state";
15
16
  import { notebookClientAtom } from "../state";
16
17
 
17
- vi.mock("../transports/notebook", () => ({ createNotebookTransport: vi.fn() }));
18
+ vi.mock("../backend", () => ({ createEditorBackend: vi.fn() }));
19
+
20
+ /** A backend that follows `transport` and cannot edit. */
21
+ function backend(transport: NotebookTransport): NotebookBackend {
22
+ return { transport, notebook: () => ({ edit: vi.fn() }) };
23
+ }
18
24
 
19
25
  /** A transport whose connections the test drives. */
20
26
  function makeTransport() {
@@ -53,6 +59,7 @@ function setup(
53
59
  transportType: "websocket" as const,
54
60
  url: () => "ws://localhost/ws",
55
61
  headers: () => ({}),
62
+ notebookUrl: (id: string) => new URL(`http://localhost/notebooks/${id}`),
56
63
  waitUntilReady: async () => undefined,
57
64
  checkHealth: async () => true,
58
65
  onNotification: vi.fn(),
@@ -70,7 +77,7 @@ function setup(
70
77
  }
71
78
 
72
79
  beforeEach(() => {
73
- vi.mocked(createNotebookTransport).mockReset();
80
+ vi.mocked(createEditorBackend).mockReset();
74
81
  });
75
82
 
76
83
  describe("useNotebookClient", () => {
@@ -78,16 +85,15 @@ describe("useNotebookClient", () => {
78
85
  "rechecks health after failed startup (healthy: %s)",
79
86
  async (healthy) => {
80
87
  const fake = makeTransport();
81
- vi.mocked(createNotebookTransport).mockImplementation(
82
- ({ url }) =>
83
- async (attempt, handlers) => {
84
- if (typeof url === "function") {
85
- await url();
86
- }
87
- if (!attempt.signal.aborted) {
88
- return fake.transport(attempt, handlers);
89
- }
90
- },
88
+ vi.mocked(createEditorBackend).mockImplementation(({ url }) =>
89
+ backend(async (attempt, handlers) => {
90
+ if (typeof url === "function") {
91
+ await url();
92
+ }
93
+ if (!attempt.signal.aborted) {
94
+ return fake.transport(attempt, handlers);
95
+ }
96
+ }),
91
97
  );
92
98
  const waitUntilReady = vi
93
99
  .fn()
@@ -125,14 +131,13 @@ describe("useNotebookClient", () => {
125
131
  it("waits for initial readiness without reviving an unmounted connection", async () => {
126
132
  const ready = new Deferred<void>();
127
133
  const resolvedUrl = vi.fn();
128
- vi.mocked(createNotebookTransport).mockImplementation(
129
- ({ url }) =>
130
- async () => {
131
- if (typeof url === "function") {
132
- await url();
133
- resolvedUrl();
134
- }
135
- },
134
+ vi.mocked(createEditorBackend).mockImplementation(({ url }) =>
135
+ backend(async () => {
136
+ if (typeof url === "function") {
137
+ await url();
138
+ resolvedUrl();
139
+ }
140
+ }),
136
141
  );
137
142
  const { result, store, unmount } = setup(false, {
138
143
  waitUntilReady: () => ready.promise,
@@ -152,7 +157,7 @@ describe("useNotebookClient", () => {
152
157
 
153
158
  it("exposes host presence to the UI without waiting for kernel-ready", async () => {
154
159
  const fake = makeTransport();
155
- vi.mocked(createNotebookTransport).mockReturnValue(fake.transport);
160
+ vi.mocked(createEditorBackend).mockReturnValue(backend(fake.transport));
156
161
  const { result, store, unmount } = setup();
157
162
  await act(async () => Promise.resolve());
158
163
  expect(store.get(notebookClientAtom)).toBe(result.current);
@@ -177,7 +182,7 @@ describe("useNotebookClient", () => {
177
182
 
178
183
  it("uses current callbacks and URL providers without reconnecting on a rerender", async () => {
179
184
  const fake = makeTransport();
180
- vi.mocked(createNotebookTransport).mockReturnValue(fake.transport);
185
+ vi.mocked(createEditorBackend).mockReturnValue(backend(fake.transport));
181
186
  const { rerender, options, unmount } = setup();
182
187
  await act(async () => Promise.resolve());
183
188
  const onNotification = vi.fn();
@@ -187,8 +192,8 @@ describe("useNotebookClient", () => {
187
192
  expect(onNotification).toHaveBeenCalledExactlyOnceWith({
188
193
  data: { op: "reconnected" },
189
194
  });
190
- expect(createNotebookTransport).toHaveBeenCalledOnce();
191
- const [{ url }] = vi.mocked(createNotebookTransport).mock.calls[0];
195
+ expect(createEditorBackend).toHaveBeenCalledOnce();
196
+ const [{ url }] = vi.mocked(createEditorBackend).mock.calls[0];
192
197
  expect(String(await (url as () => Promise<string>)())).toBe(
193
198
  "ws://redirected/ws",
194
199
  );
@@ -197,10 +202,10 @@ describe("useNotebookClient", () => {
197
202
 
198
203
  it("closes every stream under Strict Mode and only follows the active lifetime", async () => {
199
204
  const fakes: ReturnType<typeof makeTransport>[] = [];
200
- vi.mocked(createNotebookTransport).mockImplementation(() => {
205
+ vi.mocked(createEditorBackend).mockImplementation(() => {
201
206
  const next = makeTransport();
202
207
  fakes.push(next);
203
- return next.transport;
208
+ return backend(next.transport);
204
209
  });
205
210
  const { result, store, options, unmount } = setup(true);
206
211
  await act(async () => Promise.resolve());
@@ -21,8 +21,8 @@ import {
21
21
  vi,
22
22
  } from "vitest";
23
23
 
24
- vi.mock("../transports/notebook", () => ({
25
- createNotebookTransport: vi.fn(),
24
+ vi.mock("../backend", () => ({
25
+ createEditorBackend: vi.fn(),
26
26
  }));
27
27
 
28
28
  vi.mock("@/core/runtime/config", async () => {
@@ -51,7 +51,7 @@ import { generateSessionId } from "../../kernel/session";
51
51
  import { RuntimeManager } from "../../runtime/runtime";
52
52
  import { WebSocketClosedReason, ConnectionState } from "../types";
53
53
  import { classifyCloseEvent } from "../connection-state";
54
- import { createNotebookTransport } from "../transports/notebook";
54
+ import { createEditorBackend } from "../backend";
55
55
  import { useNotebookSession } from "../useNotebookSession";
56
56
  import { type NotebookTransport, StreamStopped } from "@marimo-team/client";
57
57
 
@@ -85,7 +85,10 @@ type FakeTransport = ReturnType<typeof makeTransport>;
85
85
 
86
86
  /** Use a fake transport for the next session the hook follows. */
87
87
  function withTransport(fake: FakeTransport = makeTransport()) {
88
- vi.mocked(createNotebookTransport).mockReturnValue(fake.transport);
88
+ vi.mocked(createEditorBackend).mockReturnValue({
89
+ transport: fake.transport,
90
+ notebook: () => ({ edit: vi.fn() }),
91
+ });
89
92
  return fake;
90
93
  }
91
94
 
@@ -0,0 +1,68 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { NotebookClient, type StreamUrl } from "@marimo-team/client";
4
+ import type { NotebookId } from "@marimo-team/client/protocol";
5
+ import type { NotebookBackend } from "../host/backend";
6
+ import { PyodideBridge } from "../wasm/bridge";
7
+ import { isWasm } from "../wasm/utils";
8
+ import {
9
+ idle,
10
+ pyodide,
11
+ sessionSse,
12
+ websocket,
13
+ withHeaders,
14
+ } from "./transports/notebook";
15
+
16
+ /** How the editor follows its notebook. */
17
+ export interface EditorBackendOptions {
18
+ static: boolean;
19
+ transportType: "websocket" | "sse";
20
+ /** The session route, read before each connection. */
21
+ url: StreamUrl;
22
+ /** The runtime's headers, read before each request. */
23
+ headers: () => Record<string, string>;
24
+ /** The host URL of a notebook the stream reported. */
25
+ notebookUrl: (notebookId: NotebookId) => URL;
26
+ }
27
+
28
+ /** Commands for a notebook the editor cannot change through its backend. */
29
+ function uneditable(): ReturnType<NotebookBackend["notebook"]> {
30
+ return {
31
+ edit: async () => ({
32
+ ok: false,
33
+ status: 501,
34
+ problem: {
35
+ type: "tag:marimo.io,2026:host/unsupported-operation",
36
+ title: "Document editing is unavailable on this connection",
37
+ status: 501,
38
+ },
39
+ }),
40
+ };
41
+ }
42
+
43
+ /** Choose the backend for the editor's notebook. */
44
+ export function createEditorBackend(
45
+ options: EditorBackendOptions,
46
+ ): NotebookBackend {
47
+ if (options.static) {
48
+ return { transport: idle, notebook: uneditable };
49
+ }
50
+ if (isWasm()) {
51
+ return {
52
+ transport: pyodide((consumer) =>
53
+ PyodideBridge.INSTANCE.attachMessageConsumer(consumer),
54
+ ),
55
+ notebook: uneditable,
56
+ };
57
+ }
58
+ return {
59
+ transport:
60
+ options.transportType === "sse"
61
+ ? sessionSse(options.url, options.headers)
62
+ : websocket(options.url),
63
+ notebook: (id) =>
64
+ new NotebookClient(options.notebookUrl(id), {
65
+ fetch: withHeaders(options.headers),
66
+ }),
67
+ };
68
+ }
@@ -11,18 +11,7 @@ import { createNotebookClient } from "../../../host/notebook-client";
11
11
  import { fixtureMessages } from "../../../host/__tests__/fixtures";
12
12
  import { ConnectionState } from "../../../host/connection";
13
13
  import { Deferred } from "@/utils/Deferred";
14
- import {
15
- createNotebookTransport,
16
- pyodide,
17
- sessionSse,
18
- websocket,
19
- } from "../notebook";
20
-
21
- const wasm = vi.hoisted(() => ({ enabled: false, consumer: vi.fn() }));
22
- vi.mock("../../../wasm/utils", () => ({ isWasm: () => wasm.enabled }));
23
- vi.mock("../../../wasm/bridge", () => ({
24
- PyodideBridge: { INSTANCE: { attachMessageConsumer: wasm.consumer } },
25
- }));
14
+ import { pyodide, sessionSse, websocket } from "../notebook";
26
15
 
27
16
  const encoder = new TextEncoder();
28
17
 
@@ -83,14 +72,14 @@ function network(transportType: "websocket" | "sse", url: StreamUrl) {
83
72
  );
84
73
  const count = () =>
85
74
  transportType === "websocket" ? FakeSocket.opened.length : requests.length;
86
- const transport = createNotebookTransport({
87
- static: false,
88
- transportType,
89
- url,
90
- headers: () => ({}),
91
- });
75
+ const transport =
76
+ transportType === "websocket"
77
+ ? websocket(url)
78
+ : sessionSse(url, () => ({}));
79
+ const backend = { transport, notebook: () => ({ edit: vi.fn() }) };
92
80
  let index = 0;
93
81
  return {
82
+ backend,
94
83
  transport,
95
84
  count,
96
85
  opened: async () => {
@@ -126,8 +115,6 @@ function network(transportType: "websocket" | "sse", url: StreamUrl) {
126
115
 
127
116
  afterEach(() => {
128
117
  FakeSocket.opened.length = 0;
129
- wasm.enabled = false;
130
- wasm.consumer.mockReset();
131
118
  vi.unstubAllGlobals();
132
119
  vi.useRealTimers();
133
120
  });
@@ -313,7 +300,7 @@ describe.each(["websocket", "sse"] as const)(
313
300
  const lifetime = new AbortController();
314
301
  onTestFinished(() => lifetime.abort());
315
302
  connect({
316
- transport: connection.transport,
303
+ backend: connection.backend,
317
304
  signal: lifetime.signal,
318
305
  checkHealth: async () => true,
319
306
  onNotification: vi.fn(),
@@ -380,7 +367,7 @@ describe.each(["websocket", "sse"] as const)(
380
367
  const lifetime = new AbortController();
381
368
  onTestFinished(() => lifetime.abort());
382
369
  connect({
383
- transport: connection.transport,
370
+ backend: connection.backend,
384
371
  signal: lifetime.signal,
385
372
  checkHealth: async () => true,
386
373
  onNotification: vi.fn(),
@@ -466,37 +453,3 @@ describe("pyodide", () => {
466
453
  events.close();
467
454
  });
468
455
  });
469
-
470
- describe("createNotebookTransport", () => {
471
- const options = {
472
- static: false,
473
- transportType: "websocket" as const,
474
- url: () => "ws://localhost/ws",
475
- headers: () => ({}),
476
- };
477
-
478
- it.each([
479
- ["a static notebook", { ...options, static: true }, "idle"],
480
- ["WASM", options, "wasm"],
481
- ["SSE", { ...options, transportType: "sse" as const }, "fetch"],
482
- ["the default", options, "socket"],
483
- ] as const)("follows %s", async (_name, chosen, expected) => {
484
- wasm.enabled = expected === "wasm";
485
- const fetch = vi.fn(() => new Promise<Response>(() => undefined));
486
- vi.stubGlobal("fetch", fetch);
487
- vi.stubGlobal("WebSocket", FakeSocket);
488
- const events = new NotebookEvents(createNotebookTransport(chosen));
489
- await vi.waitFor(() =>
490
- expect(
491
- {
492
- idle:
493
- events.status === "connected" && !wasm.consumer.mock.calls.length,
494
- wasm: wasm.consumer.mock.calls.length === 1,
495
- fetch: fetch.mock.calls.length === 1,
496
- socket: FakeSocket.opened.length === 1,
497
- }[expected],
498
- ).toBe(true),
499
- );
500
- events.close();
501
- });
502
- });
@@ -7,8 +7,6 @@ import {
7
7
  type StreamUrl,
8
8
  } from "@marimo-team/client";
9
9
  import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
10
- import { PyodideBridge } from "../../wasm/bridge";
11
- import { isWasm } from "../../wasm/utils";
12
10
  import { classifyCloseEvent } from "../connection-state";
13
11
 
14
12
  /** The server can be slow to accept when many notebooks are open. */
@@ -40,6 +38,19 @@ async function resolve(url: StreamUrl): Promise<URL | undefined> {
40
38
  }
41
39
  }
42
40
 
41
+ /** Send each request with the runtime's headers, read before each request. */
42
+ export function withHeaders(
43
+ headers: () => Record<string, string>,
44
+ ): typeof globalThis.fetch {
45
+ return (input, init) => {
46
+ const sent = new Headers(init?.headers);
47
+ for (const [name, value] of Object.entries(headers())) {
48
+ sent.set(name, value);
49
+ }
50
+ return globalThis.fetch(input, { ...init, headers: sent });
51
+ };
52
+ }
53
+
43
54
  /** A resume cursor and replica belong to the session that produced them. */
44
55
  function withSessionIdentity(
45
56
  url: StreamUrl,
@@ -131,13 +142,7 @@ export function sessionSse(
131
142
  return withSessionIdentity(url, (target) =>
132
143
  sse(target, {
133
144
  parseData: jsonParseWithSpecialChar,
134
- fetch: (input, init) => {
135
- const sent = new Headers(init?.headers);
136
- for (const [name, value] of Object.entries(headers())) {
137
- sent.set(name, value);
138
- }
139
- return globalThis.fetch(input, { ...init, headers: sent });
140
- },
145
+ fetch: withHeaders(headers),
141
146
  stop: (frame) =>
142
147
  frame.event === "close"
143
148
  ? stopOn(frame.data as { code?: number; reason?: string })
@@ -147,7 +152,7 @@ export function sessionSse(
147
152
  }
148
153
 
149
154
  /** A connection that opens and delivers nothing, for static notebooks. */
150
- const idle: NotebookTransport = ({ signal }, { open }) =>
155
+ export const idle: NotebookTransport = ({ signal }, { open }) =>
151
156
  new Promise((settle) => {
152
157
  open();
153
158
  signal.addEventListener("abort", () => settle(), { once: true });
@@ -183,31 +188,3 @@ export function pyodide(
183
188
  signal.addEventListener("abort", () => settle(), { once: true });
184
189
  });
185
190
  }
186
-
187
- /** How the editor follows its notebook. */
188
- export interface NotebookTransportOptions {
189
- static: boolean;
190
- transportType: "websocket" | "sse";
191
- /** The session route, read before each connection. */
192
- url: StreamUrl;
193
- /** The runtime's headers, read before each request. */
194
- headers: () => Record<string, string>;
195
- }
196
-
197
- /** Choose the transport for the editor's notebook. */
198
- export function createNotebookTransport(
199
- options: NotebookTransportOptions,
200
- ): NotebookTransport {
201
- if (options.static) {
202
- return idle;
203
- }
204
- if (isWasm()) {
205
- return pyodide((consumer) =>
206
- PyodideBridge.INSTANCE.attachMessageConsumer(consumer),
207
- );
208
- }
209
- if (options.transportType === "sse") {
210
- return sessionSse(options.url, options.headers);
211
- }
212
- return websocket(options.url);
213
- }
@@ -13,12 +13,9 @@ import {
13
13
  type NotebookClient,
14
14
  } from "../host/notebook-client";
15
15
  import { notebookClientAtom } from "./state";
16
- import {
17
- createNotebookTransport,
18
- type NotebookTransportOptions,
19
- } from "./transports/notebook";
16
+ import { createEditorBackend, type EditorBackendOptions } from "./backend";
20
17
 
21
- type Options = Omit<NotebookTransportOptions, "url"> & {
18
+ type Options = Omit<EditorBackendOptions, "url"> & {
22
19
  /** The session route, read before each connection. */
23
20
  url: () => string;
24
21
  /** Wait until the host can accept a connection. */
@@ -33,6 +30,7 @@ export function useNotebookClient(options: Options): NotebookClient {
33
30
  const controller = useMemo(() => createNotebookClient(store), [store]);
34
31
  const url = useEvent(options.url);
35
32
  const headers = useEvent(options.headers);
33
+ const notebookUrl = useEvent(options.notebookUrl);
36
34
  const waitUntilReady = useEvent(options.waitUntilReady);
37
35
  const checkHealth = useEvent(options.checkHealth);
38
36
  const onNotification = useEvent(options.onNotification);
@@ -42,7 +40,7 @@ export function useNotebookClient(options: Options): NotebookClient {
42
40
  let needsInitialReadiness = true;
43
41
  store.set(notebookClientAtom, controller.client);
44
42
  controller.connect({
45
- transport: createNotebookTransport({
43
+ backend: createEditorBackend({
46
44
  static: isStatic,
47
45
  transportType,
48
46
  url: async () => {
@@ -60,6 +58,7 @@ export function useNotebookClient(options: Options): NotebookClient {
60
58
  return url();
61
59
  },
62
60
  headers,
61
+ notebookUrl,
63
62
  }),
64
63
  signal: lifetime.signal,
65
64
  checkHealth,
@@ -80,6 +79,7 @@ export function useNotebookClient(options: Options): NotebookClient {
80
79
  transportType,
81
80
  url,
82
81
  headers,
82
+ notebookUrl,
83
83
  waitUntilReady,
84
84
  checkHealth,
85
85
  onNotification,
@@ -75,7 +75,6 @@ import {
75
75
  import { useStorageActions } from "../storage/state";
76
76
  import { useVariablesActions } from "../variables/state";
77
77
  import type { VariableName } from "../variables/types";
78
- import { isWasm } from "../wasm/utils";
79
78
  import { WebSocketClosedReason, ConnectionState } from "./types";
80
79
 
81
80
  const SUPPORTS_LAZY_KERNELS = true;
@@ -418,8 +417,10 @@ export function useNotebookSession(opts: {
418
417
  : runtimeManager.getWsURL(sessionId).toString(),
419
418
  // SSE uses headers for auth. WebSockets carry the token in the URL.
420
419
  headers: () => runtimeManager.headers(),
420
+ notebookUrl: (notebookId) => runtimeManager.getNotebookURL(notebookId),
421
+ // Only the host's transports read the URL, so static and WASM never wait.
421
422
  waitUntilReady: async () => {
422
- if (!isStaticNotebook() && !isWasm() && !runtimeManager.isSameOrigin) {
423
+ if (!runtimeManager.isSameOrigin) {
423
424
  await runtimeManager.waitForHealthy();
424
425
  }
425
426
  },