@multiplatform.one/frappe 6.6.0 → 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.
- package/dist/cjs/auth.native.js.map +1 -1
- package/dist/cjs/call.native.js.map +1 -1
- package/dist/cjs/collection.native.js.map +1 -1
- package/dist/cjs/common/types.native.js.map +1 -1
- package/dist/cjs/core/http.native.js.map +1 -1
- package/dist/cjs/core/types.native.js.map +1 -1
- package/dist/cjs/core/wire.native.js.map +1 -1
- package/dist/cjs/db.native.js.map +1 -1
- package/dist/cjs/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
- package/dist/cjs/devtools/index.native.js.map +1 -1
- package/dist/cjs/devtools/plugin.native.js.map +1 -1
- package/dist/cjs/devtools/theme.native.js.map +1 -1
- package/dist/cjs/doctype.native.js.map +1 -1
- package/dist/cjs/file.native.js.map +1 -1
- package/dist/cjs/fixtureDb.native.js.map +1 -1
- package/dist/cjs/fixtures.native.js.map +1 -1
- package/dist/cjs/frappeApp.native.js.map +1 -1
- package/dist/cjs/frappeReact.native.js +1 -1
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +3 -2
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/optimistic.native.js.map +1 -1
- package/dist/cjs/pacer.native.js.map +1 -1
- package/dist/cjs/plugin/index.native.js.map +1 -1
- package/dist/cjs/realtime.cjs +222 -11
- package/dist/cjs/realtime.native.js +352 -14
- package/dist/cjs/realtime.native.js.map +1 -1
- package/dist/cjs/sync/backfill.native.js.map +1 -1
- package/dist/cjs/sync/filters.native.js.map +1 -1
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/merge.native.js.map +1 -1
- package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
- package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
- package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
- package/dist/cjs/sync/persistence.native.js.map +1 -1
- package/dist/cjs/sync/realtime.native.js.map +1 -1
- package/dist/cjs/sync/rehydration.native.js.map +1 -1
- package/dist/cjs/sync/storage.native.js.map +1 -1
- package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
- package/dist/cjs/sync/types.native.js.map +1 -1
- package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
- package/dist/cjs/useLiveQuery.native.js.map +1 -1
- package/dist/cjs/util.native.js.map +1 -1
- package/dist/esm/auth.native.js.map +1 -1
- package/dist/esm/call.native.js.map +1 -1
- package/dist/esm/collection.native.js +1 -1
- package/dist/esm/collection.native.js.map +1 -1
- package/dist/esm/common/types.native.js.map +1 -1
- package/dist/esm/core/http.native.js +2 -2
- package/dist/esm/core/http.native.js.map +1 -1
- package/dist/esm/core/types.native.js.map +1 -1
- package/dist/esm/core/wire.native.js.map +1 -1
- package/dist/esm/db.native.js +1 -1
- package/dist/esm/db.native.js.map +1 -1
- package/dist/esm/devtools/FrappeDevtoolsPanel.native.js +2 -2
- package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
- package/dist/esm/devtools/index.native.js +2 -2
- package/dist/esm/devtools/index.native.js.map +1 -1
- package/dist/esm/devtools/plugin.native.js +1 -1
- package/dist/esm/devtools/plugin.native.js.map +1 -1
- package/dist/esm/devtools/theme.native.js.map +1 -1
- package/dist/esm/doctype.native.js +1 -1
- package/dist/esm/doctype.native.js.map +1 -1
- package/dist/esm/file.native.js.map +1 -1
- package/dist/esm/fixtureDb.native.js +1 -1
- package/dist/esm/fixtureDb.native.js.map +1 -1
- package/dist/esm/fixtures.native.js.map +1 -1
- package/dist/esm/frappeApp.native.js +7 -7
- package/dist/esm/frappeApp.native.js.map +1 -1
- package/dist/esm/frappeReact.native.js +7 -7
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.mjs +2 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +22 -22
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/optimistic.native.js.map +1 -1
- package/dist/esm/pacer.native.js +4 -4
- package/dist/esm/pacer.native.js.map +1 -1
- package/dist/esm/plugin/index.native.js.map +1 -1
- package/dist/esm/realtime.mjs +222 -11
- package/dist/esm/realtime.mjs.map +1 -1
- package/dist/esm/realtime.native.js +353 -15
- package/dist/esm/realtime.native.js.map +1 -1
- package/dist/esm/sync/backfill.native.js +1 -1
- package/dist/esm/sync/backfill.native.js.map +1 -1
- package/dist/esm/sync/filters.native.js.map +1 -1
- package/dist/esm/sync/index.native.js +8 -8
- package/dist/esm/sync/index.native.js.map +1 -1
- package/dist/esm/sync/merge.native.js.map +1 -1
- package/dist/esm/sync/mutationEngine.native.js +4 -4
- package/dist/esm/sync/mutationEngine.native.js.map +1 -1
- package/dist/esm/sync/mutationJournal.native.js +1 -1
- package/dist/esm/sync/mutationJournal.native.js.map +1 -1
- package/dist/esm/sync/normalizedCache.native.js.map +1 -1
- package/dist/esm/sync/persistence.native.js +2 -2
- package/dist/esm/sync/persistence.native.js.map +1 -1
- package/dist/esm/sync/realtime.native.js +1 -1
- package/dist/esm/sync/realtime.native.js.map +1 -1
- package/dist/esm/sync/rehydration.native.js +1 -1
- package/dist/esm/sync/rehydration.native.js.map +1 -1
- package/dist/esm/sync/storage.native.js.map +1 -1
- package/dist/esm/sync/subscriptionManager.native.js +4 -4
- package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
- package/dist/esm/sync/types.native.js.map +1 -1
- package/dist/esm/sync/virtualPageManager.native.js +1 -1
- package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
- package/dist/esm/useLiveQuery.native.js.map +1 -1
- package/dist/esm/util.native.js.map +1 -1
- package/dist/jsx/index.js +2 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +2 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +1 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/realtime.mjs +222 -11
- package/dist/jsx/realtime.mjs.map +1 -1
- package/dist/jsx/realtime.native.js +352 -14
- package/dist/jsx/realtime.native.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +13 -1
- package/src/realtime.ts +406 -14
- package/src/realtimeSse.spec.ts +547 -0
- package/types/index.d.ts +2 -1
- package/types/index.d.ts.map +1 -1
- package/types/realtime.d.ts +166 -1
- package/types/realtime.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;
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|