@marimo-team/islands 0.23.15-dev4 → 0.23.15-dev41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ConnectedDataExplorerComponent-CU4fZJzG.js → ConnectedDataExplorerComponent-7p7rt9iw.js} +4 -4
- package/dist/{ErrorBoundary-DE6tzZf-.js → ErrorBoundary-B_CAG7_e.js} +1 -1
- package/dist/{ImageComparisonComponent-CHrI72em.js → ImageComparisonComponent-1i5IBhon.js} +173 -129
- package/dist/assets/__vite-browser-external-CjNAy01L.js +1 -0
- package/dist/assets/worker-B3XPCb6y.js +98 -0
- package/dist/{chat-ui-DinOvbWu.js → chat-ui-C0z13gJB.js} +3111 -3111
- package/dist/{code-visibility-Dk8cVOny.js → code-visibility-COokQS5X.js} +946 -864
- package/dist/{constants-T20xxyNf.js → debounce-BOD3DbfP.js} +1 -24
- package/dist/{formats-Dzx4J_z1.js → formats-CitsMtUm.js} +1 -1
- package/dist/{glide-data-editor-CjTu7ukN.js → glide-data-editor-CwZz71BD.js} +3 -3
- package/dist/{html-to-image-_wGfk8V-.js → html-to-image-CEo5pRYw.js} +2291 -2250
- package/dist/{input-DtsN7xm-.js → input-BGPrFH3g.js} +2 -2
- package/dist/main.js +1297 -1201
- package/dist/{mermaid-BYqXy_NE.js → mermaid-UdmxG2PZ.js} +2 -2
- package/dist/{process-output-Mh4UrjwM.js → process-output-BOvvilRG.js} +1 -1
- package/dist/{reveal-component-B5DXLyO7.js → reveal-component-D3ySPwH5.js} +618 -605
- package/dist/{spec-Cz-Bj1JI.js → spec-DSs9v0xx.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-CWNNlFEX.js → toDate-BRJgtOGm.js} +14 -5
- package/dist/{useAsyncData-KfHB8wQR.js → useAsyncData-BSOyAbac.js} +1 -1
- package/dist/{useDeepCompareMemoize-nJMtxhm4.js → useDeepCompareMemoize-BTLeWzuR.js} +1 -1
- package/dist/{useLifecycle-DegSo0lV.js → useLifecycle-C6wHjkhW.js} +1 -1
- package/dist/{useTheme-6eZ3GOTS.js → useTheme-Df_vGflw.js} +62 -27
- package/dist/{vega-component-DzyyM9fc.js → vega-component-BAvmvTjO.js} +6 -6
- package/package.json +1 -1
- package/src/components/chat/__tests__/chat-utils.test.ts +244 -1
- package/src/components/chat/__tests__/message-queue.test.tsx +121 -0
- package/src/components/chat/chat-panel.tsx +196 -67
- package/src/components/chat/chat-utils.ts +111 -2
- package/src/components/editor/SortableCell.tsx +6 -2
- package/src/components/editor/__tests__/output-persistence.test.tsx +241 -0
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +153 -0
- package/src/components/editor/actions/pair-with-agent-commands.ts +109 -0
- package/src/components/editor/actions/pair-with-agent-modal.tsx +20 -64
- package/src/components/editor/ai/add-cell-with-ai.tsx +14 -5
- package/src/components/editor/ai/ai-completion-editor.tsx +4 -1
- package/src/components/editor/cell/cell-context-menu.tsx +7 -0
- package/src/components/editor/chrome/panels/packages-panel.tsx +11 -1
- package/src/components/editor/columns/__tests__/cell-column.test.tsx +105 -0
- package/src/components/editor/columns/cell-column.tsx +34 -4
- package/src/components/editor/columns/sortable-column.tsx +24 -4
- package/src/components/editor/file-tree/download.ts +46 -0
- package/src/components/editor/file-tree/file-explorer.tsx +3 -21
- package/src/components/editor/file-tree/file-viewer.tsx +4 -27
- package/src/components/editor/navigation/__tests__/navigation.test.ts +33 -0
- package/src/components/editor/navigation/navigation.ts +8 -1
- package/src/components/editor/notebook-cell.tsx +203 -106
- package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +66 -0
- package/src/components/editor/renderers/cell-array.tsx +26 -13
- package/src/components/editor/renderers/cells-renderer.tsx +8 -2
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +29 -0
- package/src/components/editor/renderers/slides-layout/types.ts +4 -0
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
- package/src/components/slides/reveal-component.tsx +37 -4
- package/src/components/slides/slide-form.tsx +72 -0
- package/src/components/ui/toast.tsx +11 -3
- package/src/components/ui/toaster.tsx +65 -9
- package/src/core/cells/__tests__/utils.test.ts +59 -0
- package/src/core/cells/utils.ts +51 -0
- package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +97 -3
- package/src/core/codemirror/completion/signature-hint.ts +78 -11
- package/src/core/constants.ts +6 -0
- package/src/core/islands/__tests__/bridge.test.ts +341 -46
- package/src/core/islands/__tests__/main.test.ts +176 -0
- package/src/core/islands/__tests__/parse.test.ts +105 -0
- package/src/core/islands/bootstrap.ts +8 -3
- package/src/core/islands/bridge.ts +116 -23
- package/src/core/islands/components/__tests__/web-components.test.tsx +214 -0
- package/src/core/islands/components/web-components.tsx +76 -15
- package/src/core/islands/constants.ts +1 -0
- package/src/core/islands/main.ts +69 -3
- package/src/core/islands/parse.ts +70 -23
- package/src/core/islands/worker/__tests__/controller.test.ts +173 -0
- package/src/core/islands/worker/worker.tsx +145 -57
- package/src/core/runtime/__tests__/runtime.test.ts +64 -0
- package/src/core/runtime/runtime.ts +30 -10
- package/src/core/wasm/worker/bootstrap.ts +113 -20
- package/src/css/app/Cell.css +10 -0
- package/src/hooks/__tests__/useHotkey.test.tsx +88 -0
- package/src/hooks/useHotkey.ts +29 -4
- package/src/plugins/impl/anywidget/__tests__/host.test.ts +6 -9
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +22 -61
- package/src/plugins/impl/anywidget/model-proxy.ts +0 -13
- package/src/plugins/impl/anywidget/widget-binding.ts +4 -34
- package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +53 -2
- package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +71 -0
- package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +71 -3
- package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -0
- package/src/theme/__tests__/useTheme.test.ts +68 -0
- package/src/theme/useTheme.ts +16 -1
- package/dist/assets/__vite-browser-external-BQCLNwri.js +0 -1
- 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
|
-
|
|
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:
|
|
61
|
-
await
|
|
122
|
+
startSession: async (opts: SessionRequest) => {
|
|
123
|
+
await enqueueSession(() => startSession(opts, false));
|
|
124
|
+
},
|
|
62
125
|
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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 (
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
/**
|