@dev-crew-berlin/enter-js-utils 0.98.9 → 0.98.11

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.
@@ -5,15 +5,22 @@ const BACKOFF_INITIAL_MS = 1_000;
5
5
  const BACKOFF_MAX_MS = 30_000;
6
6
  const BACKOFF_RESET_AFTER_MS = 10_000;
7
7
  const WATCHDOG_TIMEOUT_MS = 45_000;
8
- function sleep(ms, signal) {
8
+ function sleep(ms, ...signals) {
9
9
  return new Promise(resolve => {
10
10
  const timer = setTimeout(resolve, ms);
11
- signal.addEventListener('abort', () => {
11
+ const cleanup = () => {
12
12
  clearTimeout(timer);
13
13
  resolve();
14
- }, {
15
- once: true
16
- });
14
+ };
15
+ for (const signal of signals) {
16
+ if (signal.aborted) {
17
+ cleanup();
18
+ return;
19
+ }
20
+ signal.addEventListener('abort', cleanup, {
21
+ once: true
22
+ });
23
+ }
17
24
  });
18
25
  }
19
26
  export default class APIBase {
@@ -182,11 +189,38 @@ export default class APIBase {
182
189
  let lastEventId = options.initialLastEventId ?? null;
183
190
  let backoffMs = BACKOFF_INITIAL_MS;
184
191
  let isFirstConnect = true;
192
+
193
+ // iOS standalone PWAs are frozen when backgrounded; the heartbeat watchdog
194
+ // cannot fire while JS is suspended. When the user returns to the app the
195
+ // foreground events fire immediately, so we use them as a hard-reconnect
196
+ // trigger instead of waiting for the watchdog.
197
+ let currentWakeController = null;
198
+ const onForegroundResume = () => {
199
+ backoffMs = BACKOFF_INITIAL_MS;
200
+ currentWakeController?.abort();
201
+ };
202
+ const onVisibilityChange = () => {
203
+ if (document.visibilityState === 'visible') onForegroundResume();
204
+ };
205
+ const hasDocument = typeof document !== 'undefined';
206
+ if (hasDocument) {
207
+ document.addEventListener('visibilitychange', onVisibilityChange);
208
+ document.addEventListener('pageshow', onForegroundResume);
209
+ }
185
210
  try {
186
211
  while (!outerAbort.signal.aborted) {
187
212
  // Fresh abort controller per attempt so the watchdog or a read error
188
213
  // on one attempt cannot bleed into the next.
189
214
  const attemptAbort = new AbortController();
215
+ // wakeController is aborted by foreground events to immediately skip
216
+ // the backoff sleep and break out of a wedged reader.read() race.
217
+ const wakeController = new AbortController();
218
+ currentWakeController = wakeController;
219
+ // Propagate a foreground wake to the in-flight fetch so it is
220
+ // cancelled and we open a fresh connection right away.
221
+ wakeController.signal.addEventListener('abort', () => attemptAbort.abort(), {
222
+ once: true
223
+ });
190
224
  const propagateAbort = () => attemptAbort.abort();
191
225
  outerAbort.signal.addEventListener('abort', propagateAbort);
192
226
  const hadCursor = lastEventId !== null;
@@ -223,7 +257,7 @@ export default class APIBase {
223
257
  for await (const {
224
258
  data,
225
259
  id
226
- } of this.readSseEvents(res.data.body, attemptAbort, onReset)) {
260
+ } of this.readSseEvents(res.data.body, attemptAbort, onReset, wakeController.signal)) {
227
261
  if (id) lastEventId = id;
228
262
  yield data;
229
263
  }
@@ -242,13 +276,17 @@ export default class APIBase {
242
276
  const lived = connectedAt > 0 ? Date.now() - connectedAt : 0;
243
277
  if (lived >= BACKOFF_RESET_AFTER_MS) backoffMs = BACKOFF_INITIAL_MS;
244
278
  onConnectionChange?.('reconnecting');
245
- await sleep(backoffMs, outerAbort.signal);
279
+ await sleep(backoffMs, outerAbort.signal, wakeController.signal);
246
280
  backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS) * (0.5 + Math.random());
247
281
  }
248
282
  } finally {
249
283
  // Aborting here also unblocks any in-progress sleep() call.
250
284
  outerAbort.abort();
251
285
  externalSignal?.removeEventListener('abort', onExternalAbort);
286
+ if (hasDocument) {
287
+ document.removeEventListener('visibilitychange', onVisibilityChange);
288
+ document.removeEventListener('pageshow', onForegroundResume);
289
+ }
252
290
  }
253
291
  }
254
292
 
@@ -257,7 +295,7 @@ export default class APIBase {
257
295
  // if no message arrives within WATCHDOG_TIMEOUT_MS the connection is
258
296
  // considered half-open (common on mobile networks) and is aborted so the
259
297
  // outer loop can reconnect.
260
- async *readSseEvents(body, attemptAbort, onReset) {
298
+ async *readSseEvents(body, attemptAbort, onReset, wakeSignal) {
261
299
  const reader = body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream()).getReader();
262
300
  let watchdogTimer = null;
263
301
  const resetWatchdog = () => {
@@ -265,12 +303,27 @@ export default class APIBase {
265
303
  watchdogTimer = setTimeout(() => attemptAbort.abort(), WATCHDOG_TIMEOUT_MS);
266
304
  };
267
305
  resetWatchdog();
306
+
307
+ // Resolves to a sentinel when a foreground-resume event fires. Racing
308
+ // reader.read() against this promise means the loop can advance to a
309
+ // fresh connection even if the old read is permanently wedged (iOS).
310
+ const reconnect = new Promise(resolve => {
311
+ if (wakeSignal.aborted) {
312
+ resolve('reconnect');
313
+ return;
314
+ }
315
+ wakeSignal.addEventListener('abort', () => resolve('reconnect'), {
316
+ once: true
317
+ });
318
+ });
268
319
  try {
269
320
  while (true) {
321
+ const result = await Promise.race([reader.read(), reconnect]);
322
+ if (result === 'reconnect') break;
270
323
  const {
271
324
  done,
272
325
  value: event
273
- } = await reader.read();
326
+ } = result;
274
327
  if (done) break;
275
328
 
276
329
  // Any message — including heartbeats — proves the connection is alive.
@@ -645,6 +645,196 @@ describe('resilientStream', () => {
645
645
  await pending;
646
646
  });
647
647
  });
648
+
649
+ // Minimal document stub for foreground-event tests (test env is node, no DOM).
650
+ // visibilityState and dispatch() let tests simulate visibilitychange / pageshow.
651
+ function makeMockDocument() {
652
+ const listeners = new Map();
653
+ return {
654
+ visibilityState: 'visible',
655
+ addEventListener(type, fn) {
656
+ if (!listeners.has(type)) listeners.set(type, new Set());
657
+ listeners.get(type).add(fn);
658
+ },
659
+ removeEventListener(type, fn) {
660
+ listeners.get(type)?.delete(fn);
661
+ },
662
+ dispatch(type) {
663
+ for (const fn of listeners.get(type) ?? []) fn(new Event(type));
664
+ },
665
+ listenerCount(type) {
666
+ return listeners.get(type)?.size ?? 0;
667
+ }
668
+ };
669
+ }
670
+
671
+ // A stream that delivers initialChunks immediately but never closes and never
672
+ // errors, so reader.read() hangs forever once the chunks are consumed.
673
+ function makeWedgedStream(initialChunks = []) {
674
+ return new ReadableStream({
675
+ start(controller) {
676
+ for (const chunk of initialChunks) controller.enqueue(chunk);
677
+ // deliberately never closed or errored
678
+ }
679
+ });
680
+ }
681
+ describe('foreground reconnect (visibilitychange / pageshow)', () => {
682
+ let mockDoc;
683
+ beforeEach(() => {
684
+ vi.useFakeTimers();
685
+ mockDoc = makeMockDocument();
686
+ vi.stubGlobal('document', mockDoc);
687
+ });
688
+ afterEach(() => {
689
+ vi.useRealTimers();
690
+ vi.restoreAllMocks();
691
+ });
692
+
693
+ // ─── immediate reconnect on visibilitychange ──────────────────────────────
694
+
695
+ it('reconnects immediately on visibilitychange without waiting for backoff', async () => {
696
+ let connectionCount = 0;
697
+ vi.stubGlobal('fetch', (_url, init) => {
698
+ connectionCount++;
699
+ // All connections return a silent stream (never delivers events)
700
+ return Promise.resolve(okResponse(makeSilentStream(init.signal)));
701
+ });
702
+ const abortController = new AbortController();
703
+ const api = makeAPI();
704
+ const gen = api.subscribe('/test', {
705
+ signal: abortController.signal
706
+ });
707
+ gen.next();
708
+ await flushMicrotasks();
709
+ expect(connectionCount).toBe(1);
710
+
711
+ // Simulate returning to foreground — should trigger immediate reconnect
712
+ mockDoc.visibilityState = 'visible';
713
+ mockDoc.dispatch('visibilitychange');
714
+ await flushMicrotasks();
715
+
716
+ // New connection opened without advancing fake timers at all
717
+ expect(connectionCount).toBeGreaterThanOrEqual(2);
718
+ abortController.abort();
719
+ await gen.return(undefined);
720
+ });
721
+ it('reconnects immediately on pageshow without waiting for backoff', async () => {
722
+ let connectionCount = 0;
723
+ vi.stubGlobal('fetch', (_url, init) => {
724
+ connectionCount++;
725
+ return Promise.resolve(okResponse(makeSilentStream(init.signal)));
726
+ });
727
+ const abortController = new AbortController();
728
+ const api = makeAPI();
729
+ const gen = api.subscribe('/test', {
730
+ signal: abortController.signal
731
+ });
732
+ gen.next();
733
+ await flushMicrotasks();
734
+ expect(connectionCount).toBe(1);
735
+ mockDoc.dispatch('pageshow');
736
+ await flushMicrotasks();
737
+ expect(connectionCount).toBeGreaterThanOrEqual(2);
738
+ abortController.abort();
739
+ await gen.return(undefined);
740
+ });
741
+
742
+ // ─── Last-Event-ID carried on foreground reconnect ────────────────────────
743
+
744
+ it('foreground reconnect carries Last-Event-ID from last received event', async () => {
745
+ const capturedInits = [];
746
+ vi.stubGlobal('fetch', (_url, init) => {
747
+ capturedInits.push(init);
748
+ if (capturedInits.length === 1) {
749
+ // First connection: delivers one event with cursor, then hangs
750
+ return Promise.resolve(okResponse(makeWedgedStream([sseChunk([{
751
+ id: 'cursor-42',
752
+ data: '{"n":1}'
753
+ }])])));
754
+ }
755
+ // Second+ connections: abort-responsive so the test can clean up
756
+ return Promise.resolve(okResponse(makeSilentStream(init.signal)));
757
+ });
758
+ const abortController = new AbortController();
759
+ const api = makeAPI();
760
+ const gen = api.subscribe('/test', {
761
+ signal: abortController.signal
762
+ });
763
+
764
+ // Consume the first event; generator is now suspended at 'yield data'
765
+ const response1 = await gen.next();
766
+ expect(response1.value).toEqual({
767
+ n: 1
768
+ });
769
+
770
+ // Drive the generator forward so it re-enters readSseEvents's Promise.race.
771
+ // Without this call the generator stays frozen at 'yield data' and the
772
+ // foreground event cannot be picked up until the consumer drives it.
773
+ const nextPromise = gen.next();
774
+ await flushMicrotasks();
775
+
776
+ // Foreground event fires while reader.read() is wedged on the first stream
777
+ mockDoc.visibilityState = 'visible';
778
+ mockDoc.dispatch('visibilitychange');
779
+ await flushMicrotasks();
780
+ expect(capturedInits.length).toBeGreaterThanOrEqual(2);
781
+ const secondHeaders = capturedInits[1]?.headers;
782
+ expect(secondHeaders?.['Last-Event-ID']).toBe('cursor-42');
783
+ abortController.abort();
784
+ await nextPromise;
785
+ });
786
+
787
+ // ─── wedged read — recovery without stream closing ────────────────────────
788
+
789
+ it('recovers even when the prior response body never closes', async () => {
790
+ let connectionCount = 0;
791
+ vi.stubGlobal('fetch', (_url, init) => {
792
+ connectionCount++;
793
+ if (connectionCount === 1) {
794
+ // First connection is truly wedged — reader.read() hangs forever and
795
+ // the stream ignores the abort signal, simulating iOS zombie streams.
796
+ return Promise.resolve(okResponse(makeWedgedStream()));
797
+ }
798
+ // Subsequent connections are abort-responsive so the test can clean up.
799
+ return Promise.resolve(okResponse(makeSilentStream(init.signal)));
800
+ });
801
+ const abortController = new AbortController();
802
+ const api = makeAPI();
803
+ const gen = api.subscribe('/test', {
804
+ signal: abortController.signal
805
+ });
806
+ gen.next();
807
+ await flushMicrotasks();
808
+ expect(connectionCount).toBe(1);
809
+
810
+ // Foreground event — must open new connection even though the first
811
+ // stream body is completely wedged and reader.read() never resolves.
812
+ mockDoc.visibilityState = 'visible';
813
+ mockDoc.dispatch('visibilitychange');
814
+ await flushMicrotasks();
815
+ expect(connectionCount).toBeGreaterThanOrEqual(2);
816
+ abortController.abort();
817
+ await gen.return(undefined);
818
+ });
819
+
820
+ // ─── no listener / timer leaks ────────────────────────────────────────────
821
+
822
+ it('removes visibilitychange and pageshow listeners after consumer aborts', async () => {
823
+ vi.stubGlobal('fetch', (_url, init) => Promise.resolve(okResponse(makeSilentStream(init.signal))));
824
+ const abortController = new AbortController();
825
+ const api = makeAPI();
826
+ const pending = api.subscribe('/test', {
827
+ signal: abortController.signal
828
+ }).next();
829
+ await flushMicrotasks();
830
+ expect(mockDoc.listenerCount('visibilitychange')).toBe(1);
831
+ expect(mockDoc.listenerCount('pageshow')).toBe(1);
832
+ abortController.abort();
833
+ await pending;
834
+ expect(mockDoc.listenerCount('visibilitychange')).toBe(0);
835
+ expect(mockDoc.listenerCount('pageshow')).toBe(0);
836
+ });
837
+ });
648
838
  function makeFullAPI() {
649
839
  return new API({
650
840
  credentials: {
@@ -693,4 +883,56 @@ describe('getAttendeeList', () => {
693
883
  if (!result.success) return;
694
884
  expect(result.data.eventCursor).toBe('abc123');
695
885
  });
886
+ });
887
+ describe('createEvents', () => {
888
+ afterEach(() => {
889
+ vi.restoreAllMocks();
890
+ });
891
+ it('returns created and eventCursors populated from response body', async () => {
892
+ vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify({
893
+ created: 2,
894
+ event_cursors: {
895
+ 'uuid-1': 'cursor-abc',
896
+ 'uuid-2': 'cursor-def'
897
+ }
898
+ }), {
899
+ status: 201
900
+ })));
901
+ const api = makeFullAPI();
902
+ const result = await api.createEvents([]);
903
+ expect(result.success).toBe(true);
904
+ if (!result.success) return;
905
+ expect(result.data.created).toBe(2);
906
+ expect(result.data.eventCursors).toEqual({
907
+ 'uuid-1': 'cursor-abc',
908
+ 'uuid-2': 'cursor-def'
909
+ });
910
+ });
911
+ it('returns empty eventCursors when field is absent', async () => {
912
+ vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify({
913
+ created: 1
914
+ }), {
915
+ status: 201
916
+ })));
917
+ const api = makeFullAPI();
918
+ const result = await api.createEvents([]);
919
+ expect(result.success).toBe(true);
920
+ if (!result.success) return;
921
+ expect(result.data.created).toBe(1);
922
+ expect(result.data.eventCursors).toEqual({});
923
+ });
924
+ it('returns empty eventCursors when field is empty', async () => {
925
+ vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify({
926
+ created: 0,
927
+ event_cursors: {}
928
+ }), {
929
+ status: 201
930
+ })));
931
+ const api = makeFullAPI();
932
+ const result = await api.createEvents([]);
933
+ expect(result.success).toBe(true);
934
+ if (!result.success) return;
935
+ expect(result.data.created).toBe(0);
936
+ expect(result.data.eventCursors).toEqual({});
937
+ });
696
938
  });
@@ -163,7 +163,10 @@ declare class API extends APIBase {
163
163
  /**
164
164
  * @category Events
165
165
  */
166
- createEvents(args: EventInput[], options?: FetchOptions): Promise<APIResult<null>>;
166
+ createEvents(args: EventInput[], options?: FetchOptions): Promise<APIResult<{
167
+ created: number;
168
+ eventCursors: Record<string, string>;
169
+ }>>;
167
170
  /**
168
171
  * Subscribe to the SSE event stream. Yields events as they arrive.
169
172
  * Use `for await` to consume events, and `break` or `return` to unsubscribe.
@@ -228,7 +228,18 @@ class API extends APIBase {
228
228
  * @category Events
229
229
  */
230
230
  async createEvents(args, options = {}) {
231
- return this.post(`/events`, args, options);
231
+ const result = await this.fetchResponse(`/events`, {
232
+ method: 'POST',
233
+ headers: this.buildHeaders(),
234
+ body: JSON.stringify(args),
235
+ ...options
236
+ });
237
+ if (!result.success) return result;
238
+ const json = await result.data.json();
239
+ return success({
240
+ created: json.created,
241
+ eventCursors: json.event_cursors ?? {}
242
+ });
232
243
  }
233
244
 
234
245
  /**