@marimo-team/islands 0.23.15-dev4 → 0.23.15-dev40

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 (92) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CU4fZJzG.js → ConnectedDataExplorerComponent-7p7rt9iw.js} +4 -4
  2. package/dist/{ErrorBoundary-DE6tzZf-.js → ErrorBoundary-B_CAG7_e.js} +1 -1
  3. package/dist/{ImageComparisonComponent-CHrI72em.js → ImageComparisonComponent-1i5IBhon.js} +173 -129
  4. package/dist/assets/__vite-browser-external-CjNAy01L.js +1 -0
  5. package/dist/assets/worker-B3XPCb6y.js +98 -0
  6. package/dist/{chat-ui-DinOvbWu.js → chat-ui-C0z13gJB.js} +3111 -3111
  7. package/dist/{code-visibility-Dk8cVOny.js → code-visibility-CSsHnVZu.js} +946 -864
  8. package/dist/{constants-T20xxyNf.js → debounce-BOD3DbfP.js} +1 -24
  9. package/dist/{formats-Dzx4J_z1.js → formats-CitsMtUm.js} +1 -1
  10. package/dist/{glide-data-editor-CjTu7ukN.js → glide-data-editor-CwZz71BD.js} +3 -3
  11. package/dist/{html-to-image-_wGfk8V-.js → html-to-image-CEo5pRYw.js} +2291 -2250
  12. package/dist/{input-DtsN7xm-.js → input-BGPrFH3g.js} +2 -2
  13. package/dist/main.js +1297 -1201
  14. package/dist/{mermaid-BYqXy_NE.js → mermaid-UdmxG2PZ.js} +2 -2
  15. package/dist/{process-output-Mh4UrjwM.js → process-output-BOvvilRG.js} +1 -1
  16. package/dist/{reveal-component-B5DXLyO7.js → reveal-component-DGjJVNMQ.js} +618 -605
  17. package/dist/{spec-Cz-Bj1JI.js → spec-DSs9v0xx.js} +1 -1
  18. package/dist/style.css +1 -1
  19. package/dist/{toDate-CWNNlFEX.js → toDate-BRJgtOGm.js} +14 -5
  20. package/dist/{useAsyncData-KfHB8wQR.js → useAsyncData-BSOyAbac.js} +1 -1
  21. package/dist/{useDeepCompareMemoize-nJMtxhm4.js → useDeepCompareMemoize-BTLeWzuR.js} +1 -1
  22. package/dist/{useLifecycle-DegSo0lV.js → useLifecycle-C6wHjkhW.js} +1 -1
  23. package/dist/{useTheme-6eZ3GOTS.js → useTheme-Df_vGflw.js} +62 -27
  24. package/dist/{vega-component-DzyyM9fc.js → vega-component-BAvmvTjO.js} +6 -6
  25. package/package.json +1 -1
  26. package/src/components/chat/__tests__/chat-utils.test.ts +244 -1
  27. package/src/components/chat/__tests__/message-queue.test.tsx +121 -0
  28. package/src/components/chat/chat-panel.tsx +196 -67
  29. package/src/components/chat/chat-utils.ts +111 -2
  30. package/src/components/editor/SortableCell.tsx +6 -2
  31. package/src/components/editor/__tests__/output-persistence.test.tsx +241 -0
  32. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +153 -0
  33. package/src/components/editor/actions/pair-with-agent-commands.ts +109 -0
  34. package/src/components/editor/actions/pair-with-agent-modal.tsx +20 -64
  35. package/src/components/editor/ai/add-cell-with-ai.tsx +14 -5
  36. package/src/components/editor/ai/ai-completion-editor.tsx +4 -1
  37. package/src/components/editor/cell/cell-context-menu.tsx +7 -0
  38. package/src/components/editor/chrome/panels/packages-panel.tsx +11 -1
  39. package/src/components/editor/columns/__tests__/cell-column.test.tsx +105 -0
  40. package/src/components/editor/columns/cell-column.tsx +34 -4
  41. package/src/components/editor/columns/sortable-column.tsx +24 -4
  42. package/src/components/editor/file-tree/download.ts +46 -0
  43. package/src/components/editor/file-tree/file-explorer.tsx +3 -21
  44. package/src/components/editor/file-tree/file-viewer.tsx +4 -27
  45. package/src/components/editor/navigation/__tests__/navigation.test.ts +33 -0
  46. package/src/components/editor/navigation/navigation.ts +8 -1
  47. package/src/components/editor/notebook-cell.tsx +203 -106
  48. package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +66 -0
  49. package/src/components/editor/renderers/cell-array.tsx +26 -13
  50. package/src/components/editor/renderers/cells-renderer.tsx +8 -2
  51. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +29 -0
  52. package/src/components/editor/renderers/slides-layout/types.ts +4 -0
  53. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
  54. package/src/components/slides/reveal-component.tsx +37 -4
  55. package/src/components/slides/slide-form.tsx +72 -0
  56. package/src/components/ui/toast.tsx +11 -3
  57. package/src/components/ui/toaster.tsx +65 -9
  58. package/src/core/cells/__tests__/utils.test.ts +59 -0
  59. package/src/core/cells/utils.ts +51 -0
  60. package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +97 -3
  61. package/src/core/codemirror/completion/signature-hint.ts +78 -11
  62. package/src/core/constants.ts +6 -0
  63. package/src/core/islands/__tests__/bridge.test.ts +341 -46
  64. package/src/core/islands/__tests__/main.test.ts +176 -0
  65. package/src/core/islands/__tests__/parse.test.ts +105 -0
  66. package/src/core/islands/bootstrap.ts +8 -3
  67. package/src/core/islands/bridge.ts +116 -23
  68. package/src/core/islands/components/__tests__/web-components.test.tsx +214 -0
  69. package/src/core/islands/components/web-components.tsx +76 -15
  70. package/src/core/islands/constants.ts +1 -0
  71. package/src/core/islands/main.ts +69 -3
  72. package/src/core/islands/parse.ts +70 -23
  73. package/src/core/islands/worker/__tests__/controller.test.ts +173 -0
  74. package/src/core/islands/worker/worker.tsx +145 -57
  75. package/src/core/runtime/__tests__/runtime.test.ts +64 -0
  76. package/src/core/runtime/runtime.ts +30 -10
  77. package/src/core/wasm/worker/bootstrap.ts +113 -20
  78. package/src/css/app/Cell.css +10 -0
  79. package/src/hooks/__tests__/useHotkey.test.tsx +88 -0
  80. package/src/hooks/useHotkey.ts +29 -4
  81. package/src/plugins/impl/anywidget/__tests__/host.test.ts +6 -9
  82. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +22 -61
  83. package/src/plugins/impl/anywidget/model-proxy.ts +0 -13
  84. package/src/plugins/impl/anywidget/widget-binding.ts +4 -34
  85. package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +53 -2
  86. package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +71 -0
  87. package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +71 -3
  88. package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -0
  89. package/src/theme/__tests__/useTheme.test.ts +68 -0
  90. package/src/theme/useTheme.ts +16 -1
  91. package/dist/assets/__vite-browser-external-BQCLNwri.js +0 -1
  92. package/dist/assets/worker-DAWRHcPq.js +0 -73
@@ -0,0 +1,173 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { PyodideInterface } from "pyodide";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { ReadonlyWasmController } from "../controller";
6
+
7
+ class TestController extends ReadonlyWasmController {
8
+ setPyodide(pyodide: PyodideInterface) {
9
+ this.pyodide = pyodide;
10
+ }
11
+ }
12
+
13
+ function createSessionResources(
14
+ stopImplementation: () => Promise<void> = () => Promise.resolve(),
15
+ ) {
16
+ const bridge = { destroy: vi.fn() };
17
+ const init = Object.assign(vi.fn(), { destroy: vi.fn() });
18
+ const stop = Object.assign(vi.fn(stopImplementation), {
19
+ destroy: vi.fn(),
20
+ });
21
+ const packages = { destroy: vi.fn(), toJs: () => [] };
22
+ const sessionResources = Object.assign([bridge, init, packages, stop], {
23
+ destroy: vi.fn(),
24
+ });
25
+
26
+ return { bridge, init, packages, sessionResources, stop };
27
+ }
28
+
29
+ function createPyodideStub(
30
+ sessions = Array.from({ length: 4 }, () => createSessionResources()),
31
+ ) {
32
+ const [first] = sessions;
33
+ if (!first) {
34
+ throw new Error("At least one session is required");
35
+ }
36
+ const loadPackagesFromImports = vi.fn().mockResolvedValue(undefined);
37
+ const pyodide = {
38
+ runPython: vi
39
+ .fn()
40
+ .mockImplementation(() => sessions.shift()?.sessionResources),
41
+ loadPackagesFromImports,
42
+ loadedPackages: {},
43
+ runPythonAsync: vi.fn(),
44
+ } as unknown as PyodideInterface;
45
+
46
+ return {
47
+ ...first,
48
+ loadPackagesFromImports,
49
+ pyodide,
50
+ };
51
+ }
52
+
53
+ function startSession(controller: TestController, code: string) {
54
+ return controller.startSession({
55
+ code,
56
+ filename: `${code}.py`,
57
+ onMessage: vi.fn(),
58
+ });
59
+ }
60
+
61
+ describe("WASM controller session lifecycle", () => {
62
+ it("stops and releases the active session", async () => {
63
+ const { init, packages, pyodide, sessionResources, stop } =
64
+ createPyodideStub();
65
+ const controller = new TestController();
66
+ controller.setPyodide(pyodide);
67
+
68
+ await startSession(controller, "current");
69
+ await vi.waitFor(() => expect(init).toHaveBeenCalledOnce());
70
+ await controller.stopSession();
71
+
72
+ expect(stop).toHaveBeenCalledOnce();
73
+ expect(stop.destroy).toHaveBeenCalledOnce();
74
+ expect(sessionResources.destroy).toHaveBeenCalledOnce();
75
+ expect(packages.destroy).toHaveBeenCalledOnce();
76
+ expect(init.destroy).toHaveBeenCalledOnce();
77
+ });
78
+
79
+ it("loads dependencies serially and skips superseded sessions", async () => {
80
+ let finishFirstLoad!: () => void;
81
+ const { loadPackagesFromImports, pyodide } = createPyodideStub();
82
+ loadPackagesFromImports
83
+ .mockReturnValueOnce(
84
+ new Promise<void>((resolve) => {
85
+ finishFirstLoad = resolve;
86
+ }),
87
+ )
88
+ .mockResolvedValueOnce(undefined);
89
+ const controller = new TestController();
90
+ controller.setPyodide(pyodide);
91
+
92
+ await startSession(controller, "first_dependency");
93
+ await startSession(controller, "superseded_dependency");
94
+ await controller.stopSession();
95
+ await startSession(controller, "current_dependency");
96
+
97
+ expect(loadPackagesFromImports).toHaveBeenCalledOnce();
98
+ finishFirstLoad();
99
+ await vi.waitFor(() =>
100
+ expect(loadPackagesFromImports).toHaveBeenCalledTimes(2),
101
+ );
102
+
103
+ const loadedSources = loadPackagesFromImports.mock.calls.map(
104
+ ([source]) => source as string,
105
+ );
106
+ expect(loadedSources[1]).toContain("current_dependency");
107
+ expect(loadedSources.join("\n")).not.toContain("superseded_dependency");
108
+ });
109
+
110
+ it("stops every session created for a multi-app page", async () => {
111
+ const first = createSessionResources();
112
+ const second = createSessionResources();
113
+ const { pyodide } = createPyodideStub([first, second]);
114
+ const controller = new TestController();
115
+ controller.setPyodide(pyodide);
116
+
117
+ await startSession(controller, "first");
118
+ await startSession(controller, "second");
119
+ second.bridge.destroy();
120
+
121
+ expect(first.stop.destroy).not.toHaveBeenCalled();
122
+
123
+ await controller.stopSession();
124
+
125
+ expect(first.stop).toHaveBeenCalledOnce();
126
+ expect(second.stop).toHaveBeenCalledOnce();
127
+ expect(first.stop.destroy).toHaveBeenCalledOnce();
128
+ expect(second.stop.destroy).toHaveBeenCalledOnce();
129
+ });
130
+
131
+ it("continues stopping sessions after one stop fails", async () => {
132
+ const failure = new Error("stop failed");
133
+ const first = createSessionResources(() => Promise.reject(failure));
134
+ const second = createSessionResources();
135
+ const { pyodide } = createPyodideStub([first, second]);
136
+ const controller = new TestController();
137
+ controller.setPyodide(pyodide);
138
+
139
+ await startSession(controller, "first");
140
+ await startSession(controller, "second");
141
+
142
+ await expect(controller.stopSession()).rejects.toThrow("stop failed");
143
+ expect(first.stop).toHaveBeenCalledOnce();
144
+ expect(second.stop).toHaveBeenCalledOnce();
145
+ expect(first.stop.destroy).not.toHaveBeenCalled();
146
+ expect(second.stop.destroy).toHaveBeenCalledOnce();
147
+
148
+ first.stop.mockResolvedValueOnce(undefined);
149
+ await controller.stopSession();
150
+
151
+ expect(first.stop).toHaveBeenCalledTimes(2);
152
+ expect(first.stop.destroy).toHaveBeenCalledOnce();
153
+ expect(second.stop).toHaveBeenCalledOnce();
154
+ });
155
+
156
+ it("reports and retries a falsy stop failure", async () => {
157
+ const session = createSessionResources(() => Promise.reject(false));
158
+ const { pyodide } = createPyodideStub([session]);
159
+ const controller = new TestController();
160
+ controller.setPyodide(pyodide);
161
+
162
+ await startSession(controller, "current");
163
+
164
+ await expect(controller.stopSession()).rejects.toBe(false);
165
+ expect(session.stop.destroy).not.toHaveBeenCalled();
166
+
167
+ session.stop.mockResolvedValueOnce(undefined);
168
+ await controller.stopSession();
169
+
170
+ expect(session.stop).toHaveBeenCalledTimes(2);
171
+ expect(session.stop.destroy).toHaveBeenCalledOnce();
172
+ });
173
+ });
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import type { PyodideInterface } from "pyodide";
4
+ import type { PyProxy } from "pyodide/ffi";
4
5
  import {
5
6
  createRPC,
6
7
  createRPCRequestHandler,
@@ -16,7 +17,6 @@ import { MessageBuffer } from "@/core/wasm/worker/message-buffer";
16
17
  import type { RawBridge, SerializedBridge } from "@/core/wasm/worker/types";
17
18
  import type { JsonString } from "@/utils/json/base64";
18
19
  import { Logger } from "@/utils/Logger";
19
- import { Deferred } from "../../../utils/Deferred";
20
20
  import { prettyError } from "../../../utils/errors";
21
21
  import { invariant } from "../../../utils/invariant";
22
22
  import { ReadonlyWasmController } from "./controller";
@@ -50,59 +50,128 @@ const messageBuffer = new MessageBuffer(
50
50
  rpc.send.kernelMessage({ message });
51
51
  },
52
52
  );
53
- const bridgeReady = new Deferred<SerializedBridge>();
53
+ interface SessionRequest {
54
+ code: string;
55
+ appId: string;
56
+ sessionGeneration: number;
57
+ }
58
+
59
+ let activeSession:
60
+ | (Omit<SessionRequest, "code"> & { bridge: SerializedBridge })
61
+ | undefined;
62
+ let sessionQueue = Promise.resolve();
63
+
64
+ async function startSession(
65
+ opts: SessionRequest,
66
+ replace: boolean,
67
+ ): Promise<void> {
68
+ await pyodideReadyPromise;
69
+
70
+ try {
71
+ invariant(self.controller, "Controller not loaded");
72
+ if (replace) {
73
+ await stopActiveSession();
74
+ }
75
+ const notebook = await self.controller.mountFilesystem({
76
+ code: opts.code,
77
+ filename: `app-${opts.appId}.py`,
78
+ });
79
+ const nextBridge = await self.controller.startSession({
80
+ ...notebook,
81
+ onMessage: messageBuffer.push,
82
+ });
83
+ if (activeSession) {
84
+ (nextBridge as unknown as PyProxy).destroy();
85
+ } else {
86
+ activeSession = {
87
+ appId: opts.appId,
88
+ bridge: nextBridge,
89
+ sessionGeneration: opts.sessionGeneration,
90
+ };
91
+ }
92
+ rpc.send.initialized({});
93
+ } catch (error) {
94
+ rpc.send.initializedError({
95
+ error: prettyError(error),
96
+ });
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ async function stopActiveSession(): Promise<void> {
102
+ const session = activeSession;
103
+ await self.controller.stopSession();
104
+ activeSession = undefined;
105
+ (session?.bridge as unknown as PyProxy | undefined)?.destroy();
106
+ }
107
+
108
+ function enqueueSession<T>(operation: () => Promise<T>): Promise<T> {
109
+ const result = sessionQueue.then(operation);
110
+ sessionQueue = result.then(
111
+ () => undefined,
112
+ () => undefined,
113
+ );
114
+ return result;
115
+ }
54
116
 
55
117
  // Handle RPC requests
56
118
  const requestHandler = createRPCRequestHandler({
57
119
  /**
58
120
  * Start the session
59
121
  */
60
- startSession: async (opts: { code: string; appId: string }) => {
61
- await pyodideReadyPromise; // Make sure loading is done
122
+ startSession: async (opts: SessionRequest) => {
123
+ await enqueueSession(() => startSession(opts, false));
124
+ },
62
125
 
63
- try {
126
+ replaceSession: async (opts: SessionRequest) => {
127
+ await enqueueSession(() => startSession(opts, true));
128
+ },
129
+
130
+ stopSession: async (opts: { appId: string; sessionGeneration: number }) => {
131
+ await enqueueSession(async () => {
132
+ if (
133
+ activeSession?.appId !== opts.appId ||
134
+ activeSession?.sessionGeneration !== opts.sessionGeneration
135
+ ) {
136
+ return;
137
+ }
64
138
  invariant(self.controller, "Controller not loaded");
65
- const notebook = await self.controller.mountFilesystem({
66
- code: opts.code,
67
- filename: `app-${opts.appId}.py`,
68
- });
69
- const bridge = await self.controller.startSession({
70
- ...notebook,
71
- onMessage: messageBuffer.push,
72
- });
73
- bridgeReady.resolve(bridge);
74
- rpc.send.initialized({});
75
- } catch (error) {
76
- rpc.send.initializedError({
77
- error: prettyError(error),
78
- });
79
- }
80
- return;
139
+ await stopActiveSession();
140
+ });
81
141
  },
82
142
 
83
143
  /**
84
144
  * Load packages
85
145
  */
86
- loadPackages: async (code: string) => {
146
+ loadPackages: async (opts: {
147
+ appId: string;
148
+ code: string;
149
+ sessionGeneration: number;
150
+ }) => {
87
151
  await pyodideReadyPromise; // Make sure loading is done
152
+ await enqueueSession(async () => {
153
+ requireActiveBridge(opts);
154
+
155
+ let { code } = opts;
88
156
 
89
- if (shouldLoadDuckDBPackages(code)) {
90
- // Add pandas and duckdb to the code for mo.sql and for remote duckdb sources
91
- code = `import pandas\n${code}`;
92
- code = `import duckdb\n${code}`;
93
- code = `import sqlglot\n${code}`;
94
-
95
- // Polars + SQL requires pyarrow, and installing
96
- // after notebook load does not work. As a heuristic,
97
- // if it appears that the notebook uses polars, add pyarrow.
98
- if (code.includes("polars")) {
99
- code = `import pyarrow\n${code}`;
157
+ if (shouldLoadDuckDBPackages(code)) {
158
+ // Add pandas and duckdb to the code for mo.sql and for remote duckdb sources
159
+ code = `import pandas\n${code}`;
160
+ code = `import duckdb\n${code}`;
161
+ code = `import sqlglot\n${code}`;
162
+
163
+ // Polars + SQL requires pyarrow, and installing
164
+ // after notebook load does not work. As a heuristic,
165
+ // if it appears that the notebook uses polars, add pyarrow.
166
+ if (code.includes("polars")) {
167
+ code = `import pyarrow\n${code}`;
168
+ }
100
169
  }
101
- }
102
170
 
103
- await self.pyodide.loadPackagesFromImports(code, {
104
- messageCallback: Logger.log,
105
- errorCallback: Logger.error,
171
+ await self.pyodide.loadPackagesFromImports(code, {
172
+ messageCallback: Logger.log,
173
+ errorCallback: Logger.error,
174
+ });
106
175
  });
107
176
  },
108
177
 
@@ -110,37 +179,56 @@ const requestHandler = createRPCRequestHandler({
110
179
  * Call a function on the bridge
111
180
  */
112
181
  bridge: async (opts: {
182
+ appId: string;
113
183
  functionName: keyof RawBridge;
114
184
  payload: {} | undefined | null;
185
+ sessionGeneration: number;
115
186
  }) => {
116
187
  await pyodideReadyPromise; // Make sure loading is done
117
188
 
118
189
  const { functionName, payload } = opts;
119
190
 
120
191
  // Perform the function call to the Python bridge
121
- const bridge = await bridgeReady.promise;
122
-
123
- // Serialize the payload
124
- const payloadString =
125
- payload == null
126
- ? null
127
- : typeof payload === "string"
128
- ? payload
129
- : JSON.stringify(payload);
130
-
131
- // Make the request
132
- const response =
133
- payloadString == null
134
- ? // @ts-expect-error ehh TypeScript
135
- await bridge[functionName]()
136
- : // @ts-expect-error ehh TypeScript
137
- await bridge[functionName](payloadString);
138
-
139
- // Post the response back to the main thread
140
- return typeof response === "string" ? JSON.parse(response) : response;
192
+ return enqueueSession(async () => {
193
+ const bridge = requireActiveBridge(opts);
194
+
195
+ // Serialize the payload
196
+ const payloadString =
197
+ payload == null
198
+ ? null
199
+ : typeof payload === "string"
200
+ ? payload
201
+ : JSON.stringify(payload);
202
+
203
+ // Make the request
204
+ const response =
205
+ payloadString == null
206
+ ? // @ts-expect-error ehh TypeScript
207
+ await bridge[functionName]()
208
+ : // @ts-expect-error ehh TypeScript
209
+ await bridge[functionName](payloadString);
210
+
211
+ // Post the response back to the main thread
212
+ return typeof response === "string" ? JSON.parse(response) : response;
213
+ });
141
214
  },
142
215
  });
143
216
 
217
+ function requireActiveBridge(opts: {
218
+ appId: string;
219
+ sessionGeneration: number;
220
+ }): SerializedBridge {
221
+ const session = activeSession;
222
+ if (
223
+ !session ||
224
+ opts.appId !== session.appId ||
225
+ opts.sessionGeneration !== session.sessionGeneration
226
+ ) {
227
+ throw new Error("The marimo app session is no longer active.");
228
+ }
229
+ return session.bridge;
230
+ }
231
+
144
232
  // create the iframe's schema
145
233
  export type WorkerSchema = RPCSchema<
146
234
  {
@@ -154,6 +154,70 @@ describe("RuntimeManager", () => {
154
154
  });
155
155
  });
156
156
 
157
+ describe("formatNavigableHttpURL", () => {
158
+ it("adds access_token when cross-origin with authToken", () => {
159
+ const runtime = new RuntimeManager(
160
+ {
161
+ url: "https://sandbox.example.com",
162
+ lazy: true,
163
+ authToken: "my-secret-token",
164
+ },
165
+ true,
166
+ );
167
+ const url = runtime.formatNavigableHttpURL(
168
+ "api/files/download",
169
+ new URLSearchParams({ path: "/tmp/data.csv" }),
170
+ );
171
+
172
+ expect(url.pathname).toBe("/api/files/download");
173
+ expect(url.searchParams.get("path")).toBe("/tmp/data.csv");
174
+ expect(url.searchParams.get("access_token")).toBe("my-secret-token");
175
+ });
176
+
177
+ it("omits access_token when same-origin", () => {
178
+ const runtime = new RuntimeManager(
179
+ {
180
+ url: window.location.origin,
181
+ lazy: true,
182
+ authToken: "my-secret-token",
183
+ },
184
+ true,
185
+ );
186
+ const url = runtime.formatNavigableHttpURL(
187
+ "api/files/download",
188
+ new URLSearchParams({ path: "/tmp/data.csv" }),
189
+ );
190
+
191
+ expect(url.searchParams.get("access_token")).toBeNull();
192
+ expect(url.searchParams.get("path")).toBe("/tmp/data.csv");
193
+ });
194
+
195
+ it("omits access_token when no authToken configured", () => {
196
+ const runtime = new RuntimeManager(
197
+ { url: "https://sandbox.example.com", lazy: true },
198
+ true,
199
+ );
200
+ const url = runtime.formatNavigableHttpURL("api/files/download");
201
+
202
+ expect(url.searchParams.get("access_token")).toBeNull();
203
+ });
204
+
205
+ it("does not forward a page-inherited access_token when same-origin", () => {
206
+ const runtime = new RuntimeManager(
207
+ { url: window.location.origin, lazy: true },
208
+ true,
209
+ );
210
+ const original = window.location.href;
211
+ window.history.replaceState({}, "", "/?access_token=page-token");
212
+ try {
213
+ const url = runtime.formatNavigableHttpURL("api/files/download");
214
+ expect(url.searchParams.get("access_token")).toBeNull();
215
+ } finally {
216
+ window.history.replaceState({}, "", original);
217
+ }
218
+ });
219
+ });
220
+
157
221
  describe("getSseURL", () => {
158
222
  it("should return an http(s) URL with the session ID", () => {
159
223
  const runtime = new RuntimeManager(mockConfig);
@@ -86,6 +86,35 @@ export class RuntimeManager {
86
86
  return baseUrl;
87
87
  }
88
88
 
89
+ /**
90
+ * Channels that cannot send custom headers (WebSockets, and browser
91
+ * navigations such as file downloads) carry the auth token as a query
92
+ * param instead, but only when cross-origin. Same-origin requests
93
+ * authenticate via the session cookie, which keeps the token out of
94
+ * browser history and logs; cross-origin cookies are blocked by browsers,
95
+ * so the access_token query param is the only option there.
96
+ */
97
+ private appendCrossOriginAuth(url: URL): URL {
98
+ if (!this.isSameOrigin && this.config.authToken) {
99
+ url.searchParams.set(KnownQueryParams.accessToken, this.config.authToken);
100
+ }
101
+ return url;
102
+ }
103
+
104
+ /**
105
+ * An HTTP URL for requests made by browser navigation (e.g. anchor-click
106
+ * downloads), which cannot attach auth headers.
107
+ */
108
+ formatNavigableHttpURL(path: string, searchParams?: URLSearchParams): URL {
109
+ const url = this.formatHttpURL({ path, searchParams });
110
+ // Drop any token inherited from the current page's query params; re-add it
111
+ // only when cross-origin. Same-origin downloads authenticate via the
112
+ // session cookie, so a token in the URL would needlessly leak into
113
+ // proxy/server logs.
114
+ url.searchParams.delete(KnownQueryParams.accessToken);
115
+ return this.appendCrossOriginAuth(url);
116
+ }
117
+
89
118
  formatWsURL(path: string, searchParams?: URLSearchParams): URL {
90
119
  // We don't restrict to known query parameters, since mo.query_params()
91
120
  // can accept arbitrary parameters.
@@ -94,16 +123,7 @@ export class RuntimeManager {
94
123
  searchParams,
95
124
  restrictToKnownQueryParams: false,
96
125
  });
97
-
98
- // For cross-origin runtimes, pass the auth token as a query parameter.
99
- // WebSocket connections cannot send custom headers (no Authorization
100
- // header), and cross-origin cookies are blocked by browsers, so the
101
- // access_token query param is the only way to authenticate.
102
- if (!this.isSameOrigin && this.config.authToken) {
103
- url.searchParams.set(KnownQueryParams.accessToken, this.config.authToken);
104
- }
105
-
106
- return asWsUrl(url.toString());
126
+ return asWsUrl(this.appendCrossOriginAuth(url).toString());
107
127
  }
108
128
 
109
129
  /**