@multiplatform.one/frappe 6.6.0 → 7.0.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 (224) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/auth.native.js.map +1 -1
  3. package/dist/cjs/call.native.js.map +1 -1
  4. package/dist/cjs/collection.native.js.map +1 -1
  5. package/dist/cjs/common/types.native.js.map +1 -1
  6. package/dist/cjs/core/http.cjs +45 -1
  7. package/dist/cjs/core/http.native.js +72 -1
  8. package/dist/cjs/core/http.native.js.map +1 -1
  9. package/dist/cjs/core/types.native.js.map +1 -1
  10. package/dist/cjs/core/wire.native.js.map +1 -1
  11. package/dist/cjs/db.native.js.map +1 -1
  12. package/dist/cjs/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  13. package/dist/cjs/devtools/index.native.js.map +1 -1
  14. package/dist/cjs/devtools/plugin.native.js.map +1 -1
  15. package/dist/cjs/devtools/theme.native.js.map +1 -1
  16. package/dist/cjs/doctype.cjs +15 -2
  17. package/dist/cjs/doctype.native.js +22 -5
  18. package/dist/cjs/doctype.native.js.map +1 -1
  19. package/dist/cjs/file.native.js.map +1 -1
  20. package/dist/cjs/fixtureDb.native.js.map +1 -1
  21. package/dist/cjs/fixtures.native.js.map +1 -1
  22. package/dist/cjs/frappeApp.native.js.map +1 -1
  23. package/dist/cjs/frappeReact.cjs +55 -1
  24. package/dist/cjs/frappeReact.native.js +67 -2
  25. package/dist/cjs/frappeReact.native.js.map +1 -1
  26. package/dist/cjs/index.cjs +8 -0
  27. package/dist/cjs/index.native.js +10 -2
  28. package/dist/cjs/index.native.js.map +1 -1
  29. package/dist/cjs/optimistic.cjs +32 -2
  30. package/dist/cjs/optimistic.native.js +34 -1
  31. package/dist/cjs/optimistic.native.js.map +1 -1
  32. package/dist/cjs/pacer.native.js.map +1 -1
  33. package/dist/cjs/plugin/index.native.js.map +1 -1
  34. package/dist/cjs/realtime.cjs +234 -11
  35. package/dist/cjs/realtime.native.js +368 -18
  36. package/dist/cjs/realtime.native.js.map +1 -1
  37. package/dist/cjs/sync/backfill.native.js.map +1 -1
  38. package/dist/cjs/sync/filters.native.js.map +1 -1
  39. package/dist/cjs/sync/index.cjs +45 -1
  40. package/dist/cjs/sync/index.native.js +71 -1
  41. package/dist/cjs/sync/index.native.js.map +1 -1
  42. package/dist/cjs/sync/merge.native.js.map +1 -1
  43. package/dist/cjs/sync/mutationEngine.cjs +51 -15
  44. package/dist/cjs/sync/mutationEngine.native.js +69 -16
  45. package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
  46. package/dist/cjs/sync/mutationJournal.cjs +8 -0
  47. package/dist/cjs/sync/mutationJournal.native.js +10 -0
  48. package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
  49. package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
  50. package/dist/cjs/sync/persistence.native.js.map +1 -1
  51. package/dist/cjs/sync/realtime.native.js.map +1 -1
  52. package/dist/cjs/sync/rehydration.native.js.map +1 -1
  53. package/dist/cjs/sync/storage.native.js.map +1 -1
  54. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  55. package/dist/cjs/sync/types.native.js.map +1 -1
  56. package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
  57. package/dist/cjs/useLiveQuery.native.js.map +1 -1
  58. package/dist/cjs/util.native.js.map +1 -1
  59. package/dist/esm/auth.native.js.map +1 -1
  60. package/dist/esm/call.native.js.map +1 -1
  61. package/dist/esm/collection.native.js +1 -1
  62. package/dist/esm/collection.native.js.map +1 -1
  63. package/dist/esm/common/types.native.js.map +1 -1
  64. package/dist/esm/core/http.mjs +45 -2
  65. package/dist/esm/core/http.mjs.map +1 -1
  66. package/dist/esm/core/http.native.js +74 -4
  67. package/dist/esm/core/http.native.js.map +1 -1
  68. package/dist/esm/core/types.native.js.map +1 -1
  69. package/dist/esm/core/wire.native.js.map +1 -1
  70. package/dist/esm/db.native.js +1 -1
  71. package/dist/esm/db.native.js.map +1 -1
  72. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js +2 -2
  73. package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
  74. package/dist/esm/devtools/index.native.js +2 -2
  75. package/dist/esm/devtools/index.native.js.map +1 -1
  76. package/dist/esm/devtools/plugin.native.js +1 -1
  77. package/dist/esm/devtools/plugin.native.js.map +1 -1
  78. package/dist/esm/devtools/theme.native.js.map +1 -1
  79. package/dist/esm/doctype.mjs +15 -2
  80. package/dist/esm/doctype.mjs.map +1 -1
  81. package/dist/esm/doctype.native.js +23 -6
  82. package/dist/esm/doctype.native.js.map +1 -1
  83. package/dist/esm/file.native.js.map +1 -1
  84. package/dist/esm/fixtureDb.native.js +1 -1
  85. package/dist/esm/fixtureDb.native.js.map +1 -1
  86. package/dist/esm/fixtures.native.js.map +1 -1
  87. package/dist/esm/frappeApp.native.js +7 -7
  88. package/dist/esm/frappeApp.native.js.map +1 -1
  89. package/dist/esm/frappeReact.mjs +55 -3
  90. package/dist/esm/frappeReact.mjs.map +1 -1
  91. package/dist/esm/frappeReact.native.js +73 -10
  92. package/dist/esm/frappeReact.native.js.map +1 -1
  93. package/dist/esm/index.mjs +5 -5
  94. package/dist/esm/index.mjs.map +1 -1
  95. package/dist/esm/index.native.js +22 -22
  96. package/dist/esm/index.native.js.map +1 -1
  97. package/dist/esm/optimistic.mjs +27 -1
  98. package/dist/esm/optimistic.mjs.map +1 -1
  99. package/dist/esm/optimistic.native.js +30 -1
  100. package/dist/esm/optimistic.native.js.map +1 -1
  101. package/dist/esm/pacer.mjs.map +1 -1
  102. package/dist/esm/pacer.native.js +4 -4
  103. package/dist/esm/pacer.native.js.map +1 -1
  104. package/dist/esm/plugin/index.native.js.map +1 -1
  105. package/dist/esm/realtime.mjs +234 -11
  106. package/dist/esm/realtime.mjs.map +1 -1
  107. package/dist/esm/realtime.native.js +369 -19
  108. package/dist/esm/realtime.native.js.map +1 -1
  109. package/dist/esm/sync/backfill.native.js +1 -1
  110. package/dist/esm/sync/backfill.native.js.map +1 -1
  111. package/dist/esm/sync/filters.native.js.map +1 -1
  112. package/dist/esm/sync/index.mjs +45 -1
  113. package/dist/esm/sync/index.mjs.map +1 -1
  114. package/dist/esm/sync/index.native.js +79 -9
  115. package/dist/esm/sync/index.native.js.map +1 -1
  116. package/dist/esm/sync/merge.native.js.map +1 -1
  117. package/dist/esm/sync/mutationEngine.mjs +51 -15
  118. package/dist/esm/sync/mutationEngine.mjs.map +1 -1
  119. package/dist/esm/sync/mutationEngine.native.js +73 -20
  120. package/dist/esm/sync/mutationEngine.native.js.map +1 -1
  121. package/dist/esm/sync/mutationJournal.mjs +8 -0
  122. package/dist/esm/sync/mutationJournal.mjs.map +1 -1
  123. package/dist/esm/sync/mutationJournal.native.js +11 -1
  124. package/dist/esm/sync/mutationJournal.native.js.map +1 -1
  125. package/dist/esm/sync/normalizedCache.native.js.map +1 -1
  126. package/dist/esm/sync/persistence.native.js +2 -2
  127. package/dist/esm/sync/persistence.native.js.map +1 -1
  128. package/dist/esm/sync/realtime.native.js +1 -1
  129. package/dist/esm/sync/realtime.native.js.map +1 -1
  130. package/dist/esm/sync/rehydration.native.js +1 -1
  131. package/dist/esm/sync/rehydration.native.js.map +1 -1
  132. package/dist/esm/sync/storage.native.js.map +1 -1
  133. package/dist/esm/sync/subscriptionManager.native.js +4 -4
  134. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  135. package/dist/esm/sync/types.mjs.map +1 -1
  136. package/dist/esm/sync/types.native.js.map +1 -1
  137. package/dist/esm/sync/virtualPageManager.native.js +1 -1
  138. package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
  139. package/dist/esm/useLiveQuery.native.js.map +1 -1
  140. package/dist/esm/util.native.js.map +1 -1
  141. package/dist/jsx/core/http.mjs +45 -2
  142. package/dist/jsx/core/http.mjs.map +1 -1
  143. package/dist/jsx/core/http.native.js +72 -1
  144. package/dist/jsx/core/http.native.js.map +1 -1
  145. package/dist/jsx/doctype.mjs +15 -2
  146. package/dist/jsx/doctype.mjs.map +1 -1
  147. package/dist/jsx/doctype.native.js +22 -5
  148. package/dist/jsx/doctype.native.js.map +1 -1
  149. package/dist/jsx/frappeReact.mjs +55 -3
  150. package/dist/jsx/frappeReact.mjs.map +1 -1
  151. package/dist/jsx/frappeReact.native.js +66 -1
  152. package/dist/jsx/frappeReact.native.js.map +1 -1
  153. package/dist/jsx/index.js +5 -5
  154. package/dist/jsx/index.js.map +1 -1
  155. package/dist/jsx/index.mjs +5 -5
  156. package/dist/jsx/index.mjs.map +1 -1
  157. package/dist/jsx/index.native.js +8 -0
  158. package/dist/jsx/index.native.js.map +1 -1
  159. package/dist/jsx/optimistic.mjs +27 -1
  160. package/dist/jsx/optimistic.mjs.map +1 -1
  161. package/dist/jsx/optimistic.native.js +34 -1
  162. package/dist/jsx/optimistic.native.js.map +1 -1
  163. package/dist/jsx/pacer.mjs.map +1 -1
  164. package/dist/jsx/pacer.native.js.map +1 -1
  165. package/dist/jsx/realtime.mjs +234 -11
  166. package/dist/jsx/realtime.mjs.map +1 -1
  167. package/dist/jsx/realtime.native.js +368 -18
  168. package/dist/jsx/realtime.native.js.map +1 -1
  169. package/dist/jsx/sync/index.mjs +45 -1
  170. package/dist/jsx/sync/index.mjs.map +1 -1
  171. package/dist/jsx/sync/index.native.js +71 -1
  172. package/dist/jsx/sync/index.native.js.map +1 -1
  173. package/dist/jsx/sync/mutationEngine.mjs +51 -15
  174. package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
  175. package/dist/jsx/sync/mutationEngine.native.js +69 -16
  176. package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
  177. package/dist/jsx/sync/mutationJournal.mjs +8 -0
  178. package/dist/jsx/sync/mutationJournal.mjs.map +1 -1
  179. package/dist/jsx/sync/mutationJournal.native.js +10 -0
  180. package/dist/jsx/sync/mutationJournal.native.js.map +1 -1
  181. package/dist/jsx/sync/types.mjs.map +1 -1
  182. package/dist/jsx/sync/types.native.js.map +1 -1
  183. package/package.json +8 -8
  184. package/src/core/http.spec.ts +81 -0
  185. package/src/core/http.ts +80 -3
  186. package/src/doctype.spec.ts +82 -3
  187. package/src/doctype.ts +51 -7
  188. package/src/frappeReact.ts +126 -4
  189. package/src/index.ts +28 -3
  190. package/src/optimistic.spec.ts +113 -2
  191. package/src/optimistic.ts +95 -0
  192. package/src/pacer.ts +8 -0
  193. package/src/realtime.spec.ts +62 -0
  194. package/src/realtime.ts +440 -14
  195. package/src/realtimeSse.spec.ts +547 -0
  196. package/src/sync/index.ts +44 -1
  197. package/src/sync/mutationEngine.spec.ts +7 -2
  198. package/src/sync/mutationEngine.ts +83 -27
  199. package/src/sync/mutationJournal.ts +9 -0
  200. package/src/sync/mutations.spec.ts +1 -1
  201. package/src/sync/syncModule.spec.ts +255 -1
  202. package/src/sync/types.ts +20 -0
  203. package/types/core/http.d.ts +11 -0
  204. package/types/core/http.d.ts.map +1 -1
  205. package/types/doctype.d.ts +7 -0
  206. package/types/doctype.d.ts.map +1 -1
  207. package/types/frappeReact.d.ts +50 -2
  208. package/types/frappeReact.d.ts.map +1 -1
  209. package/types/index.d.ts +8 -6
  210. package/types/index.d.ts.map +1 -1
  211. package/types/optimistic.d.ts +38 -0
  212. package/types/optimistic.d.ts.map +1 -1
  213. package/types/pacer.d.ts +8 -0
  214. package/types/pacer.d.ts.map +1 -1
  215. package/types/realtime.d.ts +183 -1
  216. package/types/realtime.d.ts.map +1 -1
  217. package/types/sync/index.d.ts +16 -1
  218. package/types/sync/index.d.ts.map +1 -1
  219. package/types/sync/mutationEngine.d.ts +7 -0
  220. package/types/sync/mutationEngine.d.ts.map +1 -1
  221. package/types/sync/mutationJournal.d.ts +6 -0
  222. package/types/sync/mutationJournal.d.ts.map +1 -1
  223. package/types/sync/types.d.ts +20 -0
  224. package/types/sync/types.d.ts.map +1 -1
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;
@@ -28,6 +120,23 @@ export interface RealtimeConfig {
28
120
  * `/socket.io` to the SocketIO backend.
29
121
  */
30
122
  socketPort?: number;
123
+ /**
124
+ * Dial the Socket.IO connection at this origin instead of deriving host and
125
+ * port from `baseURL` + `socketPort`.
126
+ *
127
+ * Built for dev servers that proxy `/socket.io` on their own origin (e.g.
128
+ * apps/one's `frappeSocketioDevProxy`): pointing the socket at the PAGE
129
+ * origin makes the handshake same-origin, so the browser stops blocking the
130
+ * cross-origin polling response (the reflected allow-origin is the proxy's
131
+ * injected bench origin, not the page's). Only the connection target
132
+ * changes — the namespace still comes from `siteName` (falling back to the
133
+ * `baseURL` hostname), because Frappe resolves the site server-side from
134
+ * the proxy-injected headers, not from where the client dialed.
135
+ *
136
+ * Wins over `socketPort` when both are set. Omit in production, where the
137
+ * reverse proxy already serves `/socket.io` on the `baseURL` origin.
138
+ */
139
+ socketBaseURL?: string;
31
140
  /**
32
141
  * Frappe site name, used as the Socket.IO namespace.
33
142
  *
@@ -43,10 +152,28 @@ export interface RealtimeConfig {
43
152
  siteName?: string;
44
153
  platform?: RealtimePlatform;
45
154
  socketOptions?: Partial<ManagerOptions & SocketOptions>;
155
+ /**
156
+ * Wire transport for realtime events.
157
+ *
158
+ * - `"socketio"` (default): Frappe's real realtime server — Socket.IO with
159
+ * doc/doctype rooms and two-way subscribe emits. Omitting `transport`
160
+ * keeps this exact behavior.
161
+ * - `"sse"`: a one-way `text/event-stream` endpoint consumed via
162
+ * EventSource. Built for Frappe-wire-compatible servers that serve
163
+ * realtime as SSE instead of Socket.IO (e.g. the SHC daemon's
164
+ * `GET /api/method/shc.events.stream`). Requires `sse.url` or `sse.path`.
165
+ * Room subscriptions become client-side bookkeeping (nothing can be
166
+ * emitted upstream) and `sse.mapFrame` decides how server frames fan out
167
+ * to handler events. Missed-event resume via the `Last-Event-ID` header
168
+ * is native EventSource behavior and is not reimplemented here.
169
+ */
170
+ transport?: RealtimeTransport;
171
+ /** SSE transport options — only read when `transport` is `"sse"`. */
172
+ sse?: RealtimeSSEConfig;
46
173
  }
47
174
 
48
- type DocRoom = { doctype: string; name: string };
49
- type ListRoom = { doctype: string; filters?: Record<string, unknown> };
175
+ export type DocRoom = { doctype: string; name: string };
176
+ export type ListRoom = { doctype: string; filters?: Record<string, unknown> };
50
177
 
51
178
  function docRoomKey(doctype: string, name: string) {
52
179
  return `${doctype}\0${name}`;
@@ -54,11 +181,20 @@ function docRoomKey(doctype: string, name: string) {
54
181
 
55
182
  export class RealtimeModule {
56
183
  private socket: Socket | undefined;
184
+ /** Live EventSource when the `"sse"` transport is active. */
185
+ private eventSource: SSEEventSourceLike | undefined;
57
186
  private handlerMap = new Map<string, Set<Handler>>();
58
187
  /** Tracks events with socket listeners attached to prevent duplicates. */
59
188
  private attachedSocketEvents = new Set<string>();
60
189
  /** Stored socket event handler references for proper cleanup. */
61
190
  private socketEventHandlers = new Map<string, (payload: unknown) => void>();
191
+ /** Tracks SSE event types with listeners attached to prevent duplicates. */
192
+ private attachedSseEvents = new Set<string>();
193
+ /** Stored SSE listener references for proper cleanup. */
194
+ private sseListeners = new Map<string, (event: SSEMessageEventLike) => void>();
195
+ /** One-shot debug notices for SSE's one-way limitations. */
196
+ private sseRoomNoticeLogged = false;
197
+ private sseEmitNoticeLogged = false;
62
198
  /** Active doc rooms — re-emitted on every (re)connect. */
63
199
  private docRooms = new Map<string, DocRoom>();
64
200
  /** Active list rooms — re-emitted on every (re)connect. */
@@ -104,6 +240,23 @@ export class RealtimeModule {
104
240
 
105
241
  private getSocketURL(): string {
106
242
  const base = this.config.baseURL.replace(/\/$/, "");
243
+ // Same-origin override: dial socketBaseURL directly (e.g. the page
244
+ // origin, whose dev server proxies /socket.io to the bench's socketio).
245
+ // The namespace is still siteName — falling back to the baseURL hostname
246
+ // exactly like the derived path below, NOT the socketBaseURL hostname,
247
+ // which would be the dev-server host and never a Frappe site.
248
+ if (this.config.socketBaseURL) {
249
+ const socketBase = this.config.socketBaseURL.replace(/\/$/, "");
250
+ let siteName = this.config.siteName;
251
+ if (!siteName) {
252
+ try {
253
+ siteName = new URL(base).hostname;
254
+ } catch {
255
+ siteName = base.replace(/^https?:\/\//, "").split(/[/:]/)[0];
256
+ }
257
+ }
258
+ return `${socketBase}/${siteName}`;
259
+ }
107
260
  try {
108
261
  const url = new URL(base);
109
262
  // Assemble origin from parts instead of mutating url.port and reading
@@ -220,8 +373,12 @@ export class RealtimeModule {
220
373
  this.healthyWaiters.clear();
221
374
  }
222
375
 
376
+ private isSSE(): boolean {
377
+ return this.config.transport === "sse";
378
+ }
379
+
223
380
  private waitUntilHealthy(timeoutMs = 20_000): Promise<boolean> {
224
- if (this.socketHealthy && this.socket?.connected) return Promise.resolve(true);
381
+ if (this.isHealthy()) return Promise.resolve(true);
225
382
  return new Promise((resolve) => {
226
383
  const timer = setTimeout(() => {
227
384
  this.healthyWaiters.delete(onReady);
@@ -236,8 +393,8 @@ export class RealtimeModule {
236
393
  }
237
394
 
238
395
  private async ensureConnected(): Promise<void> {
239
- if (this.socketHealthy && this.socket?.connected) return;
240
- if (!this.socket && !this.isConnecting) {
396
+ if (this.isHealthy()) return;
397
+ if (!this.socket && !this.eventSource && !this.isConnecting) {
241
398
  await this.connect();
242
399
  }
243
400
  await this.waitUntilHealthy();
@@ -255,7 +412,16 @@ export class RealtimeModule {
255
412
  }
256
413
  }
257
414
 
258
- private async connect() {
415
+ /**
416
+ * Open the realtime transport now. Room-driven auto-connect
417
+ * (subscribeDoc/subscribeList) remains the primary lifecycle in both
418
+ * transports — this is public for SSE consumers that use no doc/list room
419
+ * bookkeeping: a one-way stream has nothing to emit on join, so nothing
420
+ * else would ever open it. Idempotent while a transport exists or a
421
+ * connect is in flight.
422
+ */
423
+ async connect(): Promise<void> {
424
+ if (this.isSSE()) return this.connectSSE();
259
425
  if (this.socket || this.isConnecting) return;
260
426
  this.isConnecting = true;
261
427
  try {
@@ -297,6 +463,197 @@ export class RealtimeModule {
297
463
  }
298
464
  }
299
465
 
466
+ /**
467
+ * SSE branch of connect(): construct an EventSource against the configured
468
+ * stream URL. EventSource connects at construction, auto-reconnects on
469
+ * failure, and resumes missed frames itself by sending the stream's last
470
+ * `id:` as the `Last-Event-ID` request header — none of that is
471
+ * reimplemented here.
472
+ */
473
+ private async connectSSE(): Promise<void> {
474
+ if (this.eventSource || this.isConnecting) return;
475
+ this.isConnecting = true;
476
+ try {
477
+ const sse = this.config.sse;
478
+ const EventSourceCtor =
479
+ sse?.EventSource ?? (globalThis as { EventSource?: SSEEventSourceConstructor }).EventSource;
480
+ if (!EventSourceCtor) {
481
+ throw new Error(
482
+ '[RealtimeModule] transport "sse" needs an EventSource implementation, and this ' +
483
+ "runtime has no global EventSource (React Native and Node < 22 ship none). Pass a " +
484
+ 'constructor via config.sse.EventSource — e.g. the "eventsource" npm package on ' +
485
+ 'Node or "react-native-sse" on React Native.',
486
+ );
487
+ }
488
+ const url = await this.buildSSEURL();
489
+ this.eventSource = new EventSourceCtor(url, {
490
+ withCredentials: sse?.withCredentials,
491
+ });
492
+ this.initSSEHandlers();
493
+ } catch (err) {
494
+ this.isConnecting = false;
495
+ this.resolveHealthyWaiters(false);
496
+ throw err;
497
+ }
498
+ }
499
+
500
+ private async buildSSEURL(): Promise<string> {
501
+ const sse = this.config.sse;
502
+ let url: string;
503
+ if (sse?.url) {
504
+ url = sse.url;
505
+ } else if (sse?.path) {
506
+ const base = this.config.baseURL.replace(/\/$/, "");
507
+ url = `${base}${sse.path.startsWith("/") ? "" : "/"}${sse.path}`;
508
+ } else {
509
+ throw new Error(
510
+ '[RealtimeModule] transport "sse" requires config.sse.url or config.sse.path. There ' +
511
+ "is no default stream path: SSE endpoints are server-specific (the SHC daemon " +
512
+ 'serves "/api/method/shc.events.stream"; a real Frappe bench has no SSE endpoint ' +
513
+ "at all), so a framework default would only ever 404.",
514
+ );
515
+ }
516
+ const params = Object.entries(this.config.sse?.queryParams ?? {});
517
+ if (params.length === 0) return url;
518
+ // Built by string concatenation (not the URL constructor) so minimal URL
519
+ // polyfills (GJS) work; getters are re-resolved on every (re)creation so
520
+ // rotated tokens ride the next stream request.
521
+ const parts: string[] = [];
522
+ for (const [key, value] of params) {
523
+ const resolved = typeof value === "function" ? await value() : value;
524
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(resolved)}`);
525
+ }
526
+ return `${url}${url.includes("?") ? "&" : "?"}${parts.join("&")}`;
527
+ }
528
+
529
+ private initSSEHandlers(): void {
530
+ // New EventSource instance: reset tracking so listeners re-register fresh.
531
+ this.attachedSseEvents.clear();
532
+ this.sseListeners.clear();
533
+ // Lifecycle events + unnamed (`event:`-less) frames.
534
+ this.attachSSEListener("open");
535
+ this.attachSSEListener("error");
536
+ this.attachSSEListener("message");
537
+ // Named frame types the server classifies with `event:` lines —
538
+ // EventSource only dispatches a named event when a listener for that
539
+ // exact type is attached, so mapFrame-driven apps declare them up front.
540
+ for (const type of this.config.sse?.events ?? []) {
541
+ this.attachSSEListener(type);
542
+ }
543
+ // Handler-map keys mirror socket.io's attach-on-register semantics: with
544
+ // the default mapping, the frame type IS the handler event name.
545
+ for (const event of this.handlerMap.keys()) {
546
+ this.attachSSEListener(event);
547
+ }
548
+ }
549
+
550
+ private attachSSEListener(type: string): void {
551
+ if (!this.eventSource || this.attachedSseEvents.has(type)) return;
552
+ const listener = (event: SSEMessageEventLike) => {
553
+ this.handleSSEEvent(type, event);
554
+ };
555
+ this.sseListeners.set(type, listener);
556
+ this.attachedSseEvents.add(type);
557
+ this.eventSource.addEventListener(type, listener);
558
+ }
559
+
560
+ private detachSSEListener(type: string): void {
561
+ // Lifecycle types and declared server frame types stay attached even
562
+ // when a same-named app handler is removed — they feed health tracking
563
+ // and mapFrame routing, not just that one handler.
564
+ if (this.isCoreSSEType(type)) return;
565
+ if (!this.eventSource || !this.attachedSseEvents.has(type)) return;
566
+ const listener = this.sseListeners.get(type);
567
+ if (listener) {
568
+ this.eventSource.removeEventListener(type, listener);
569
+ this.sseListeners.delete(type);
570
+ }
571
+ this.attachedSseEvents.delete(type);
572
+ }
573
+
574
+ private isCoreSSEType(type: string): boolean {
575
+ return (
576
+ type === "open" ||
577
+ type === "error" ||
578
+ type === "message" ||
579
+ (this.config.sse?.events ?? []).includes(type)
580
+ );
581
+ }
582
+
583
+ /**
584
+ * Single entry point for everything the EventSource dispatches. Dataless
585
+ * events are transport lifecycle (`open`/`error` as plain Events); anything
586
+ * carrying `data` is a server frame — including frames the server
587
+ * classified `event: error`, which arrive as MessageEvents WITH data and
588
+ * must not be mistaken for connection errors.
589
+ */
590
+ private handleSSEEvent(type: string, event: SSEMessageEventLike): void {
591
+ if (event?.data === undefined) {
592
+ if (type === "open") {
593
+ this.isConnecting = false;
594
+ this.socketHealthy = true;
595
+ this.emitToHandlers("connected", { transport: "sse" });
596
+ this.resolveHealthyWaiters(true);
597
+ } else if (type === "error") {
598
+ // EventSource surfaces initial-connect failures and mid-stream drops
599
+ // through the same `error` event and keeps reconnecting on its own —
600
+ // report unhealthy and deliver a `disconnect` (never `connect_error`)
601
+ // so lifecycle consumers see one consistent signal, then stay out of
602
+ // the retry loop's way. The next `open` flips healthy again.
603
+ this.isConnecting = false;
604
+ this.socketHealthy = false;
605
+ this.emitToHandlers("disconnect", { reason: "sse-error" });
606
+ this.resolveHealthyWaiters(false);
607
+ }
608
+ return;
609
+ }
610
+ const frame: SSEFrame = {
611
+ type,
612
+ data: String(event.data),
613
+ lastEventId: event.lastEventId ?? "",
614
+ };
615
+ const mapFrame = this.config.sse?.mapFrame;
616
+ if (mapFrame) {
617
+ let mapped: SSEMappedEvent[] | null | undefined;
618
+ try {
619
+ mapped = mapFrame(frame);
620
+ } catch (err) {
621
+ // A throwing mapper must not kill the stream — drop the frame.
622
+ console.warn("[RealtimeModule] sse.mapFrame threw; frame dropped:", err);
623
+ return;
624
+ }
625
+ if (!mapped) return;
626
+ for (const { event: mappedEvent, payload } of mapped) {
627
+ this.emitToHandlers(mappedEvent, payload);
628
+ }
629
+ return;
630
+ }
631
+ // Default mapping: deliver under the SSE `event:` type name with the
632
+ // JSON-parsed data as payload, raw string when it isn't JSON.
633
+ let payload: unknown = frame.data;
634
+ try {
635
+ payload = JSON.parse(frame.data);
636
+ } catch {
637
+ // Not JSON — deliver the raw string.
638
+ }
639
+ this.emitToHandlers(frame.type, payload);
640
+ }
641
+
642
+ /**
643
+ * Logged once: SSE is one-way, so doc/list room membership can't be sent
644
+ * upstream — rooms are recorded client-side (see getDocRooms/getListRooms)
645
+ * for mapFrame-driven filtering and for a future two-way transport.
646
+ */
647
+ private logSSERoomNoticeOnce(): void {
648
+ if (this.sseRoomNoticeLogged) return;
649
+ this.sseRoomNoticeLogged = true;
650
+ console.debug(
651
+ "[RealtimeModule] SSE transport is one-way: doc/list room subscriptions are recorded " +
652
+ "client-side only (getDocRooms()/getListRooms()) and nothing is sent to the server — " +
653
+ "filter server-side via sse.queryParams or route frames with sse.mapFrame.",
654
+ );
655
+ }
656
+
300
657
  /**
301
658
  * Tear down the socket without clearing app handlers or room subscriptions.
302
659
  * Used by auth refresh so listeners/rooms survive credential rotation.
@@ -310,8 +667,17 @@ export class RealtimeModule {
310
667
  this.socket.disconnect();
311
668
  this.socket = undefined;
312
669
  }
670
+ if (this.eventSource) {
671
+ for (const [type, listener] of this.sseListeners) {
672
+ this.eventSource.removeEventListener(type, listener);
673
+ }
674
+ this.eventSource.close();
675
+ this.eventSource = undefined;
676
+ }
313
677
  this.attachedSocketEvents.clear();
314
678
  this.socketEventHandlers.clear();
679
+ this.attachedSseEvents.clear();
680
+ this.sseListeners.clear();
315
681
  this.socketHealthy = false;
316
682
  this.isConnecting = false;
317
683
  this.resolveHealthyWaiters(false);
@@ -408,7 +774,11 @@ export class RealtimeModule {
408
774
  // handlers up front) booted a socket that nothing would ever close —
409
775
  // against an unreachable host that is an infinite reconnect-poll loop
410
776
  // surviving unmount (the fixture/mock-story socket leak).
411
- this.attachSocketListener(event);
777
+ if (this.isSSE()) {
778
+ this.attachSSEListener(event);
779
+ } else {
780
+ this.attachSocketListener(event);
781
+ }
412
782
  }
413
783
 
414
784
  off(event: string, handler: Handler): void {
@@ -417,14 +787,23 @@ export class RealtimeModule {
417
787
  handlerSet.delete(handler);
418
788
  if (handlerSet.size === 0) {
419
789
  this.handlerMap.delete(event);
420
- this.detachSocketListener(event);
790
+ if (this.isSSE()) {
791
+ this.detachSSEListener(event);
792
+ } else {
793
+ this.detachSocketListener(event);
794
+ }
421
795
  }
422
796
  }
423
797
 
424
798
  async subscribeDoc(doctype: string, name: string): Promise<void> {
425
799
  this.docRooms.set(docRoomKey(doctype, name), { doctype, name });
800
+ // SSE: room membership is recorded client-side only — subscriptions
801
+ // cannot be emitted over a one-way stream. The room still drives the
802
+ // transport lifecycle (connect here, idle teardown on last unsubscribe)
803
+ // exactly like socket.io mode.
804
+ if (this.isSSE()) this.logSSERoomNoticeOnce();
426
805
  await this.ensureConnected();
427
- if (this.socket && this.socketHealthy) {
806
+ if (!this.isSSE() && this.socket && this.socketHealthy) {
428
807
  // Positional args — frappe's handler signature is (doctype, docname).
429
808
  this.socket.emit("doc_subscribe", doctype, name);
430
809
  }
@@ -432,7 +811,7 @@ export class RealtimeModule {
432
811
 
433
812
  unsubscribeDoc(doctype: string, name: string): void {
434
813
  this.docRooms.delete(docRoomKey(doctype, name));
435
- if (this.socket && this.socketHealthy) {
814
+ if (!this.isSSE() && this.socket && this.socketHealthy) {
436
815
  this.socket.emit("doc_unsubscribe", doctype, name);
437
816
  }
438
817
  this.teardownWhenIdle();
@@ -447,15 +826,16 @@ export class RealtimeModule {
447
826
  */
448
827
  async subscribeList(doctype: string, filters?: Record<string, unknown>): Promise<void> {
449
828
  this.listRooms.set(doctype, { doctype, filters });
829
+ if (this.isSSE()) this.logSSERoomNoticeOnce();
450
830
  await this.ensureConnected();
451
- if (this.socket && this.socketHealthy) {
831
+ if (!this.isSSE() && this.socket && this.socketHealthy) {
452
832
  this.socket.emit("doctype_subscribe", doctype);
453
833
  }
454
834
  }
455
835
 
456
836
  unsubscribeList(doctype: string): void {
457
837
  this.listRooms.delete(doctype);
458
- if (this.socket && this.socketHealthy) {
838
+ if (!this.isSSE() && this.socket && this.socketHealthy) {
459
839
  this.socket.emit("doctype_unsubscribe", doctype);
460
840
  }
461
841
  this.teardownWhenIdle();
@@ -472,7 +852,7 @@ export class RealtimeModule {
472
852
  */
473
853
  private teardownWhenIdle(): void {
474
854
  if (this.docRooms.size > 0 || this.listRooms.size > 0) return;
475
- if (!this.socket && !this.isConnecting) return;
855
+ if (!this.socket && !this.eventSource && !this.isConnecting) return;
476
856
  // Surface the teardown to lifecycle listeners (connection indicators)
477
857
  // before the listeners are detached from the socket.
478
858
  this.emitToHandlers("disconnect", { reason: "idle-teardown" });
@@ -480,6 +860,9 @@ export class RealtimeModule {
480
860
  }
481
861
 
482
862
  isHealthy() {
863
+ // SSE has no `connected` flag to consult — health is tracked by the
864
+ // open/error lifecycle events on the live EventSource.
865
+ if (this.isSSE()) return this.socketHealthy && this.eventSource !== undefined;
483
866
  return this.socketHealthy && this.socket?.connected === true;
484
867
  }
485
868
 
@@ -496,7 +879,19 @@ export class RealtimeModule {
496
879
  // socket serves. Handlers alone (registered eagerly by the sync engine)
497
880
  // don't warrant a socket: an idle module stays idle across credential
498
881
  // rotation, and the next room join connects with the fresh auth.
499
- const shouldReconnect = this.docRooms.size > 0 || this.listRooms.size > 0;
882
+ //
883
+ // SSE limitation: browsers give EventSource no way to set request
884
+ // headers, so `config.auth` (Authorization/Cookie headers) cannot ride
885
+ // the stream request the way it rides the socket.io handshake. SSE auth
886
+ // is cookies via `sse.withCredentials` and/or tokens via
887
+ // `sse.queryParams` getters — both take effect by RECREATING the
888
+ // EventSource, which is what this does. An explicitly opened stream
889
+ // (public connect(), no rooms) is recreated too: unlike socket.io mode,
890
+ // nothing else in the room lifecycle would ever reopen it.
891
+ const shouldReconnect =
892
+ this.docRooms.size > 0 ||
893
+ this.listRooms.size > 0 ||
894
+ (this.isSSE() && this.eventSource !== undefined);
500
895
  // Soft disconnect preserves handlers + rooms; hard disconnect() would wipe them
501
896
  // and make the post-check always false (previous production bug).
502
897
  this.softDisconnect();
@@ -506,6 +901,18 @@ export class RealtimeModule {
506
901
  }
507
902
 
508
903
  emit(event: string, data: unknown) {
904
+ if (this.isSSE()) {
905
+ // SSE is one-way (server → client): there is no upstream channel to
906
+ // carry app emits. Dropped, with a one-shot debug notice.
907
+ if (!this.sseEmitNoticeLogged) {
908
+ this.sseEmitNoticeLogged = true;
909
+ console.debug(
910
+ "[RealtimeModule] emit() is a no-op under the SSE transport — SSE is one-way " +
911
+ "(server → client). Use HTTP calls for client → server communication.",
912
+ );
913
+ }
914
+ return;
915
+ }
509
916
  if (this.socket && this.socketHealthy) {
510
917
  this.socket.emit(event, data);
511
918
  }
@@ -514,4 +921,23 @@ export class RealtimeModule {
514
921
  getSocket() {
515
922
  return this.socket;
516
923
  }
924
+
925
+ /** Live EventSource when the SSE transport is connected (undefined otherwise). */
926
+ getEventSource(): SSEEventSourceLike | undefined {
927
+ return this.eventSource;
928
+ }
929
+
930
+ /**
931
+ * Doc rooms recorded by subscribeDoc. Under SSE these are client-side
932
+ * bookkeeping only (nothing is emitted upstream) — mapFrame-driven apps
933
+ * consult them to filter or route frames.
934
+ */
935
+ getDocRooms(): DocRoom[] {
936
+ return [...this.docRooms.values()];
937
+ }
938
+
939
+ /** List rooms recorded by subscribeList — same client-side semantics as getDocRooms. */
940
+ getListRooms(): ListRoom[] {
941
+ return [...this.listRooms.values()];
942
+ }
517
943
  }