@dev-crew-berlin/enter-js-utils 0.97.4 → 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.
@@ -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
- private buildHeaders;
40
- private fetchResponse;
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
- protected stream<T>(endpoint: string, options?: FetchOptions): AsyncGenerator<T>;
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 res = await this.fetchResponse(endpoint, {
140
- method: 'GET',
141
- headers: this.buildHeaders({
142
- Accept: 'text/event-stream'
143
- }),
144
- ...options
145
- });
146
- if (!res.success) {
147
- throw new Error(res.error[0]?.message ?? 'Unknown error');
148
- }
149
- if (!res.data.body) {
150
- throw new Error('Response body is null');
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
- const reader = res.data.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream()).getReader();
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
- if (event.data === '[DONE]') return;
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 JSON.parse(event.data);
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,7 +1,7 @@
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
- import { Event } from '../models';
4
+ import { Event, EventInput } from '../models';
5
5
  import { CheckinCounts } from '../models/checkin-counts';
6
6
  import { FieldGroups } from '../models/field-group';
7
7
  import { Instance } from '../models/instance';
@@ -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<Attendee[]>>;
76
+ }, options?: FetchOptions): Promise<APIResult<{
77
+ attendees: Attendee[];
78
+ eventCursor: string | null;
79
+ }>>;
77
80
  /**
78
81
  * @category Attendees
79
82
  */
@@ -160,7 +163,7 @@ declare class API extends APIBase {
160
163
  /**
161
164
  * @category Events
162
165
  */
163
- createEvents(args: Event[], options?: FetchOptions): Promise<APIResult<null>>;
166
+ createEvents(args: EventInput[], options?: FetchOptions): Promise<APIResult<null>>;
164
167
  /**
165
168
  * Subscribe to the SSE event stream. Yields events as they arrive.
166
169
  * Use `for await` to consume events, and `break` or `return` to unsubscribe.
@@ -175,6 +178,6 @@ declare class API extends APIBase {
175
178
  subscribeToEvents(args: {
176
179
  instanceName: string;
177
180
  eventType?: string[];
178
- }, options?: FetchOptions): AsyncGenerator<Event>;
181
+ }, options?: SubscribeOptions): AsyncGenerator<Event>;
179
182
  }
180
183
  export default API;
@@ -86,11 +86,20 @@ class API extends APIBase {
86
86
  if (args.includeDeleted) {
87
87
  query.append('include_deleted', 'true');
88
88
  }
89
- const instanceName = args.instanceName;
90
- const queryString = `?${query.toString()}`;
91
- const result = await this.get(`/instances/${instanceName}/attendees${queryString}`, options);
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
- return success(result.data.map(attendeeJSON => new Attendee(attendeeJSON)));
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
  /**
@@ -10,3 +10,12 @@ export type CheckinCreatedEvent = components['schemas']['CheckinCreatedEvent-Out
10
10
  export type CheckinDeletedEvent = components['schemas']['CheckinDeletedEvent-Output'];
11
11
  export type PaymentPaidEvent = components['schemas']['PaymentPaidEvent-Output'];
12
12
  export type Event = AttendeeCreatedEvent | AttendeeUpdatedEvent | AttendeeRespondedEvent | AttendeeDeletedEvent | AttendeeAuthCodeRequestedEvent | EmailUnsubscribedEvent | EmailStatusUpdatedEvent | CheckinCreatedEvent | CheckinDeletedEvent | PaymentPaidEvent;
13
+ export type AttendeeCreatedEventInput = components['schemas']['AttendeeCreatedEvent-Input'];
14
+ export type AttendeeUpdatedEventInput = components['schemas']['AttendeeUpdatedEvent-Input'];
15
+ export type AttendeeRespondedEventInput = components['schemas']['AttendeeRespondedEvent-Input'];
16
+ export type AttendeeDeletedEventInput = components['schemas']['AttendeeDeletedEvent-Input'];
17
+ export type AttendeeAuthCodeRequestedEventInput = components['schemas']['AttendeeAuthCodeRequested-Input'];
18
+ export type CheckinCreatedEventInput = components['schemas']['CheckinCreatedEvent-Input'];
19
+ export type CheckinDeletedEventInput = components['schemas']['CheckinDeletedEvent-Input'];
20
+ export type PaymentPaidEventInput = components['schemas']['PaymentPaidEvent-Input'];
21
+ export type EventInput = AttendeeCreatedEventInput | AttendeeUpdatedEventInput | AttendeeRespondedEventInput | AttendeeDeletedEventInput | AttendeeAuthCodeRequestedEventInput | EmailUnsubscribedEvent | EmailStatusUpdatedEvent | CheckinCreatedEventInput | CheckinDeletedEventInput | PaymentPaidEventInput;
@@ -9,7 +9,7 @@ export { Rsvp } from './rsvp';
9
9
  export type { CheckinJSON } from './checkin';
10
10
  export { Checkins } from './checkins';
11
11
  export type { CheckinsJSON } from './checkins';
12
- export type { Event, AttendeeUpdatedEvent, AttendeeCreatedEvent, AttendeeRespondedEvent, AttendeeDeletedEvent, AttendeeAuthCodeRequestedEvent, EmailUnsubscribedEvent, CheckinCreatedEvent, CheckinDeletedEvent, PaymentPaidEvent, } from './event';
12
+ export type { Event, AttendeeUpdatedEvent, AttendeeCreatedEvent, AttendeeRespondedEvent, AttendeeDeletedEvent, AttendeeAuthCodeRequestedEvent, EmailUnsubscribedEvent, CheckinCreatedEvent, CheckinDeletedEvent, PaymentPaidEvent, EventInput, AttendeeCreatedEventInput, AttendeeUpdatedEventInput, AttendeeRespondedEventInput, AttendeeDeletedEventInput, AttendeeAuthCodeRequestedEventInput, CheckinCreatedEventInput, CheckinDeletedEventInput, PaymentPaidEventInput, } from './event';
13
13
  export type { FieldGroup, FieldGroupJSON } from './field-group';
14
14
  export { FieldGroups } from './field-group';
15
15
  export type { FieldValue } from './field-value';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-crew-berlin/enter-js-utils",
3
- "version": "0.97.4",
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": "echo \"Error: no test specified\" && exit 1",
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": "^17.0.38",
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": "^6.7.6",
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": "^9.0.0"
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,6 +0,0 @@
1
- import { Meta, StoryObj } from '@storybook/react';
2
- import { Label } from './label';
3
- declare const meta: Meta<typeof Label>;
4
- export default meta;
5
- type Story = StoryObj<typeof Label>;
6
- export declare const Basic: 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;
@@ -1,6 +0,0 @@
1
- import { Meta, StoryObj } from '@storybook/react';
2
- import { Tag } from './tag';
3
- declare const meta: Meta<typeof Tag>;
4
- export default meta;
5
- type Story = StoryObj<typeof Tag>;
6
- export declare const Basic: Story;