@dev-crew-berlin/enter-js-utils 0.97.5 → 0.98.9
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/api-client/api-base.d.ts +24 -3
- package/dist/api-client/api-base.js +146 -15
- package/dist/api-client/api-client.test.js +696 -0
- package/dist/api-client/index.d.ts +7 -4
- package/dist/api-client/index.js +13 -4
- package/package.json +7 -7
- package/dist/ui/button.stories.d.ts +0 -16
- package/dist/ui/checkin-count-indicator.stories.d.ts +0 -14
- package/dist/ui/checkin-progress-bar.stories.d.ts +0 -12
- package/dist/ui/companion-info.stories.d.ts +0 -7
- package/dist/ui/enter-logo.stories.d.ts +0 -7
- package/dist/ui/form-elements/input.stories.d.ts +0 -8
- package/dist/ui/form-elements/label.stories.d.ts +0 -6
- package/dist/ui/form-elements/search-input.stories.d.ts +0 -7
- package/dist/ui/form-elements/segmented-control.stories.d.ts +0 -6
- package/dist/ui/form-elements/select.stories.d.ts +0 -8
- package/dist/ui/guest-card.stories.d.ts +0 -14
- package/dist/ui/icons/add-guest-icon.stories.d.ts +0 -7
- package/dist/ui/icons/caret-icon.stories.d.ts +0 -7
- package/dist/ui/icons/filter-icon.stories.d.ts +0 -7
- package/dist/ui/icons/search-icon.stories.d.ts +0 -7
- package/dist/ui/icons/settings-icon.stories.d.ts +0 -7
- package/dist/ui/icons/sort-list-icon.stories.d.ts +0 -7
- package/dist/ui/tag.stories.d.ts +0 -6
|
@@ -23,6 +23,12 @@ export type FetchOptions = {
|
|
|
23
23
|
revalidate: false | 0 | number;
|
|
24
24
|
};
|
|
25
25
|
};
|
|
26
|
+
export type SubscribeOptions = {
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
onReset?: () => void;
|
|
29
|
+
onConnectionChange?: (state: 'connected' | 'reconnecting') => void;
|
|
30
|
+
initialLastEventId?: string;
|
|
31
|
+
};
|
|
26
32
|
export default class APIBase {
|
|
27
33
|
private credentials;
|
|
28
34
|
private isLoggedIn;
|
|
@@ -36,8 +42,8 @@ export default class APIBase {
|
|
|
36
42
|
onLogout: (reason?: string) => void;
|
|
37
43
|
});
|
|
38
44
|
private static fetchResult;
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
protected buildHeaders(extraHeaders?: Record<string, string>): Record<string, string>;
|
|
46
|
+
protected fetchResponse(endpoint: string, init: RequestInit): Promise<APIResult<Response>>;
|
|
41
47
|
/**
|
|
42
48
|
* @category Auth
|
|
43
49
|
*/
|
|
@@ -56,7 +62,22 @@ export default class APIBase {
|
|
|
56
62
|
expires: Date;
|
|
57
63
|
}>>;
|
|
58
64
|
private fetchFromEnter;
|
|
59
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Self-healing SSE stream with automatic reconnect, heartbeat watchdog, and
|
|
67
|
+
* cursor-based replay. Yields domain events as parsed JSON.
|
|
68
|
+
*
|
|
69
|
+
* Reconnect behaviour: when the connection drops (network error, server
|
|
70
|
+
* close, or watchdog timeout) the method sleeps with exponential backoff and
|
|
71
|
+
* jitter, then opens a new request. The last received SSE `id` is sent as
|
|
72
|
+
* `Last-Event-ID` so the server can replay missed events. Backoff resets
|
|
73
|
+
* after a connection that stayed alive long enough to be considered healthy.
|
|
74
|
+
*
|
|
75
|
+
* The loop exits without retrying on 401 (session expired — logout is
|
|
76
|
+
* handled by fetchResponse) or when the consumer breaks / the external
|
|
77
|
+
* signal fires.
|
|
78
|
+
*/
|
|
79
|
+
protected stream<T>(endpoint: string, options?: SubscribeOptions): AsyncGenerator<T>;
|
|
80
|
+
private readSseEvents;
|
|
60
81
|
protected get<Path extends keyof paths>(endpoint: Path, options: FetchOptions): Promise<APIResult<ResponseType<Path, 'get'>>>;
|
|
61
82
|
protected post<Path extends keyof paths>(endpoint: Path, data: RequestBody<Path, 'post'>, options: FetchOptions): Promise<APIResult<ResponseType<Path, 'post'>>>;
|
|
62
83
|
protected put<Path extends keyof paths>(endpoint: Path, data: RequestBody<Path, 'put'>, options: FetchOptions): Promise<APIResult<ResponseType<Path, 'put'>>>;
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import jws from 'jws';
|
|
2
2
|
import { EventSourceParserStream } from 'eventsource-parser/stream';
|
|
3
3
|
import { failure, success } from '../lib';
|
|
4
|
+
const BACKOFF_INITIAL_MS = 1_000;
|
|
5
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
6
|
+
const BACKOFF_RESET_AFTER_MS = 10_000;
|
|
7
|
+
const WATCHDOG_TIMEOUT_MS = 45_000;
|
|
8
|
+
function sleep(ms, signal) {
|
|
9
|
+
return new Promise(resolve => {
|
|
10
|
+
const timer = setTimeout(resolve, ms);
|
|
11
|
+
signal.addEventListener('abort', () => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
resolve();
|
|
14
|
+
}, {
|
|
15
|
+
once: true
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
4
19
|
export default class APIBase {
|
|
5
20
|
constructor(args) {
|
|
6
21
|
this.credentials = args.credentials;
|
|
@@ -135,21 +150,121 @@ export default class APIBase {
|
|
|
135
150
|
const json = await res.data.json();
|
|
136
151
|
return success(json);
|
|
137
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Self-healing SSE stream with automatic reconnect, heartbeat watchdog, and
|
|
156
|
+
* cursor-based replay. Yields domain events as parsed JSON.
|
|
157
|
+
*
|
|
158
|
+
* Reconnect behaviour: when the connection drops (network error, server
|
|
159
|
+
* close, or watchdog timeout) the method sleeps with exponential backoff and
|
|
160
|
+
* jitter, then opens a new request. The last received SSE `id` is sent as
|
|
161
|
+
* `Last-Event-ID` so the server can replay missed events. Backoff resets
|
|
162
|
+
* after a connection that stayed alive long enough to be considered healthy.
|
|
163
|
+
*
|
|
164
|
+
* The loop exits without retrying on 401 (session expired — logout is
|
|
165
|
+
* handled by fetchResponse) or when the consumer breaks / the external
|
|
166
|
+
* signal fires.
|
|
167
|
+
*/
|
|
138
168
|
async *stream(endpoint, options = {}) {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
169
|
+
const {
|
|
170
|
+
signal: externalSignal,
|
|
171
|
+
onReset,
|
|
172
|
+
onConnectionChange
|
|
173
|
+
} = options;
|
|
174
|
+
|
|
175
|
+
// outerAbort is the single switch that stops the whole reconnect loop.
|
|
176
|
+
// It is wired to the external signal and also fired in the finally block
|
|
177
|
+
// so that the sleep() in the backoff path is always interrupted on exit.
|
|
178
|
+
const outerAbort = new AbortController();
|
|
179
|
+
if (externalSignal?.aborted) return;
|
|
180
|
+
const onExternalAbort = () => outerAbort.abort();
|
|
181
|
+
externalSignal?.addEventListener('abort', onExternalAbort);
|
|
182
|
+
let lastEventId = options.initialLastEventId ?? null;
|
|
183
|
+
let backoffMs = BACKOFF_INITIAL_MS;
|
|
184
|
+
let isFirstConnect = true;
|
|
185
|
+
try {
|
|
186
|
+
while (!outerAbort.signal.aborted) {
|
|
187
|
+
// Fresh abort controller per attempt so the watchdog or a read error
|
|
188
|
+
// on one attempt cannot bleed into the next.
|
|
189
|
+
const attemptAbort = new AbortController();
|
|
190
|
+
const propagateAbort = () => attemptAbort.abort();
|
|
191
|
+
outerAbort.signal.addEventListener('abort', propagateAbort);
|
|
192
|
+
const hadCursor = lastEventId !== null;
|
|
193
|
+
const headers = {
|
|
194
|
+
Accept: 'text/event-stream'
|
|
195
|
+
};
|
|
196
|
+
// Sending Last-Event-ID lets the server replay events we missed while
|
|
197
|
+
// disconnected. If the cursor is unknown or too old, the server replies
|
|
198
|
+
// with event:reset instead of replaying.
|
|
199
|
+
if (hadCursor) headers['Last-Event-ID'] = lastEventId;
|
|
200
|
+
|
|
201
|
+
// connectedAt stays 0 when the attempt never reached a live stream,
|
|
202
|
+
// which prevents a spurious backoff reset on pure network failures.
|
|
203
|
+
let connectedAt = 0;
|
|
204
|
+
try {
|
|
205
|
+
const res = await this.fetchResponse(endpoint, {
|
|
206
|
+
method: 'GET',
|
|
207
|
+
headers: this.buildHeaders(headers),
|
|
208
|
+
signal: attemptAbort.signal
|
|
209
|
+
});
|
|
210
|
+
if (!res.success) {
|
|
211
|
+
const err = res.error[0];
|
|
212
|
+
// 401 means the session is gone — fetchResponse already called
|
|
213
|
+
// logout(), so stop retrying rather than hammering the server.
|
|
214
|
+
if (err?.type === 'http-error' && err.statusCode === 401) return;
|
|
215
|
+
// Any other failure (network error, 5xx, …) falls through to backoff.
|
|
216
|
+
} else if (res.data.body) {
|
|
217
|
+
connectedAt = Date.now();
|
|
218
|
+
onConnectionChange?.('connected');
|
|
219
|
+
// On a reconnect with no cursor we cannot know what events were
|
|
220
|
+
// missed, so tell the consumer to refetch their full state.
|
|
221
|
+
if (!isFirstConnect && !hadCursor) onReset?.();
|
|
222
|
+
isFirstConnect = false;
|
|
223
|
+
for await (const {
|
|
224
|
+
data,
|
|
225
|
+
id
|
|
226
|
+
} of this.readSseEvents(res.data.body, attemptAbort, onReset)) {
|
|
227
|
+
if (id) lastEventId = id;
|
|
228
|
+
yield data;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
// Covers fetch throwing (network error) and the stream aborting
|
|
233
|
+
// mid-read — both fall through to the backoff sleep below.
|
|
234
|
+
} finally {
|
|
235
|
+
outerAbort.signal.removeEventListener('abort', propagateAbort);
|
|
236
|
+
attemptAbort.abort();
|
|
237
|
+
}
|
|
238
|
+
if (outerAbort.signal.aborted) break;
|
|
239
|
+
|
|
240
|
+
// Reset backoff when the previous connection was healthy long enough;
|
|
241
|
+
// otherwise double it (with jitter) up to the cap.
|
|
242
|
+
const lived = connectedAt > 0 ? Date.now() - connectedAt : 0;
|
|
243
|
+
if (lived >= BACKOFF_RESET_AFTER_MS) backoffMs = BACKOFF_INITIAL_MS;
|
|
244
|
+
onConnectionChange?.('reconnecting');
|
|
245
|
+
await sleep(backoffMs, outerAbort.signal);
|
|
246
|
+
backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS) * (0.5 + Math.random());
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
// Aborting here also unblocks any in-progress sleep() call.
|
|
250
|
+
outerAbort.abort();
|
|
251
|
+
externalSignal?.removeEventListener('abort', onExternalAbort);
|
|
151
252
|
}
|
|
152
|
-
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Reads parsed SSE events from a single response body until the stream
|
|
256
|
+
// closes or the attemptAbort signal fires. Manages the heartbeat watchdog:
|
|
257
|
+
// if no message arrives within WATCHDOG_TIMEOUT_MS the connection is
|
|
258
|
+
// considered half-open (common on mobile networks) and is aborted so the
|
|
259
|
+
// outer loop can reconnect.
|
|
260
|
+
async *readSseEvents(body, attemptAbort, onReset) {
|
|
261
|
+
const reader = body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream()).getReader();
|
|
262
|
+
let watchdogTimer = null;
|
|
263
|
+
const resetWatchdog = () => {
|
|
264
|
+
if (watchdogTimer) clearTimeout(watchdogTimer);
|
|
265
|
+
watchdogTimer = setTimeout(() => attemptAbort.abort(), WATCHDOG_TIMEOUT_MS);
|
|
266
|
+
};
|
|
267
|
+
resetWatchdog();
|
|
153
268
|
try {
|
|
154
269
|
while (true) {
|
|
155
270
|
const {
|
|
@@ -157,15 +272,31 @@ export default class APIBase {
|
|
|
157
272
|
value: event
|
|
158
273
|
} = await reader.read();
|
|
159
274
|
if (done) break;
|
|
160
|
-
|
|
275
|
+
|
|
276
|
+
// Any message — including heartbeats — proves the connection is alive.
|
|
277
|
+
resetWatchdog();
|
|
278
|
+
|
|
279
|
+
// Heartbeats are only keepalives; never yield them to the consumer.
|
|
280
|
+
if (event.event === 'heartbeat') continue;
|
|
281
|
+
// The server sends reset when the requested cursor is unknown or too
|
|
282
|
+
// old. The consumer should refetch full state; we do not yield it.
|
|
283
|
+
if (event.event === 'reset') {
|
|
284
|
+
onReset?.();
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
161
287
|
try {
|
|
162
|
-
yield
|
|
288
|
+
yield {
|
|
289
|
+
data: JSON.parse(event.data),
|
|
290
|
+
id: event.id
|
|
291
|
+
};
|
|
163
292
|
} catch {
|
|
164
293
|
console.warn('[SSE] Failed to parse event data:', event.data);
|
|
165
294
|
}
|
|
166
295
|
}
|
|
167
296
|
} finally {
|
|
297
|
+
if (watchdogTimer) clearTimeout(watchdogTimer);
|
|
168
298
|
reader.releaseLock();
|
|
299
|
+
attemptAbort.abort();
|
|
169
300
|
}
|
|
170
301
|
}
|
|
171
302
|
async get(endpoint, options) {
|
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import APIBase from './api-base';
|
|
3
|
+
import API from './index';
|
|
4
|
+
class TestAPI extends APIBase {
|
|
5
|
+
async *subscribe(endpoint, options = {}) {
|
|
6
|
+
yield* this.stream(endpoint, options);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function makeAPI() {
|
|
10
|
+
return new TestAPI({
|
|
11
|
+
credentials: {
|
|
12
|
+
accessToken: 'token',
|
|
13
|
+
url: 'https://api.example.com'
|
|
14
|
+
},
|
|
15
|
+
requesterId: 'req-1',
|
|
16
|
+
deviceName: 'test',
|
|
17
|
+
onLogout: vi.fn()
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function sseChunk(messages) {
|
|
21
|
+
const text = messages.map(m => {
|
|
22
|
+
let s = '';
|
|
23
|
+
if (m.id !== undefined) s += `id: ${m.id}\n`;
|
|
24
|
+
if (m.event !== undefined) s += `event: ${m.event}\n`;
|
|
25
|
+
s += `data: ${m.data}\n\n`;
|
|
26
|
+
return s;
|
|
27
|
+
}).join('');
|
|
28
|
+
return new TextEncoder().encode(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Creates a stream that enqueues chunks but does NOT close automatically.
|
|
32
|
+
// Returns an explicit close() handle — the caller decides when the server
|
|
33
|
+
// "shuts the connection", making the cause of any reconnect visible at the
|
|
34
|
+
// call site rather than hidden inside a helper.
|
|
35
|
+
// If the abort signal fires, the stream errors so in-progress reads throw.
|
|
36
|
+
function makeStream(chunks, signal) {
|
|
37
|
+
let ctrl;
|
|
38
|
+
const stream = new ReadableStream({
|
|
39
|
+
start(controller) {
|
|
40
|
+
ctrl = controller;
|
|
41
|
+
if (signal?.aborted) {
|
|
42
|
+
controller.error(new DOMException('Aborted', 'AbortError'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
signal?.addEventListener('abort', () => controller.error(new DOMException('Aborted', 'AbortError')), {
|
|
46
|
+
once: true
|
|
47
|
+
});
|
|
48
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
stream,
|
|
53
|
+
push: chunk => ctrl.enqueue(chunk),
|
|
54
|
+
close: () => ctrl.close()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Stream that hangs until the signal fires — intentionally never closed.
|
|
59
|
+
function makeSilentStream(signal) {
|
|
60
|
+
return makeStream([], signal).stream;
|
|
61
|
+
}
|
|
62
|
+
function okResponse(body) {
|
|
63
|
+
return new Response(body, {
|
|
64
|
+
status: 200
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function unauthorizedResponse() {
|
|
68
|
+
return new Response(null, {
|
|
69
|
+
status: 401,
|
|
70
|
+
statusText: 'Unauthorized'
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Flush the microtask queue by yielding many times.
|
|
75
|
+
async function flushMicrotasks(rounds = 30) {
|
|
76
|
+
for (let i = 0; i < rounds; i++) await Promise.resolve();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Flush pending microtasks (so the generator reaches its sleep() call and
|
|
80
|
+
// registers the setTimeout), then advance fake timers by ms, then flush again
|
|
81
|
+
// so the generator processes the reconnect before we assert.
|
|
82
|
+
async function advanceTime(ms) {
|
|
83
|
+
await flushMicrotasks();
|
|
84
|
+
await vi.advanceTimersByTimeAsync(ms);
|
|
85
|
+
await flushMicrotasks();
|
|
86
|
+
}
|
|
87
|
+
describe('resilientStream', () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.useFakeTimers();
|
|
90
|
+
});
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
vi.useRealTimers();
|
|
93
|
+
vi.restoreAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─── basic event delivery ────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
it('yields parsed JSON events', async () => {
|
|
99
|
+
const payload = {
|
|
100
|
+
type: 'attendee.updated',
|
|
101
|
+
id: '42'
|
|
102
|
+
};
|
|
103
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
104
|
+
const {
|
|
105
|
+
stream
|
|
106
|
+
} = makeStream([sseChunk([{
|
|
107
|
+
data: JSON.stringify(payload)
|
|
108
|
+
}])], init.signal);
|
|
109
|
+
return Promise.resolve(okResponse(stream));
|
|
110
|
+
});
|
|
111
|
+
const abortController = new AbortController();
|
|
112
|
+
const api = makeAPI();
|
|
113
|
+
const gen = api.subscribe('/test', {
|
|
114
|
+
signal: abortController.signal
|
|
115
|
+
});
|
|
116
|
+
const response = await gen.next();
|
|
117
|
+
expect(response.value).toEqual(payload);
|
|
118
|
+
expect(response.done).toBe(false);
|
|
119
|
+
abortController.abort();
|
|
120
|
+
await gen.return(undefined);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── reconnect after stream end ──────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
it('reconnects after stream ends normally', async () => {
|
|
126
|
+
const event1 = {
|
|
127
|
+
n: 1
|
|
128
|
+
};
|
|
129
|
+
const event2 = {
|
|
130
|
+
n: 2
|
|
131
|
+
};
|
|
132
|
+
const events = [event1, event2];
|
|
133
|
+
let connectionCount = 0;
|
|
134
|
+
let closeStream;
|
|
135
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
136
|
+
connectionCount++;
|
|
137
|
+
const {
|
|
138
|
+
stream,
|
|
139
|
+
close
|
|
140
|
+
} = makeStream([sseChunk([{
|
|
141
|
+
data: JSON.stringify(events.shift())
|
|
142
|
+
}])], init.signal);
|
|
143
|
+
closeStream = close;
|
|
144
|
+
return Promise.resolve(okResponse(stream));
|
|
145
|
+
});
|
|
146
|
+
const abortController = new AbortController();
|
|
147
|
+
const api = makeAPI();
|
|
148
|
+
const gen = api.subscribe('/test', {
|
|
149
|
+
signal: abortController.signal
|
|
150
|
+
});
|
|
151
|
+
const response1 = await gen.next();
|
|
152
|
+
expect(response1.value).toEqual(event1);
|
|
153
|
+
|
|
154
|
+
// Closing the stream is what triggers the reconnect
|
|
155
|
+
// the generator exits the read loop and enters backoff.
|
|
156
|
+
closeStream();
|
|
157
|
+
|
|
158
|
+
// Dont await here so we can advance time and dont have to wait for backoff
|
|
159
|
+
const response2Promise = gen.next();
|
|
160
|
+
await advanceTime(3000); // past max jittered initial backoff
|
|
161
|
+
const response2 = await response2Promise;
|
|
162
|
+
expect(response2.value).toEqual(event2);
|
|
163
|
+
expect(connectionCount).toBe(2);
|
|
164
|
+
abortController.abort();
|
|
165
|
+
await gen.return(undefined);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ─── reconnect after fetch error ─────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
it('reconnects after fetch network error', async () => {
|
|
171
|
+
const event = {
|
|
172
|
+
ok: true
|
|
173
|
+
};
|
|
174
|
+
let connectionCount = 0;
|
|
175
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
176
|
+
connectionCount++;
|
|
177
|
+
if (connectionCount === 1) return Promise.reject(new Error('Network failure'));
|
|
178
|
+
const {
|
|
179
|
+
stream
|
|
180
|
+
} = makeStream([sseChunk([{
|
|
181
|
+
data: JSON.stringify(event)
|
|
182
|
+
}])], init.signal);
|
|
183
|
+
return Promise.resolve(okResponse(stream));
|
|
184
|
+
});
|
|
185
|
+
const abortController = new AbortController();
|
|
186
|
+
const api = makeAPI();
|
|
187
|
+
const gen = api.subscribe('/test', {
|
|
188
|
+
signal: abortController.signal
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// fails on first attempt and hides retry, but takes some backoff time before retrying
|
|
192
|
+
const response1Promise = gen.next();
|
|
193
|
+
await advanceTime(3000);
|
|
194
|
+
const response1 = await response1Promise;
|
|
195
|
+
expect(response1.value).toEqual(event);
|
|
196
|
+
expect(connectionCount).toBe(2);
|
|
197
|
+
abortController.abort();
|
|
198
|
+
await gen.return(undefined);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ─── Last-Event-ID ───────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
it('sends Last-Event-ID header on reconnect', async () => {
|
|
204
|
+
const event = {
|
|
205
|
+
x: 1
|
|
206
|
+
};
|
|
207
|
+
const capturedInits = [];
|
|
208
|
+
// First connection delivers one event with a cursor; second is silent.
|
|
209
|
+
const streamChunks = [[sseChunk([{
|
|
210
|
+
id: 'cursor-99',
|
|
211
|
+
data: JSON.stringify(event)
|
|
212
|
+
}])], []];
|
|
213
|
+
let closeStream;
|
|
214
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
215
|
+
capturedInits.push(init);
|
|
216
|
+
const {
|
|
217
|
+
stream,
|
|
218
|
+
close
|
|
219
|
+
} = makeStream(streamChunks.shift() ?? [], init.signal);
|
|
220
|
+
closeStream = close;
|
|
221
|
+
return Promise.resolve(okResponse(stream));
|
|
222
|
+
});
|
|
223
|
+
const abortController = new AbortController();
|
|
224
|
+
const api = makeAPI();
|
|
225
|
+
const gen = api.subscribe('/test', {
|
|
226
|
+
signal: abortController.signal
|
|
227
|
+
});
|
|
228
|
+
const response1 = await gen.next();
|
|
229
|
+
expect(response1.value).toEqual(event);
|
|
230
|
+
|
|
231
|
+
// Close first stream — reconnect trigger
|
|
232
|
+
closeStream();
|
|
233
|
+
const response2Promise = gen.next();
|
|
234
|
+
await advanceTime(3000);
|
|
235
|
+
expect(capturedInits.length).toBeGreaterThanOrEqual(2);
|
|
236
|
+
const secondHeaders = capturedInits[1]?.headers;
|
|
237
|
+
expect(secondHeaders?.['Last-Event-ID']).toBe('cursor-99');
|
|
238
|
+
abortController.abort();
|
|
239
|
+
await response2Promise;
|
|
240
|
+
});
|
|
241
|
+
it('does not send Last-Event-ID on first connect', async () => {
|
|
242
|
+
const capturedInits = [];
|
|
243
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
244
|
+
capturedInits.push(init);
|
|
245
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
246
|
+
});
|
|
247
|
+
const abortController = new AbortController();
|
|
248
|
+
const api = makeAPI();
|
|
249
|
+
const pending = api.subscribe('/test', {
|
|
250
|
+
signal: abortController.signal
|
|
251
|
+
}).next();
|
|
252
|
+
await flushMicrotasks();
|
|
253
|
+
const firstHeaders = capturedInits[0]?.headers;
|
|
254
|
+
expect(firstHeaders?.['Last-Event-ID']).toBeUndefined();
|
|
255
|
+
abortController.abort();
|
|
256
|
+
await pending;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ─── heartbeat watchdog ───────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
it('fires watchdog after 45s of silence and triggers reconnect', async () => {
|
|
262
|
+
let connectionCount = 0;
|
|
263
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
264
|
+
connectionCount++;
|
|
265
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
266
|
+
});
|
|
267
|
+
const abortController = new AbortController();
|
|
268
|
+
const api = makeAPI();
|
|
269
|
+
const gen = api.subscribe('/test', {
|
|
270
|
+
signal: abortController.signal
|
|
271
|
+
});
|
|
272
|
+
const nextPromise = gen.next();
|
|
273
|
+
await flushMicrotasks();
|
|
274
|
+
expect(connectionCount).toBe(1);
|
|
275
|
+
|
|
276
|
+
// Fire the 45s watchdog then let the backoff sleep through
|
|
277
|
+
await advanceTime(45_001);
|
|
278
|
+
await advanceTime(3_000);
|
|
279
|
+
expect(connectionCount).toBeGreaterThanOrEqual(2);
|
|
280
|
+
abortController.abort();
|
|
281
|
+
await nextPromise;
|
|
282
|
+
});
|
|
283
|
+
it('heartbeats reset the watchdog so it does not fire during activity', async () => {
|
|
284
|
+
const realEvent = {
|
|
285
|
+
data: 'live'
|
|
286
|
+
};
|
|
287
|
+
let connectionCount = 0;
|
|
288
|
+
let pushChunk;
|
|
289
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
290
|
+
connectionCount++;
|
|
291
|
+
const {
|
|
292
|
+
stream,
|
|
293
|
+
push
|
|
294
|
+
} = makeStream([], init.signal);
|
|
295
|
+
pushChunk = push;
|
|
296
|
+
return Promise.resolve(okResponse(stream));
|
|
297
|
+
});
|
|
298
|
+
const abortController = new AbortController();
|
|
299
|
+
const api = makeAPI();
|
|
300
|
+
const gen = api.subscribe('/test', {
|
|
301
|
+
signal: abortController.signal
|
|
302
|
+
});
|
|
303
|
+
const responsePromise = gen.next();
|
|
304
|
+
await flushMicrotasks();
|
|
305
|
+
|
|
306
|
+
// Advance to just under the 45s watchdog — it has not fired yet
|
|
307
|
+
await advanceTime(44_000);
|
|
308
|
+
expect(connectionCount).toBe(1);
|
|
309
|
+
|
|
310
|
+
// Heartbeat arrives and resets the watchdog to another 45s from now
|
|
311
|
+
pushChunk(sseChunk([{
|
|
312
|
+
event: 'heartbeat',
|
|
313
|
+
data: '{}'
|
|
314
|
+
}]));
|
|
315
|
+
await flushMicrotasks();
|
|
316
|
+
|
|
317
|
+
// Advance another 44s — total 88s elapsed but watchdog was reset at 44s,
|
|
318
|
+
// so it still hasn't fired
|
|
319
|
+
await advanceTime(44_000);
|
|
320
|
+
expect(connectionCount).toBe(1);
|
|
321
|
+
|
|
322
|
+
// Real event arrives — generator yields it
|
|
323
|
+
pushChunk(sseChunk([{
|
|
324
|
+
data: JSON.stringify(realEvent)
|
|
325
|
+
}]));
|
|
326
|
+
const response = await responsePromise;
|
|
327
|
+
expect(response.value).toEqual(realEvent);
|
|
328
|
+
expect(connectionCount).toBe(1);
|
|
329
|
+
abortController.abort();
|
|
330
|
+
await gen.return(undefined);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ─── heartbeat filtering ──────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
it('never yields heartbeat events to the consumer', async () => {
|
|
336
|
+
const realEvent = {
|
|
337
|
+
type: 'real'
|
|
338
|
+
};
|
|
339
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
340
|
+
const {
|
|
341
|
+
stream
|
|
342
|
+
} = makeStream([sseChunk([{
|
|
343
|
+
event: 'heartbeat',
|
|
344
|
+
data: '{}'
|
|
345
|
+
}, {
|
|
346
|
+
data: JSON.stringify(realEvent)
|
|
347
|
+
}, {
|
|
348
|
+
event: 'heartbeat',
|
|
349
|
+
data: '{}'
|
|
350
|
+
}])], init.signal);
|
|
351
|
+
return Promise.resolve(okResponse(stream));
|
|
352
|
+
});
|
|
353
|
+
const abortController = new AbortController();
|
|
354
|
+
const api = makeAPI();
|
|
355
|
+
const gen = api.subscribe('/test', {
|
|
356
|
+
signal: abortController.signal
|
|
357
|
+
});
|
|
358
|
+
const response = await gen.next();
|
|
359
|
+
expect(response.value).toEqual(realEvent);
|
|
360
|
+
abortController.abort();
|
|
361
|
+
await gen.return(undefined);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ─── reset handling ───────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
it('calls onReset for server reset event and does not yield it', async () => {
|
|
367
|
+
const realEvent = {
|
|
368
|
+
type: 'real'
|
|
369
|
+
};
|
|
370
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
371
|
+
const {
|
|
372
|
+
stream
|
|
373
|
+
} = makeStream([sseChunk([{
|
|
374
|
+
event: 'reset',
|
|
375
|
+
data: '{}'
|
|
376
|
+
}, {
|
|
377
|
+
data: JSON.stringify(realEvent)
|
|
378
|
+
}])], init.signal);
|
|
379
|
+
return Promise.resolve(okResponse(stream));
|
|
380
|
+
});
|
|
381
|
+
const onReset = vi.fn();
|
|
382
|
+
const abortController = new AbortController();
|
|
383
|
+
const api = makeAPI();
|
|
384
|
+
const gen = api.subscribe('/test', {
|
|
385
|
+
onReset,
|
|
386
|
+
signal: abortController.signal
|
|
387
|
+
});
|
|
388
|
+
const response = await gen.next();
|
|
389
|
+
expect(response.value).toEqual(realEvent);
|
|
390
|
+
expect(onReset).toHaveBeenCalledTimes(1);
|
|
391
|
+
abortController.abort();
|
|
392
|
+
await gen.return(undefined);
|
|
393
|
+
});
|
|
394
|
+
it('calls onReset when reconnecting without a stored cursor', async () => {
|
|
395
|
+
// No id fields → cursor never stored → onReset fires on reconnect
|
|
396
|
+
const events = ['{"n":1}', '{"n":2}'];
|
|
397
|
+
let closeStream;
|
|
398
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
399
|
+
const {
|
|
400
|
+
stream,
|
|
401
|
+
close
|
|
402
|
+
} = makeStream([sseChunk([{
|
|
403
|
+
data: events.shift() ?? ''
|
|
404
|
+
}])], init.signal);
|
|
405
|
+
closeStream = close;
|
|
406
|
+
return Promise.resolve(okResponse(stream));
|
|
407
|
+
});
|
|
408
|
+
const onReset = vi.fn();
|
|
409
|
+
const abortController = new AbortController();
|
|
410
|
+
const api = makeAPI();
|
|
411
|
+
const gen = api.subscribe('/test', {
|
|
412
|
+
onReset,
|
|
413
|
+
signal: abortController.signal
|
|
414
|
+
});
|
|
415
|
+
const response1 = await gen.next();
|
|
416
|
+
expect(response1.value).toEqual({
|
|
417
|
+
n: 1
|
|
418
|
+
});
|
|
419
|
+
expect(onReset).not.toHaveBeenCalled(); // no onReset on first connect
|
|
420
|
+
|
|
421
|
+
// Closing the first stream (no cursor stored) triggers onReset on reconnect
|
|
422
|
+
closeStream();
|
|
423
|
+
const response2Promise = gen.next();
|
|
424
|
+
await advanceTime(3000);
|
|
425
|
+
const response2 = await response2Promise;
|
|
426
|
+
expect(response2.value).toEqual({
|
|
427
|
+
n: 2
|
|
428
|
+
});
|
|
429
|
+
expect(onReset).toHaveBeenCalledTimes(1);
|
|
430
|
+
abortController.abort();
|
|
431
|
+
await gen.return(undefined);
|
|
432
|
+
});
|
|
433
|
+
it('does not call onReset on the first connect', async () => {
|
|
434
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
435
|
+
const {
|
|
436
|
+
stream
|
|
437
|
+
} = makeStream([sseChunk([{
|
|
438
|
+
data: '{"n":1}'
|
|
439
|
+
}])], init.signal);
|
|
440
|
+
return Promise.resolve(okResponse(stream));
|
|
441
|
+
});
|
|
442
|
+
const onReset = vi.fn();
|
|
443
|
+
const abortController = new AbortController();
|
|
444
|
+
const api = makeAPI();
|
|
445
|
+
const gen = api.subscribe('/test', {
|
|
446
|
+
onReset,
|
|
447
|
+
signal: abortController.signal
|
|
448
|
+
});
|
|
449
|
+
await gen.next();
|
|
450
|
+
expect(onReset).not.toHaveBeenCalled();
|
|
451
|
+
abortController.abort();
|
|
452
|
+
await gen.return(undefined);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ─── clean termination ────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
it('does not reconnect after consumer break', async () => {
|
|
458
|
+
let connectionCount = 0;
|
|
459
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
460
|
+
connectionCount++;
|
|
461
|
+
const {
|
|
462
|
+
stream
|
|
463
|
+
} = makeStream([sseChunk([{
|
|
464
|
+
data: '{"n":1}'
|
|
465
|
+
}, {
|
|
466
|
+
data: '{"n":2}'
|
|
467
|
+
}])], init.signal);
|
|
468
|
+
return Promise.resolve(okResponse(stream));
|
|
469
|
+
});
|
|
470
|
+
const api = makeAPI();
|
|
471
|
+
const gen = api.subscribe('/test');
|
|
472
|
+
const response1 = await gen.next();
|
|
473
|
+
expect(response1.value.n).toBe(1);
|
|
474
|
+
|
|
475
|
+
// Consumer breaks — generator terminates cleanly; no server close needed
|
|
476
|
+
await gen.return(undefined);
|
|
477
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
478
|
+
await flushMicrotasks();
|
|
479
|
+
expect(connectionCount).toBe(1);
|
|
480
|
+
});
|
|
481
|
+
it('does not reconnect when external AbortSignal is fired', async () => {
|
|
482
|
+
let connectionCount = 0;
|
|
483
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
484
|
+
connectionCount++;
|
|
485
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
486
|
+
});
|
|
487
|
+
const abortController = new AbortController();
|
|
488
|
+
const api = makeAPI();
|
|
489
|
+
const pending = api.subscribe('/test', {
|
|
490
|
+
signal: abortController.signal
|
|
491
|
+
}).next();
|
|
492
|
+
await flushMicrotasks();
|
|
493
|
+
expect(connectionCount).toBe(1);
|
|
494
|
+
abortController.abort();
|
|
495
|
+
await pending; // resolves as {done: true}
|
|
496
|
+
|
|
497
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
498
|
+
await flushMicrotasks();
|
|
499
|
+
expect(connectionCount).toBe(1);
|
|
500
|
+
});
|
|
501
|
+
it('does not reconnect after a 401 response', async () => {
|
|
502
|
+
let connectionCount = 0;
|
|
503
|
+
vi.stubGlobal('fetch', () => {
|
|
504
|
+
connectionCount++;
|
|
505
|
+
return Promise.resolve(unauthorizedResponse());
|
|
506
|
+
});
|
|
507
|
+
const api = makeAPI();
|
|
508
|
+
const gen = api.subscribe('/test');
|
|
509
|
+
const response = await gen.next();
|
|
510
|
+
expect(response.done).toBe(true);
|
|
511
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
512
|
+
await flushMicrotasks();
|
|
513
|
+
expect(connectionCount).toBe(1);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ─── backoff behaviour ────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
it('backoff grows across successive reconnects', async () => {
|
|
519
|
+
// Pin jitter to maximum (Math.random() = 1 → multiplier = 1.5) so timing is deterministic:
|
|
520
|
+
// 1st sleep = 1_000 ms (initial backoffMs, jitter applied after)
|
|
521
|
+
// 2nd sleep = Math.min(1_000 × 2, 30_000) × 1.5 = 3_000 ms
|
|
522
|
+
vi.spyOn(Math, 'random').mockReturnValue(1);
|
|
523
|
+
let connectionCount = 0;
|
|
524
|
+
let closeStream;
|
|
525
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
526
|
+
connectionCount++;
|
|
527
|
+
const {
|
|
528
|
+
stream,
|
|
529
|
+
close
|
|
530
|
+
} = makeStream([], init.signal);
|
|
531
|
+
closeStream = close;
|
|
532
|
+
return Promise.resolve(okResponse(stream));
|
|
533
|
+
});
|
|
534
|
+
const abortController = new AbortController();
|
|
535
|
+
const api = makeAPI();
|
|
536
|
+
const gen = api.subscribe('/test', {
|
|
537
|
+
signal: abortController.signal
|
|
538
|
+
});
|
|
539
|
+
gen.next();
|
|
540
|
+
await flushMicrotasks();
|
|
541
|
+
|
|
542
|
+
// 1st disconnect → 1_000 ms backoff
|
|
543
|
+
closeStream();
|
|
544
|
+
await advanceTime(999);
|
|
545
|
+
expect(connectionCount).toBe(1); // not yet
|
|
546
|
+
|
|
547
|
+
await advanceTime(2); // crosses 1_000 ms
|
|
548
|
+
expect(connectionCount).toBe(2);
|
|
549
|
+
|
|
550
|
+
// 2nd disconnect → 3_000 ms backoff (grew)
|
|
551
|
+
closeStream();
|
|
552
|
+
await advanceTime(2_999);
|
|
553
|
+
expect(connectionCount).toBe(2); // not yet
|
|
554
|
+
|
|
555
|
+
await advanceTime(2); // crosses 3_000 ms
|
|
556
|
+
expect(connectionCount).toBe(3);
|
|
557
|
+
abortController.abort();
|
|
558
|
+
await gen.return(undefined);
|
|
559
|
+
});
|
|
560
|
+
it('resets backoff after a connection that lived longer than the threshold', async () => {
|
|
561
|
+
let connectionCount = 0;
|
|
562
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
563
|
+
connectionCount++;
|
|
564
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
565
|
+
});
|
|
566
|
+
const abortController = new AbortController();
|
|
567
|
+
const api = makeAPI();
|
|
568
|
+
const gen = api.subscribe('/test', {
|
|
569
|
+
signal: abortController.signal
|
|
570
|
+
});
|
|
571
|
+
gen.next();
|
|
572
|
+
await flushMicrotasks();
|
|
573
|
+
expect(connectionCount).toBe(1);
|
|
574
|
+
|
|
575
|
+
// Watchdog fires at 45 s (connection lived > 10 s threshold → backoff resets)
|
|
576
|
+
await advanceTime(45_001);
|
|
577
|
+
|
|
578
|
+
// Backoff should have been reset to ~1 s; advance 2 s to cover jitter
|
|
579
|
+
await advanceTime(2_000);
|
|
580
|
+
|
|
581
|
+
// Second fetch triggered
|
|
582
|
+
expect(connectionCount).toBeGreaterThanOrEqual(2);
|
|
583
|
+
abortController.abort();
|
|
584
|
+
await gen.return(undefined);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ─── connection state callbacks ───────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
it('emits connected then reconnecting on connection loss', async () => {
|
|
590
|
+
// First connection delivers one event; second is silent (only the state
|
|
591
|
+
// sequence matters here, not a second event).
|
|
592
|
+
const streamChunks = [[sseChunk([{
|
|
593
|
+
data: '{"n":1}'
|
|
594
|
+
}])], []];
|
|
595
|
+
let closeStream;
|
|
596
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
597
|
+
const {
|
|
598
|
+
stream,
|
|
599
|
+
close
|
|
600
|
+
} = makeStream(streamChunks.shift() ?? [], init.signal);
|
|
601
|
+
closeStream = close;
|
|
602
|
+
return Promise.resolve(okResponse(stream));
|
|
603
|
+
});
|
|
604
|
+
const states = [];
|
|
605
|
+
const abortController = new AbortController();
|
|
606
|
+
const api = makeAPI();
|
|
607
|
+
const gen = api.subscribe('/test', {
|
|
608
|
+
signal: abortController.signal,
|
|
609
|
+
onConnectionChange: s => states.push(s)
|
|
610
|
+
});
|
|
611
|
+
const response1 = await gen.next();
|
|
612
|
+
expect(response1.value).toEqual({
|
|
613
|
+
n: 1
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Closing the first stream triggers 'reconnecting', then 'connected' on the second
|
|
617
|
+
closeStream();
|
|
618
|
+
const response2Promise = gen.next();
|
|
619
|
+
await advanceTime(3000);
|
|
620
|
+
expect(states).toContain('connected');
|
|
621
|
+
expect(states).toContain('reconnecting');
|
|
622
|
+
expect(states.indexOf('connected')).toBeLessThan(states.indexOf('reconnecting'));
|
|
623
|
+
abortController.abort();
|
|
624
|
+
await response2Promise;
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// ─── initialLastEventId ───────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
it('sends initialLastEventId as Last-Event-ID on the very first connect', async () => {
|
|
630
|
+
const capturedInits = [];
|
|
631
|
+
vi.stubGlobal('fetch', (_url, init) => {
|
|
632
|
+
capturedInits.push(init);
|
|
633
|
+
return Promise.resolve(okResponse(makeSilentStream(init.signal)));
|
|
634
|
+
});
|
|
635
|
+
const abortController = new AbortController();
|
|
636
|
+
const api = makeAPI();
|
|
637
|
+
const pending = api.subscribe('/test', {
|
|
638
|
+
signal: abortController.signal,
|
|
639
|
+
initialLastEventId: 'seed-cursor'
|
|
640
|
+
}).next();
|
|
641
|
+
await flushMicrotasks();
|
|
642
|
+
const firstHeaders = capturedInits[0]?.headers;
|
|
643
|
+
expect(firstHeaders?.['Last-Event-ID']).toBe('seed-cursor');
|
|
644
|
+
abortController.abort();
|
|
645
|
+
await pending;
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
function makeFullAPI() {
|
|
649
|
+
return new API({
|
|
650
|
+
credentials: {
|
|
651
|
+
accessToken: 'token',
|
|
652
|
+
url: 'https://api.example.com'
|
|
653
|
+
},
|
|
654
|
+
requesterId: 'req-1',
|
|
655
|
+
deviceName: 'test',
|
|
656
|
+
onLogout: vi.fn()
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
describe('getAttendeeList', () => {
|
|
660
|
+
afterEach(() => {
|
|
661
|
+
vi.restoreAllMocks();
|
|
662
|
+
});
|
|
663
|
+
it('returns eventCursor: null when X-Event-Cursor header is absent', async () => {
|
|
664
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify([{
|
|
665
|
+
id: 'att-1'
|
|
666
|
+
}]), {
|
|
667
|
+
status: 200
|
|
668
|
+
})));
|
|
669
|
+
const api = makeFullAPI();
|
|
670
|
+
const result = await api.getAttendeeList({
|
|
671
|
+
instanceName: 'test-instance'
|
|
672
|
+
});
|
|
673
|
+
expect(result.success).toBe(true);
|
|
674
|
+
if (!result.success) return;
|
|
675
|
+
expect(result.data.eventCursor).toBeNull();
|
|
676
|
+
expect(result.data.attendees).toHaveLength(1);
|
|
677
|
+
expect(result.data.attendees[0].id).toBe('att-1');
|
|
678
|
+
});
|
|
679
|
+
it('returns eventCursor from X-Event-Cursor response header', async () => {
|
|
680
|
+
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify([{
|
|
681
|
+
id: 'att-1'
|
|
682
|
+
}]), {
|
|
683
|
+
status: 200,
|
|
684
|
+
headers: {
|
|
685
|
+
'X-Event-Cursor': 'abc123'
|
|
686
|
+
}
|
|
687
|
+
})));
|
|
688
|
+
const api = makeFullAPI();
|
|
689
|
+
const result = await api.getAttendeeList({
|
|
690
|
+
instanceName: 'test-instance'
|
|
691
|
+
});
|
|
692
|
+
expect(result.success).toBe(true);
|
|
693
|
+
if (!result.success) return;
|
|
694
|
+
expect(result.data.eventCursor).toBe('abc123');
|
|
695
|
+
});
|
|
696
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import APIBase, { FetchOptions, RequestBody } from './api-base';
|
|
1
|
+
import APIBase, { FetchOptions, RequestBody, SubscribeOptions } from './api-base';
|
|
2
2
|
import { APIResult } from '../lib';
|
|
3
3
|
import { Attendee } from '../models/attendee';
|
|
4
4
|
import { Event, EventInput } from '../models';
|
|
@@ -9,7 +9,7 @@ import { User } from '../models/user';
|
|
|
9
9
|
import { Variable } from '../models/variable';
|
|
10
10
|
import { components } from '../generated/api-schema';
|
|
11
11
|
import { Email } from '../models/email';
|
|
12
|
-
export type { APICredentials } from './api-base';
|
|
12
|
+
export type { APICredentials, SubscribeOptions } from './api-base';
|
|
13
13
|
/**
|
|
14
14
|
* api client for the enter backend
|
|
15
15
|
*/
|
|
@@ -73,7 +73,10 @@ declare class API extends APIBase {
|
|
|
73
73
|
instanceName: string;
|
|
74
74
|
filter?: string;
|
|
75
75
|
includeDeleted?: boolean;
|
|
76
|
-
}, options?: FetchOptions): Promise<APIResult<
|
|
76
|
+
}, options?: FetchOptions): Promise<APIResult<{
|
|
77
|
+
attendees: Attendee[];
|
|
78
|
+
eventCursor: string | null;
|
|
79
|
+
}>>;
|
|
77
80
|
/**
|
|
78
81
|
* @category Attendees
|
|
79
82
|
*/
|
|
@@ -175,6 +178,6 @@ declare class API extends APIBase {
|
|
|
175
178
|
subscribeToEvents(args: {
|
|
176
179
|
instanceName: string;
|
|
177
180
|
eventType?: string[];
|
|
178
|
-
}, options?:
|
|
181
|
+
}, options?: SubscribeOptions): AsyncGenerator<Event>;
|
|
179
182
|
}
|
|
180
183
|
export default API;
|
package/dist/api-client/index.js
CHANGED
|
@@ -86,11 +86,20 @@ class API extends APIBase {
|
|
|
86
86
|
if (args.includeDeleted) {
|
|
87
87
|
query.append('include_deleted', 'true');
|
|
88
88
|
}
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const result = await this.
|
|
89
|
+
const qs = query.toString();
|
|
90
|
+
const endpoint = `/instances/${args.instanceName}/attendees${qs ? `?${qs}` : ''}`;
|
|
91
|
+
const result = await this.fetchResponse(endpoint, {
|
|
92
|
+
method: 'GET',
|
|
93
|
+
headers: this.buildHeaders(),
|
|
94
|
+
...options
|
|
95
|
+
});
|
|
92
96
|
if (!result.success) return result;
|
|
93
|
-
|
|
97
|
+
const data = await result.data.json();
|
|
98
|
+
const eventCursor = result.data.headers.get('X-Event-Cursor');
|
|
99
|
+
return success({
|
|
100
|
+
attendees: data.map(attendeeJSON => new Attendee(attendeeJSON)),
|
|
101
|
+
eventCursor: eventCursor ?? null
|
|
102
|
+
});
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-crew-berlin/enter-js-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.98.9",
|
|
4
4
|
"description": "utils such as vaildation and other helpers to work with data from the enter app",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"prepare": "husky install && npm run build",
|
|
20
20
|
"build": "rm -r dist & babel src --extensions '.ts','.tsx' -d dist && tsc -d",
|
|
21
21
|
"lint": "eslint src/ --ext .ts",
|
|
22
|
-
"test": "
|
|
22
|
+
"test": "vitest run",
|
|
23
23
|
"storybook": "storybook dev -p 6006",
|
|
24
24
|
"build-storybook": "storybook build",
|
|
25
25
|
"generate-client": "openapi-typescript https://api.dev.enter.events/openapi.json --output src/generated/api-schema.ts --empty-objects-unknown --export-type",
|
|
@@ -55,22 +55,22 @@
|
|
|
55
55
|
"@storybook/test": "^8.5.8",
|
|
56
56
|
"@types/cookie": "^0.6.0",
|
|
57
57
|
"@types/jws": "^3.2.9",
|
|
58
|
-
"@types/node": "^
|
|
58
|
+
"@types/node": "^22.19.21",
|
|
59
59
|
"@types/react": "^18.3.3",
|
|
60
60
|
"@types/react-dom": "^18.3.0",
|
|
61
|
-
"@types/uuid": "^8.3.4",
|
|
62
61
|
"@typescript-eslint/eslint-plugin": "^5.27.0",
|
|
63
62
|
"@typescript-eslint/parser": "^5.27.0",
|
|
64
63
|
"eslint": "^8.16.0",
|
|
65
64
|
"eslint-config-prettier": "^8.5.0",
|
|
66
65
|
"husky": "^7.0.1",
|
|
67
|
-
"openapi-typescript": "^
|
|
66
|
+
"openapi-typescript": "^7.13.0",
|
|
68
67
|
"prettier": "^2.6.2",
|
|
69
68
|
"react": "^19.0.0",
|
|
70
69
|
"react-dom": "^19.0.0",
|
|
71
70
|
"typedoc": "^0.27.8",
|
|
72
71
|
"typedoc-plugin-rename-defaults": "^0.7.2",
|
|
73
|
-
"typescript": "^5.7.3"
|
|
72
|
+
"typescript": "^5.7.3",
|
|
73
|
+
"vitest": "^4.1.8"
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
76
|
"react": "^19.0.0",
|
|
@@ -82,6 +82,6 @@
|
|
|
82
82
|
"jws": "^4.0.0",
|
|
83
83
|
"stable-hash": "^0.0.5",
|
|
84
84
|
"styled-jsx": "^5.1.6",
|
|
85
|
-
"uuid": "^
|
|
85
|
+
"uuid": "^14.0.0"
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { Button } from './button';
|
|
3
|
-
declare const meta: Meta<typeof Button>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof Button>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const FullWidth: Story;
|
|
8
|
-
export declare const Primary: Story;
|
|
9
|
-
export declare const Danger: Story;
|
|
10
|
-
export declare const Green: Story;
|
|
11
|
-
export declare const Borderless: Story;
|
|
12
|
-
export declare const BorderlessPrimary: Story;
|
|
13
|
-
export declare const BorderlessDanger: Story;
|
|
14
|
-
export declare const BorderlessGreen: Story;
|
|
15
|
-
export declare const Disabled: Story;
|
|
16
|
-
export declare const OnePrimaryAndTwoBorderless: Story;
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { CheckinCountIndicator } from './checkin-count-indicator';
|
|
3
|
-
declare const meta: Meta<typeof CheckinCountIndicator>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof CheckinCountIndicator>;
|
|
6
|
-
export declare const SingleResponse: Story;
|
|
7
|
-
export declare const Decline: Story;
|
|
8
|
-
export declare const NoResponse: Story;
|
|
9
|
-
export declare const MultipleResponses: Story;
|
|
10
|
-
export declare const CheckinAndResponse: Story;
|
|
11
|
-
export declare const MultipleCheckinsAndResponse: Story;
|
|
12
|
-
export declare const CheckinsWithDecline: Story;
|
|
13
|
-
export declare const CheckinWithoutResponse: Story;
|
|
14
|
-
export declare const MultipleCheckins: Story;
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { CheckinProgressBar } from './checkin-progress-bar';
|
|
3
|
-
declare const meta: Meta<typeof CheckinProgressBar>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof CheckinProgressBar>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const WithoutTagName: Story;
|
|
8
|
-
export declare const NoCheckins: Story;
|
|
9
|
-
export declare const AllCheckinsComplete: Story;
|
|
10
|
-
export declare const MoreCheckinsThanExpected: Story;
|
|
11
|
-
export declare const NoRSVP: Story;
|
|
12
|
-
export declare const CustomLabels: Story;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { CompanionInfo } from './companion-info';
|
|
3
|
-
declare const meta: Meta<typeof CompanionInfo>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof CompanionInfo>;
|
|
6
|
-
export declare const WithMainGuest: Story;
|
|
7
|
-
export declare const NotACompanion: Story;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { EnterLogo } from './enter-logo';
|
|
3
|
-
declare const meta: Meta<typeof EnterLogo>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof EnterLogo>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const White: Story;
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { Input } from './input';
|
|
3
|
-
declare const meta: Meta<typeof Input>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof Input>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const NumberInput: Story;
|
|
8
|
-
export declare const WithValue: Story;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { SearchInput } from './search-input';
|
|
3
|
-
declare const meta: Meta<typeof SearchInput>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof SearchInput>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const WhiteText: Story;
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { SegmentedControl } from './segmented-control';
|
|
3
|
-
declare const meta: Meta<typeof SegmentedControl>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof SegmentedControl>;
|
|
6
|
-
export declare const Basic: Story;
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { Select } from './select';
|
|
3
|
-
declare const meta: Meta<typeof Select>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof Select>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const WithPleaseSelect: Story;
|
|
8
|
-
export declare const WithCustomFormat: Story;
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { GuestCard } from './guest-card';
|
|
3
|
-
declare const meta: Meta<typeof GuestCard>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof GuestCard>;
|
|
6
|
-
export declare const WithBackground: Story;
|
|
7
|
-
export declare const Plain: Story;
|
|
8
|
-
export declare const Dense: Story;
|
|
9
|
-
export declare const FilteredTags: Story;
|
|
10
|
-
export declare const WithColorLabel: Story;
|
|
11
|
-
export declare const WithMultipleColorLabels: Story;
|
|
12
|
-
export declare const WithInfoText: Story;
|
|
13
|
-
export declare const WithCompanion: Story;
|
|
14
|
-
export declare const WithCompanionButNoMainGuestInfo: Story;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { AddGuestIcon } from './add-guest-icon';
|
|
3
|
-
declare const meta: Meta<typeof AddGuestIcon>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof AddGuestIcon>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const White: Story;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { CaretIcon } from './caret-icon';
|
|
3
|
-
declare const meta: Meta<typeof CaretIcon>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof CaretIcon>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const White: Story;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { FilterIcon } from './filter-icon';
|
|
3
|
-
declare const meta: Meta<typeof FilterIcon>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof FilterIcon>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const White: Story;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { SearchIcon } from './search-icon';
|
|
3
|
-
declare const meta: Meta<typeof SearchIcon>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof SearchIcon>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const White: Story;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { SettingsIcon } from './settings-icon';
|
|
3
|
-
declare const meta: Meta<typeof SettingsIcon>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof SettingsIcon>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const White: Story;
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
-
import { SortListIcon } from './sort-list-icon';
|
|
3
|
-
declare const meta: Meta<typeof SortListIcon>;
|
|
4
|
-
export default meta;
|
|
5
|
-
type Story = StoryObj<typeof SortListIcon>;
|
|
6
|
-
export declare const Basic: Story;
|
|
7
|
-
export declare const White: Story;
|