@multiplatform.one/frappe 6.4.3 → 6.7.0

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 (125) hide show
  1. package/dist/cjs/collection.cjs +25 -9
  2. package/dist/cjs/collection.native.js +25 -9
  3. package/dist/cjs/collection.native.js.map +1 -1
  4. package/dist/cjs/db.cjs +13 -2
  5. package/dist/cjs/db.native.js +18 -2
  6. package/dist/cjs/db.native.js.map +1 -1
  7. package/dist/cjs/frappeReact.cjs +10 -3
  8. package/dist/cjs/frappeReact.native.js +10 -3
  9. package/dist/cjs/frappeReact.native.js.map +1 -1
  10. package/dist/cjs/index.cjs +1 -0
  11. package/dist/cjs/index.native.js +1 -0
  12. package/dist/cjs/index.native.js.map +1 -1
  13. package/dist/cjs/realtime.cjs +222 -11
  14. package/dist/cjs/realtime.native.js +352 -14
  15. package/dist/cjs/realtime.native.js.map +1 -1
  16. package/dist/cjs/sync/index.cjs +6 -1
  17. package/dist/cjs/sync/index.native.js +6 -1
  18. package/dist/cjs/sync/index.native.js.map +1 -1
  19. package/dist/cjs/sync/mutationEngine.cjs +43 -19
  20. package/dist/cjs/sync/mutationEngine.native.js +51 -21
  21. package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
  22. package/dist/cjs/sync/subscriptionManager.cjs +4 -0
  23. package/dist/cjs/sync/subscriptionManager.native.js +4 -0
  24. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  25. package/dist/cjs/sync/types.native.js.map +1 -1
  26. package/dist/esm/collection.mjs +24 -9
  27. package/dist/esm/collection.mjs.map +1 -1
  28. package/dist/esm/collection.native.js +24 -9
  29. package/dist/esm/collection.native.js.map +1 -1
  30. package/dist/esm/db.mjs +13 -2
  31. package/dist/esm/db.mjs.map +1 -1
  32. package/dist/esm/db.native.js +18 -2
  33. package/dist/esm/db.native.js.map +1 -1
  34. package/dist/esm/frappeReact.mjs +11 -4
  35. package/dist/esm/frappeReact.mjs.map +1 -1
  36. package/dist/esm/frappeReact.native.js +11 -4
  37. package/dist/esm/frappeReact.native.js.map +1 -1
  38. package/dist/esm/index.mjs +2 -2
  39. package/dist/esm/index.mjs.map +1 -1
  40. package/dist/esm/index.native.js +2 -2
  41. package/dist/esm/index.native.js.map +1 -1
  42. package/dist/esm/realtime.mjs +222 -11
  43. package/dist/esm/realtime.mjs.map +1 -1
  44. package/dist/esm/realtime.native.js +352 -14
  45. package/dist/esm/realtime.native.js.map +1 -1
  46. package/dist/esm/sync/index.mjs +6 -1
  47. package/dist/esm/sync/index.mjs.map +1 -1
  48. package/dist/esm/sync/index.native.js +6 -1
  49. package/dist/esm/sync/index.native.js.map +1 -1
  50. package/dist/esm/sync/mutationEngine.mjs +43 -19
  51. package/dist/esm/sync/mutationEngine.mjs.map +1 -1
  52. package/dist/esm/sync/mutationEngine.native.js +51 -21
  53. package/dist/esm/sync/mutationEngine.native.js.map +1 -1
  54. package/dist/esm/sync/subscriptionManager.mjs +4 -0
  55. package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
  56. package/dist/esm/sync/subscriptionManager.native.js +4 -0
  57. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  58. package/dist/esm/sync/types.mjs.map +1 -1
  59. package/dist/esm/sync/types.native.js.map +1 -1
  60. package/dist/jsx/collection.mjs +24 -9
  61. package/dist/jsx/collection.mjs.map +1 -1
  62. package/dist/jsx/collection.native.js +25 -9
  63. package/dist/jsx/collection.native.js.map +1 -1
  64. package/dist/jsx/db.mjs +13 -2
  65. package/dist/jsx/db.mjs.map +1 -1
  66. package/dist/jsx/db.native.js +18 -2
  67. package/dist/jsx/db.native.js.map +1 -1
  68. package/dist/jsx/frappeReact.mjs +11 -4
  69. package/dist/jsx/frappeReact.mjs.map +1 -1
  70. package/dist/jsx/frappeReact.native.js +10 -3
  71. package/dist/jsx/frappeReact.native.js.map +1 -1
  72. package/dist/jsx/index.js +2 -2
  73. package/dist/jsx/index.js.map +1 -1
  74. package/dist/jsx/index.mjs +2 -2
  75. package/dist/jsx/index.mjs.map +1 -1
  76. package/dist/jsx/index.native.js +1 -0
  77. package/dist/jsx/index.native.js.map +1 -1
  78. package/dist/jsx/realtime.mjs +222 -11
  79. package/dist/jsx/realtime.mjs.map +1 -1
  80. package/dist/jsx/realtime.native.js +352 -14
  81. package/dist/jsx/realtime.native.js.map +1 -1
  82. package/dist/jsx/sync/index.mjs +6 -1
  83. package/dist/jsx/sync/index.mjs.map +1 -1
  84. package/dist/jsx/sync/index.native.js +6 -1
  85. package/dist/jsx/sync/index.native.js.map +1 -1
  86. package/dist/jsx/sync/mutationEngine.mjs +43 -19
  87. package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
  88. package/dist/jsx/sync/mutationEngine.native.js +51 -21
  89. package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
  90. package/dist/jsx/sync/subscriptionManager.mjs +4 -0
  91. package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
  92. package/dist/jsx/sync/subscriptionManager.native.js +4 -0
  93. package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
  94. package/dist/jsx/sync/types.mjs.map +1 -1
  95. package/dist/jsx/sync/types.native.js.map +1 -1
  96. package/package.json +7 -7
  97. package/src/collection.ts +53 -8
  98. package/src/db.spec.ts +70 -0
  99. package/src/db.ts +28 -2
  100. package/src/frappeReact.spec.ts +7 -1
  101. package/src/frappeReact.ts +30 -6
  102. package/src/frappeReactLiveQueryGc.spec.ts +44 -4
  103. package/src/index.ts +13 -1
  104. package/src/realtime.ts +406 -14
  105. package/src/realtimeSse.spec.ts +547 -0
  106. package/src/sync/index.ts +10 -1
  107. package/src/sync/mutationEngine.ts +81 -20
  108. package/src/sync/optimisticCreateReconcile.spec.ts +372 -0
  109. package/src/sync/subscriptionManager.spec.ts +41 -0
  110. package/src/sync/subscriptionManager.ts +10 -0
  111. package/src/sync/subscriptions.spec.ts +4 -0
  112. package/src/sync/types.ts +9 -0
  113. package/types/collection.d.ts +12 -0
  114. package/types/collection.d.ts.map +1 -1
  115. package/types/db.d.ts.map +1 -1
  116. package/types/frappeReact.d.ts.map +1 -1
  117. package/types/index.d.ts +2 -1
  118. package/types/index.d.ts.map +1 -1
  119. package/types/realtime.d.ts +166 -1
  120. package/types/realtime.d.ts.map +1 -1
  121. package/types/sync/index.d.ts.map +1 -1
  122. package/types/sync/mutationEngine.d.ts.map +1 -1
  123. package/types/sync/subscriptionManager.d.ts.map +1 -1
  124. package/types/sync/types.d.ts +9 -0
  125. package/types/sync/types.d.ts.map +1 -1
@@ -281,7 +281,7 @@ describe("shared collection GC vs real live queries", () => {
281
281
  expect(liveQueryErrors()).toHaveLength(0);
282
282
  });
283
283
 
284
- it("force window: a leaked query cannot pin the collection forever, and a fresh mount recovers", async () => {
284
+ it("force window: a leaked query releases the sync WITHOUT erroring, and a fresh mount recovers", async () => {
285
285
  const config = configFor("leak");
286
286
  const hook = renderHook(() => useFrappeCollection<WorkspaceDoc>(config, "Workspace"));
287
287
  await flushMicrotasks();
@@ -290,18 +290,29 @@ describe("shared collection GC vs real live queries", () => {
290
290
  // A leaked query: created (sync started, source subscribed) but never
291
291
  // given a subscriber — the shape a discarded render leaves behind.
292
292
  // TanStack DB never arms its GC for it, so only the force window frees
293
- // the source collection.
294
- createLiveQueryCollection({
293
+ // the sync resources.
294
+ const leakedQuery = createLiveQueryCollection({
295
295
  query: (q) => q.from({ workspace: collection }),
296
296
  startSync: true,
297
297
  gcTime: 300_000,
298
298
  });
299
299
  await flushMicrotasks();
300
300
  expect(collection.subscriberCount).toBeGreaterThan(0);
301
+ const unsubscribesBefore = mockUnsubscribe.mock.calls.length;
301
302
 
302
303
  hook.unmount();
303
304
  await advanceTime(FORCE_WAIT_MS);
304
- expect(collection.status).toBe("cleaned-up");
305
+ // The regression this pins: the force window used to call cleanup() on
306
+ // the pinned collection, permanently erroring every attached query with
307
+ // "[Live Query Error] Source collection 'Workspace' was manually cleaned
308
+ // up while live query … depends on it" (7× in the console sweep).
309
+ // Now the SyncModule subscription is released externally instead: the
310
+ // collection is never transitioned to cleaned-up, the leaked query
311
+ // never errors, and no console error is emitted.
312
+ expect(mockUnsubscribe.mock.calls.length).toBeGreaterThan(unsubscribesBefore);
313
+ expect(collection.status).not.toBe("cleaned-up");
314
+ expect(leakedQuery.status).not.toBe("error");
315
+ expect(liveQueryErrors()).toHaveLength(0);
305
316
 
306
317
  // A consumer mounting afterwards gets a FRESH working collection — a new
307
318
  // live query over it loads and reaches ready.
@@ -316,5 +327,34 @@ describe("shared collection GC vs real live queries", () => {
316
327
  expect(fresh.result.current!.status).toBe("ready");
317
328
  expect(freshQuery.status).toBe("ready");
318
329
  expect(freshQuery.toArray).toHaveLength(2);
330
+ expect(liveQueryErrors()).toHaveLength(0);
331
+ });
332
+
333
+ it("errored entry: idle cleanup with a leaked query attached releases sync without erroring", async () => {
334
+ // The production path from the console sweep: initial sync fails (401),
335
+ // consumers unmount (error screen), the errored entry is evicted through
336
+ // cleanupEntryCollectionWhenIdle — which, past the force window, used to
337
+ // cleanup() the collection while leaked queries were still attached.
338
+ const config = configFor("error");
339
+ mockSubscribe.mockRejectedValueOnce(new Error("AuthenticationError"));
340
+
341
+ const hook = renderHook(() => useFrappeCollection<WorkspaceDoc>(config, "Workspace"));
342
+ await flushMicrotasks();
343
+ const collection = hook.result.current!;
344
+
345
+ const leakedQuery = createLiveQueryCollection({
346
+ query: (q) => q.from({ workspace: collection }),
347
+ startSync: true,
348
+ gcTime: 300_000,
349
+ });
350
+ await flushMicrotasks();
351
+ expect(collection.subscriberCount).toBeGreaterThan(0);
352
+
353
+ hook.unmount();
354
+ await advanceTime(FORCE_WAIT_MS);
355
+
356
+ expect(collection.status).not.toBe("cleaned-up");
357
+ expect(leakedQuery.status).not.toBe("error");
358
+ expect(liveQueryErrors()).toHaveLength(0);
319
359
  });
320
360
  });
package/src/index.ts CHANGED
@@ -12,7 +12,19 @@ export { AuthModule } from "./auth";
12
12
  export { CallModule } from "./call";
13
13
  export { DbModule } from "./db";
14
14
  export { FileModule } from "./file";
15
- export { RealtimeModule } from "./realtime";
15
+ export { RealtimeModule, RealtimePlatform } from "./realtime";
16
+ export type {
17
+ RealtimeConfig,
18
+ RealtimeTransport,
19
+ RealtimeSSEConfig,
20
+ SSEFrame,
21
+ SSEMappedEvent,
22
+ SSEMessageEventLike,
23
+ SSEEventSourceLike,
24
+ SSEEventSourceConstructor,
25
+ DocRoom,
26
+ ListRoom,
27
+ } from "./realtime";
16
28
  export { SyncModule } from "./sync";
17
29
 
18
30
  // TanStack DB collection integration
package/src/realtime.ts CHANGED
@@ -12,6 +12,98 @@ export enum RealtimePlatform {
12
12
  ReactNative = "react-native",
13
13
  }
14
14
 
15
+ export type RealtimeTransport = "socketio" | "sse";
16
+
17
+ /** One SSE frame as received off the wire (before any mapping). */
18
+ export interface SSEFrame {
19
+ /** SSE event type — the `event:` classification line, or `"message"` for unnamed frames. */
20
+ type: string;
21
+ /** Raw `data:` payload (SSE data is always a string). */
22
+ data: string;
23
+ /** The stream's last `id:` line as of this frame (empty when the server sent none). */
24
+ lastEventId: string;
25
+ }
26
+
27
+ /** One handler-map delivery produced by `RealtimeSSEConfig.mapFrame`. */
28
+ export interface SSEMappedEvent {
29
+ event: string;
30
+ payload: unknown;
31
+ }
32
+
33
+ /** Minimal structural slice of a WHATWG `MessageEvent` the SSE transport reads. */
34
+ export interface SSEMessageEventLike {
35
+ type?: string;
36
+ data?: unknown;
37
+ lastEventId?: string;
38
+ }
39
+
40
+ /**
41
+ * Minimal structural slice of the WHATWG EventSource interface used by the
42
+ * SSE transport. The global browser/Node implementation satisfies it, and so
43
+ * do the common polyfills (`eventsource` on npm, `react-native-sse`).
44
+ */
45
+ export interface SSEEventSourceLike {
46
+ addEventListener(type: string, listener: (event: SSEMessageEventLike) => void): void;
47
+ removeEventListener(type: string, listener: (event: SSEMessageEventLike) => void): void;
48
+ close(): void;
49
+ }
50
+
51
+ export type SSEEventSourceConstructor = new (
52
+ url: string,
53
+ init?: { withCredentials?: boolean },
54
+ ) => SSEEventSourceLike;
55
+
56
+ export interface RealtimeSSEConfig {
57
+ /** Absolute stream URL. Wins over `path` when both are set. */
58
+ url?: string;
59
+ /**
60
+ * Stream path resolved against `baseURL` (e.g.
61
+ * `"/api/method/shc.events.stream"` for the SHC daemon). One of `url` or
62
+ * `path` is REQUIRED: SSE endpoints are server-specific — a real Frappe
63
+ * bench has none at all — so a framework default would only ever 404, and
64
+ * `connect()` throws a descriptive error instead of guessing.
65
+ */
66
+ path?: string;
67
+ /**
68
+ * Browser EventSource credential mode: send cookies on the stream request.
69
+ * This is the primary browser auth path — EventSource cannot set request
70
+ * headers, so header-based token auth is impossible there (see
71
+ * `queryParams` for the token alternative).
72
+ */
73
+ withCredentials?: boolean;
74
+ /**
75
+ * Injectable EventSource constructor for runtimes without a global one
76
+ * (React Native, Node < 22, GJS) — e.g. the `eventsource` npm package or
77
+ * `react-native-sse`. When omitted the global `EventSource` is used, and
78
+ * `connect()` throws an error naming this option when neither exists.
79
+ */
80
+ EventSource?: SSEEventSourceConstructor;
81
+ /**
82
+ * Named SSE event types (`event:` classification lines) to listen for, in
83
+ * addition to unnamed `"message"` frames and handler-registered names.
84
+ * EventSource only dispatches a NAMED event when a listener for that exact
85
+ * type is attached, so `mapFrame`-driven apps — whose handler names differ
86
+ * from the wire's frame types — must list the server's classifications
87
+ * here (e.g. `["docker", "audit", "error", "event"]` for the SHC daemon).
88
+ */
89
+ events?: string[];
90
+ /**
91
+ * Translate one SSE frame into zero or more handler deliveries. Return
92
+ * `null`/`undefined` to drop the frame. When omitted, the default mapping
93
+ * delivers under the frame's `event:` type name with `JSON.parse(data)` as
94
+ * the payload (falling back to the raw string when the data isn't JSON).
95
+ */
96
+ mapFrame?: (frame: SSEFrame) => SSEMappedEvent[] | null | undefined;
97
+ /**
98
+ * Extra query parameters appended to the stream URL — the token-auth path
99
+ * for SSE (headers are off-limits in browsers, see `withCredentials`).
100
+ * Values may be getters; they are re-resolved every time the EventSource
101
+ * is (re)created, so a rotated token is picked up by `updateAuth()`'s
102
+ * reconnect.
103
+ */
104
+ queryParams?: Record<string, string | (() => string | Promise<string>)>;
105
+ }
106
+
15
107
  export interface RealtimeConfig {
16
108
  auth?: AuthConfig;
17
109
  baseURL: string;
@@ -43,10 +135,28 @@ export interface RealtimeConfig {
43
135
  siteName?: string;
44
136
  platform?: RealtimePlatform;
45
137
  socketOptions?: Partial<ManagerOptions & SocketOptions>;
138
+ /**
139
+ * Wire transport for realtime events.
140
+ *
141
+ * - `"socketio"` (default): Frappe's real realtime server — Socket.IO with
142
+ * doc/doctype rooms and two-way subscribe emits. Omitting `transport`
143
+ * keeps this exact behavior.
144
+ * - `"sse"`: a one-way `text/event-stream` endpoint consumed via
145
+ * EventSource. Built for Frappe-wire-compatible servers that serve
146
+ * realtime as SSE instead of Socket.IO (e.g. the SHC daemon's
147
+ * `GET /api/method/shc.events.stream`). Requires `sse.url` or `sse.path`.
148
+ * Room subscriptions become client-side bookkeeping (nothing can be
149
+ * emitted upstream) and `sse.mapFrame` decides how server frames fan out
150
+ * to handler events. Missed-event resume via the `Last-Event-ID` header
151
+ * is native EventSource behavior and is not reimplemented here.
152
+ */
153
+ transport?: RealtimeTransport;
154
+ /** SSE transport options — only read when `transport` is `"sse"`. */
155
+ sse?: RealtimeSSEConfig;
46
156
  }
47
157
 
48
- type DocRoom = { doctype: string; name: string };
49
- type ListRoom = { doctype: string; filters?: Record<string, unknown> };
158
+ export type DocRoom = { doctype: string; name: string };
159
+ export type ListRoom = { doctype: string; filters?: Record<string, unknown> };
50
160
 
51
161
  function docRoomKey(doctype: string, name: string) {
52
162
  return `${doctype}\0${name}`;
@@ -54,11 +164,20 @@ function docRoomKey(doctype: string, name: string) {
54
164
 
55
165
  export class RealtimeModule {
56
166
  private socket: Socket | undefined;
167
+ /** Live EventSource when the `"sse"` transport is active. */
168
+ private eventSource: SSEEventSourceLike | undefined;
57
169
  private handlerMap = new Map<string, Set<Handler>>();
58
170
  /** Tracks events with socket listeners attached to prevent duplicates. */
59
171
  private attachedSocketEvents = new Set<string>();
60
172
  /** Stored socket event handler references for proper cleanup. */
61
173
  private socketEventHandlers = new Map<string, (payload: unknown) => void>();
174
+ /** Tracks SSE event types with listeners attached to prevent duplicates. */
175
+ private attachedSseEvents = new Set<string>();
176
+ /** Stored SSE listener references for proper cleanup. */
177
+ private sseListeners = new Map<string, (event: SSEMessageEventLike) => void>();
178
+ /** One-shot debug notices for SSE's one-way limitations. */
179
+ private sseRoomNoticeLogged = false;
180
+ private sseEmitNoticeLogged = false;
62
181
  /** Active doc rooms — re-emitted on every (re)connect. */
63
182
  private docRooms = new Map<string, DocRoom>();
64
183
  /** Active list rooms — re-emitted on every (re)connect. */
@@ -220,8 +339,12 @@ export class RealtimeModule {
220
339
  this.healthyWaiters.clear();
221
340
  }
222
341
 
342
+ private isSSE(): boolean {
343
+ return this.config.transport === "sse";
344
+ }
345
+
223
346
  private waitUntilHealthy(timeoutMs = 20_000): Promise<boolean> {
224
- if (this.socketHealthy && this.socket?.connected) return Promise.resolve(true);
347
+ if (this.isHealthy()) return Promise.resolve(true);
225
348
  return new Promise((resolve) => {
226
349
  const timer = setTimeout(() => {
227
350
  this.healthyWaiters.delete(onReady);
@@ -236,8 +359,8 @@ export class RealtimeModule {
236
359
  }
237
360
 
238
361
  private async ensureConnected(): Promise<void> {
239
- if (this.socketHealthy && this.socket?.connected) return;
240
- if (!this.socket && !this.isConnecting) {
362
+ if (this.isHealthy()) return;
363
+ if (!this.socket && !this.eventSource && !this.isConnecting) {
241
364
  await this.connect();
242
365
  }
243
366
  await this.waitUntilHealthy();
@@ -255,7 +378,16 @@ export class RealtimeModule {
255
378
  }
256
379
  }
257
380
 
258
- private async connect() {
381
+ /**
382
+ * Open the realtime transport now. Room-driven auto-connect
383
+ * (subscribeDoc/subscribeList) remains the primary lifecycle in both
384
+ * transports — this is public for SSE consumers that use no doc/list room
385
+ * bookkeeping: a one-way stream has nothing to emit on join, so nothing
386
+ * else would ever open it. Idempotent while a transport exists or a
387
+ * connect is in flight.
388
+ */
389
+ async connect(): Promise<void> {
390
+ if (this.isSSE()) return this.connectSSE();
259
391
  if (this.socket || this.isConnecting) return;
260
392
  this.isConnecting = true;
261
393
  try {
@@ -297,6 +429,197 @@ export class RealtimeModule {
297
429
  }
298
430
  }
299
431
 
432
+ /**
433
+ * SSE branch of connect(): construct an EventSource against the configured
434
+ * stream URL. EventSource connects at construction, auto-reconnects on
435
+ * failure, and resumes missed frames itself by sending the stream's last
436
+ * `id:` as the `Last-Event-ID` request header — none of that is
437
+ * reimplemented here.
438
+ */
439
+ private async connectSSE(): Promise<void> {
440
+ if (this.eventSource || this.isConnecting) return;
441
+ this.isConnecting = true;
442
+ try {
443
+ const sse = this.config.sse;
444
+ const EventSourceCtor =
445
+ sse?.EventSource ?? (globalThis as { EventSource?: SSEEventSourceConstructor }).EventSource;
446
+ if (!EventSourceCtor) {
447
+ throw new Error(
448
+ '[RealtimeModule] transport "sse" needs an EventSource implementation, and this ' +
449
+ "runtime has no global EventSource (React Native and Node < 22 ship none). Pass a " +
450
+ 'constructor via config.sse.EventSource — e.g. the "eventsource" npm package on ' +
451
+ 'Node or "react-native-sse" on React Native.',
452
+ );
453
+ }
454
+ const url = await this.buildSSEURL();
455
+ this.eventSource = new EventSourceCtor(url, {
456
+ withCredentials: sse?.withCredentials,
457
+ });
458
+ this.initSSEHandlers();
459
+ } catch (err) {
460
+ this.isConnecting = false;
461
+ this.resolveHealthyWaiters(false);
462
+ throw err;
463
+ }
464
+ }
465
+
466
+ private async buildSSEURL(): Promise<string> {
467
+ const sse = this.config.sse;
468
+ let url: string;
469
+ if (sse?.url) {
470
+ url = sse.url;
471
+ } else if (sse?.path) {
472
+ const base = this.config.baseURL.replace(/\/$/, "");
473
+ url = `${base}${sse.path.startsWith("/") ? "" : "/"}${sse.path}`;
474
+ } else {
475
+ throw new Error(
476
+ '[RealtimeModule] transport "sse" requires config.sse.url or config.sse.path. There ' +
477
+ "is no default stream path: SSE endpoints are server-specific (the SHC daemon " +
478
+ 'serves "/api/method/shc.events.stream"; a real Frappe bench has no SSE endpoint ' +
479
+ "at all), so a framework default would only ever 404.",
480
+ );
481
+ }
482
+ const params = Object.entries(this.config.sse?.queryParams ?? {});
483
+ if (params.length === 0) return url;
484
+ // Built by string concatenation (not the URL constructor) so minimal URL
485
+ // polyfills (GJS) work; getters are re-resolved on every (re)creation so
486
+ // rotated tokens ride the next stream request.
487
+ const parts: string[] = [];
488
+ for (const [key, value] of params) {
489
+ const resolved = typeof value === "function" ? await value() : value;
490
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(resolved)}`);
491
+ }
492
+ return `${url}${url.includes("?") ? "&" : "?"}${parts.join("&")}`;
493
+ }
494
+
495
+ private initSSEHandlers(): void {
496
+ // New EventSource instance: reset tracking so listeners re-register fresh.
497
+ this.attachedSseEvents.clear();
498
+ this.sseListeners.clear();
499
+ // Lifecycle events + unnamed (`event:`-less) frames.
500
+ this.attachSSEListener("open");
501
+ this.attachSSEListener("error");
502
+ this.attachSSEListener("message");
503
+ // Named frame types the server classifies with `event:` lines —
504
+ // EventSource only dispatches a named event when a listener for that
505
+ // exact type is attached, so mapFrame-driven apps declare them up front.
506
+ for (const type of this.config.sse?.events ?? []) {
507
+ this.attachSSEListener(type);
508
+ }
509
+ // Handler-map keys mirror socket.io's attach-on-register semantics: with
510
+ // the default mapping, the frame type IS the handler event name.
511
+ for (const event of this.handlerMap.keys()) {
512
+ this.attachSSEListener(event);
513
+ }
514
+ }
515
+
516
+ private attachSSEListener(type: string): void {
517
+ if (!this.eventSource || this.attachedSseEvents.has(type)) return;
518
+ const listener = (event: SSEMessageEventLike) => {
519
+ this.handleSSEEvent(type, event);
520
+ };
521
+ this.sseListeners.set(type, listener);
522
+ this.attachedSseEvents.add(type);
523
+ this.eventSource.addEventListener(type, listener);
524
+ }
525
+
526
+ private detachSSEListener(type: string): void {
527
+ // Lifecycle types and declared server frame types stay attached even
528
+ // when a same-named app handler is removed — they feed health tracking
529
+ // and mapFrame routing, not just that one handler.
530
+ if (this.isCoreSSEType(type)) return;
531
+ if (!this.eventSource || !this.attachedSseEvents.has(type)) return;
532
+ const listener = this.sseListeners.get(type);
533
+ if (listener) {
534
+ this.eventSource.removeEventListener(type, listener);
535
+ this.sseListeners.delete(type);
536
+ }
537
+ this.attachedSseEvents.delete(type);
538
+ }
539
+
540
+ private isCoreSSEType(type: string): boolean {
541
+ return (
542
+ type === "open" ||
543
+ type === "error" ||
544
+ type === "message" ||
545
+ (this.config.sse?.events ?? []).includes(type)
546
+ );
547
+ }
548
+
549
+ /**
550
+ * Single entry point for everything the EventSource dispatches. Dataless
551
+ * events are transport lifecycle (`open`/`error` as plain Events); anything
552
+ * carrying `data` is a server frame — including frames the server
553
+ * classified `event: error`, which arrive as MessageEvents WITH data and
554
+ * must not be mistaken for connection errors.
555
+ */
556
+ private handleSSEEvent(type: string, event: SSEMessageEventLike): void {
557
+ if (event?.data === undefined) {
558
+ if (type === "open") {
559
+ this.isConnecting = false;
560
+ this.socketHealthy = true;
561
+ this.emitToHandlers("connected", { transport: "sse" });
562
+ this.resolveHealthyWaiters(true);
563
+ } else if (type === "error") {
564
+ // EventSource surfaces initial-connect failures and mid-stream drops
565
+ // through the same `error` event and keeps reconnecting on its own —
566
+ // report unhealthy and deliver a `disconnect` (never `connect_error`)
567
+ // so lifecycle consumers see one consistent signal, then stay out of
568
+ // the retry loop's way. The next `open` flips healthy again.
569
+ this.isConnecting = false;
570
+ this.socketHealthy = false;
571
+ this.emitToHandlers("disconnect", { reason: "sse-error" });
572
+ this.resolveHealthyWaiters(false);
573
+ }
574
+ return;
575
+ }
576
+ const frame: SSEFrame = {
577
+ type,
578
+ data: String(event.data),
579
+ lastEventId: event.lastEventId ?? "",
580
+ };
581
+ const mapFrame = this.config.sse?.mapFrame;
582
+ if (mapFrame) {
583
+ let mapped: SSEMappedEvent[] | null | undefined;
584
+ try {
585
+ mapped = mapFrame(frame);
586
+ } catch (err) {
587
+ // A throwing mapper must not kill the stream — drop the frame.
588
+ console.warn("[RealtimeModule] sse.mapFrame threw; frame dropped:", err);
589
+ return;
590
+ }
591
+ if (!mapped) return;
592
+ for (const { event: mappedEvent, payload } of mapped) {
593
+ this.emitToHandlers(mappedEvent, payload);
594
+ }
595
+ return;
596
+ }
597
+ // Default mapping: deliver under the SSE `event:` type name with the
598
+ // JSON-parsed data as payload, raw string when it isn't JSON.
599
+ let payload: unknown = frame.data;
600
+ try {
601
+ payload = JSON.parse(frame.data);
602
+ } catch {
603
+ // Not JSON — deliver the raw string.
604
+ }
605
+ this.emitToHandlers(frame.type, payload);
606
+ }
607
+
608
+ /**
609
+ * Logged once: SSE is one-way, so doc/list room membership can't be sent
610
+ * upstream — rooms are recorded client-side (see getDocRooms/getListRooms)
611
+ * for mapFrame-driven filtering and for a future two-way transport.
612
+ */
613
+ private logSSERoomNoticeOnce(): void {
614
+ if (this.sseRoomNoticeLogged) return;
615
+ this.sseRoomNoticeLogged = true;
616
+ console.debug(
617
+ "[RealtimeModule] SSE transport is one-way: doc/list room subscriptions are recorded " +
618
+ "client-side only (getDocRooms()/getListRooms()) and nothing is sent to the server — " +
619
+ "filter server-side via sse.queryParams or route frames with sse.mapFrame.",
620
+ );
621
+ }
622
+
300
623
  /**
301
624
  * Tear down the socket without clearing app handlers or room subscriptions.
302
625
  * Used by auth refresh so listeners/rooms survive credential rotation.
@@ -310,8 +633,17 @@ export class RealtimeModule {
310
633
  this.socket.disconnect();
311
634
  this.socket = undefined;
312
635
  }
636
+ if (this.eventSource) {
637
+ for (const [type, listener] of this.sseListeners) {
638
+ this.eventSource.removeEventListener(type, listener);
639
+ }
640
+ this.eventSource.close();
641
+ this.eventSource = undefined;
642
+ }
313
643
  this.attachedSocketEvents.clear();
314
644
  this.socketEventHandlers.clear();
645
+ this.attachedSseEvents.clear();
646
+ this.sseListeners.clear();
315
647
  this.socketHealthy = false;
316
648
  this.isConnecting = false;
317
649
  this.resolveHealthyWaiters(false);
@@ -408,7 +740,11 @@ export class RealtimeModule {
408
740
  // handlers up front) booted a socket that nothing would ever close —
409
741
  // against an unreachable host that is an infinite reconnect-poll loop
410
742
  // surviving unmount (the fixture/mock-story socket leak).
411
- this.attachSocketListener(event);
743
+ if (this.isSSE()) {
744
+ this.attachSSEListener(event);
745
+ } else {
746
+ this.attachSocketListener(event);
747
+ }
412
748
  }
413
749
 
414
750
  off(event: string, handler: Handler): void {
@@ -417,14 +753,23 @@ export class RealtimeModule {
417
753
  handlerSet.delete(handler);
418
754
  if (handlerSet.size === 0) {
419
755
  this.handlerMap.delete(event);
420
- this.detachSocketListener(event);
756
+ if (this.isSSE()) {
757
+ this.detachSSEListener(event);
758
+ } else {
759
+ this.detachSocketListener(event);
760
+ }
421
761
  }
422
762
  }
423
763
 
424
764
  async subscribeDoc(doctype: string, name: string): Promise<void> {
425
765
  this.docRooms.set(docRoomKey(doctype, name), { doctype, name });
766
+ // SSE: room membership is recorded client-side only — subscriptions
767
+ // cannot be emitted over a one-way stream. The room still drives the
768
+ // transport lifecycle (connect here, idle teardown on last unsubscribe)
769
+ // exactly like socket.io mode.
770
+ if (this.isSSE()) this.logSSERoomNoticeOnce();
426
771
  await this.ensureConnected();
427
- if (this.socket && this.socketHealthy) {
772
+ if (!this.isSSE() && this.socket && this.socketHealthy) {
428
773
  // Positional args — frappe's handler signature is (doctype, docname).
429
774
  this.socket.emit("doc_subscribe", doctype, name);
430
775
  }
@@ -432,7 +777,7 @@ export class RealtimeModule {
432
777
 
433
778
  unsubscribeDoc(doctype: string, name: string): void {
434
779
  this.docRooms.delete(docRoomKey(doctype, name));
435
- if (this.socket && this.socketHealthy) {
780
+ if (!this.isSSE() && this.socket && this.socketHealthy) {
436
781
  this.socket.emit("doc_unsubscribe", doctype, name);
437
782
  }
438
783
  this.teardownWhenIdle();
@@ -447,15 +792,16 @@ export class RealtimeModule {
447
792
  */
448
793
  async subscribeList(doctype: string, filters?: Record<string, unknown>): Promise<void> {
449
794
  this.listRooms.set(doctype, { doctype, filters });
795
+ if (this.isSSE()) this.logSSERoomNoticeOnce();
450
796
  await this.ensureConnected();
451
- if (this.socket && this.socketHealthy) {
797
+ if (!this.isSSE() && this.socket && this.socketHealthy) {
452
798
  this.socket.emit("doctype_subscribe", doctype);
453
799
  }
454
800
  }
455
801
 
456
802
  unsubscribeList(doctype: string): void {
457
803
  this.listRooms.delete(doctype);
458
- if (this.socket && this.socketHealthy) {
804
+ if (!this.isSSE() && this.socket && this.socketHealthy) {
459
805
  this.socket.emit("doctype_unsubscribe", doctype);
460
806
  }
461
807
  this.teardownWhenIdle();
@@ -472,7 +818,7 @@ export class RealtimeModule {
472
818
  */
473
819
  private teardownWhenIdle(): void {
474
820
  if (this.docRooms.size > 0 || this.listRooms.size > 0) return;
475
- if (!this.socket && !this.isConnecting) return;
821
+ if (!this.socket && !this.eventSource && !this.isConnecting) return;
476
822
  // Surface the teardown to lifecycle listeners (connection indicators)
477
823
  // before the listeners are detached from the socket.
478
824
  this.emitToHandlers("disconnect", { reason: "idle-teardown" });
@@ -480,6 +826,9 @@ export class RealtimeModule {
480
826
  }
481
827
 
482
828
  isHealthy() {
829
+ // SSE has no `connected` flag to consult — health is tracked by the
830
+ // open/error lifecycle events on the live EventSource.
831
+ if (this.isSSE()) return this.socketHealthy && this.eventSource !== undefined;
483
832
  return this.socketHealthy && this.socket?.connected === true;
484
833
  }
485
834
 
@@ -496,7 +845,19 @@ export class RealtimeModule {
496
845
  // socket serves. Handlers alone (registered eagerly by the sync engine)
497
846
  // don't warrant a socket: an idle module stays idle across credential
498
847
  // rotation, and the next room join connects with the fresh auth.
499
- const shouldReconnect = this.docRooms.size > 0 || this.listRooms.size > 0;
848
+ //
849
+ // SSE limitation: browsers give EventSource no way to set request
850
+ // headers, so `config.auth` (Authorization/Cookie headers) cannot ride
851
+ // the stream request the way it rides the socket.io handshake. SSE auth
852
+ // is cookies via `sse.withCredentials` and/or tokens via
853
+ // `sse.queryParams` getters — both take effect by RECREATING the
854
+ // EventSource, which is what this does. An explicitly opened stream
855
+ // (public connect(), no rooms) is recreated too: unlike socket.io mode,
856
+ // nothing else in the room lifecycle would ever reopen it.
857
+ const shouldReconnect =
858
+ this.docRooms.size > 0 ||
859
+ this.listRooms.size > 0 ||
860
+ (this.isSSE() && this.eventSource !== undefined);
500
861
  // Soft disconnect preserves handlers + rooms; hard disconnect() would wipe them
501
862
  // and make the post-check always false (previous production bug).
502
863
  this.softDisconnect();
@@ -506,6 +867,18 @@ export class RealtimeModule {
506
867
  }
507
868
 
508
869
  emit(event: string, data: unknown) {
870
+ if (this.isSSE()) {
871
+ // SSE is one-way (server → client): there is no upstream channel to
872
+ // carry app emits. Dropped, with a one-shot debug notice.
873
+ if (!this.sseEmitNoticeLogged) {
874
+ this.sseEmitNoticeLogged = true;
875
+ console.debug(
876
+ "[RealtimeModule] emit() is a no-op under the SSE transport — SSE is one-way " +
877
+ "(server → client). Use HTTP calls for client → server communication.",
878
+ );
879
+ }
880
+ return;
881
+ }
509
882
  if (this.socket && this.socketHealthy) {
510
883
  this.socket.emit(event, data);
511
884
  }
@@ -514,4 +887,23 @@ export class RealtimeModule {
514
887
  getSocket() {
515
888
  return this.socket;
516
889
  }
890
+
891
+ /** Live EventSource when the SSE transport is connected (undefined otherwise). */
892
+ getEventSource(): SSEEventSourceLike | undefined {
893
+ return this.eventSource;
894
+ }
895
+
896
+ /**
897
+ * Doc rooms recorded by subscribeDoc. Under SSE these are client-side
898
+ * bookkeeping only (nothing is emitted upstream) — mapFrame-driven apps
899
+ * consult them to filter or route frames.
900
+ */
901
+ getDocRooms(): DocRoom[] {
902
+ return [...this.docRooms.values()];
903
+ }
904
+
905
+ /** List rooms recorded by subscribeList — same client-side semantics as getDocRooms. */
906
+ getListRooms(): ListRoom[] {
907
+ return [...this.listRooms.values()];
908
+ }
517
909
  }