@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.
- package/dist/index.cjs +118 -28
- package/dist/index.mjs +118 -28
- 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
|
-
|
|
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))
|
|
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
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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:
|
|
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
|
-
|
|
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))
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
59
|
+
"kizunajs": "2.0.0-beta.21",
|
|
60
60
|
"zod": "^4.0.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"kizunajs": "2.0.0-beta.
|
|
64
|
-
"@kizunajs/express": "2.0.0-beta.
|
|
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",
|