eventsourcingdb 1.9.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +77 -6
  2. package/dist/Client.d.ts +7 -6
  3. package/dist/Client.js +189 -204
  4. package/dist/ClientOptions.d.ts +4 -0
  5. package/dist/ClientOptions.js +1 -0
  6. package/dist/ConnectionStalledError.d.ts +4 -0
  7. package/dist/ConnectionStalledError.js +13 -0
  8. package/dist/Event.d.ts +2 -1
  9. package/dist/Event.js +58 -11
  10. package/dist/HashVerificationFailedError.d.ts +4 -0
  11. package/dist/HashVerificationFailedError.js +7 -0
  12. package/dist/HeartbeatTimeoutError.d.ts +1 -1
  13. package/dist/HeartbeatTimeoutError.js +4 -2
  14. package/dist/SignatureVerificationFailedError.d.ts +4 -0
  15. package/dist/SignatureVerificationFailedError.js +7 -0
  16. package/dist/UnsupportedHashVersionError.d.ts +4 -0
  17. package/dist/UnsupportedHashVersionError.js +7 -0
  18. package/dist/convertCloudEventToEvent.d.ts +1 -1
  19. package/dist/convertCloudEventToEvent.js +6 -1
  20. package/dist/getImageTag.d.ts +2 -0
  21. package/dist/getImageTag.js +13 -0
  22. package/dist/index.d.ts +7 -2
  23. package/dist/index.js +5 -1
  24. package/dist/json/getRawJsonElements.d.ts +2 -0
  25. package/dist/json/getRawJsonElements.js +22 -0
  26. package/dist/json/getRawJsonMember.d.ts +2 -0
  27. package/dist/json/getRawJsonMember.js +29 -0
  28. package/dist/json/scanJson.d.ts +4 -0
  29. package/dist/json/scanJson.js +65 -0
  30. package/dist/ndjson/readNdJsonStream.d.ts +7 -1
  31. package/dist/ndjson/readNdJsonStream.js +7 -28
  32. package/dist/stall/StallGuard.d.ts +14 -0
  33. package/dist/stall/StallGuard.js +83 -0
  34. package/dist/stall/createFetchTimeoutError.d.ts +2 -0
  35. package/dist/stall/createFetchTimeoutError.js +16 -0
  36. package/dist/stall/defaultStallTimeout.d.ts +3 -0
  37. package/dist/stall/defaultStallTimeout.js +6 -0
  38. package/dist/stall/formatDuration.d.ts +2 -0
  39. package/dist/stall/formatDuration.js +9 -0
  40. package/dist/stall/readResponseText.d.ts +3 -0
  41. package/dist/stall/readResponseText.js +26 -0
  42. package/dist/stall/runWithStallGuard.d.ts +6 -0
  43. package/dist/stall/runWithStallGuard.js +18 -0
  44. package/package.json +3 -3
  45. package/dist/heartbeatTimeout.d.ts +0 -4
  46. package/dist/heartbeatTimeout.js +0 -6
package/dist/Client.js CHANGED
@@ -1,8 +1,14 @@
1
1
  import { convertCloudEventToEvent } from './convertCloudEventToEvent.js';
2
- import { heartbeatTimeout } from './heartbeatTimeout.js';
2
+ import { HeartbeatTimeoutError } from './HeartbeatTimeoutError.js';
3
3
  import { isCloudEvent } from './isCloudEvent.js';
4
4
  import { isValidServerHeader } from './isValidServerHeader.js';
5
+ import { getRawJsonElements } from './json/getRawJsonElements.js';
6
+ import { getRawJsonMember } from './json/getRawJsonMember.js';
5
7
  import { readNdJsonStream } from './ndjson/readNdJsonStream.js';
8
+ import { defaultStallTimeout, maximumStallTimeout } from './stall/defaultStallTimeout.js';
9
+ import { readResponseText } from './stall/readResponseText.js';
10
+ import { runWithStallGuard } from './stall/runWithStallGuard.js';
11
+ import { StallGuard } from './stall/StallGuard.js';
6
12
  import { isStreamCloudEvent } from './stream/isStreamCloudEvent.js';
7
13
  import { isStreamError } from './stream/isStreamError.js';
8
14
  import { isStreamEventType } from './stream/isStreamEventType.js';
@@ -15,107 +21,118 @@ import { isString } from './types/isString.js';
15
21
  class Client {
16
22
  #url;
17
23
  #apiToken;
24
+ #stallTimeout;
18
25
  #getUrl(path) {
19
26
  return new URL(path, this.#url).toString();
20
27
  }
21
- constructor(url, apiToken) {
28
+ constructor(url, apiToken, options = {}) {
29
+ const stallTimeout = options.stallTimeout ?? defaultStallTimeout;
30
+ if (!(stallTimeout >= 1 && stallTimeout <= maximumStallTimeout)) {
31
+ throw new Error(`Stall timeout must be between 1 and ${maximumStallTimeout} milliseconds, got '${stallTimeout}'.`);
32
+ }
22
33
  this.#url = url;
23
34
  this.#apiToken = apiToken;
35
+ this.#stallTimeout = stallTimeout;
24
36
  }
25
- async ping() {
37
+ async ping(signal) {
26
38
  const url = this.#getUrl('/api/v1/ping');
27
- const response = await fetch(url, {
28
- method: 'get',
39
+ const stallTimeout = this.#stallTimeout;
40
+ await runWithStallGuard({ stallTimeout, signal }, async (stallGuard) => {
41
+ const response = await stallGuard.send(url, {
42
+ method: 'get',
43
+ });
44
+ if (!isValidServerHeader(response)) {
45
+ throw new Error('Server must be EventSourcingDB.');
46
+ }
47
+ if (response.status !== 200) {
48
+ throw new Error(`Failed to ping, got HTTP status code '${response.status}', expected '200'.`);
49
+ }
50
+ const responseBody = JSON.parse(await readResponseText(response, stallGuard));
51
+ if (!hasShapeOf(responseBody, {
52
+ type: isString,
53
+ })) {
54
+ throw new Error('Failed to parse response.');
55
+ }
56
+ const eventType = 'io.eventsourcingdb.api.ping-received';
57
+ if (responseBody.type !== eventType) {
58
+ throw new Error('Failed to ping.');
59
+ }
29
60
  });
30
- if (!isValidServerHeader(response)) {
31
- throw new Error('Server must be EventSourcingDB.');
32
- }
33
- if (response.status !== 200) {
34
- throw new Error(`Failed to ping, got HTTP status code '${response.status}', expected '200'.`);
35
- }
36
- const responseBody = await response.json();
37
- if (!hasShapeOf(responseBody, {
38
- type: isString,
39
- })) {
40
- throw new Error('Failed to parse response.');
41
- }
42
- const eventType = 'io.eventsourcingdb.api.ping-received';
43
- if (responseBody.type !== eventType) {
44
- throw new Error('Failed to ping.');
45
- }
46
61
  }
47
- async verifyApiToken() {
62
+ async verifyApiToken(signal) {
48
63
  const url = this.#getUrl('/api/v1/verify-api-token');
49
- const response = await fetch(url, {
50
- method: 'post',
51
- headers: {
52
- authorization: `Bearer ${this.#apiToken}`,
53
- },
64
+ const stallTimeout = this.#stallTimeout;
65
+ await runWithStallGuard({ stallTimeout, signal }, async (stallGuard) => {
66
+ const response = await stallGuard.send(url, {
67
+ method: 'post',
68
+ headers: {
69
+ authorization: `Bearer ${this.#apiToken}`,
70
+ },
71
+ });
72
+ if (!isValidServerHeader(response)) {
73
+ throw new Error('Server must be EventSourcingDB.');
74
+ }
75
+ if (response.status !== 200) {
76
+ throw new Error(`Failed to verify API token, got HTTP status code '${response.status}', expected '200'.`);
77
+ }
78
+ const responseBody = JSON.parse(await readResponseText(response, stallGuard));
79
+ if (!hasShapeOf(responseBody, {
80
+ type: isString,
81
+ })) {
82
+ throw new Error('Failed to parse response.');
83
+ }
84
+ const eventType = 'io.eventsourcingdb.api.api-token-verified';
85
+ if (responseBody.type !== eventType) {
86
+ throw new Error('Failed to verify API token.');
87
+ }
54
88
  });
55
- if (!isValidServerHeader(response)) {
56
- throw new Error('Server must be EventSourcingDB.');
57
- }
58
- if (response.status !== 200) {
59
- throw new Error(`Failed to verify API token, got HTTP status code '${response.status}', expected '200'.`);
60
- }
61
- const responseBody = await response.json();
62
- if (!hasShapeOf(responseBody, {
63
- type: isString,
64
- })) {
65
- throw new Error('Failed to parse response.');
66
- }
67
- const eventType = 'io.eventsourcingdb.api.api-token-verified';
68
- if (responseBody.type !== eventType) {
69
- throw new Error('Failed to verify API token.');
70
- }
71
89
  }
72
- async writeEvents(events, preconditions = []) {
90
+ async writeEvents(events, preconditions = [], signal) {
73
91
  const url = this.#getUrl('/api/v1/write-events');
74
- const response = await fetch(url, {
75
- method: 'post',
76
- headers: {
77
- authorization: `Bearer ${this.#apiToken}`,
78
- 'content-type': 'application/json',
79
- },
80
- body: JSON.stringify({
81
- events,
82
- preconditions,
83
- }),
84
- });
85
- if (!isValidServerHeader(response)) {
86
- throw new Error('Server must be EventSourcingDB.');
87
- }
88
- if (response.status !== 200) {
89
- throw new Error(`Failed to write events, got HTTP status code '${response.status}', expected '200'.`);
90
- }
91
- const responseBody = await response.json();
92
- if (!Array.isArray(responseBody)) {
93
- throw new Error('Failed to parse response.');
94
- }
95
- const writtenEvents = responseBody.map(item => {
96
- if (!isCloudEvent(item)) {
97
- throw new Error('Failed to parse response item.');
92
+ const stallTimeout = this.#stallTimeout;
93
+ return await runWithStallGuard({ stallTimeout, signal }, async (stallGuard) => {
94
+ const response = await stallGuard.send(url, {
95
+ method: 'post',
96
+ headers: {
97
+ authorization: `Bearer ${this.#apiToken}`,
98
+ 'content-type': 'application/json',
99
+ },
100
+ body: JSON.stringify({
101
+ events,
102
+ preconditions,
103
+ }),
104
+ });
105
+ if (!isValidServerHeader(response)) {
106
+ throw new Error('Server must be EventSourcingDB.');
98
107
  }
99
- const event = convertCloudEventToEvent(item);
100
- return event;
108
+ if (response.status !== 200) {
109
+ throw new Error(`Failed to write events, got HTTP status code '${response.status}', expected '200'.`);
110
+ }
111
+ const responseText = await readResponseText(response, stallGuard);
112
+ const responseBody = JSON.parse(responseText);
113
+ if (!Array.isArray(responseBody)) {
114
+ throw new Error('Failed to parse response.');
115
+ }
116
+ const responseItemTexts = getRawJsonElements(responseText);
117
+ const writtenEvents = responseBody.map((item, index) => {
118
+ if (!isCloudEvent(item)) {
119
+ throw new Error('Failed to parse response item.');
120
+ }
121
+ const event = convertCloudEventToEvent(item, responseItemTexts[index]);
122
+ return event;
123
+ });
124
+ return writtenEvents;
101
125
  });
102
- return writtenEvents;
103
126
  }
104
127
  readEvents(subject, options, signal) {
105
128
  const url = this.#getUrl('/api/v1/read-events');
106
129
  const apiToken = this.#apiToken;
130
+ const stallTimeout = this.#stallTimeout;
107
131
  return (async function* () {
108
- const internalAbortController = new AbortController();
109
- const combinedSignal = signal ?? internalAbortController.signal;
110
- const shouldAbortInternally = !signal;
111
- let removeAbortListener;
112
- if (signal && !signal.aborted) {
113
- const onAbort = () => internalAbortController.abort();
114
- signal.addEventListener('abort', onAbort, { once: true });
115
- removeAbortListener = () => signal.removeEventListener('abort', onAbort);
116
- }
132
+ const stallGuard = new StallGuard(stallTimeout, signal);
117
133
  try {
118
- const response = await fetch(url, {
134
+ stallGuard.restart();
135
+ const response = await stallGuard.send(url, {
119
136
  method: 'post',
120
137
  headers: {
121
138
  authorization: `Bearer ${apiToken}`,
@@ -125,7 +142,6 @@ class Client {
125
142
  subject,
126
143
  options,
127
144
  }),
128
- signal: combinedSignal,
129
145
  });
130
146
  if (!isValidServerHeader(response)) {
131
147
  const serverHeader = response.headers.get('Server');
@@ -140,57 +156,50 @@ class Client {
140
156
  if (!response.body) {
141
157
  throw new Error('Failed to read events.');
142
158
  }
143
- for await (const line of readNdJsonStream(response.body, combinedSignal)) {
159
+ for await (const { value: line, text } of readNdJsonStream(response.body, stallGuard.signal, stallGuard)) {
144
160
  if (isStreamError(line)) {
145
161
  throw new Error(`${line.payload.error}.`);
146
162
  }
147
163
  if (isStreamCloudEvent(line)) {
148
- const event = convertCloudEventToEvent(line.payload);
164
+ const event = convertCloudEventToEvent(line.payload, getRawJsonMember(text, 'payload'));
149
165
  yield event;
150
166
  continue;
151
167
  }
152
168
  throw new Error('Failed to read events.');
153
169
  }
170
+ stallGuard.throwIfStalled();
154
171
  }
155
172
  catch (error) {
173
+ stallGuard.throwIfStalled();
156
174
  if (error instanceof DOMException && error.name === 'AbortError') {
157
175
  return;
158
176
  }
159
177
  throw error;
160
178
  }
161
179
  finally {
162
- if (removeAbortListener) {
163
- removeAbortListener();
164
- }
165
- if (shouldAbortInternally) {
166
- internalAbortController.abort();
167
- }
180
+ stallGuard.dispose({ abortRequest: true });
168
181
  }
169
182
  })();
170
183
  }
171
184
  runEventQlQuery(query, signal) {
172
185
  const url = this.#getUrl('/api/v1/run-eventql-query');
173
186
  const apiToken = this.#apiToken;
187
+ const stallTimeout = this.#stallTimeout;
174
188
  return (async function* () {
175
- const internalAbortController = new AbortController();
176
- const combinedSignal = signal ?? internalAbortController.signal;
177
- const shouldAbortInternally = !signal;
178
- let removeAbortListener;
179
- if (signal && !signal.aborted) {
180
- const onAbort = () => internalAbortController.abort();
181
- signal.addEventListener('abort', onAbort, { once: true });
182
- removeAbortListener = () => signal.removeEventListener('abort', onAbort);
183
- }
189
+ const stallGuard = new StallGuard(stallTimeout, signal);
184
190
  try {
185
- const response = await fetch(url, {
191
+ stallGuard.restart();
192
+ const response = await stallGuard.send(url, {
186
193
  method: 'post',
187
194
  headers: {
188
195
  authorization: `Bearer ${apiToken}`,
189
196
  'content-type': 'application/json',
190
197
  },
191
198
  body: JSON.stringify({ query }),
192
- signal: combinedSignal,
193
199
  });
200
+ // Once the headers have arrived, the server sends a heartbeat every
201
+ // second, so from now on a stall means that heartbeats are missing.
202
+ stallGuard.onStall(() => new HeartbeatTimeoutError(stallTimeout));
194
203
  if (!isValidServerHeader(response)) {
195
204
  const serverHeader = response.headers.get('Server');
196
205
  if (!serverHeader) {
@@ -204,7 +213,7 @@ class Client {
204
213
  if (!response.body) {
205
214
  throw new Error('Failed to run EventQL query.');
206
215
  }
207
- for await (const line of readNdJsonStream(response.body, combinedSignal, heartbeatTimeout.milliseconds)) {
216
+ for await (const { value: line } of readNdJsonStream(response.body, stallGuard.signal, stallGuard)) {
208
217
  if (isStreamHeartbeat(line)) {
209
218
  continue;
210
219
  }
@@ -218,38 +227,29 @@ class Client {
218
227
  }
219
228
  throw new Error('Failed to run EventQL query.');
220
229
  }
230
+ stallGuard.throwIfStalled();
221
231
  }
222
232
  catch (error) {
233
+ stallGuard.throwIfStalled();
223
234
  if (error instanceof DOMException && error.name === 'AbortError') {
224
235
  return;
225
236
  }
226
237
  throw error;
227
238
  }
228
239
  finally {
229
- if (removeAbortListener) {
230
- removeAbortListener();
231
- }
232
- if (shouldAbortInternally) {
233
- internalAbortController.abort();
234
- }
240
+ stallGuard.dispose({ abortRequest: true });
235
241
  }
236
242
  })();
237
243
  }
238
244
  observeEvents(subject, options, signal) {
239
245
  const url = this.#getUrl('/api/v1/observe-events');
240
246
  const apiToken = this.#apiToken;
247
+ const stallTimeout = this.#stallTimeout;
241
248
  return (async function* () {
242
- const internalAbortController = new AbortController();
243
- const combinedSignal = signal ?? internalAbortController.signal;
244
- const shouldAbortInternally = !signal;
245
- let removeAbortListener;
246
- if (signal && !signal.aborted) {
247
- const onAbort = () => internalAbortController.abort();
248
- signal.addEventListener('abort', onAbort, { once: true });
249
- removeAbortListener = () => signal.removeEventListener('abort', onAbort);
250
- }
249
+ const stallGuard = new StallGuard(stallTimeout, signal);
251
250
  try {
252
- const response = await fetch(url, {
251
+ stallGuard.restart();
252
+ const response = await stallGuard.send(url, {
253
253
  method: 'post',
254
254
  headers: {
255
255
  authorization: `Bearer ${apiToken}`,
@@ -259,8 +259,10 @@ class Client {
259
259
  subject,
260
260
  options,
261
261
  }),
262
- signal: combinedSignal,
263
262
  });
263
+ // Once the headers have arrived, the server sends a heartbeat every
264
+ // second, so from now on a stall means that heartbeats are missing.
265
+ stallGuard.onStall(() => new HeartbeatTimeoutError(stallTimeout));
264
266
  if (!isValidServerHeader(response)) {
265
267
  const serverHeader = response.headers.get('Server');
266
268
  if (!serverHeader) {
@@ -274,7 +276,7 @@ class Client {
274
276
  if (!response.body) {
275
277
  throw new Error('Failed to observe events.');
276
278
  }
277
- for await (const line of readNdJsonStream(response.body, combinedSignal, heartbeatTimeout.milliseconds)) {
279
+ for await (const { value: line, text } of readNdJsonStream(response.body, stallGuard.signal, stallGuard)) {
278
280
  if (isStreamHeartbeat(line)) {
279
281
  continue;
280
282
  }
@@ -282,64 +284,58 @@ class Client {
282
284
  throw new Error(`${line.payload.error}.`);
283
285
  }
284
286
  if (isStreamCloudEvent(line)) {
285
- const event = convertCloudEventToEvent(line.payload);
287
+ const event = convertCloudEventToEvent(line.payload, getRawJsonMember(text, 'payload'));
286
288
  yield event;
287
289
  continue;
288
290
  }
289
291
  throw new Error('Failed to observe events.');
290
292
  }
293
+ stallGuard.throwIfStalled();
291
294
  }
292
295
  catch (error) {
296
+ stallGuard.throwIfStalled();
293
297
  if (error instanceof DOMException && error.name === 'AbortError') {
294
298
  return;
295
299
  }
296
300
  throw error;
297
301
  }
298
302
  finally {
299
- if (removeAbortListener) {
300
- removeAbortListener();
301
- }
302
- if (shouldAbortInternally) {
303
- internalAbortController.abort();
304
- }
303
+ stallGuard.dispose({ abortRequest: true });
305
304
  }
306
305
  })();
307
306
  }
308
- async registerEventSchema(eventType, schema) {
307
+ async registerEventSchema(eventType, schema, signal) {
309
308
  const url = this.#getUrl('/api/v1/register-event-schema');
310
- const response = await fetch(url, {
311
- method: 'post',
312
- headers: {
313
- authorization: `Bearer ${this.#apiToken}`,
314
- 'content-type': 'application/json',
315
- },
316
- body: JSON.stringify({
317
- eventType,
318
- schema,
319
- }),
309
+ const stallTimeout = this.#stallTimeout;
310
+ await runWithStallGuard({ stallTimeout, signal }, async (stallGuard) => {
311
+ const response = await stallGuard.send(url, {
312
+ method: 'post',
313
+ headers: {
314
+ authorization: `Bearer ${this.#apiToken}`,
315
+ 'content-type': 'application/json',
316
+ },
317
+ body: JSON.stringify({
318
+ eventType,
319
+ schema,
320
+ }),
321
+ });
322
+ if (!isValidServerHeader(response)) {
323
+ throw new Error('Server must be EventSourcingDB.');
324
+ }
325
+ if (response.status !== 200) {
326
+ throw new Error(`Failed to register event schema, got HTTP status code '${response.status}', expected '200'.`);
327
+ }
320
328
  });
321
- if (!isValidServerHeader(response)) {
322
- throw new Error('Server must be EventSourcingDB.');
323
- }
324
- if (response.status !== 200) {
325
- throw new Error(`Failed to register event schema, got HTTP status code '${response.status}', expected '200'.`);
326
- }
327
329
  }
328
330
  readSubjects(baseSubject, signal) {
329
331
  const url = this.#getUrl('/api/v1/read-subjects');
330
332
  const apiToken = this.#apiToken;
333
+ const stallTimeout = this.#stallTimeout;
331
334
  return (async function* () {
332
- const internalAbortController = new AbortController();
333
- const combinedSignal = signal ?? internalAbortController.signal;
334
- const shouldAbortInternally = !signal;
335
- let removeAbortListener;
336
- if (signal && !signal.aborted) {
337
- const onAbort = () => internalAbortController.abort();
338
- signal.addEventListener('abort', onAbort, { once: true });
339
- removeAbortListener = () => signal.removeEventListener('abort', onAbort);
340
- }
335
+ const stallGuard = new StallGuard(stallTimeout, signal);
341
336
  try {
342
- const response = await fetch(url, {
337
+ stallGuard.restart();
338
+ const response = await stallGuard.send(url, {
343
339
  method: 'post',
344
340
  headers: {
345
341
  authorization: `Bearer ${apiToken}`,
@@ -348,7 +344,6 @@ class Client {
348
344
  body: JSON.stringify({
349
345
  baseSubject,
350
346
  }),
351
- signal: combinedSignal,
352
347
  });
353
348
  if (!isValidServerHeader(response)) {
354
349
  const serverHeader = response.headers.get('Server');
@@ -363,7 +358,7 @@ class Client {
363
358
  if (!response.body) {
364
359
  throw new Error('Failed to read subjects.');
365
360
  }
366
- for await (const line of readNdJsonStream(response.body, combinedSignal)) {
361
+ for await (const { value: line } of readNdJsonStream(response.body, stallGuard.signal, stallGuard)) {
367
362
  if (isStreamError(line)) {
368
363
  throw new Error(`${line.payload.error}.`);
369
364
  }
@@ -373,70 +368,63 @@ class Client {
373
368
  }
374
369
  throw new Error('Failed to read subjects.');
375
370
  }
371
+ stallGuard.throwIfStalled();
376
372
  }
377
373
  catch (error) {
374
+ stallGuard.throwIfStalled();
378
375
  if (error instanceof DOMException && error.name === 'AbortError') {
379
376
  return;
380
377
  }
381
378
  throw error;
382
379
  }
383
380
  finally {
384
- if (removeAbortListener) {
385
- removeAbortListener();
386
- }
387
- if (shouldAbortInternally) {
388
- internalAbortController.abort();
389
- }
381
+ stallGuard.dispose({ abortRequest: true });
390
382
  }
391
383
  })();
392
384
  }
393
- async readEventType(eventType) {
385
+ async readEventType(eventType, signal) {
394
386
  const url = this.#getUrl('/api/v1/read-event-type');
395
- const response = await fetch(url, {
396
- method: 'post',
397
- headers: {
398
- authorization: `Bearer ${this.#apiToken}`,
399
- 'content-type': 'application/json',
400
- },
401
- body: JSON.stringify({
402
- eventType,
403
- }),
387
+ const stallTimeout = this.#stallTimeout;
388
+ return await runWithStallGuard({ stallTimeout, signal }, async (stallGuard) => {
389
+ const response = await stallGuard.send(url, {
390
+ method: 'post',
391
+ headers: {
392
+ authorization: `Bearer ${this.#apiToken}`,
393
+ 'content-type': 'application/json',
394
+ },
395
+ body: JSON.stringify({
396
+ eventType,
397
+ }),
398
+ });
399
+ if (!isValidServerHeader(response)) {
400
+ throw new Error('Server must be EventSourcingDB.');
401
+ }
402
+ if (response.status !== 200) {
403
+ throw new Error(`Failed to read event type, got HTTP status code '${response.status}', expected '200'.`);
404
+ }
405
+ const responseBody = JSON.parse(await readResponseText(response, stallGuard));
406
+ if (!hasShapeOf(responseBody, {
407
+ eventType: isString,
408
+ isPhantom: isBoolean,
409
+ })) {
410
+ throw new Error('Failed to parse response.');
411
+ }
412
+ return responseBody;
404
413
  });
405
- if (!isValidServerHeader(response)) {
406
- throw new Error('Server must be EventSourcingDB.');
407
- }
408
- if (response.status !== 200) {
409
- throw new Error(`Failed to read event type, got HTTP status code '${response.status}', expected '200'.`);
410
- }
411
- const responseBody = await response.json();
412
- if (!hasShapeOf(responseBody, {
413
- eventType: isString,
414
- isPhantom: isBoolean,
415
- })) {
416
- throw new Error('Failed to parse response.');
417
- }
418
- return responseBody;
419
414
  }
420
415
  readEventTypes(signal) {
421
416
  const url = this.#getUrl('/api/v1/read-event-types');
422
417
  const apiToken = this.#apiToken;
418
+ const stallTimeout = this.#stallTimeout;
423
419
  return (async function* () {
424
- const internalAbortController = new AbortController();
425
- const combinedSignal = signal ?? internalAbortController.signal;
426
- const shouldAbortInternally = !signal;
427
- let removeAbortListener;
428
- if (signal && !signal.aborted) {
429
- const onAbort = () => internalAbortController.abort();
430
- signal.addEventListener('abort', onAbort, { once: true });
431
- removeAbortListener = () => signal.removeEventListener('abort', onAbort);
432
- }
420
+ const stallGuard = new StallGuard(stallTimeout, signal);
433
421
  try {
434
- const response = await fetch(url, {
422
+ stallGuard.restart();
423
+ const response = await stallGuard.send(url, {
435
424
  method: 'post',
436
425
  headers: {
437
426
  authorization: `Bearer ${apiToken}`,
438
427
  },
439
- signal: combinedSignal,
440
428
  });
441
429
  if (!isValidServerHeader(response)) {
442
430
  const serverHeader = response.headers.get('Server');
@@ -451,7 +439,7 @@ class Client {
451
439
  if (!response.body) {
452
440
  throw new Error('Failed to read event types.');
453
441
  }
454
- for await (const line of readNdJsonStream(response.body, combinedSignal)) {
442
+ for await (const { value: line } of readNdJsonStream(response.body, stallGuard.signal, stallGuard)) {
455
443
  if (isStreamHeartbeat(line)) {
456
444
  continue;
457
445
  }
@@ -464,20 +452,17 @@ class Client {
464
452
  }
465
453
  throw new Error('Failed to read event types.');
466
454
  }
455
+ stallGuard.throwIfStalled();
467
456
  }
468
457
  catch (error) {
458
+ stallGuard.throwIfStalled();
469
459
  if (error instanceof DOMException && error.name === 'AbortError') {
470
460
  return;
471
461
  }
472
462
  throw error;
473
463
  }
474
464
  finally {
475
- if (removeAbortListener) {
476
- removeAbortListener();
477
- }
478
- if (shouldAbortInternally) {
479
- internalAbortController.abort();
480
- }
465
+ stallGuard.dispose({ abortRequest: true });
481
466
  }
482
467
  })();
483
468
  }
@@ -0,0 +1,4 @@
1
+ interface ClientOptions {
2
+ stallTimeout?: number;
3
+ }
4
+ export type { ClientOptions };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ declare class ConnectionStalledError extends TypeError {
2
+ constructor(stallTimeout?: number, options?: ErrorOptions);
3
+ }
4
+ export { ConnectionStalledError };
@@ -0,0 +1,13 @@
1
+ import { defaultStallTimeout } from './stall/defaultStallTimeout.js';
2
+ import { formatDuration } from './stall/formatDuration.js';
3
+ // Extends TypeError, since fetch fails with a TypeError if a request stalls
4
+ // for longer than its own timeouts allow, so code that catches that keeps
5
+ // working. For the same reason, its cause carries the code of the timeout
6
+ // error of fetch for the same situation.
7
+ class ConnectionStalledError extends TypeError {
8
+ constructor(stallTimeout = defaultStallTimeout, options) {
9
+ super(`Nothing arrived from the server for ${formatDuration(stallTimeout)}.`, options);
10
+ this.name = 'ConnectionStalledError';
11
+ }
12
+ }
13
+ export { ConnectionStalledError };
package/dist/Event.d.ts CHANGED
@@ -14,7 +14,7 @@ declare class Event {
14
14
  traceparent?: string;
15
15
  tracestate?: string;
16
16
  signature: string | null;
17
- constructor({ specversion, id, time, timeFromServer, source, subject, type, datacontenttype, data, hash, predecessorhash, traceparent, tracestate, signature, }: {
17
+ constructor({ specversion, id, time, timeFromServer, source, subject, type, datacontenttype, data, dataFromServer, hash, predecessorhash, traceparent, tracestate, signature, }: {
18
18
  specversion: string;
19
19
  id: string;
20
20
  time: Date;
@@ -24,6 +24,7 @@ declare class Event {
24
24
  type: string;
25
25
  datacontenttype: string;
26
26
  data: Record<string, unknown>;
27
+ dataFromServer: string;
27
28
  hash: string;
28
29
  predecessorhash: string;
29
30
  traceparent?: string;