@marimo-team/islands 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 (35) hide show
  1. package/dist/{ErrorBoundary-CF0lzT8W.js → ErrorBoundary-Dh-VaAao.js} +1 -1
  2. package/dist/{code-visibility-cv4J7KDX.js → code-visibility-BwTJAT1U.js} +1 -1
  3. package/dist/{common-C2fKBgIk.js → common-BTpcnvZO.js} +1 -1
  4. package/dist/{data-grid-overlay-editor-wIDXNxbi.js → data-grid-overlay-editor-C95q5QIf.js} +1 -1
  5. package/dist/{feedback-button-BrHm1nRa.js → feedback-button-sOLkDWYm.js} +2 -2
  6. package/dist/{glide-data-editor-CPm2l4v_.js → glide-data-editor-D6GkY0Z2.js} +3 -3
  7. package/dist/{globals-Crq94BHx.js → globals-rH_9s1xB.js} +1 -1
  8. package/dist/main.js +6 -6
  9. package/dist/{number-overlay-editor-C7EDk8Hn.js → number-overlay-editor-BjJttHms.js} +1 -1
  10. package/dist/{reveal-component-DZ5yZnHD.js → reveal-component-Ckn4ABk3.js} +2 -2
  11. package/package.json +1 -1
  12. package/src/core/host/AGENTS.md +6 -5
  13. package/src/core/host/__tests__/notebook-client.test.ts +368 -350
  14. package/src/core/host/connection.ts +1 -40
  15. package/src/core/host/notebook-client.ts +129 -186
  16. package/src/core/wasm/bridge.ts +0 -8
  17. package/src/core/websocket/__tests__/connection-state.test.ts +0 -12
  18. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +133 -59
  19. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +152 -250
  20. package/src/core/websocket/connection-state.ts +0 -10
  21. package/src/core/websocket/transports/__tests__/notebook.test.ts +502 -0
  22. package/src/core/websocket/transports/notebook.ts +213 -0
  23. package/src/core/websocket/useNotebookClient.ts +28 -17
  24. package/src/core/host/__tests__/events.test.ts +0 -49
  25. package/src/core/host/events.ts +0 -68
  26. package/src/core/websocket/__tests__/createConnectionTransport.test.ts +0 -49
  27. package/src/core/websocket/transports/__tests__/basic.test.ts +0 -275
  28. package/src/core/websocket/transports/__tests__/sse.test.ts +0 -352
  29. package/src/core/websocket/transports/__tests__/transport.test.ts +0 -308
  30. package/src/core/websocket/transports/__tests__/ws.test.ts +0 -125
  31. package/src/core/websocket/transports/basic.ts +0 -102
  32. package/src/core/websocket/transports/create.ts +0 -31
  33. package/src/core/websocket/transports/sse.ts +0 -320
  34. package/src/core/websocket/transports/transport.ts +0 -50
  35. package/src/core/websocket/transports/ws.ts +0 -98
@@ -0,0 +1,213 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ type NotebookTransport,
5
+ sse,
6
+ StreamStopped,
7
+ type StreamUrl,
8
+ } from "@marimo-team/client";
9
+ import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
10
+ import { PyodideBridge } from "../../wasm/bridge";
11
+ import { isWasm } from "../../wasm/utils";
12
+ import { classifyCloseEvent } from "../connection-state";
13
+
14
+ /** The server can be slow to accept when many notebooks are open. */
15
+ const CONNECT_MS = 10_000;
16
+
17
+ /**
18
+ * The error that ends a stream on this close, or none to retry.
19
+ *
20
+ * `cause` is the connection status the editor shows.
21
+ */
22
+ function stopOn(close: { code?: number; reason?: string }) {
23
+ const decision = classifyCloseEvent(close);
24
+ if (decision.kind === "retry") {
25
+ return undefined;
26
+ }
27
+ const reason = "reason" in decision.status ? decision.status.reason : "";
28
+ return new StreamStopped(reason, { cause: decision.status });
29
+ }
30
+
31
+ /** Read a URL provider, stopping the stream when it gives up. */
32
+ async function resolve(url: StreamUrl): Promise<URL | undefined> {
33
+ try {
34
+ return new URL(typeof url === "function" ? await url() : url);
35
+ } catch (error) {
36
+ if (error instanceof StreamStopped) {
37
+ throw error;
38
+ }
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ /** A resume cursor and replica belong to the session that produced them. */
44
+ function withSessionIdentity(
45
+ url: StreamUrl,
46
+ connect: (target: URL) => NotebookTransport,
47
+ ): NotebookTransport {
48
+ let previous: string | undefined;
49
+ return async (attempt, handlers) => {
50
+ const target = await resolve(url);
51
+ if (target === undefined || attempt.signal.aborted) {
52
+ return;
53
+ }
54
+ target.searchParams.delete("cursor");
55
+ const identity = new URL(target);
56
+ identity.searchParams.delete("access_token");
57
+ identity.searchParams.sort();
58
+ identity.hash = "";
59
+ const key = identity.toString();
60
+ let { cursor } = attempt;
61
+ if (previous !== undefined && previous !== key) {
62
+ // Reset both the stream's cursor and its subscribers' state.
63
+ handlers.message({ event: "reset", data: {} });
64
+ cursor = null;
65
+ }
66
+ previous = key;
67
+ if (!attempt.signal.aborted) {
68
+ return connect(target)({ ...attempt, cursor }, handlers);
69
+ }
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Follow a marimo session over its WebSocket route.
75
+ *
76
+ * Each attempt opens one socket with the cursor as a query parameter. The
77
+ * editor's close policy decides which closes end the stream.
78
+ *
79
+ * TODO: Notice a connection that died without closing, such as after a laptop
80
+ * sleeps. `/sse` sends a heartbeat that its silence limit relies on, and
81
+ * `/ws` drops it. Sending it on `/ws` would let this transport close a
82
+ * silent socket.
83
+ */
84
+ export function websocket(url: StreamUrl): NotebookTransport {
85
+ return withSessionIdentity(
86
+ url,
87
+ (target) =>
88
+ async ({ cursor, signal }, { open, message }) => {
89
+ if (cursor !== null) {
90
+ target.searchParams.set("cursor", cursor);
91
+ }
92
+ await new Promise<void>((settle, reject) => {
93
+ const socket = new WebSocket(target);
94
+ const timeout = setTimeout(() => socket.close(), CONNECT_MS);
95
+ const abort = () => socket.close();
96
+ signal.addEventListener("abort", abort, { once: true });
97
+ socket.addEventListener("open", () => {
98
+ clearTimeout(timeout);
99
+ open();
100
+ });
101
+ socket.addEventListener("message", ({ data }) =>
102
+ // marimo's parser reads `NaN`. A message it cannot read arrives as an
103
+ // empty object, which `NotebookEvents` rejects as not an event.
104
+ message(jsonParseWithSpecialChar(String(data))),
105
+ );
106
+ socket.addEventListener("close", (event) => {
107
+ clearTimeout(timeout);
108
+ signal.removeEventListener("abort", abort);
109
+ const stopped = signal.aborted ? undefined : stopOn(event);
110
+ if (stopped === undefined) {
111
+ settle();
112
+ } else {
113
+ reject(stopped);
114
+ }
115
+ });
116
+ });
117
+ },
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Follow a marimo session over its server-sent events route.
123
+ *
124
+ * The session's `close` frames carry the same reasons as WebSocket closes.
125
+ * marimo's parser decodes tagged bigints and non-finite numbers in every frame.
126
+ */
127
+ export function sessionSse(
128
+ url: StreamUrl,
129
+ headers: () => Record<string, string>,
130
+ ): NotebookTransport {
131
+ return withSessionIdentity(url, (target) =>
132
+ sse(target, {
133
+ 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
+ },
141
+ stop: (frame) =>
142
+ frame.event === "close"
143
+ ? stopOn(frame.data as { code?: number; reason?: string })
144
+ : undefined,
145
+ }),
146
+ );
147
+ }
148
+
149
+ /** A connection that opens and delivers nothing, for static notebooks. */
150
+ const idle: NotebookTransport = ({ signal }, { open }) =>
151
+ new Promise((settle) => {
152
+ open();
153
+ signal.addEventListener("abort", () => settle(), { once: true });
154
+ });
155
+
156
+ /**
157
+ * Follow the notebook running in the WASM worker.
158
+ *
159
+ * The worker sends marimo notifications as `{ data: notification }`, which
160
+ * arrive as `marimo` events.
161
+ */
162
+ export function pyodide(
163
+ attach: (consumer: (message: MessageEvent<string>) => void) => void,
164
+ ): NotebookTransport {
165
+ return ({ signal }, { open, message }) =>
166
+ new Promise((settle) => {
167
+ open();
168
+ attach(({ data }) => {
169
+ if (signal.aborted) {
170
+ return;
171
+ }
172
+ const value = jsonParseWithSpecialChar(data);
173
+ // The worker sends notifications without the host's event envelope.
174
+ message(
175
+ typeof value === "object" &&
176
+ value !== null &&
177
+ !("event" in value) &&
178
+ "data" in value
179
+ ? { event: "marimo", data: { marimo: value.data } }
180
+ : value,
181
+ );
182
+ });
183
+ signal.addEventListener("abort", () => settle(), { once: true });
184
+ });
185
+ }
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
+ }
@@ -1,9 +1,9 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ import { StreamStopped } from "@marimo-team/client";
3
4
  import { useStore } from "jotai";
4
5
  import { useEffect, useMemo } from "react";
5
6
  import useEvent from "react-use-event-hook";
6
- import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
7
7
  import type {
8
8
  NotificationMessage,
9
9
  NotificationPayload,
@@ -12,14 +12,16 @@ import {
12
12
  createNotebookClient,
13
13
  type NotebookClient,
14
14
  } from "../host/notebook-client";
15
- import { classifyCloseEvent } from "./connection-state";
16
15
  import { notebookClientAtom } from "./state";
17
16
  import {
18
- createConnectionTransport,
19
- type ConnectionTransportOptions,
20
- } from "./transports/create";
17
+ createNotebookTransport,
18
+ type NotebookTransportOptions,
19
+ } from "./transports/notebook";
21
20
 
22
- type Options = ConnectionTransportOptions & {
21
+ type Options = Omit<NotebookTransportOptions, "url"> & {
22
+ /** The session route, read before each connection. */
23
+ url: () => string;
24
+ /** Wait until the host can accept a connection. */
23
25
  waitUntilReady: () => Promise<void>;
24
26
  checkHealth: () => Promise<boolean>;
25
27
  onNotification: (notification: NotificationPayload) => void;
@@ -37,24 +39,33 @@ export function useNotebookClient(options: Options): NotebookClient {
37
39
 
38
40
  useEffect(() => {
39
41
  const lifetime = new AbortController();
42
+ let needsInitialReadiness = true;
40
43
  store.set(notebookClientAtom, controller.client);
41
44
  controller.connect({
42
- createTransport: (url) =>
43
- createConnectionTransport({
44
- url,
45
- headers,
46
- static: isStatic,
47
- transportType,
48
- }),
49
- url,
45
+ transport: createNotebookTransport({
46
+ static: isStatic,
47
+ transportType,
48
+ url: async () => {
49
+ if (needsInitialReadiness) {
50
+ // Explicit retries check current health, even if startup failed.
51
+ needsInitialReadiness = false;
52
+ try {
53
+ await waitUntilReady();
54
+ } catch (error) {
55
+ throw new StreamStopped("Healthy connection never made", {
56
+ cause: error,
57
+ });
58
+ }
59
+ }
60
+ return url();
61
+ },
62
+ headers,
63
+ }),
50
64
  signal: lifetime.signal,
51
- waitUntilReady,
52
65
  checkHealth,
53
66
  // The host validates the envelope, not the native notification contents.
54
67
  onNotification: (notification) =>
55
68
  onNotification({ data: notification as NotificationMessage }),
56
- decodeJson: jsonParseWithSpecialChar,
57
- classifyClose: classifyCloseEvent,
58
69
  });
59
70
  return () => {
60
71
  lifetime.abort();
@@ -1,49 +0,0 @@
1
- /* Copyright 2026 Marimo. All rights reserved. */
2
-
3
- import { describe, expect, expectTypeOf, it } from "vitest";
4
- import { z } from "zod";
5
- import { type NotebookEvent, parseNotebookEvent } from "../events";
6
-
7
- describe("notebook messages", () => {
8
- it("normalizes session and host envelopes for native marimo messages", () => {
9
- const notification = {
10
- op: "variables",
11
- variables: [{ name: "x", value: Number.NaN }],
12
- };
13
- expectTypeOf<
14
- Extract<NotebookEvent, { event: "marimo" }>["data"]["marimo"]
15
- >().toBeUnknown();
16
- const host = { event: "marimo", data: { marimo: notification } };
17
- expect(parseNotebookEvent({ data: notification })).toEqual(host);
18
- expect(parseNotebookEvent({ ...host, id: "42" })).toEqual({
19
- ...host,
20
- id: "42",
21
- });
22
- });
23
-
24
- it("retains unknown event cursors without interpreting their payloads", () => {
25
- expect(
26
- parseNotebookEvent({ event: "future", id: "42", data: null }),
27
- ).toEqual({ event: "unknown", name: "future", id: "42" });
28
- });
29
-
30
- it("decodes known events outside the client's subscription filters", () => {
31
- const event = {
32
- event: "run.removed",
33
- id: "42",
34
- data: { id: "r1", time: "2026-10-19T12:00:00Z" },
35
- };
36
- expect(parseNotebookEvent(event)).toEqual(event);
37
- });
38
-
39
- it.each([
40
- { event: "cell", id: "42", data: { cell: { id: "c1" } } },
41
- { event: "run.removed", id: "42", data: {} },
42
- { event: "ready", data: {} },
43
- { event: "marimo", data: {} },
44
- {},
45
- null,
46
- ])("rejects malformed known messages: %j", (message) => {
47
- expect(() => parseNotebookEvent(message)).toThrow(z.ZodError);
48
- });
49
- });
@@ -1,68 +0,0 @@
1
- /* Copyright 2026 Marimo. All rights reserved. */
2
-
3
- import { z } from "zod";
4
- import * as Protocol from "@marimo-team/client/protocol";
5
-
6
- /**
7
- * Notebook stream events, including native marimo notifications.
8
- *
9
- * Known events follow the generated protocol. Unknown events retain their name
10
- * and cursor so clients can skip them without losing their place in the stream.
11
- */
12
-
13
- const envelope = z.object({
14
- event: z.string().min(1),
15
- data: z.unknown(),
16
- id: z.string().min(1).optional(),
17
- });
18
-
19
- const knownNames = new Set<string>(
20
- Protocol.Event.options.flatMap((schema) => schema.shape.event.options),
21
- );
22
-
23
- /**
24
- * A native marimo message carried by a host stream.
25
- *
26
- * The protocol leaves extension events to their producer. The payload is
27
- * passed through for the session handler to interpret.
28
- */
29
- const MarimoEvent = z.object({
30
- event: z.literal("marimo"),
31
- id: z.string().min(1).optional(),
32
- data: z.object({ marimo: z.unknown() }),
33
- });
34
-
35
- /** A decoded event, or the name and cursor of an unrecognized event. */
36
- export type NotebookEvent =
37
- | Protocol.EventOutput
38
- | z.output<typeof MarimoEvent>
39
- | {
40
- readonly event: "unknown";
41
- readonly name: string;
42
- readonly id?: string;
43
- };
44
-
45
- const sessionEnvelope = z.object({ data: z.unknown() });
46
-
47
- /**
48
- * Decode a host event or an in-process WASM `{ data: notification }` message.
49
- *
50
- * Throws if the envelope or a known event is malformed. Native notification
51
- * payloads are passed through for the session handler to interpret.
52
- */
53
- export function parseNotebookEvent(value: unknown): NotebookEvent {
54
- if (typeof value === "object" && value !== null && !("event" in value)) {
55
- return {
56
- event: "marimo",
57
- data: { marimo: sessionEnvelope.parse(value).data },
58
- };
59
- }
60
- const parsed = envelope.parse(value);
61
- if (parsed.event === "marimo") {
62
- return MarimoEvent.parse(parsed);
63
- }
64
- if (knownNames.has(parsed.event)) {
65
- return Protocol.Event.parse(parsed);
66
- }
67
- return { event: "unknown", name: parsed.event, id: parsed.id };
68
- }
@@ -1,49 +0,0 @@
1
- /* Copyright 2026 Marimo. All rights reserved. */
2
-
3
- import { afterEach, describe, expect, it, vi } from "vitest";
4
- import { BasicTransport } from "../transports/basic";
5
- import { SseTransport } from "../transports/sse";
6
- import { WsTransport } from "../transports/ws";
7
- import { createConnectionTransport } from "../transports/create";
8
-
9
- vi.mock("../../wasm/utils", () => ({
10
- isWasm: () => false,
11
- }));
12
-
13
- describe("createConnectionTransport", () => {
14
- afterEach(() => {
15
- vi.restoreAllMocks();
16
- });
17
-
18
- const options = {
19
- url: () => "http://localhost/ws",
20
- headers: () => ({}),
21
- };
22
-
23
- it("uses BasicTransport for static notebooks", () => {
24
- const transport = createConnectionTransport({
25
- ...options,
26
- static: true,
27
- transportType: "websocket",
28
- });
29
- expect(transport).toBeInstanceOf(BasicTransport);
30
- });
31
-
32
- it("uses WsTransport by default", () => {
33
- const transport = createConnectionTransport({
34
- ...options,
35
- static: false,
36
- transportType: "websocket",
37
- });
38
- expect(transport).toBeInstanceOf(WsTransport);
39
- });
40
-
41
- it("uses SseTransport when the transport type is sse", () => {
42
- const transport = createConnectionTransport({
43
- ...options,
44
- static: false,
45
- transportType: "sse",
46
- });
47
- expect(transport).toBeInstanceOf(SseTransport);
48
- });
49
- });