@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.
- package/README.md +1 -1
- 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.cjs +45 -1
- package/dist/cjs/core/http.native.js +72 -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.cjs +15 -2
- package/dist/cjs/doctype.native.js +22 -5
- 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.cjs +55 -1
- package/dist/cjs/frappeReact.native.js +67 -2
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +8 -0
- package/dist/cjs/index.native.js +10 -2
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/optimistic.cjs +32 -2
- package/dist/cjs/optimistic.native.js +34 -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 +234 -11
- package/dist/cjs/realtime.native.js +368 -18
- 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.cjs +45 -1
- package/dist/cjs/sync/index.native.js +71 -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.cjs +51 -15
- package/dist/cjs/sync/mutationEngine.native.js +69 -16
- package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
- package/dist/cjs/sync/mutationJournal.cjs +8 -0
- package/dist/cjs/sync/mutationJournal.native.js +10 -0
- 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.mjs +45 -2
- package/dist/esm/core/http.mjs.map +1 -1
- package/dist/esm/core/http.native.js +74 -4
- 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.mjs +15 -2
- package/dist/esm/doctype.mjs.map +1 -1
- package/dist/esm/doctype.native.js +23 -6
- 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.mjs +55 -3
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +73 -10
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.mjs +5 -5
- 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.mjs +27 -1
- package/dist/esm/optimistic.mjs.map +1 -1
- package/dist/esm/optimistic.native.js +30 -1
- package/dist/esm/optimistic.native.js.map +1 -1
- package/dist/esm/pacer.mjs.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 +234 -11
- package/dist/esm/realtime.mjs.map +1 -1
- package/dist/esm/realtime.native.js +369 -19
- 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.mjs +45 -1
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +79 -9
- 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.mjs +51 -15
- package/dist/esm/sync/mutationEngine.mjs.map +1 -1
- package/dist/esm/sync/mutationEngine.native.js +73 -20
- package/dist/esm/sync/mutationEngine.native.js.map +1 -1
- package/dist/esm/sync/mutationJournal.mjs +8 -0
- package/dist/esm/sync/mutationJournal.mjs.map +1 -1
- package/dist/esm/sync/mutationJournal.native.js +11 -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.mjs.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/core/http.mjs +45 -2
- package/dist/jsx/core/http.mjs.map +1 -1
- package/dist/jsx/core/http.native.js +72 -1
- package/dist/jsx/core/http.native.js.map +1 -1
- package/dist/jsx/doctype.mjs +15 -2
- package/dist/jsx/doctype.mjs.map +1 -1
- package/dist/jsx/doctype.native.js +22 -5
- package/dist/jsx/doctype.native.js.map +1 -1
- package/dist/jsx/frappeReact.mjs +55 -3
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +66 -1
- package/dist/jsx/frappeReact.native.js.map +1 -1
- package/dist/jsx/index.js +5 -5
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +5 -5
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +8 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/optimistic.mjs +27 -1
- package/dist/jsx/optimistic.mjs.map +1 -1
- package/dist/jsx/optimistic.native.js +34 -1
- package/dist/jsx/optimistic.native.js.map +1 -1
- package/dist/jsx/pacer.mjs.map +1 -1
- package/dist/jsx/pacer.native.js.map +1 -1
- package/dist/jsx/realtime.mjs +234 -11
- package/dist/jsx/realtime.mjs.map +1 -1
- package/dist/jsx/realtime.native.js +368 -18
- package/dist/jsx/realtime.native.js.map +1 -1
- package/dist/jsx/sync/index.mjs +45 -1
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +71 -1
- package/dist/jsx/sync/index.native.js.map +1 -1
- package/dist/jsx/sync/mutationEngine.mjs +51 -15
- package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
- package/dist/jsx/sync/mutationEngine.native.js +69 -16
- package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
- package/dist/jsx/sync/mutationJournal.mjs +8 -0
- package/dist/jsx/sync/mutationJournal.mjs.map +1 -1
- package/dist/jsx/sync/mutationJournal.native.js +10 -0
- package/dist/jsx/sync/mutationJournal.native.js.map +1 -1
- package/dist/jsx/sync/types.mjs.map +1 -1
- package/dist/jsx/sync/types.native.js.map +1 -1
- package/package.json +8 -8
- package/src/core/http.spec.ts +81 -0
- package/src/core/http.ts +80 -3
- package/src/doctype.spec.ts +82 -3
- package/src/doctype.ts +51 -7
- package/src/frappeReact.ts +126 -4
- package/src/index.ts +28 -3
- package/src/optimistic.spec.ts +113 -2
- package/src/optimistic.ts +95 -0
- package/src/pacer.ts +8 -0
- package/src/realtime.spec.ts +62 -0
- package/src/realtime.ts +440 -14
- package/src/realtimeSse.spec.ts +547 -0
- package/src/sync/index.ts +44 -1
- package/src/sync/mutationEngine.spec.ts +7 -2
- package/src/sync/mutationEngine.ts +83 -27
- package/src/sync/mutationJournal.ts +9 -0
- package/src/sync/mutations.spec.ts +1 -1
- package/src/sync/syncModule.spec.ts +255 -1
- package/src/sync/types.ts +20 -0
- package/types/core/http.d.ts +11 -0
- package/types/core/http.d.ts.map +1 -1
- package/types/doctype.d.ts +7 -0
- package/types/doctype.d.ts.map +1 -1
- package/types/frappeReact.d.ts +50 -2
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +8 -6
- package/types/index.d.ts.map +1 -1
- package/types/optimistic.d.ts +38 -0
- package/types/optimistic.d.ts.map +1 -1
- package/types/pacer.d.ts +8 -0
- package/types/pacer.d.ts.map +1 -1
- package/types/realtime.d.ts +183 -1
- package/types/realtime.d.ts.map +1 -1
- package/types/sync/index.d.ts +16 -1
- package/types/sync/index.d.ts.map +1 -1
- package/types/sync/mutationEngine.d.ts +7 -0
- package/types/sync/mutationEngine.d.ts.map +1 -1
- package/types/sync/mutationJournal.d.ts +6 -0
- package/types/sync/mutationJournal.d.ts.map +1 -1
- package/types/sync/types.d.ts +20 -0
- 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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|