@kizunajs/fetch 2.0.0-beta.20 → 2.0.0-beta.21

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 (3) hide show
  1. package/dist/index.cjs +118 -28
  2. package/dist/index.mjs +118 -28
  3. package/package.json +4 -4
package/dist/index.cjs CHANGED
@@ -58,6 +58,7 @@ export interface ClientConfig {
58
58
  fetch?: typeof fetch;
59
59
  onRequest?: (request: OutgoingRequest) => void | Promise<void>;
60
60
  retry?: RetryOptions;
61
+ reconnect?: ReconnectOptions;
61
62
  }
62
63
 
63
64
  /**
@@ -79,6 +80,25 @@ export interface RetryOptions {
79
80
  maximumDelay?: number;
80
81
  }
81
82
 
83
+ /**
84
+ * Reconnects an event stream on a GET, HEAD, OPTIONS, PUT or DELETE when its
85
+ * connection drops, sending the id of the last event as Last-Event-ID.
86
+ */
87
+ export interface ReconnectOptions {
88
+ /**
89
+ * Reconnections in a row before the stream fails. An event received resets the count.
90
+ */
91
+ maximumAttempts?: number;
92
+ /**
93
+ * Milliseconds to wait before reconnecting, until the server sends a retry field.
94
+ */
95
+ reconnectionTime?: number;
96
+ /**
97
+ * Milliseconds the wait may grow to after reconnections in a row that fail.
98
+ */
99
+ maximumDelay?: number;
100
+ }
101
+
82
102
  /**
83
103
  * One method on the client. Its '~route' names the method it sends and
84
104
  * whether its response streams, so a wrapper like @kizunajs/tanstack-query
@@ -187,17 +207,29 @@ const findLineEnd = (buffer: string, final: boolean): { index: number; length: n
187
207
  /**
188
208
  * Reads text/event-stream as the WHATWG HTML specification parses it.
189
209
  */
190
- async function* readEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<ServerSentEvent> {
210
+ interface EventStreamState {
211
+ lastEventId: string;
212
+ reconnectionTime: number;
213
+ }
214
+
215
+ async function* readEvents(
216
+ body: ReadableStream<Uint8Array>,
217
+ state: EventStreamState = {
218
+ lastEventId: '',
219
+ reconnectionTime: 3000,
220
+ }
221
+ ): AsyncGenerator<ServerSentEvent> {
191
222
  const reader = body.getReader();
192
223
  const decoder = new TextDecoder();
193
224
  let buffer = '';
194
225
  let first = true;
195
226
  let dataLines: string[] = [];
196
227
  let eventType = '';
197
- let lastEventId = '';
228
+ let lastEventId = state.lastEventId;
198
229
  let retry: number | undefined;
199
230
 
200
231
  const dispatch = (): ServerSentEvent | undefined => {
232
+ state.lastEventId = lastEventId;
201
233
  if (dataLines.length === 0) {
202
234
  eventType = '';
203
235
  return undefined;
@@ -232,7 +264,10 @@ async function* readEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<Ser
232
264
  if (!value.includes('\0')) lastEventId = value;
233
265
  break;
234
266
  case 'retry':
235
- if (/^\d+$/.test(value)) retry = Number(value);
267
+ if (/^\d+$/.test(value)) {
268
+ retry = Number(value);
269
+ state.reconnectionTime = Math.min(retry, 3_600_000);
270
+ }
236
271
  break;
237
272
  }
238
273
  return undefined;
@@ -262,6 +297,57 @@ async function* readEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<Ser
262
297
  }
263
298
  }
264
299
 
300
+ async function* reconnectingEvents(
301
+ first: Response,
302
+ reopen: (lastEventId: string) => Promise<Response>,
303
+ options: ReconnectOptions,
304
+ signal: AbortSignal | null | undefined
305
+ ): AsyncGenerator<ServerSentEvent> {
306
+ const maximumAttempts = options.maximumAttempts ?? 5;
307
+ const maximumDelay = options.maximumDelay ?? 30_000;
308
+ const state: EventStreamState = {
309
+ lastEventId: '',
310
+ reconnectionTime: options.reconnectionTime ?? 3000,
311
+ };
312
+ let response: Response | undefined = first;
313
+ let attempts = 0;
314
+ let failure: unknown;
315
+ for (;;) {
316
+ if (response?.body) {
317
+ try {
318
+ for await (const message of readEvents(response.body, state)) {
319
+ attempts = 0;
320
+ failure = undefined;
321
+ yield message;
322
+ }
323
+ } catch (error) {
324
+ failure = error;
325
+ }
326
+ }
327
+ if (signal?.aborted) throw signal.reason;
328
+ if (attempts >= maximumAttempts) {
329
+ if (failure !== undefined) throw failure;
330
+ return;
331
+ }
332
+ attempts += 1;
333
+ const ceiling = Math.min(maximumDelay, state.reconnectionTime * 2 ** (attempts - 1));
334
+ await wait(state.reconnectionTime + Math.random() * Math.max(0, ceiling - state.reconnectionTime), signal);
335
+ try {
336
+ response = await reopen(state.lastEventId);
337
+ } catch (error) {
338
+ if (signal?.aborted) throw error;
339
+ failure = error;
340
+ response = undefined;
341
+ continue;
342
+ }
343
+ if (response.status === 204) return;
344
+ const contentType = response.headers.get('content-type') ?? '';
345
+ if (response.status !== first.status || !contentType.toLowerCase().startsWith('text/event-stream')) {
346
+ throw new Error('Reconnecting to the event stream failed with status ' + response.status);
347
+ }
348
+ }
349
+ }
350
+
265
351
  async function* readText(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
266
352
  const reader = body.getReader();
267
353
  const decoder = new TextDecoder();
@@ -360,41 +446,45 @@ const buildMethod = (route: GeneratedRoute, config: ClientConfig) => {
360
446
  const call = async (args: CallArgs = {}) => {
361
447
  const query = args.query === undefined ? '' : buildSearchParams(args.query).toString();
362
448
  const url = config.baseUrl + buildPath(route.path, args.params) + (query.length > 0 ? '?' + query : '');
363
- const headers = new Headers(config.baseHeaders);
364
- for (const [name, value] of Object.entries(args.headers ?? {})) {
365
- if (value !== undefined) headers.set(name, value);
366
- }
367
- const init: RequestInit = {
368
- method: route.method,
369
- headers,
370
- };
371
- if (args.body !== undefined) init.body = encodeBody(route, args.body, headers);
372
- if (config.credentials !== undefined) init.credentials = config.credentials;
373
- await config.onRequest?.({
374
- url,
375
- method: route.method,
376
- headers,
377
- route,
378
- });
379
-
380
- const response = await send(
381
- url,
382
- {
449
+ const prepare = async (lastEventId = ''): Promise<RequestInit> => {
450
+ const headers = new Headers(config.baseHeaders);
451
+ for (const [name, value] of Object.entries(args.headers ?? {})) {
452
+ if (value !== undefined) headers.set(name, value);
453
+ }
454
+ if (lastEventId !== '') headers.set('Last-Event-ID', lastEventId);
455
+ const init: RequestInit = {
456
+ method: route.method,
457
+ headers,
458
+ };
459
+ if (args.body !== undefined) init.body = encodeBody(route, args.body, headers);
460
+ if (config.credentials !== undefined) init.credentials = config.credentials;
461
+ await config.onRequest?.({
462
+ url,
463
+ method: route.method,
464
+ headers,
465
+ route,
466
+ });
467
+ return {
383
468
  ...init,
384
469
  ...args.fetchOptions,
385
- },
386
- route,
387
- config
388
- );
470
+ };
471
+ };
472
+
473
+ const response = await send(url, await prepare(), route, config);
389
474
  const responseHeaders: Record<string, string> = {};
390
475
  response.headers.forEach((value, name) => {
391
476
  responseHeaders[name] = value;
392
477
  });
393
478
  const declared = route.responses[response.status];
394
479
  if (declared !== undefined && 'stream' in declared && response.body !== null) {
480
+ const essence = (declared.contentType.split(';')[0] ?? '').trim().toLowerCase();
481
+ const reconnect = essence === 'text/event-stream' && IDEMPOTENT_METHODS.has(route.method) ? config.reconnect : undefined;
482
+ const reopen = async (lastEventId: string) => send(url, await prepare(lastEventId), route, config);
395
483
  return {
396
484
  status: response.status,
397
- body: readStream(response.body, declared.contentType),
485
+ body: reconnect
486
+ ? reconnectingEvents(response, reopen, reconnect, args.fetchOptions?.signal)
487
+ : readStream(response.body, declared.contentType),
398
488
  headers: responseHeaders,
399
489
  };
400
490
  }
package/dist/index.mjs CHANGED
@@ -57,6 +57,7 @@ export interface ClientConfig {
57
57
  fetch?: typeof fetch;
58
58
  onRequest?: (request: OutgoingRequest) => void | Promise<void>;
59
59
  retry?: RetryOptions;
60
+ reconnect?: ReconnectOptions;
60
61
  }
61
62
 
62
63
  /**
@@ -78,6 +79,25 @@ export interface RetryOptions {
78
79
  maximumDelay?: number;
79
80
  }
80
81
 
82
+ /**
83
+ * Reconnects an event stream on a GET, HEAD, OPTIONS, PUT or DELETE when its
84
+ * connection drops, sending the id of the last event as Last-Event-ID.
85
+ */
86
+ export interface ReconnectOptions {
87
+ /**
88
+ * Reconnections in a row before the stream fails. An event received resets the count.
89
+ */
90
+ maximumAttempts?: number;
91
+ /**
92
+ * Milliseconds to wait before reconnecting, until the server sends a retry field.
93
+ */
94
+ reconnectionTime?: number;
95
+ /**
96
+ * Milliseconds the wait may grow to after reconnections in a row that fail.
97
+ */
98
+ maximumDelay?: number;
99
+ }
100
+
81
101
  /**
82
102
  * One method on the client. Its '~route' names the method it sends and
83
103
  * whether its response streams, so a wrapper like @kizunajs/tanstack-query
@@ -186,17 +206,29 @@ const findLineEnd = (buffer: string, final: boolean): { index: number; length: n
186
206
  /**
187
207
  * Reads text/event-stream as the WHATWG HTML specification parses it.
188
208
  */
189
- async function* readEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<ServerSentEvent> {
209
+ interface EventStreamState {
210
+ lastEventId: string;
211
+ reconnectionTime: number;
212
+ }
213
+
214
+ async function* readEvents(
215
+ body: ReadableStream<Uint8Array>,
216
+ state: EventStreamState = {
217
+ lastEventId: '',
218
+ reconnectionTime: 3000,
219
+ }
220
+ ): AsyncGenerator<ServerSentEvent> {
190
221
  const reader = body.getReader();
191
222
  const decoder = new TextDecoder();
192
223
  let buffer = '';
193
224
  let first = true;
194
225
  let dataLines: string[] = [];
195
226
  let eventType = '';
196
- let lastEventId = '';
227
+ let lastEventId = state.lastEventId;
197
228
  let retry: number | undefined;
198
229
 
199
230
  const dispatch = (): ServerSentEvent | undefined => {
231
+ state.lastEventId = lastEventId;
200
232
  if (dataLines.length === 0) {
201
233
  eventType = '';
202
234
  return undefined;
@@ -231,7 +263,10 @@ async function* readEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<Ser
231
263
  if (!value.includes('\0')) lastEventId = value;
232
264
  break;
233
265
  case 'retry':
234
- if (/^\d+$/.test(value)) retry = Number(value);
266
+ if (/^\d+$/.test(value)) {
267
+ retry = Number(value);
268
+ state.reconnectionTime = Math.min(retry, 3_600_000);
269
+ }
235
270
  break;
236
271
  }
237
272
  return undefined;
@@ -261,6 +296,57 @@ async function* readEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<Ser
261
296
  }
262
297
  }
263
298
 
299
+ async function* reconnectingEvents(
300
+ first: Response,
301
+ reopen: (lastEventId: string) => Promise<Response>,
302
+ options: ReconnectOptions,
303
+ signal: AbortSignal | null | undefined
304
+ ): AsyncGenerator<ServerSentEvent> {
305
+ const maximumAttempts = options.maximumAttempts ?? 5;
306
+ const maximumDelay = options.maximumDelay ?? 30_000;
307
+ const state: EventStreamState = {
308
+ lastEventId: '',
309
+ reconnectionTime: options.reconnectionTime ?? 3000,
310
+ };
311
+ let response: Response | undefined = first;
312
+ let attempts = 0;
313
+ let failure: unknown;
314
+ for (;;) {
315
+ if (response?.body) {
316
+ try {
317
+ for await (const message of readEvents(response.body, state)) {
318
+ attempts = 0;
319
+ failure = undefined;
320
+ yield message;
321
+ }
322
+ } catch (error) {
323
+ failure = error;
324
+ }
325
+ }
326
+ if (signal?.aborted) throw signal.reason;
327
+ if (attempts >= maximumAttempts) {
328
+ if (failure !== undefined) throw failure;
329
+ return;
330
+ }
331
+ attempts += 1;
332
+ const ceiling = Math.min(maximumDelay, state.reconnectionTime * 2 ** (attempts - 1));
333
+ await wait(state.reconnectionTime + Math.random() * Math.max(0, ceiling - state.reconnectionTime), signal);
334
+ try {
335
+ response = await reopen(state.lastEventId);
336
+ } catch (error) {
337
+ if (signal?.aborted) throw error;
338
+ failure = error;
339
+ response = undefined;
340
+ continue;
341
+ }
342
+ if (response.status === 204) return;
343
+ const contentType = response.headers.get('content-type') ?? '';
344
+ if (response.status !== first.status || !contentType.toLowerCase().startsWith('text/event-stream')) {
345
+ throw new Error('Reconnecting to the event stream failed with status ' + response.status);
346
+ }
347
+ }
348
+ }
349
+
264
350
  async function* readText(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
265
351
  const reader = body.getReader();
266
352
  const decoder = new TextDecoder();
@@ -359,41 +445,45 @@ const buildMethod = (route: GeneratedRoute, config: ClientConfig) => {
359
445
  const call = async (args: CallArgs = {}) => {
360
446
  const query = args.query === undefined ? '' : buildSearchParams(args.query).toString();
361
447
  const url = config.baseUrl + buildPath(route.path, args.params) + (query.length > 0 ? '?' + query : '');
362
- const headers = new Headers(config.baseHeaders);
363
- for (const [name, value] of Object.entries(args.headers ?? {})) {
364
- if (value !== undefined) headers.set(name, value);
365
- }
366
- const init: RequestInit = {
367
- method: route.method,
368
- headers,
369
- };
370
- if (args.body !== undefined) init.body = encodeBody(route, args.body, headers);
371
- if (config.credentials !== undefined) init.credentials = config.credentials;
372
- await config.onRequest?.({
373
- url,
374
- method: route.method,
375
- headers,
376
- route,
377
- });
378
-
379
- const response = await send(
380
- url,
381
- {
448
+ const prepare = async (lastEventId = ''): Promise<RequestInit> => {
449
+ const headers = new Headers(config.baseHeaders);
450
+ for (const [name, value] of Object.entries(args.headers ?? {})) {
451
+ if (value !== undefined) headers.set(name, value);
452
+ }
453
+ if (lastEventId !== '') headers.set('Last-Event-ID', lastEventId);
454
+ const init: RequestInit = {
455
+ method: route.method,
456
+ headers,
457
+ };
458
+ if (args.body !== undefined) init.body = encodeBody(route, args.body, headers);
459
+ if (config.credentials !== undefined) init.credentials = config.credentials;
460
+ await config.onRequest?.({
461
+ url,
462
+ method: route.method,
463
+ headers,
464
+ route,
465
+ });
466
+ return {
382
467
  ...init,
383
468
  ...args.fetchOptions,
384
- },
385
- route,
386
- config
387
- );
469
+ };
470
+ };
471
+
472
+ const response = await send(url, await prepare(), route, config);
388
473
  const responseHeaders: Record<string, string> = {};
389
474
  response.headers.forEach((value, name) => {
390
475
  responseHeaders[name] = value;
391
476
  });
392
477
  const declared = route.responses[response.status];
393
478
  if (declared !== undefined && 'stream' in declared && response.body !== null) {
479
+ const essence = (declared.contentType.split(';')[0] ?? '').trim().toLowerCase();
480
+ const reconnect = essence === 'text/event-stream' && IDEMPOTENT_METHODS.has(route.method) ? config.reconnect : undefined;
481
+ const reopen = async (lastEventId: string) => send(url, await prepare(lastEventId), route, config);
394
482
  return {
395
483
  status: response.status,
396
- body: readStream(response.body, declared.contentType),
484
+ body: reconnect
485
+ ? reconnectingEvents(response, reopen, reconnect, args.fetchOptions?.signal)
486
+ : readStream(response.body, declared.contentType),
397
487
  headers: responseHeaders,
398
488
  };
399
489
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kizunajs/fetch",
3
- "version": "2.0.0-beta.20",
3
+ "version": "2.0.0-beta.21",
4
4
  "description": "Fetch client generation for Kizuna.js APIs",
5
5
  "keywords": [
6
6
  "kizunajs",
@@ -56,12 +56,12 @@
56
56
  "access": "public"
57
57
  },
58
58
  "peerDependencies": {
59
- "kizunajs": "2.0.0-beta.20",
59
+ "kizunajs": "2.0.0-beta.21",
60
60
  "zod": "^4.0.0"
61
61
  },
62
62
  "devDependencies": {
63
- "kizunajs": "2.0.0-beta.20",
64
- "@kizunajs/express": "2.0.0-beta.20",
63
+ "kizunajs": "2.0.0-beta.21",
64
+ "@kizunajs/express": "2.0.0-beta.21",
65
65
  "@types/express": "^5.0.0",
66
66
  "express": "^5.0.0",
67
67
  "tsdown": "^0.21.0",