@dokebi/sse 0.0.0-stage → 0.1.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/AI.md +132 -0
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/browser/sse-client.js +201 -0
- package/dist/client/index.d.ts +90 -0
- package/dist/client/index.js +353 -0
- package/dist/client/react.d.ts +18 -0
- package/dist/client/react.js +96 -0
- package/dist/client/vue.d.ts +15 -0
- package/dist/client/vue.js +64 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +16 -0
- package/dist/gateway.d.ts +41 -0
- package/dist/gateway.js +733 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/install.d.ts +18 -0
- package/dist/install.js +24 -0
- package/dist/log.d.ts +25 -0
- package/dist/log.js +102 -0
- package/dist/options.d.ts +27 -0
- package/dist/options.js +63 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.js +90 -0
- package/dist/types.d.ts +132 -0
- package/dist/types.js +4 -0
- package/dist/wire.d.ts +17 -0
- package/dist/wire.js +44 -0
- package/options.schema.json +73 -0
- package/package.json +101 -4
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// `@dokebi/sse/client` — the browser side of @dokebi/sse. Browser only, no node: imports, and
|
|
2
|
+
// nothing touches window, location or EventSource until connectEvents() runs (safe to import
|
|
3
|
+
// during server rendering).
|
|
4
|
+
//
|
|
5
|
+
// const events = connectEvents({ path: '/events', version: APP_VERSION })
|
|
6
|
+
// const off = events.on('order.changed', (order) => render(order))
|
|
7
|
+
//
|
|
8
|
+
// What EventSource can and cannot tell:
|
|
9
|
+
// - It never exposes the HTTP status. A refused stream (401, 403, 503, a wrong content type)
|
|
10
|
+
// only fires `error` with readyState CLOSED, and the browser stops for good.
|
|
11
|
+
// - A stream that ended or a network that dropped fires `error` with readyState CONNECTING; the
|
|
12
|
+
// browser reconnects by itself after the server's `retry:` and sends `Last-Event-ID`.
|
|
13
|
+
// - It does not expose `retry:` either, so the server repeats it as `dokebi-open` data.
|
|
14
|
+
// This client lets the browser handle the second case and handles the first itself: it closes
|
|
15
|
+
// the EventSource, asks the same URL with `dokebiProbe=1` (the server answers 204, 401, 403 or
|
|
16
|
+
// 503 without opening a stream) and stops on 401/403; anything else reopens with backoff and
|
|
17
|
+
// jitter, never sooner than the server's `retry:`. A reopened EventSource cannot send
|
|
18
|
+
// `Last-Event-ID`, so the last id seen goes in the `lastEventId` query, which the server reads.
|
|
19
|
+
//
|
|
20
|
+
// Seal hosts (D-0062): the server seals the `data` of every event of the stream (`E:<ts>:…`, the
|
|
21
|
+
// key holds the stream path). Pass the seal bridge of @dokebi/seal (`currentSealBridge()` of
|
|
22
|
+
// `@dokebi/seal/http`) as `seal`; it is undefined on a plain host, so the same page code runs on
|
|
23
|
+
// both. With a bridge, plain data is refused; without one, sealed data is (fail closed).
|
|
24
|
+
const CLOSED = 2;
|
|
25
|
+
const RELOAD = 'dokebi-reload';
|
|
26
|
+
const RESTART = 'dokebi-restart';
|
|
27
|
+
const OPEN = 'dokebi-open';
|
|
28
|
+
/** The `retryMs` of the server's own `{"retryMs":N}` data (dokebi-open, dokebi-restart). */
|
|
29
|
+
function retryOf(text) {
|
|
30
|
+
const match = /^\{"retryMs":(\d{1,9})\}$/.exec(text);
|
|
31
|
+
return match ? Number(match[1]) : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** Opens an event stream (browser only). */
|
|
34
|
+
export function connectEvents(options) {
|
|
35
|
+
const Source = options.EventSource ?? globalThis.EventSource;
|
|
36
|
+
const reconnecting = options.reconnect !== false;
|
|
37
|
+
const minMs = options.reconnect === false ? 0 : Math.max(0, options.reconnect?.minMs ?? 1000);
|
|
38
|
+
const maxMs = options.reconnect === false ? 0 : Math.max(minMs, options.reconnect?.maxMs ?? 30_000);
|
|
39
|
+
const handlers = new Map();
|
|
40
|
+
const dispatchers = new Map();
|
|
41
|
+
const statusHandlers = new Set();
|
|
42
|
+
const errorHandlers = new Set();
|
|
43
|
+
let source;
|
|
44
|
+
let status = 'connecting';
|
|
45
|
+
let lastEventId;
|
|
46
|
+
let serverRetry = 0;
|
|
47
|
+
let attempt = 0;
|
|
48
|
+
let timer;
|
|
49
|
+
let stopped = false;
|
|
50
|
+
let paused = false;
|
|
51
|
+
/** The next open is this client's own (not the browser's): it carries lastEventId. */
|
|
52
|
+
let manual = false;
|
|
53
|
+
const base = options.url ?? options.path;
|
|
54
|
+
if (typeof base !== 'string' || !base)
|
|
55
|
+
throw new TypeError('connectEvents needs url or path');
|
|
56
|
+
const seal = options.seal;
|
|
57
|
+
if (seal !== undefined && typeof seal?.unsealFrame !== 'function')
|
|
58
|
+
throw new TypeError('connectEvents seal needs unsealFrame(frame, url)');
|
|
59
|
+
let streamPath;
|
|
60
|
+
function setStatus(next) {
|
|
61
|
+
if (status === next)
|
|
62
|
+
return;
|
|
63
|
+
status = next;
|
|
64
|
+
options.onStatus?.(next);
|
|
65
|
+
for (const handler of [...statusHandlers])
|
|
66
|
+
handler(next);
|
|
67
|
+
}
|
|
68
|
+
function report(error) {
|
|
69
|
+
if (!options.onError && !errorHandlers.size) {
|
|
70
|
+
console.warn(`[dokebi-sse] ${error.code}`, error.error ?? error.status ?? '');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
options.onError?.(error);
|
|
74
|
+
for (const handler of [...errorHandlers])
|
|
75
|
+
handler(error);
|
|
76
|
+
}
|
|
77
|
+
function address(extra) {
|
|
78
|
+
const page = globalThis.location?.href;
|
|
79
|
+
const url = new URL(base, page);
|
|
80
|
+
for (const [name, value] of Object.entries(options.query ?? {}))
|
|
81
|
+
if (value !== undefined && value !== null)
|
|
82
|
+
url.searchParams.set(name, String(value));
|
|
83
|
+
if (options.version !== undefined)
|
|
84
|
+
url.searchParams.set('dokebiVersion', options.version);
|
|
85
|
+
if (manual && lastEventId !== undefined)
|
|
86
|
+
url.searchParams.set('lastEventId', lastEventId);
|
|
87
|
+
for (const [name, value] of Object.entries(extra ?? {}))
|
|
88
|
+
url.searchParams.set(name, value);
|
|
89
|
+
// The key path of a sealed stream: what the browser sends, without the query.
|
|
90
|
+
streamPath = url.pathname;
|
|
91
|
+
return url.href;
|
|
92
|
+
}
|
|
93
|
+
/** The event's data as text: opened on a sealed stream; undefined (reported) when refused. */
|
|
94
|
+
function textOf(event, raw) {
|
|
95
|
+
const sealed = raw.startsWith('E:');
|
|
96
|
+
if (!seal) {
|
|
97
|
+
if (!sealed)
|
|
98
|
+
return raw;
|
|
99
|
+
report({ code: 'SEAL_BRIDGE_MISSING', event });
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
if (!sealed)
|
|
104
|
+
throw new Error('plain data on a sealed stream');
|
|
105
|
+
return seal.unsealFrame(raw, streamPath ?? '/');
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
report({ code: 'SEAL_FAILED', event, error });
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function dispatcher(event) {
|
|
113
|
+
return (message) => {
|
|
114
|
+
if (message.lastEventId)
|
|
115
|
+
lastEventId = message.lastEventId;
|
|
116
|
+
const text = textOf(event, message.data);
|
|
117
|
+
if (text === undefined)
|
|
118
|
+
return;
|
|
119
|
+
let data;
|
|
120
|
+
try {
|
|
121
|
+
data = JSON.parse(text);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
report({ code: 'BAD_DATA', event, error });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const meta = { event, id: message.lastEventId || undefined };
|
|
128
|
+
for (const handler of [...(handlers.get(event) ?? [])])
|
|
129
|
+
try {
|
|
130
|
+
handler(data, meta);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
report({ code: 'HANDLER_FAILED', event, error });
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function clearTimer() {
|
|
138
|
+
if (timer !== undefined)
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
timer = undefined;
|
|
141
|
+
}
|
|
142
|
+
function drop() {
|
|
143
|
+
if (!source)
|
|
144
|
+
return;
|
|
145
|
+
source.onopen = null;
|
|
146
|
+
source.onerror = null;
|
|
147
|
+
source.close();
|
|
148
|
+
source = undefined;
|
|
149
|
+
}
|
|
150
|
+
function open() {
|
|
151
|
+
clearTimer();
|
|
152
|
+
if (stopped || paused)
|
|
153
|
+
return;
|
|
154
|
+
if (!Source) {
|
|
155
|
+
stopped = true;
|
|
156
|
+
setStatus('closed');
|
|
157
|
+
report({ code: 'NO_EVENTSOURCE' });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const es = new Source(address(), options.withCredentials ? { withCredentials: true } : undefined);
|
|
161
|
+
source = es;
|
|
162
|
+
es.onopen = () => {
|
|
163
|
+
if (source !== es)
|
|
164
|
+
return;
|
|
165
|
+
attempt = 0;
|
|
166
|
+
setStatus('open');
|
|
167
|
+
};
|
|
168
|
+
es.onerror = () => {
|
|
169
|
+
if (source === es)
|
|
170
|
+
failed(es);
|
|
171
|
+
};
|
|
172
|
+
es.addEventListener(OPEN, (message) => {
|
|
173
|
+
serverRetry = retryOf(textOf(OPEN, message.data) ?? '') ?? serverRetry;
|
|
174
|
+
});
|
|
175
|
+
es.addEventListener(RELOAD, (message) => {
|
|
176
|
+
// A reload that does not open on a sealed stream is not obeyed (it may not be the server's).
|
|
177
|
+
if (source === es && textOf(RELOAD, message.data) !== undefined)
|
|
178
|
+
reload();
|
|
179
|
+
});
|
|
180
|
+
es.addEventListener(RESTART, (message) => {
|
|
181
|
+
if (source !== es)
|
|
182
|
+
return;
|
|
183
|
+
serverRetry = retryOf(textOf(RESTART, message.data) ?? '') ?? serverRetry;
|
|
184
|
+
// The server is stopping (a deploy, a rolling restart): reopen, spread out by jitter so
|
|
185
|
+
// its clients do not all land on the other workers at the same moment.
|
|
186
|
+
drop();
|
|
187
|
+
setStatus('reconnecting');
|
|
188
|
+
later();
|
|
189
|
+
});
|
|
190
|
+
for (const [event, listener] of dispatchers)
|
|
191
|
+
es.addEventListener(event, listener);
|
|
192
|
+
}
|
|
193
|
+
function failed(es) {
|
|
194
|
+
if (es.readyState !== CLOSED) {
|
|
195
|
+
// The browser reconnects by itself (after `retry:`, with Last-Event-ID).
|
|
196
|
+
setStatus('reconnecting');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
drop();
|
|
200
|
+
if (!reconnecting) {
|
|
201
|
+
stopped = true;
|
|
202
|
+
setStatus('closed');
|
|
203
|
+
report({ code: 'CONNECTION_FAILED' });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
setStatus('reconnecting');
|
|
207
|
+
const fetcher = globalThis.fetch;
|
|
208
|
+
if (options.probe === false || !fetcher) {
|
|
209
|
+
report({ code: 'CONNECTION_FAILED' });
|
|
210
|
+
later();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
void probe(fetcher);
|
|
214
|
+
}
|
|
215
|
+
async function probe(fetcher) {
|
|
216
|
+
let answer;
|
|
217
|
+
try {
|
|
218
|
+
const response = await fetcher(address({ dokebiProbe: '1' }), {
|
|
219
|
+
credentials: options.withCredentials ? 'include' : 'same-origin',
|
|
220
|
+
cache: 'no-store',
|
|
221
|
+
});
|
|
222
|
+
answer = response.status;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
report({ code: 'CONNECTION_FAILED', error });
|
|
226
|
+
later();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (stopped || paused)
|
|
230
|
+
return;
|
|
231
|
+
if (answer === 401 || answer === 403) {
|
|
232
|
+
stopped = true;
|
|
233
|
+
clearTimer();
|
|
234
|
+
setStatus('closed');
|
|
235
|
+
report({ code: answer === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN', status: answer });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
report({ code: answer === 503 ? 'UNAVAILABLE' : 'CONNECTION_FAILED', status: answer });
|
|
239
|
+
later();
|
|
240
|
+
}
|
|
241
|
+
/** Reopens after full-jitter backoff: between half and all of min(max, base · 2^attempt). */
|
|
242
|
+
function later() {
|
|
243
|
+
clearTimer();
|
|
244
|
+
if (stopped || paused)
|
|
245
|
+
return;
|
|
246
|
+
if (!reconnecting) {
|
|
247
|
+
stopped = true;
|
|
248
|
+
setStatus('closed');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const floor = Math.max(minMs, serverRetry);
|
|
252
|
+
const ceiling = Math.min(Math.max(maxMs, floor), floor * 2 ** attempt);
|
|
253
|
+
attempt = Math.min(attempt + 1, 20);
|
|
254
|
+
manual = true;
|
|
255
|
+
timer = setTimeout(open, Math.round(ceiling / 2 + (Math.random() * ceiling) / 2));
|
|
256
|
+
}
|
|
257
|
+
function reload() {
|
|
258
|
+
stopped = true;
|
|
259
|
+
drop();
|
|
260
|
+
clearTimer();
|
|
261
|
+
setStatus('closed');
|
|
262
|
+
if (options.onReload)
|
|
263
|
+
options.onReload();
|
|
264
|
+
else
|
|
265
|
+
globalThis.location?.reload();
|
|
266
|
+
}
|
|
267
|
+
const page = globalThis;
|
|
268
|
+
// The back/forward cache keeps a hidden page's connections open; close it while hidden.
|
|
269
|
+
const onHide = (event) => {
|
|
270
|
+
if (!event.persisted || stopped)
|
|
271
|
+
return;
|
|
272
|
+
paused = true;
|
|
273
|
+
drop();
|
|
274
|
+
clearTimer();
|
|
275
|
+
setStatus('paused');
|
|
276
|
+
};
|
|
277
|
+
const onShow = (event) => {
|
|
278
|
+
if (!event.persisted || stopped || !paused)
|
|
279
|
+
return;
|
|
280
|
+
paused = false;
|
|
281
|
+
attempt = 0;
|
|
282
|
+
manual = true;
|
|
283
|
+
setStatus('connecting');
|
|
284
|
+
open();
|
|
285
|
+
};
|
|
286
|
+
page.addEventListener?.('pagehide', onHide);
|
|
287
|
+
page.addEventListener?.('pageshow', onShow);
|
|
288
|
+
const client = {
|
|
289
|
+
get status() {
|
|
290
|
+
return status;
|
|
291
|
+
},
|
|
292
|
+
get lastEventId() {
|
|
293
|
+
return lastEventId;
|
|
294
|
+
},
|
|
295
|
+
on(event, handler) {
|
|
296
|
+
if (typeof event !== 'string' || !event || typeof handler !== 'function')
|
|
297
|
+
throw new TypeError('on(event, handler) needs an event name and a function');
|
|
298
|
+
let set = handlers.get(event);
|
|
299
|
+
if (!set) {
|
|
300
|
+
set = new Set();
|
|
301
|
+
handlers.set(event, set);
|
|
302
|
+
const listener = dispatcher(event);
|
|
303
|
+
dispatchers.set(event, listener);
|
|
304
|
+
source?.addEventListener(event, listener);
|
|
305
|
+
}
|
|
306
|
+
const entry = handler;
|
|
307
|
+
set.add(entry);
|
|
308
|
+
return () => {
|
|
309
|
+
const current = handlers.get(event);
|
|
310
|
+
if (!current?.delete(entry) || current.size)
|
|
311
|
+
return;
|
|
312
|
+
handlers.delete(event);
|
|
313
|
+
const listener = dispatchers.get(event);
|
|
314
|
+
dispatchers.delete(event);
|
|
315
|
+
if (listener)
|
|
316
|
+
source?.removeEventListener(event, listener);
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
onStatus(handler) {
|
|
320
|
+
statusHandlers.add(handler);
|
|
321
|
+
return () => statusHandlers.delete(handler);
|
|
322
|
+
},
|
|
323
|
+
onError(handler) {
|
|
324
|
+
errorHandlers.add(handler);
|
|
325
|
+
return () => errorHandlers.delete(handler);
|
|
326
|
+
},
|
|
327
|
+
close() {
|
|
328
|
+
stopped = true;
|
|
329
|
+
paused = false;
|
|
330
|
+
drop();
|
|
331
|
+
clearTimer();
|
|
332
|
+
page.removeEventListener?.('pagehide', onHide);
|
|
333
|
+
page.removeEventListener?.('pageshow', onShow);
|
|
334
|
+
setStatus('closed');
|
|
335
|
+
},
|
|
336
|
+
reconnect() {
|
|
337
|
+
const wasStopped = stopped;
|
|
338
|
+
stopped = false;
|
|
339
|
+
paused = false;
|
|
340
|
+
attempt = 0;
|
|
341
|
+
manual = source !== undefined || manual;
|
|
342
|
+
drop();
|
|
343
|
+
if (wasStopped) {
|
|
344
|
+
page.addEventListener?.('pagehide', onHide);
|
|
345
|
+
page.addEventListener?.('pageshow', onShow);
|
|
346
|
+
}
|
|
347
|
+
setStatus('connecting');
|
|
348
|
+
open();
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
open();
|
|
352
|
+
return client;
|
|
353
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type EventHandler, type EventsClient, type EventsOptions, type EventsStatus } from './index.js';
|
|
2
|
+
export interface UseEvents {
|
|
3
|
+
readonly status: EventsStatus;
|
|
4
|
+
/** Undefined until the effect ran (the first render, server rendering). */
|
|
5
|
+
readonly client: EventsClient | undefined;
|
|
6
|
+
}
|
|
7
|
+
/** Connects (shared, ref-counted) in an effect and follows the stream's status. */
|
|
8
|
+
export declare function useEvents(options: EventsOptions): UseEvents;
|
|
9
|
+
/** Shares one stream with every useEvent(name, handler) below it. */
|
|
10
|
+
export declare function EventsProvider(props: {
|
|
11
|
+
readonly options: EventsOptions;
|
|
12
|
+
readonly children?: unknown;
|
|
13
|
+
}): unknown;
|
|
14
|
+
/**
|
|
15
|
+
* Listens to one event of `events` (a useEvents result) or, without it, of the nearest
|
|
16
|
+
* EventsProvider. The latest handler is called; changing it does not resubscribe.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useEvent<T = unknown>(name: string, handler: EventHandler<T>, events?: UseEvents): void;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `@dokebi/sse/client/react` — React hooks. Streams are shared: every useEvents with the same
|
|
2
|
+
// url/path/query/version/withCredentials uses one EventSource, counted by reference and closed
|
|
3
|
+
// a tick after the last user unmounts (StrictMode's mount-unmount-mount keeps the same stream).
|
|
4
|
+
//
|
|
5
|
+
// const events = useEvents({ path: '/events', version: APP_VERSION })
|
|
6
|
+
// useEvent('order.changed', (order) => setOrders((list) => [...list, order]), events)
|
|
7
|
+
//
|
|
8
|
+
// <EventsProvider options={{ path: '/events' }}><Orders /></EventsProvider>
|
|
9
|
+
// function Orders() { useEvent('order.changed', (order) => ...) } // from the provider
|
|
10
|
+
import { createContext, createElement, useContext, useEffect, useRef, useState } from 'react';
|
|
11
|
+
import { connectEvents, } from './index.js';
|
|
12
|
+
const shared = new Map();
|
|
13
|
+
function keyOf(options) {
|
|
14
|
+
return JSON.stringify([
|
|
15
|
+
options.url ?? null,
|
|
16
|
+
options.path ?? null,
|
|
17
|
+
options.query ?? null,
|
|
18
|
+
options.version ?? null,
|
|
19
|
+
options.withCredentials === true,
|
|
20
|
+
options.seal !== undefined,
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
/** The shared stream for these options; the first user's callbacks and reconnect settings win. */
|
|
24
|
+
function acquire(options) {
|
|
25
|
+
const key = keyOf(options);
|
|
26
|
+
const found = shared.get(key);
|
|
27
|
+
if (found) {
|
|
28
|
+
if (found.timer !== undefined)
|
|
29
|
+
clearTimeout(found.timer);
|
|
30
|
+
found.timer = undefined;
|
|
31
|
+
found.refs++;
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
const entry = { key, client: connectEvents(options), refs: 1, timer: undefined };
|
|
35
|
+
shared.set(key, entry);
|
|
36
|
+
return entry;
|
|
37
|
+
}
|
|
38
|
+
function release(entry) {
|
|
39
|
+
if (--entry.refs > 0)
|
|
40
|
+
return;
|
|
41
|
+
entry.timer = setTimeout(() => {
|
|
42
|
+
if (entry.refs > 0)
|
|
43
|
+
return;
|
|
44
|
+
shared.delete(entry.key);
|
|
45
|
+
entry.client.close();
|
|
46
|
+
}, 0);
|
|
47
|
+
}
|
|
48
|
+
/** Connects (shared, ref-counted) in an effect and follows the stream's status. */
|
|
49
|
+
export function useEvents(options) {
|
|
50
|
+
const latest = useRef(options);
|
|
51
|
+
latest.current = options;
|
|
52
|
+
const [client, setClient] = useState(undefined);
|
|
53
|
+
const [status, setStatus] = useState('connecting');
|
|
54
|
+
const key = keyOf(options);
|
|
55
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: key is what identifies the stream; the latest callbacks are read through the ref.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const current = latest.current;
|
|
58
|
+
const entry = acquire({
|
|
59
|
+
...current,
|
|
60
|
+
...(current.onReload ? { onReload: () => latest.current.onReload?.() } : {}),
|
|
61
|
+
...(current.onError ? { onError: (error) => latest.current.onError?.(error) } : {}),
|
|
62
|
+
});
|
|
63
|
+
setClient(entry.client);
|
|
64
|
+
setStatus(entry.client.status);
|
|
65
|
+
const off = entry.client.onStatus((next) => {
|
|
66
|
+
setStatus(next);
|
|
67
|
+
latest.current.onStatus?.(next);
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
off();
|
|
71
|
+
release(entry);
|
|
72
|
+
setClient(undefined);
|
|
73
|
+
};
|
|
74
|
+
}, [key]);
|
|
75
|
+
return { status, client };
|
|
76
|
+
}
|
|
77
|
+
const EventsContext = createContext(undefined);
|
|
78
|
+
/** Shares one stream with every useEvent(name, handler) below it. */
|
|
79
|
+
export function EventsProvider(props) {
|
|
80
|
+
const events = useEvents(props.options);
|
|
81
|
+
return createElement(EventsContext.Provider, { value: events }, props.children);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Listens to one event of `events` (a useEvents result) or, without it, of the nearest
|
|
85
|
+
* EventsProvider. The latest handler is called; changing it does not resubscribe.
|
|
86
|
+
*/
|
|
87
|
+
export function useEvent(name, handler, events) {
|
|
88
|
+
const provided = useContext(EventsContext);
|
|
89
|
+
const source = events ?? provided;
|
|
90
|
+
if (!source)
|
|
91
|
+
throw new Error('useEvent needs a useEvents() result or an <EventsProvider>');
|
|
92
|
+
const latest = useRef(handler);
|
|
93
|
+
latest.current = handler;
|
|
94
|
+
const client = source.client;
|
|
95
|
+
useEffect(() => client?.on(name, (data, meta) => latest.current(data, meta)), [client, name]);
|
|
96
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ShallowRef } from 'vue';
|
|
2
|
+
import { type EventHandler, type EventsClient, type EventsOptions, type EventsStatus } from './index.js';
|
|
3
|
+
export interface UseEvents {
|
|
4
|
+
readonly status: Readonly<ShallowRef<EventsStatus>>;
|
|
5
|
+
/** The client once connected (after mount); undefined before. */
|
|
6
|
+
readonly client: Readonly<ShallowRef<EventsClient | undefined>>;
|
|
7
|
+
/** Listens to one event; works before mount too (attached when the stream opens). */
|
|
8
|
+
on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
|
|
9
|
+
close(): void;
|
|
10
|
+
reconnect(): void;
|
|
11
|
+
}
|
|
12
|
+
/** Opens the stream on mount (or at once outside a component) and closes it on scope dispose. */
|
|
13
|
+
export declare function useEvents(options: EventsOptions): UseEvents;
|
|
14
|
+
/** Listens to one event of `events` until the current scope ends (or the returned function). */
|
|
15
|
+
export declare function useEvent<T = unknown>(events: Pick<UseEvents, 'on'> | EventsClient, event: string, handler: EventHandler<T>): () => void;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// `@dokebi/sse/client/vue` — Vue 3 composables. The stream opens when the component mounts (never
|
|
2
|
+
// during server rendering) and closes when its scope ends.
|
|
3
|
+
//
|
|
4
|
+
// const events = useEvents({ path: '/events', version: APP_VERSION })
|
|
5
|
+
// useEvent(events, 'order.changed', (order) => orders.value.push(order))
|
|
6
|
+
// // template: events.status.value === 'open'
|
|
7
|
+
import { getCurrentInstance, getCurrentScope, onMounted, onScopeDispose, shallowRef } from 'vue';
|
|
8
|
+
import { connectEvents, } from './index.js';
|
|
9
|
+
/** Opens the stream on mount (or at once outside a component) and closes it on scope dispose. */
|
|
10
|
+
export function useEvents(options) {
|
|
11
|
+
const status = shallowRef('connecting');
|
|
12
|
+
const client = shallowRef(undefined);
|
|
13
|
+
const subscriptions = new Set();
|
|
14
|
+
let disposed = false;
|
|
15
|
+
const connect = () => {
|
|
16
|
+
if (client.value || disposed)
|
|
17
|
+
return;
|
|
18
|
+
const connected = connectEvents({
|
|
19
|
+
...options,
|
|
20
|
+
onStatus: (next) => {
|
|
21
|
+
status.value = next;
|
|
22
|
+
options.onStatus?.(next);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
client.value = connected;
|
|
26
|
+
status.value = connected.status;
|
|
27
|
+
for (const subscription of subscriptions)
|
|
28
|
+
subscription.off = connected.on(subscription.event, subscription.handler);
|
|
29
|
+
};
|
|
30
|
+
if (getCurrentInstance())
|
|
31
|
+
onMounted(connect);
|
|
32
|
+
else
|
|
33
|
+
connect();
|
|
34
|
+
if (getCurrentScope())
|
|
35
|
+
onScopeDispose(() => {
|
|
36
|
+
disposed = true;
|
|
37
|
+
client.value?.close();
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
status,
|
|
41
|
+
client,
|
|
42
|
+
on(event, handler) {
|
|
43
|
+
const subscription = {
|
|
44
|
+
event,
|
|
45
|
+
handler: handler,
|
|
46
|
+
off: client.value?.on(event, handler),
|
|
47
|
+
};
|
|
48
|
+
subscriptions.add(subscription);
|
|
49
|
+
return () => {
|
|
50
|
+
subscriptions.delete(subscription);
|
|
51
|
+
subscription.off?.();
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
close: () => client.value?.close(),
|
|
55
|
+
reconnect: () => client.value?.reconnect(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Listens to one event of `events` until the current scope ends (or the returned function). */
|
|
59
|
+
export function useEvent(events, event, handler) {
|
|
60
|
+
const off = events.on(event, handler);
|
|
61
|
+
if (getCurrentScope())
|
|
62
|
+
onScopeDispose(off);
|
|
63
|
+
return off;
|
|
64
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type SseErrorCode = 'INVALID_OPTIONS' | 'INVALID_ROUTE' | 'INVALID_IDENTITY' | 'INVALID_EVENT' | 'INVALID_DATA' | 'INVALID_TARGET' | 'INVALID_ROOM' | 'EVENT_TOO_LARGE' | 'ROOM_LIMIT' | 'FROZEN' | 'CLOSED' | 'BUS_UNAVAILABLE' | 'BUS_PUBLISH_FAILED' | 'SSE_LOCAL_IN_CLUSTER';
|
|
2
|
+
/** Errors of @dokebi/sse; compare `code`, not the class (another copy of the package may throw). */
|
|
3
|
+
export declare class SseError extends Error {
|
|
4
|
+
readonly code: SseErrorCode;
|
|
5
|
+
/** BUS_PUBLISH_FAILED: the event id and how many streams of this process already got it. */
|
|
6
|
+
readonly id?: string;
|
|
7
|
+
readonly local?: number;
|
|
8
|
+
constructor(code: SseErrorCode, message: string, options?: ErrorOptions & {
|
|
9
|
+
readonly id?: string;
|
|
10
|
+
readonly local?: number;
|
|
11
|
+
});
|
|
12
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Errors of @dokebi/sse; compare `code`, not the class (another copy of the package may throw). */
|
|
2
|
+
export class SseError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
/** BUS_PUBLISH_FAILED: the event id and how many streams of this process already got it. */
|
|
5
|
+
id;
|
|
6
|
+
local;
|
|
7
|
+
constructor(code, message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = 'SseError';
|
|
11
|
+
if (options?.id !== undefined)
|
|
12
|
+
this.id = options.id;
|
|
13
|
+
if (options?.local !== undefined)
|
|
14
|
+
this.local = options.local;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PluginContext } from '@dokebi/core';
|
|
2
|
+
import type { WebApi } from '@dokebi/web';
|
|
3
|
+
import type { Options } from './options.js';
|
|
4
|
+
import { type SseApi } from './types.js';
|
|
5
|
+
/** The part of a dokebi-event-bus bus this plugin uses (never imported: the bus is optional). */
|
|
6
|
+
export interface SseBus {
|
|
7
|
+
readonly state: string;
|
|
8
|
+
subscribe(event: string, handler: (payload: unknown) => void): {
|
|
9
|
+
readonly ready: Promise<void>;
|
|
10
|
+
unsubscribe(): Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
publish(event: string, payload: unknown): Promise<{
|
|
13
|
+
readonly id: string;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
/** The part of dokebi-health this plugin uses. */
|
|
17
|
+
export interface SseHealth {
|
|
18
|
+
register(name: string, options?: {
|
|
19
|
+
readonly required?: boolean;
|
|
20
|
+
}): {
|
|
21
|
+
ready(): void;
|
|
22
|
+
fail(reason: string): void;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export declare class Gateway {
|
|
27
|
+
#private;
|
|
28
|
+
readonly context: PluginContext;
|
|
29
|
+
readonly web: WebApi;
|
|
30
|
+
readonly options: Options;
|
|
31
|
+
readonly bus: SseBus | undefined;
|
|
32
|
+
readonly api: SseApi;
|
|
33
|
+
constructor(context: PluginContext, web: WebApi, options: Options, bus: SseBus | undefined, health: SseHealth | undefined);
|
|
34
|
+
/** Subscribes to the bus (setup): every process on it delivers to its own matching streams. */
|
|
35
|
+
subscribe(): Promise<void>;
|
|
36
|
+
start(): void;
|
|
37
|
+
/** Sends `dokebi-restart` to every stream, ends them, and cuts the rest at the deadline. */
|
|
38
|
+
drain(signal: AbortSignal): Promise<void>;
|
|
39
|
+
/** Cleanup: after a failed start no drain ran, so streams may still be open. */
|
|
40
|
+
close(): void;
|
|
41
|
+
}
|