@dunx/http 3.5.1 → 3.6.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.
- package/README.md +5 -4
- package/dist/client/json.d.ts +12 -11
- package/dist/client/options.d.ts +3 -3
- package/dist/client/retry.d.ts +17 -31
- package/dist/client/service.d.ts +8 -2
- package/dist/client/sse.d.ts +26 -0
- package/dist/client.d.ts +8 -1
- package/dist/client.js +131 -108
- package/dist/server/options.d.ts +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -64,11 +64,12 @@ The guide is canonical for every row; this table is the index.
|
|
|
64
64
|
| WebSocket gateways | `@Gateway`, handlers, `PubSub`, multi-node relay | [WebSockets](../../docs/guide/09-websockets.md) |
|
|
65
65
|
| Request logging | One structured entry per request, on by default | [Logging](../../docs/guide/13-logging.md) |
|
|
66
66
|
| Trace context | W3C `traceparent` adopted and propagated, on by default | [Logging](../../docs/guide/13-logging.md) |
|
|
67
|
-
| Metrics | Per-route counts and timings, off by default | [Metrics](../../docs/guide/
|
|
68
|
-
| Health and draining | `/health/live`, `/health/ready`, readiness during a rollout | [Health checks](../../docs/guide/
|
|
67
|
+
| Metrics | Per-route counts and timings, off by default | [Metrics](../../docs/guide/23-metrics.md) |
|
|
68
|
+
| Health and draining | `/health/live`, `/health/ready`, readiness during a rollout | [Health checks](../../docs/guide/21-health-checks.md) |
|
|
69
69
|
| Throttling | `@Throttle`, `@SkipThrottle`, memory and Redis counters | [Middleware and guards](../../docs/guide/08-middleware-and-guards.md) |
|
|
70
|
-
|
|
|
71
|
-
|
|
|
70
|
+
| Outbound resilience | `HttpRetryClassifier`: which statuses retry, and `Retry-After` | [Resilience](../../docs/guide/25-resilience.md) |
|
|
71
|
+
| Static files | `Bun.file` behind a mount, with a cache policy | [Deployment](../../docs/guide/20-deployment.md) |
|
|
72
|
+
| Compression | zstd and gzip on Bun's own compressors | [Deployment](../../docs/guide/20-deployment.md) |
|
|
72
73
|
|
|
73
74
|
## Subpaths
|
|
74
75
|
|
package/dist/client/json.d.ts
CHANGED
|
@@ -8,17 +8,7 @@
|
|
|
8
8
|
* the caller and should say so.
|
|
9
9
|
*/
|
|
10
10
|
export declare const safeStringify: (value: unknown) => string;
|
|
11
|
-
|
|
12
|
-
* A plain object: `{}`, `Object.create(null)`, or a JSON-parsed value. Anything
|
|
13
|
-
* with its own prototype - `Date`, `Map`, `Error`, a class instance - is not one.
|
|
14
|
-
*
|
|
15
|
-
* The prototype check rather than the reference's `typeof === 'object' && !Array
|
|
16
|
-
* && !(instanceof Error)`, which answered `true` for a `Date` and for every class
|
|
17
|
-
* instance, so "is this a plain object" did not mean what it said. Body routing
|
|
18
|
-
* does not use this - see {@link isJsonBody} - so tightening it changes no
|
|
19
|
-
* behaviour beyond making the predicate honest.
|
|
20
|
-
*/
|
|
21
|
-
export declare const isPlainObject: (value: unknown) => value is Record<string, unknown>;
|
|
11
|
+
export { isPlainObject } from '@dunx/core';
|
|
22
12
|
/**
|
|
23
13
|
* Whether a payload should be JSON-encoded, or handed to `fetch` as-is.
|
|
24
14
|
*
|
|
@@ -32,3 +22,14 @@ export declare const isPlainObject: (value: unknown) => value is Record<string,
|
|
|
32
22
|
* JSON-encodable; a `Blob` is neither.
|
|
33
23
|
*/
|
|
34
24
|
export declare const isJsonBody: (payload: unknown) => boolean;
|
|
25
|
+
/**
|
|
26
|
+
* JSON when the upstream said so or the body parses; text otherwise; undefined
|
|
27
|
+
* for empty.
|
|
28
|
+
*
|
|
29
|
+
* **A failed read rejects.** It used to be caught and reported as an empty body,
|
|
30
|
+
* so a 2xx whose body died mid-stream reached the caller as a success with no
|
|
31
|
+
* data, and a retry policy saw nothing to retry. A caller that wants the old
|
|
32
|
+
* behaviour asks for it: the two error paths in `HttpService` do, because there
|
|
33
|
+
* the status is the signal and an unreadable body should not replace it.
|
|
34
|
+
*/
|
|
35
|
+
export declare const readBody: (response: Response) => Promise<unknown>;
|
package/dist/client/options.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HttpRetryOptions } from './retry.js';
|
|
2
2
|
/**
|
|
3
3
|
* Named `HttpClientOptions`, not `HttpOptions`: the server half already exports
|
|
4
4
|
* that from `@dunx/http` for `HttpFactory.create`, and two things called
|
|
@@ -15,7 +15,7 @@ export interface HttpClientOptionsInit {
|
|
|
15
15
|
readonly timeoutMs?: number;
|
|
16
16
|
/** Sent on every request, under anything a call sets itself. */
|
|
17
17
|
readonly headers?: Readonly<Record<string, string>>;
|
|
18
|
-
readonly retry?:
|
|
18
|
+
readonly retry?: HttpRetryOptions;
|
|
19
19
|
/**
|
|
20
20
|
* Forward W3C Trace Context upstream as `traceparent`, so the callee's spans
|
|
21
21
|
* join this request's trace and one trace spans both services.
|
|
@@ -71,7 +71,7 @@ export declare class HttpClientOptions {
|
|
|
71
71
|
readonly baseUrl: string | undefined;
|
|
72
72
|
readonly timeoutMs: number;
|
|
73
73
|
readonly headers: Readonly<Record<string, string>>;
|
|
74
|
-
readonly retry:
|
|
74
|
+
readonly retry: HttpRetryOptions;
|
|
75
75
|
readonly propagateTrace: boolean;
|
|
76
76
|
readonly name: string | undefined;
|
|
77
77
|
readonly fetchOptions: Readonly<Record<string, unknown>>;
|
package/dist/client/retry.d.ts
CHANGED
|
@@ -1,49 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
/** Base delay, doubled each attempt. */
|
|
3
|
-
readonly baseMs: number;
|
|
4
|
-
/** @default 2 */
|
|
5
|
-
readonly power?: number;
|
|
6
|
-
/** Upper bound of the random component added to each delay. @default 1000 */
|
|
7
|
-
readonly jitterMs?: number;
|
|
8
|
-
/** @default 30000 */
|
|
9
|
-
readonly maxMs?: number;
|
|
10
|
-
}
|
|
11
|
-
/** `base * power^attempt + jitter`, capped. `attempt` is 0 for the first retry. */
|
|
12
|
-
export declare const backoffDelay: (attempt: number, { baseMs, power, jitterMs, maxMs }: BackoffOptions) => number;
|
|
1
|
+
import { RetryClassifier, type RetryOptions, type RetryVerdict } from '@dunx/core';
|
|
13
2
|
/**
|
|
14
3
|
* The wait an upstream asked for, in ms, or undefined.
|
|
15
4
|
*
|
|
16
5
|
* RFC 9110 allows either a delay in seconds or an HTTP date, and both appear in
|
|
17
|
-
* the wild
|
|
18
|
-
*
|
|
19
|
-
* told you exactly how long to wait.
|
|
6
|
+
* the wild: GitHub sends seconds, some CDNs send a date. Ignoring the header means
|
|
7
|
+
* retrying straight back into a rate limit that had just said how long to wait.
|
|
20
8
|
*/
|
|
21
9
|
export declare const retryAfterMs: (headers: Headers, now?: number) => number | undefined;
|
|
22
10
|
/**
|
|
23
11
|
* Statuses worth trying again: a server that failed, one that is overloaded, and
|
|
24
|
-
* one that timed out.
|
|
25
|
-
*
|
|
26
|
-
* it again unchanged gets the same answer.
|
|
12
|
+
* one that timed out. Narrower than 409 and 422, which are the server rejecting
|
|
13
|
+
* the *request* - sending it again unchanged gets the same answer.
|
|
27
14
|
*/
|
|
28
15
|
export declare const isRetryableStatus: (status: number) => boolean;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
readonly maxRetries?: number;
|
|
32
|
-
/** @default 1000 */
|
|
33
|
-
readonly retryDelayMs?: number;
|
|
34
|
-
readonly backoff?: Omit<BackoffOptions, 'baseMs'>;
|
|
16
|
+
/** Core's generic retry knobs plus the two an HTTP failure carries. */
|
|
17
|
+
export interface HttpRetryOptions<T = unknown> extends RetryOptions<T> {
|
|
35
18
|
/** @default isRetryableStatus */
|
|
36
19
|
readonly shouldRetryOnStatus?: (status: number) => boolean;
|
|
37
20
|
/** Honour a `Retry-After` header over the computed backoff. @default true */
|
|
38
21
|
readonly respectRetryAfter?: boolean;
|
|
39
|
-
readonly onAttempt?: (attempt: number, isRetry: boolean) => void;
|
|
40
|
-
readonly onError?: (error: unknown, attempt: number, willRetry: boolean) => void;
|
|
41
|
-
readonly onSuccess?: (result: T, attempt: number) => void;
|
|
42
22
|
}
|
|
43
23
|
/**
|
|
44
|
-
*
|
|
24
|
+
* The HTTP half of the retry decision, and the only place in dunx's resilience
|
|
25
|
+
* path that knows what a status is.
|
|
45
26
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
27
|
+
* An abort is never retried: the caller's signal fired or the timeout expired, and
|
|
28
|
+
* both mean the budget for this call is spent. A transport failure is retried,
|
|
29
|
+
* because a refused connection is the case retrying exists for.
|
|
48
30
|
*/
|
|
49
|
-
export declare
|
|
31
|
+
export declare class HttpRetryClassifier extends RetryClassifier {
|
|
32
|
+
private readonly options;
|
|
33
|
+
constructor(options?: HttpRetryOptions);
|
|
34
|
+
classify(error: unknown): RetryVerdict;
|
|
35
|
+
}
|
package/dist/client/service.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Logger, RequestContext } from '@dunx/core';
|
|
|
2
2
|
import { UrlHelper, type ParamsType } from '@arkv/shared';
|
|
3
3
|
import type { HttpMethod } from '../route/marker.js';
|
|
4
4
|
import { HttpClientOptions } from './options.js';
|
|
5
|
-
import { type
|
|
5
|
+
import { type HttpRetryOptions } from './retry.js';
|
|
6
6
|
/** The client speaks two more verbs than a route can declare. */
|
|
7
7
|
export type RequestMethod = HttpMethod | 'HEAD' | 'OPTIONS';
|
|
8
8
|
/**
|
|
@@ -36,7 +36,7 @@ export interface RequestConfig<TRequest = unknown, TResponse = unknown> {
|
|
|
36
36
|
readonly headerFactory?: HeaderFactory;
|
|
37
37
|
/** Merged into the async context for this call, so its logs carry it. */
|
|
38
38
|
readonly flow?: string;
|
|
39
|
-
readonly retry?:
|
|
39
|
+
readonly retry?: HttpRetryOptions<TResponse>;
|
|
40
40
|
/** Cancels the call. Combined with the timeout, whichever fires first. */
|
|
41
41
|
readonly signal?: AbortSignal;
|
|
42
42
|
}
|
|
@@ -86,6 +86,12 @@ export declare class HttpService extends UrlHelper {
|
|
|
86
86
|
* regex, so it cannot disagree with `new URL`.
|
|
87
87
|
*/
|
|
88
88
|
private urlFor;
|
|
89
|
+
/**
|
|
90
|
+
* One policy per call, because the budget and the caller's signal are. Core owns
|
|
91
|
+
* the timeout, the loop and the backoff; `HttpRetryClassifier` is the only part
|
|
92
|
+
* of it that knows what a status is.
|
|
93
|
+
*/
|
|
94
|
+
private policyFor;
|
|
89
95
|
/** `serialised` is what a `headerFactory` signs, and is `''` for no body. */
|
|
90
96
|
private bodyFor;
|
|
91
97
|
private send;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `data:` payloads of a server-sent-events body, in order, ending when the
|
|
3
|
+
* stream does or when a line reads `[DONE]`.
|
|
4
|
+
*
|
|
5
|
+
* Async iteration rather than `getReader()`: it acquires the reader and releases
|
|
6
|
+
* it on completion, on a `break` in the consumer, and on the `[DONE]` return,
|
|
7
|
+
* which is the case the manual form needed a `releaseLock()` in a `finally` for.
|
|
8
|
+
*
|
|
9
|
+
* Hand-rolled because Bun exposes no `EventSource` global and no SSE parser,
|
|
10
|
+
* which was measured rather than assumed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function sseData(body: ReadableStream<Uint8Array>): AsyncGenerator<string>;
|
|
13
|
+
/**
|
|
14
|
+
* A deadline on the connect alone.
|
|
15
|
+
*
|
|
16
|
+
* An `AbortSignal.timeout` handed to `fetch` keeps aborting after the headers
|
|
17
|
+
* arrive, which cut a 600 ms stream at 200 ms when the policy supplied one. This
|
|
18
|
+
* holds its timer so the caller can drop it the moment `fetch` resolves, leaving
|
|
19
|
+
* the body cancellable only by the caller's own signal.
|
|
20
|
+
*/
|
|
21
|
+
export declare class ConnectDeadline {
|
|
22
|
+
#private;
|
|
23
|
+
constructor(ms: number, target: string);
|
|
24
|
+
get signal(): AbortSignal;
|
|
25
|
+
clear(): void;
|
|
26
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { FetchError, FetchTransportError } from './client/errors.js';
|
|
9
9
|
export { HttpClientOptions, type HttpClientOptionsInit, } from './client/options.js';
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Retry, backoff and jitter are `@dunx/core`'s, re-exported so an import of this
|
|
12
|
+
* subpath still names them. What stays here is the HTTP half of the decision:
|
|
13
|
+
* `HttpRetryClassifier` reads a status and a `Retry-After`, which is the seam that
|
|
14
|
+
* keeps both out of core.
|
|
15
|
+
*/
|
|
16
|
+
export type { BackoffOptions, RetryOptions } from '@dunx/core';
|
|
17
|
+
export { HttpRetryClassifier, type HttpRetryOptions } from './client/retry.js';
|
|
11
18
|
export { httpClient, HttpModule, type ClientTarget } from './client/module.js';
|
|
12
19
|
export { HttpService, type HeaderFactory, type RequestConfig, type RequestMethod, } from './client/service.js';
|
package/dist/client.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
import {
|
|
3
|
+
HttpStatusCode2
|
|
4
|
+
} from "./chunk-bg0dr54z.js";
|
|
2
5
|
import {
|
|
3
6
|
TRACEPARENT_HEADER2,
|
|
4
7
|
TRACESTATE_HEADER2,
|
|
5
8
|
TraceContext2
|
|
6
9
|
} from "./chunk-gmtwad7f.js";
|
|
7
|
-
import {
|
|
8
|
-
HttpStatusCode2
|
|
9
|
-
} from "./chunk-bg0dr54z.js";
|
|
10
10
|
|
|
11
11
|
// src/client/errors.ts
|
|
12
12
|
import { AppError } from "@dunx/core";
|
|
@@ -71,6 +71,45 @@ class HttpClientOptions {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
Object.defineProperty(HttpClientOptions, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "init: HttpClientOptionsInit = {}", optional: true }] });
|
|
74
|
+
// src/client/retry.ts
|
|
75
|
+
import {
|
|
76
|
+
RetryClassifier
|
|
77
|
+
} from "@dunx/core";
|
|
78
|
+
var retryAfterMs = (headers, now = Date.now()) => {
|
|
79
|
+
const header = headers.get("retry-after");
|
|
80
|
+
if (header === null)
|
|
81
|
+
return;
|
|
82
|
+
const seconds = Number(header);
|
|
83
|
+
if (Number.isFinite(seconds))
|
|
84
|
+
return Math.max(0, seconds * 1000);
|
|
85
|
+
const at = Date.parse(header);
|
|
86
|
+
return Number.isNaN(at) ? undefined : Math.max(0, at - now);
|
|
87
|
+
};
|
|
88
|
+
var isRetryableStatus = (status) => status >= HttpStatusCode2.INTERNAL_SERVER_ERROR || status === HttpStatusCode2.REQUEST_TIMEOUT || status === HttpStatusCode2.TOO_MANY_REQUESTS;
|
|
89
|
+
|
|
90
|
+
class HttpRetryClassifier extends RetryClassifier {
|
|
91
|
+
options;
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
super();
|
|
94
|
+
this.options = options;
|
|
95
|
+
}
|
|
96
|
+
classify(error) {
|
|
97
|
+
if (error instanceof FetchTransportError)
|
|
98
|
+
return { retry: !error.aborted };
|
|
99
|
+
if (error instanceof FetchError) {
|
|
100
|
+
const {
|
|
101
|
+
shouldRetryOnStatus = isRetryableStatus,
|
|
102
|
+
respectRetryAfter = true
|
|
103
|
+
} = this.options;
|
|
104
|
+
if (!shouldRetryOnStatus(error.status))
|
|
105
|
+
return { retry: false };
|
|
106
|
+
const asked = respectRetryAfter ? retryAfterMs(error.response.headers) : undefined;
|
|
107
|
+
return asked === undefined ? { retry: true } : { retry: true, delayMs: asked };
|
|
108
|
+
}
|
|
109
|
+
return { retry: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
Object.defineProperty(HttpRetryClassifier, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "private readonly options: HttpRetryOptions = {}", optional: true }] });
|
|
74
113
|
// src/client/module.ts
|
|
75
114
|
import {
|
|
76
115
|
Logger as Logger2,
|
|
@@ -80,10 +119,16 @@ import {
|
|
|
80
119
|
} from "@dunx/core";
|
|
81
120
|
|
|
82
121
|
// src/client/service.ts
|
|
83
|
-
import {
|
|
122
|
+
import {
|
|
123
|
+
Logger,
|
|
124
|
+
RequestContext,
|
|
125
|
+
ResilienceOptions,
|
|
126
|
+
ResiliencePolicy
|
|
127
|
+
} from "@dunx/core";
|
|
84
128
|
import { UrlHelper } from "@arkv/shared";
|
|
85
129
|
|
|
86
130
|
// src/client/json.ts
|
|
131
|
+
import { isPlainObject } from "@dunx/core";
|
|
87
132
|
var safeStringify = (value) => {
|
|
88
133
|
const seen = new WeakSet;
|
|
89
134
|
return JSON.stringify(value, (_key, entry) => {
|
|
@@ -102,69 +147,57 @@ var isJsonBody = (payload) => {
|
|
|
102
147
|
return typeof payload !== "string";
|
|
103
148
|
return !(payload instanceof FormData || payload instanceof URLSearchParams || payload instanceof Blob || payload instanceof ArrayBuffer || payload instanceof ReadableStream || ArrayBuffer.isView(payload));
|
|
104
149
|
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const buffer = new Uint32Array(1);
|
|
109
|
-
crypto.getRandomValues(buffer);
|
|
110
|
-
return (buffer[0] ?? 0) / 2 ** 32;
|
|
111
|
-
};
|
|
112
|
-
var backoffDelay = (attempt, { baseMs, power = 2, jitterMs = 1000, maxMs = 30000 }) => Math.min(baseMs * power ** attempt + uniform() * jitterMs, maxMs);
|
|
113
|
-
var retryAfterMs = (headers, now = Date.now()) => {
|
|
114
|
-
const header = headers.get("retry-after");
|
|
115
|
-
if (header === null)
|
|
150
|
+
var readBody = async (response) => {
|
|
151
|
+
const text = await response.text();
|
|
152
|
+
if (text === "")
|
|
116
153
|
return;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return Number.isNaN(at) ? undefined : Math.max(0, at - now);
|
|
122
|
-
};
|
|
123
|
-
var isRetryableStatus = (status) => status >= HttpStatusCode2.INTERNAL_SERVER_ERROR || status === HttpStatusCode2.REQUEST_TIMEOUT || status === HttpStatusCode2.TOO_MANY_REQUESTS;
|
|
124
|
-
var decide = (error, attempt, options) => {
|
|
125
|
-
const {
|
|
126
|
-
retryDelayMs = 1000,
|
|
127
|
-
backoff,
|
|
128
|
-
shouldRetryOnStatus = isRetryableStatus,
|
|
129
|
-
respectRetryAfter = true
|
|
130
|
-
} = options;
|
|
131
|
-
const computed = backoffDelay(attempt, { baseMs: retryDelayMs, ...backoff });
|
|
132
|
-
if (error instanceof FetchTransportError) {
|
|
133
|
-
return { retry: !error.aborted, delayMs: computed };
|
|
134
|
-
}
|
|
135
|
-
if (error instanceof FetchError) {
|
|
136
|
-
if (!shouldRetryOnStatus(error.status))
|
|
137
|
-
return { retry: false, delayMs: 0 };
|
|
138
|
-
const asked = respectRetryAfter ? retryAfterMs(error.response.headers) : undefined;
|
|
139
|
-
const maxMs = backoff?.maxMs ?? 30000;
|
|
140
|
-
return {
|
|
141
|
-
retry: true,
|
|
142
|
-
delayMs: asked === undefined ? computed : Math.min(asked, maxMs)
|
|
143
|
-
};
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(text);
|
|
156
|
+
} catch {
|
|
157
|
+
return text;
|
|
144
158
|
}
|
|
145
|
-
return { retry: true, delayMs: computed };
|
|
146
159
|
};
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (!
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
|
|
161
|
+
// src/client/sse.ts
|
|
162
|
+
async function* sseData(body) {
|
|
163
|
+
const decoder = new TextDecoder;
|
|
164
|
+
let buffer = "";
|
|
165
|
+
for await (const chunk of body) {
|
|
166
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
167
|
+
let newline = buffer.indexOf(`
|
|
168
|
+
`);
|
|
169
|
+
while (newline !== -1) {
|
|
170
|
+
const line = buffer.slice(0, newline).trim();
|
|
171
|
+
buffer = buffer.slice(newline + 1);
|
|
172
|
+
newline = buffer.indexOf(`
|
|
173
|
+
`);
|
|
174
|
+
if (!line.startsWith("data:"))
|
|
175
|
+
continue;
|
|
176
|
+
const data = line.slice(5).trim();
|
|
177
|
+
if (data === "[DONE]")
|
|
178
|
+
return;
|
|
179
|
+
yield data;
|
|
164
180
|
}
|
|
165
181
|
}
|
|
166
|
-
|
|
167
|
-
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
class ConnectDeadline {
|
|
185
|
+
#controller = new AbortController;
|
|
186
|
+
#timer;
|
|
187
|
+
constructor(ms, target) {
|
|
188
|
+
this.#timer = ms > 0 ? setTimeout(() => {
|
|
189
|
+
this.#controller.abort(new DOMException(`Connecting to ${target} timed out after ${ms}ms`, "TimeoutError"));
|
|
190
|
+
}, ms) : undefined;
|
|
191
|
+
}
|
|
192
|
+
get signal() {
|
|
193
|
+
return this.#controller.signal;
|
|
194
|
+
}
|
|
195
|
+
clear() {
|
|
196
|
+
if (this.#timer !== undefined)
|
|
197
|
+
clearTimeout(this.#timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
Object.defineProperty(ConnectDeadline, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "ms: number" }, { unresolved: "target: string" }] });
|
|
168
201
|
|
|
169
202
|
// src/client/service.ts
|
|
170
203
|
class HttpService extends UrlHelper {
|
|
@@ -184,12 +217,14 @@ class HttpService extends UrlHelper {
|
|
|
184
217
|
let status;
|
|
185
218
|
const { body, serialised } = this.bodyFor(config.payload);
|
|
186
219
|
const replayable = !(config.payload instanceof ReadableStream);
|
|
187
|
-
const attempt = async () => {
|
|
220
|
+
const attempt = async (signal) => {
|
|
188
221
|
attempts += 1;
|
|
189
|
-
const response = await this.send(config, url, body, serialised);
|
|
222
|
+
const response = await this.send(config, url, body, serialised, signal);
|
|
190
223
|
status = response.status;
|
|
191
224
|
if (!response.ok) {
|
|
192
|
-
throw new FetchError(response.status, response.statusText, await readBody(response)
|
|
225
|
+
throw new FetchError(response.status, response.statusText, await readBody(response).catch(() => {
|
|
226
|
+
return;
|
|
227
|
+
}), {
|
|
193
228
|
method: config.method,
|
|
194
229
|
url: url.href,
|
|
195
230
|
headers: response.headers
|
|
@@ -198,15 +233,16 @@ class HttpService extends UrlHelper {
|
|
|
198
233
|
return await readBody(response);
|
|
199
234
|
};
|
|
200
235
|
const describe = () => `${config.method} ${url.href}`;
|
|
236
|
+
const policy = this.policyFor(config, {
|
|
237
|
+
...this.options.retry,
|
|
238
|
+
...config.retry,
|
|
239
|
+
...replayable ? {} : { maxRetries: 0 }
|
|
240
|
+
});
|
|
201
241
|
try {
|
|
202
242
|
const result = await this.requestContext.runWithContext({
|
|
203
243
|
...config.flow === undefined ? {} : { flow: config.flow },
|
|
204
244
|
event: config.path ?? url.pathname
|
|
205
|
-
}, () =>
|
|
206
|
-
...this.options.retry,
|
|
207
|
-
...config.retry,
|
|
208
|
-
...replayable ? {} : { maxRetries: 0 }
|
|
209
|
-
}));
|
|
245
|
+
}, () => policy.run(attempt));
|
|
210
246
|
this.logger.debug(`${describe()} succeeded`, {
|
|
211
247
|
status,
|
|
212
248
|
attempts,
|
|
@@ -265,30 +301,23 @@ class HttpService extends UrlHelper {
|
|
|
265
301
|
const method = config.method ?? "POST";
|
|
266
302
|
const startedAt = Date.now();
|
|
267
303
|
const { body, serialised } = this.bodyFor(config.payload);
|
|
268
|
-
const
|
|
304
|
+
const deadline = new ConnectDeadline(config.timeoutMs ?? this.options.timeoutMs, url.href);
|
|
305
|
+
let response;
|
|
306
|
+
try {
|
|
307
|
+
const policy = this.policyFor({ ...config, timeoutMs: 0 }, {
|
|
308
|
+
maxRetries: 0
|
|
309
|
+
});
|
|
310
|
+
response = await policy.run((signal) => this.send({ ...config, method }, url, body, serialised, AbortSignal.any([signal, deadline.signal]), "text/event-stream"));
|
|
311
|
+
} finally {
|
|
312
|
+
deadline.clear();
|
|
313
|
+
}
|
|
269
314
|
if (!response.ok || response.body === null) {
|
|
270
|
-
throw new FetchError(response.status, response.statusText, await readBody(response)
|
|
315
|
+
throw new FetchError(response.status, response.statusText, await readBody(response).catch(() => {
|
|
316
|
+
return;
|
|
317
|
+
}), { method, url: url.href, headers: response.headers });
|
|
271
318
|
}
|
|
272
|
-
const decoder = new TextDecoder;
|
|
273
|
-
let buffer = "";
|
|
274
319
|
try {
|
|
275
|
-
|
|
276
|
-
buffer += decoder.decode(chunk, { stream: true });
|
|
277
|
-
let newline = buffer.indexOf(`
|
|
278
|
-
`);
|
|
279
|
-
while (newline !== -1) {
|
|
280
|
-
const line = buffer.slice(0, newline).trim();
|
|
281
|
-
buffer = buffer.slice(newline + 1);
|
|
282
|
-
newline = buffer.indexOf(`
|
|
283
|
-
`);
|
|
284
|
-
if (!line.startsWith("data:"))
|
|
285
|
-
continue;
|
|
286
|
-
const data = line.slice(5).trim();
|
|
287
|
-
if (data === "[DONE]")
|
|
288
|
-
return;
|
|
289
|
-
yield data;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
320
|
+
yield* sseData(response.body);
|
|
292
321
|
} finally {
|
|
293
322
|
this.logger.debug(`SSE ${method} ${url.href} closed`, {
|
|
294
323
|
elapsedMs: Date.now() - startedAt
|
|
@@ -313,6 +342,14 @@ class HttpService extends UrlHelper {
|
|
|
313
342
|
...config.queryParams === undefined ? {} : { queryParams: config.queryParams }
|
|
314
343
|
});
|
|
315
344
|
}
|
|
345
|
+
policyFor(config, retry) {
|
|
346
|
+
return new ResiliencePolicy(new ResilienceOptions({
|
|
347
|
+
timeoutMs: config.timeoutMs ?? this.options.timeoutMs,
|
|
348
|
+
...config.signal === undefined ? {} : { signal: config.signal },
|
|
349
|
+
retry,
|
|
350
|
+
classifier: new HttpRetryClassifier(retry)
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
316
353
|
bodyFor(payload) {
|
|
317
354
|
if (payload === undefined || payload === null) {
|
|
318
355
|
return { body: undefined, serialised: "", json: false };
|
|
@@ -323,7 +360,7 @@ class HttpService extends UrlHelper {
|
|
|
323
360
|
const serialised = JSON.stringify(payload);
|
|
324
361
|
return { body: serialised, serialised, json: true };
|
|
325
362
|
}
|
|
326
|
-
async send(config, url, body, serialised, accept = "application/json") {
|
|
363
|
+
async send(config, url, body, serialised, signal, accept = "application/json") {
|
|
327
364
|
const trace = this.options.propagateTrace ? this.requestContext.getContext() : undefined;
|
|
328
365
|
const headers = {
|
|
329
366
|
accept,
|
|
@@ -345,17 +382,12 @@ class HttpService extends UrlHelper {
|
|
|
345
382
|
}),
|
|
346
383
|
...config.headers
|
|
347
384
|
};
|
|
348
|
-
const timeoutMs = config.timeoutMs ?? this.options.timeoutMs;
|
|
349
|
-
const signals = [
|
|
350
|
-
...timeoutMs > 0 ? [AbortSignal.timeout(timeoutMs)] : [],
|
|
351
|
-
...config.signal === undefined ? [] : [config.signal]
|
|
352
|
-
];
|
|
353
385
|
try {
|
|
354
386
|
return await fetch(url.href, {
|
|
355
387
|
method: config.method,
|
|
356
388
|
headers,
|
|
357
389
|
...body === undefined ? {} : { body },
|
|
358
|
-
|
|
390
|
+
signal,
|
|
359
391
|
...this.options.fetchOptions
|
|
360
392
|
});
|
|
361
393
|
} catch (error) {
|
|
@@ -366,16 +398,6 @@ class HttpService extends UrlHelper {
|
|
|
366
398
|
}
|
|
367
399
|
Object.defineProperty(HttpService, Symbol.for("dunx.deps"), { value: () => [HttpClientOptions, Logger, RequestContext] });
|
|
368
400
|
var urlOf = (url) => url === undefined ? {} : { url };
|
|
369
|
-
var readBody = async (response) => {
|
|
370
|
-
const text = await response.text().catch(() => "");
|
|
371
|
-
if (text === "")
|
|
372
|
-
return;
|
|
373
|
-
try {
|
|
374
|
-
return JSON.parse(text);
|
|
375
|
-
} catch {
|
|
376
|
-
return text;
|
|
377
|
-
}
|
|
378
|
-
};
|
|
379
401
|
var describeError = (error) => {
|
|
380
402
|
if (error instanceof FetchError) {
|
|
381
403
|
return {
|
|
@@ -458,6 +480,7 @@ export {
|
|
|
458
480
|
FetchTransportError,
|
|
459
481
|
HttpClientOptions,
|
|
460
482
|
HttpModule,
|
|
483
|
+
HttpRetryClassifier,
|
|
461
484
|
HttpService,
|
|
462
485
|
httpClient
|
|
463
486
|
};
|
package/dist/server/options.d.ts
CHANGED
|
@@ -146,7 +146,7 @@ export interface HttpOptions extends AppOptions {
|
|
|
146
146
|
* and never both. The pair without a `gatewayPort` is a boot error.
|
|
147
147
|
*
|
|
148
148
|
* `0` takes any free port and is the one value that does not warn when no
|
|
149
|
-
* gateway is declared. See docs/guide/
|
|
149
|
+
* gateway is declared. See docs/guide/20-deployment.md.
|
|
150
150
|
*/
|
|
151
151
|
readonly gatewayPort?: number;
|
|
152
152
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dunx/http",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "Bun.serve adapter for the dunx framework: controllers, middleware and WebSocket gateways",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"@opentelemetry/sdk-trace-node": "2.11.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
|
-
"@dunx/core": "^3.
|
|
68
|
+
"@dunx/core": "^3.6.0",
|
|
69
69
|
"@types/bun": ">=1.4.1"
|
|
70
70
|
},
|
|
71
71
|
"peerDependenciesMeta": {
|