@dunx/http 0.5.0 → 0.6.1

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 CHANGED
@@ -766,6 +766,101 @@ const name: HttpStatusName = 'CONFLICT'; // 'OK' | 'CREATED' | ...
766
766
  `HttpError.status` stays `number`, so an uncommon code the table omits (451, 507)
767
767
  still works.
768
768
 
769
+ ## Calling out: `@dunx/http/client`
770
+
771
+ The outbound half, on a subpath because `HttpFactory` in the root barrel already
772
+ means the inbound direction:
773
+
774
+ ```ts
775
+ import { HttpFactory } from '@dunx/http'; // serving
776
+ import { HttpModule, HttpService } from '@dunx/http/client'; // calling out
777
+ ```
778
+
779
+ ```ts
780
+ @Module({
781
+ imports: [
782
+ HttpModule.forRootAsync({
783
+ useFactory: (config: AppConfigService) => ({
784
+ baseUrl: config.get('upstream').url,
785
+ timeoutMs: 5_000,
786
+ retry: { maxRetries: 3, retryDelayMs: 500 },
787
+ }),
788
+ inject: [AppConfigService],
789
+ }),
790
+ ],
791
+ })
792
+ export class UpstreamModule {}
793
+ ```
794
+
795
+ ```ts
796
+ export class Rates {
797
+ constructor(private readonly http: HttpService) {}
798
+
799
+ async latest(base: string): Promise<Quote> {
800
+ return this.http.get<Quote>('/rates/{base}', {
801
+ pathParams: { base },
802
+ queryParams: { precision: 4 },
803
+ });
804
+ }
805
+ }
806
+ ```
807
+
808
+ `fetch` and nothing else underneath: it is a Web standard Bun implements natively,
809
+ which is why `axios` and `node-fetch` are banned repo-wide and why there is no
810
+ client dependency to justify. What the service adds is the part every caller
811
+ otherwise rewrites slightly differently.
812
+
813
+ | | |
814
+ | ---------------------- | ------------------------------------------------------------------------------------------------- |
815
+ | **Timeout** | `AbortSignal.timeout`, combined with a caller's own signal through `AbortSignal.any` |
816
+ | **Retry** | Exponential backoff with jitter from `crypto.getRandomValues`, and `Bun.sleep` between attempts |
817
+ | **`Retry-After`** | Honoured over the computed backoff, in seconds or as an HTTP date, still capped by the ceiling |
818
+ | **URLs** | `buildUrl` and `interpolate` from `@arkv/shared`, so `{param}` and query building are not rewritten |
819
+ | **Tracing** | The inbound request id is forwarded as `x-request-id`, so one trace spans both services |
820
+ | **Bun-only** | `proxy`, `tls`, `unix`, `decompress` passed straight through to `fetch` |
821
+ | **SSE** | `streamSse` yields each `data:` payload; deliberately never retried |
822
+
823
+ ### A failure is not your status
824
+
825
+ A non-2xx throws `FetchError`, which is **not** an `HttpError`:
826
+
827
+ ```ts
828
+ try {
829
+ return await this.http.get<User>(`/users/${id}`);
830
+ } catch (error) {
831
+ if (error instanceof FetchError && error.status === 404) return null;
832
+ throw new HttpError(HttpStatusCode.BAD_GATEWAY, 'user service unavailable');
833
+ }
834
+ ```
835
+
836
+ An `HttpError` is the inbound contract - the error mapper reads its status and
837
+ answers with it - so an upstream 401 arriving as `HttpError(401)` would tell *your*
838
+ client they are unauthorized, when what happened is that your service could not
839
+ authenticate upstream. Unhandled, a `FetchError` becomes a 500, which is honest;
840
+ only the caller knows whether 404 means "gone" or "not my problem".
841
+
842
+ A request that never got a response - DNS, refused connection, TLS, or the timeout -
843
+ throws `FetchTransportError` instead, with `aborted` saying which. An abort is never
844
+ retried: the budget for that call is already spent.
845
+
846
+ ### Several upstreams
847
+
848
+ A named client binds its own options, so two can coexist alongside one default:
849
+
850
+ ```ts
851
+ imports: [
852
+ HttpModule.forRoot({ baseUrl: internal }),
853
+ HttpModule.forRoot({ name: 'stripe', baseUrl: stripe, timeoutMs: 10_000 }),
854
+ ];
855
+
856
+ class Payments {
857
+ readonly stripe = inject(httpClient('stripe'));
858
+ }
859
+ ```
860
+
861
+ `inject()` in a field initialiser rather than a constructor parameter, because a
862
+ `Token` is not a constructor type.
863
+
769
864
  ## Notes
770
865
 
771
866
  - Routes are discovered at boot by walking each controller's prototype chain, so an
@@ -0,0 +1,40 @@
1
+ // @bun
2
+ // src/server/status.ts
3
+ var HttpStatusCode = Object.freeze({
4
+ OK: 200,
5
+ CREATED: 201,
6
+ ACCEPTED: 202,
7
+ NO_CONTENT: 204,
8
+ MOVED_PERMANENTLY: 301,
9
+ FOUND: 302,
10
+ NOT_MODIFIED: 304,
11
+ TEMPORARY_REDIRECT: 307,
12
+ PERMANENT_REDIRECT: 308,
13
+ BAD_REQUEST: 400,
14
+ UNAUTHORIZED: 401,
15
+ PAYMENT_REQUIRED: 402,
16
+ FORBIDDEN: 403,
17
+ NOT_FOUND: 404,
18
+ METHOD_NOT_ALLOWED: 405,
19
+ NOT_ACCEPTABLE: 406,
20
+ REQUEST_TIMEOUT: 408,
21
+ CONFLICT: 409,
22
+ GONE: 410,
23
+ PRECONDITION_FAILED: 412,
24
+ PAYLOAD_TOO_LARGE: 413,
25
+ URI_TOO_LONG: 414,
26
+ UNSUPPORTED_MEDIA_TYPE: 415,
27
+ IM_A_TEAPOT: 418,
28
+ UNPROCESSABLE_ENTITY: 422,
29
+ TOO_MANY_REQUESTS: 429,
30
+ INTERNAL_SERVER_ERROR: 500,
31
+ NOT_IMPLEMENTED: 501,
32
+ BAD_GATEWAY: 502,
33
+ SERVICE_UNAVAILABLE: 503,
34
+ GATEWAY_TIMEOUT: 504
35
+ });
36
+
37
+ export { HttpStatusCode };
38
+
39
+ //# debugId=881F02139124CAE264756E2164756E21
40
+ //# sourceMappingURL=chunk-x80f562w.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/server/status.ts"],
4
+ "sourcesContent": [
5
+ "/**\n * Frozen object plus an indexed-access union, not an `enum`. An enum emits a\n * runtime object that no other syntax can produce, which is why the repo bans it -\n * see CLAUDE.md. This gives the same `HttpStatusCode.NOT_FOUND` ergonomics, a\n * narrower type, and erases cleanly.\n */\nexport const HttpStatusCode = Object.freeze({\n OK: 200,\n CREATED: 201,\n ACCEPTED: 202,\n NO_CONTENT: 204,\n MOVED_PERMANENTLY: 301,\n FOUND: 302,\n NOT_MODIFIED: 304,\n TEMPORARY_REDIRECT: 307,\n PERMANENT_REDIRECT: 308,\n BAD_REQUEST: 400,\n UNAUTHORIZED: 401,\n PAYMENT_REQUIRED: 402,\n FORBIDDEN: 403,\n NOT_FOUND: 404,\n METHOD_NOT_ALLOWED: 405,\n NOT_ACCEPTABLE: 406,\n REQUEST_TIMEOUT: 408,\n CONFLICT: 409,\n GONE: 410,\n PRECONDITION_FAILED: 412,\n PAYLOAD_TOO_LARGE: 413,\n URI_TOO_LONG: 414,\n UNSUPPORTED_MEDIA_TYPE: 415,\n IM_A_TEAPOT: 418,\n UNPROCESSABLE_ENTITY: 422,\n TOO_MANY_REQUESTS: 429,\n INTERNAL_SERVER_ERROR: 500,\n NOT_IMPLEMENTED: 501,\n BAD_GATEWAY: 502,\n SERVICE_UNAVAILABLE: 503,\n GATEWAY_TIMEOUT: 504,\n} as const);\n\n/** The status numbers: `200 | 201 | ...`. */\nexport type HttpStatusCode =\n (typeof HttpStatusCode)[keyof typeof HttpStatusCode];\n\n/** The names: `'OK' | 'CREATED' | ...`. */\nexport type HttpStatusName = keyof typeof HttpStatusCode;\n"
6
+ ],
7
+ "mappings": ";;AAMO,IAAM,iBAAiB,OAAO,OAAO;AAAA,EAC1C,IAAI;AAAA,EACJ,SAAS;AAAA,EACT,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,mBAAmB;AAAA,EACnB,OAAO;AAAA,EACP,cAAc;AAAA,EACd,oBAAoB;AAAA,EACpB,oBAAoB;AAAA,EACpB,aAAa;AAAA,EACb,cAAc;AAAA,EACd,kBAAkB;AAAA,EAClB,WAAW;AAAA,EACX,WAAW;AAAA,EACX,oBAAoB;AAAA,EACpB,gBAAgB;AAAA,EAChB,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,MAAM;AAAA,EACN,qBAAqB;AAAA,EACrB,mBAAmB;AAAA,EACnB,cAAc;AAAA,EACd,wBAAwB;AAAA,EACxB,aAAa;AAAA,EACb,sBAAsB;AAAA,EACtB,mBAAmB;AAAA,EACnB,uBAAuB;AAAA,EACvB,iBAAiB;AAAA,EACjB,aAAa;AAAA,EACb,qBAAqB;AAAA,EACrB,iBAAiB;AACnB,CAAU;",
8
+ "debugId": "881F02139124CAE264756E2164756E21",
9
+ "names": []
10
+ }
@@ -0,0 +1,64 @@
1
+ import { AppError } from '@dunx/core';
2
+ /**
3
+ * Any non-2xx response from an outbound call, carrying the parsed body.
4
+ *
5
+ * **Deliberately not an `HttpError`.** `HttpError` is the inbound contract - the
6
+ * default error mapper reads its `status` and answers the caller with it - so an
7
+ * upstream 401 arriving as an `HttpError(401)` would make this service reply 401,
8
+ * telling *its* client "you are unauthorized" when what actually happened is that
9
+ * this service could not authenticate upstream. Extending `AppError` instead means
10
+ * an unhandled upstream failure surfaces as a 500, which is the honest default, and
11
+ * a caller who knows better maps it:
12
+ *
13
+ * ```ts
14
+ * try {
15
+ * return await this.http.get(url);
16
+ * } catch (error) {
17
+ * if (error instanceof FetchError && error.status === 404) return null;
18
+ * throw new HttpError(HttpStatusCode.BAD_GATEWAY, 'upstream unavailable');
19
+ * }
20
+ * ```
21
+ */
22
+ export declare class FetchError extends AppError {
23
+ readonly status: number;
24
+ readonly statusText: string;
25
+ /** The response body, parsed as JSON when it was, else text, else undefined. */
26
+ readonly body: unknown;
27
+ readonly response: {
28
+ readonly method: string;
29
+ readonly url: string;
30
+ readonly headers: Headers;
31
+ };
32
+ readonly name = "FetchError";
33
+ constructor(status: number, statusText: string,
34
+ /** The response body, parsed as JSON when it was, else text, else undefined. */
35
+ body: unknown, response: {
36
+ readonly method: string;
37
+ readonly url: string;
38
+ readonly headers: Headers;
39
+ });
40
+ }
41
+ /**
42
+ * The request never produced a response: DNS failure, connection refused, TLS
43
+ * rejection, or the timeout firing. `fetch` reports these as a `TypeError` or an
44
+ * `AbortError`, neither of which says which call died.
45
+ *
46
+ * Separate from {@link FetchError} because there is no status to branch on and the
47
+ * retry decision is different: a transport failure is worth retrying by default,
48
+ * while a 400 never is.
49
+ */
50
+ export declare class FetchTransportError extends AppError {
51
+ readonly response: {
52
+ readonly method: string;
53
+ readonly url: string;
54
+ };
55
+ /** True when the timeout or the caller's signal aborted it. */
56
+ readonly aborted: boolean;
57
+ readonly name = "FetchTransportError";
58
+ constructor(response: {
59
+ readonly method: string;
60
+ readonly url: string;
61
+ },
62
+ /** True when the timeout or the caller's signal aborted it. */
63
+ aborted: boolean, options?: ErrorOptions);
64
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A `JSON.stringify` that survives a cycle. **For logging only.**
3
+ *
4
+ * Never for a request body. The implementation this was ported from used it for
5
+ * both, so a circular payload was *sent* upstream as `"[Circular]"` - a wrong body
6
+ * that reads as a successful call and comes back as someone else's 400. A body goes
7
+ * through plain `JSON.stringify`, which throws, because a cycle there is a bug in
8
+ * the caller and should say so.
9
+ */
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>;
22
+ /**
23
+ * Whether a payload should be JSON-encoded, or handed to `fetch` as-is.
24
+ *
25
+ * `fetch` already knows what to do with a `BodyInit` - it sets the boundary for a
26
+ * `FormData`, the content type for a `URLSearchParams`, streams a `ReadableStream`
27
+ * - so the only question is whether this value is one. Everything else, including
28
+ * a `Date` or a class instance, is JSON: that is what `JSON.stringify` is for.
29
+ *
30
+ * Listed explicitly rather than inferred from `isPlainObject`, because the two
31
+ * questions have different answers. `new Date()` is not a plain object but is
32
+ * JSON-encodable; a `Blob` is neither.
33
+ */
34
+ export declare const isJsonBody: (payload: unknown) => boolean;
@@ -0,0 +1,66 @@
1
+ import { type Deps, type DynamicModule, type FactoryProvider, type Token } from '@dunx/core';
2
+ import { type HttpClientOptionsInit } from './options.js';
3
+ import { HttpService } from './service.js';
4
+ /**
5
+ * The token a named client is bound to.
6
+ *
7
+ * Memoised, because `token()` returns a fresh object every call - without this the
8
+ * module and the consumer would hold different tokens for `'stripe'` and the lookup
9
+ * would miss. Same name in, same token out.
10
+ *
11
+ * A `Token` is not a constructor type, so a named client cannot be a constructor
12
+ * parameter. Reach it with `inject()` in a field initialiser:
13
+ *
14
+ * ```ts
15
+ * class Payments {
16
+ * readonly stripe = inject(httpClient('stripe'));
17
+ * }
18
+ * ```
19
+ */
20
+ export declare const httpClient: (name: string) => Token<HttpService>;
21
+ /**
22
+ * The outbound half of `@dunx/http`.
23
+ *
24
+ * Named `HttpModule` and `HttpService` under the `./client` subpath rather than in
25
+ * the root barrel, where `HttpFactory` already means the inbound direction. The
26
+ * subpath is what keeps the name unambiguous at the import site:
27
+ *
28
+ * ```ts
29
+ * import { HttpFactory } from '@dunx/http'; // serving
30
+ * import { HttpModule } from '@dunx/http/client'; // calling out
31
+ * ```
32
+ *
33
+ * It depends on `Logger` and `RequestContext`, both of which core always binds, so
34
+ * it works in an app that imported no logging module at all.
35
+ */
36
+ export declare class HttpModule {
37
+ /**
38
+ * Binds `HttpService` and `HttpClientOptions`, or `httpClient(init.name)` alone
39
+ * when `name` is set - a named registration deliberately does not also claim
40
+ * `HttpService`, so several upstreams can coexist alongside one default.
41
+ */
42
+ static forRoot(init?: HttpClientOptionsInit): DynamicModule;
43
+ /**
44
+ * `forRoot` with the options behind a factory, which is the one thing a
45
+ * zero-argument `forRoot` cannot do: read the base url or the timeout off
46
+ * `ConfigService`.
47
+ *
48
+ * There is no separate async machinery - the container resolves eagerly and
49
+ * awaits factories before any constructor runs, so awaited config is settled by
50
+ * the time anything is built.
51
+ *
52
+ * ```ts
53
+ * HttpModule.forRootAsync({
54
+ * useFactory: (config: AppConfigService) => ({
55
+ * baseUrl: config.get('upstream').url,
56
+ * }),
57
+ * inject: [AppConfigService],
58
+ * });
59
+ * ```
60
+ *
61
+ * `name` is a parameter rather than a field of the awaited init, because the
62
+ * token has to exist before the factory runs.
63
+ */
64
+ static forRootAsync(load: () => HttpClientOptionsInit | Promise<HttpClientOptionsInit>, name?: string): DynamicModule;
65
+ static forRootAsync<const D extends Deps>(config: FactoryProvider<HttpClientOptionsInit, D>, name?: string): DynamicModule;
66
+ }
@@ -0,0 +1,57 @@
1
+ import type { RetryOptions } from './retry.js';
2
+ /**
3
+ * Named `HttpClientOptions`, not `HttpOptions`: the server half already exports
4
+ * that from `@dunx/http` for `HttpFactory.create`, and two things called
5
+ * `HttpOptions` meaning opposite directions of traffic is the confusion this
6
+ * subpath exists to avoid.
7
+ */
8
+ export interface HttpClientOptionsInit {
9
+ /**
10
+ * Prefixed to a relative `path`. With it, calls name a path; without it, every
11
+ * call passes a whole url.
12
+ */
13
+ readonly baseUrl?: string | URL;
14
+ /** Per-request budget, enforced with `AbortSignal.timeout`. @default 30000 */
15
+ readonly timeoutMs?: number;
16
+ /** Sent on every request, under anything a call sets itself. */
17
+ readonly headers?: Readonly<Record<string, string>>;
18
+ readonly retry?: RetryOptions<unknown>;
19
+ /**
20
+ * Forward the inbound request id to the upstream, so one trace spans both
21
+ * services. `true` uses `x-request-id`; a string names the header. Read from
22
+ * `RequestContext`, so it only carries when there is a request in scope.
23
+ *
24
+ * @default true
25
+ */
26
+ readonly propagateRequestId?: boolean | string;
27
+ /** Bound as its own token, so a second client can be injected by name. */
28
+ readonly name?: string;
29
+ /**
30
+ * Bun-only `fetch` extensions, passed straight through. None of these exist on
31
+ * Node's fetch, and they are the reason an outbound client on Bun can do things a
32
+ * ported one cannot: talk through a proxy, pin a certificate, or reach a unix
33
+ * socket, with no dependency.
34
+ */
35
+ readonly proxy?: string;
36
+ readonly tls?: Bun.TLSOptions;
37
+ readonly unix?: string;
38
+ /** @default true - Bun decompresses by default. */
39
+ readonly decompress?: boolean;
40
+ /** Bun's own request/response tracing on stderr. Never on in production. */
41
+ readonly verbose?: boolean;
42
+ }
43
+ export declare const DEFAULT_REQUEST_ID_HEADER = "x-request-id";
44
+ /**
45
+ * The resolved options, as a class so it is both the injection token and the type
46
+ * a factory annotates - the same trick `RedisOptions` and `ConfigService` use.
47
+ */
48
+ export declare class HttpClientOptions {
49
+ readonly baseUrl: string | undefined;
50
+ readonly timeoutMs: number;
51
+ readonly headers: Readonly<Record<string, string>>;
52
+ readonly retry: RetryOptions<unknown>;
53
+ readonly requestIdHeader: string | undefined;
54
+ readonly name: string | undefined;
55
+ readonly fetchOptions: Readonly<Record<string, unknown>>;
56
+ constructor(init?: HttpClientOptionsInit);
57
+ }
@@ -0,0 +1,49 @@
1
+ export interface BackoffOptions {
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;
13
+ /**
14
+ * The wait an upstream asked for, in ms, or undefined.
15
+ *
16
+ * RFC 9110 allows either a delay in seconds or an HTTP date, and both appear in
17
+ * the wild - GitHub sends seconds, some CDNs send a date. Ignoring the header, as
18
+ * the reference did, means retrying straight back into a rate limit that had just
19
+ * told you exactly how long to wait.
20
+ */
21
+ export declare const retryAfterMs: (headers: Headers, now?: number) => number | undefined;
22
+ /**
23
+ * Statuses worth trying again: a server that failed, one that is overloaded, and
24
+ * one that timed out. Deliberately narrower than the source, which also retried
25
+ * 409 and 422 - both of those are the server rejecting the *request*, and sending
26
+ * it again unchanged gets the same answer.
27
+ */
28
+ export declare const isRetryableStatus: (status: number) => boolean;
29
+ export interface RetryOptions<T> {
30
+ /** Retries *after* the first attempt, so 3 means up to 4 calls. @default 3 */
31
+ readonly maxRetries?: number;
32
+ /** @default 1000 */
33
+ readonly retryDelayMs?: number;
34
+ readonly backoff?: Omit<BackoffOptions, 'baseMs'>;
35
+ /** @default isRetryableStatus */
36
+ readonly shouldRetryOnStatus?: (status: number) => boolean;
37
+ /** Honour a `Retry-After` header over the computed backoff. @default true */
38
+ 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
+ }
43
+ /**
44
+ * Runs `operation`, retrying per `options`.
45
+ *
46
+ * `Bun.sleep` rather than a `setTimeout` promise: it is the runtime's own timer and
47
+ * needs no wrapper.
48
+ */
49
+ export declare const executeWithRetry: <T>(operation: () => Promise<T> | T, options?: RetryOptions<T>) => Promise<T>;
@@ -0,0 +1,104 @@
1
+ import { Logger, RequestContext } from '@dunx/core';
2
+ import { UrlHelper, type ParamsType } from '@arkv/shared';
3
+ import type { HttpMethod } from '../route/marker.js';
4
+ import { HttpClientOptions } from './options.js';
5
+ import { type RetryOptions } from './retry.js';
6
+ /** The client speaks two more verbs than a route can declare. */
7
+ export type RequestMethod = HttpMethod | 'HEAD' | 'OPTIONS';
8
+ /**
9
+ * `@arkv/shared`'s own param type, imported rather than restated - a local copy
10
+ * would drift from what `buildUrl` actually accepts, which is how `null` ended up
11
+ * in the first draft of this file and `interpolate` would never have seen it.
12
+ */
13
+ type Params = ParamsType;
14
+ export type HeaderFactory = (params: {
15
+ /** Unix seconds, which is what every HMAC scheme signs. */
16
+ readonly timestamp: number;
17
+ readonly method: RequestMethod;
18
+ /** `pathname + search`, the part such schemes sign. */
19
+ readonly requestPath: string;
20
+ /** The serialised body, or `''`. */
21
+ readonly body: string;
22
+ }) => Record<string, string>;
23
+ export interface RequestConfig<TRequest = unknown, TResponse = unknown> {
24
+ readonly method: RequestMethod;
25
+ /** Absolute, or relative to `baseUrl`. Omit when `baseUrl` plus `path` is enough. */
26
+ readonly url?: string | URL;
27
+ readonly payload?: TRequest;
28
+ readonly headers?: Readonly<Record<string, string>>;
29
+ /** Appended to the base, with `{param}` interpolated from `pathParams`. */
30
+ readonly path?: string;
31
+ readonly pathParams?: Params;
32
+ readonly queryParams?: Params;
33
+ /** Overrides the client's default budget. */
34
+ readonly timeoutMs?: number;
35
+ /** Called once per attempt, so a signature covers the body it is sent with. */
36
+ readonly headerFactory?: HeaderFactory;
37
+ /** Merged into the async context for this call, so its logs carry it. */
38
+ readonly flow?: string;
39
+ readonly retry?: RetryOptions<TResponse>;
40
+ /** Cancels the call. Combined with the timeout, whichever fires first. */
41
+ readonly signal?: AbortSignal;
42
+ }
43
+ type BaseOptions<TRequest, TResponse> = Omit<RequestConfig<TRequest, TResponse>, 'method' | 'url' | 'payload'>;
44
+ /**
45
+ * A `fetch` client with a per-request timeout, retry with backoff, request-id
46
+ * propagation and one log line per call.
47
+ *
48
+ * `fetch` and nothing else: it is a Web standard Bun implements natively, so there
49
+ * is no client dependency to justify - which is also why `axios` and `node-fetch`
50
+ * are banned repo-wide. What this adds over calling `fetch` yourself is the parts
51
+ * every caller otherwise reimplements slightly differently: the timeout, the
52
+ * retry policy, `Retry-After`, url building, and a failure that says which call
53
+ * failed.
54
+ *
55
+ * Extends `UrlHelper` from `@arkv/shared`, so `buildUrl` and `interpolate` are
56
+ * available on the service, and there is one implementation of them across the
57
+ * owner's projects rather than a fork per repo.
58
+ */
59
+ export declare class HttpService extends UrlHelper {
60
+ private readonly options;
61
+ private readonly logger;
62
+ private readonly requestContext;
63
+ constructor(options: HttpClientOptions, logger: Logger, requestContext: RequestContext);
64
+ request<TRequest = unknown, TResponse = unknown>(config: RequestConfig<TRequest, TResponse>): Promise<TResponse>;
65
+ get<TResponse = unknown>(url?: string | URL, options?: BaseOptions<never, TResponse>): Promise<TResponse>;
66
+ post<TRequest = unknown, TResponse = unknown>(url?: string | URL, payload?: TRequest, options?: BaseOptions<TRequest, TResponse>): Promise<TResponse>;
67
+ put<TRequest = unknown, TResponse = unknown>(url?: string | URL, payload?: TRequest, options?: BaseOptions<TRequest, TResponse>): Promise<TResponse>;
68
+ patch<TRequest = unknown, TResponse = unknown>(url?: string | URL, payload?: TRequest, options?: BaseOptions<TRequest, TResponse>): Promise<TResponse>;
69
+ delete<TResponse = unknown>(url?: string | URL, options?: BaseOptions<never, TResponse>): Promise<TResponse>;
70
+ /**
71
+ * Yields each `data:` payload of a Server-Sent-Events response, consuming the
72
+ * terminating `[DONE]` sentinel rather than yielding it.
73
+ *
74
+ * **No retry**, deliberately: a partially consumed stream cannot be replayed, so
75
+ * retrying would re-deliver events the caller has already seen. The timeout
76
+ * covers the connect only - it is dropped once headers arrive, or a long-lived
77
+ * stream would be cut off mid-flight.
78
+ *
79
+ * Hand-rolled rather than delegated: Bun exposes no `EventSource` global and no
80
+ * SSE parser, which was measured rather than assumed.
81
+ */
82
+ streamSse<TRequest = unknown>(config: Omit<RequestConfig<TRequest>, 'method' | 'retry'> & {
83
+ readonly method?: 'GET' | 'POST';
84
+ }): AsyncGenerator<string>;
85
+ /**
86
+ * Resolves the target, accepting the three forms a caller actually reaches for:
87
+ * an absolute url, a path relative to `baseUrl`, or `baseUrl` plus an explicit
88
+ * `path`.
89
+ *
90
+ * `get('/users')` is the one worth calling out. A relative first argument is what
91
+ * every HTTP client takes once a base url exists, and passing it straight to
92
+ * `buildUrl` throws `ERR_INVALID_URL` from inside `new URL()` - a message naming
93
+ * neither the call nor the missing base. So a first argument that is not an
94
+ * absolute url is treated as the path, which is what it reads as.
95
+ *
96
+ * `URL.canParse` decides, rather than a regex over `//` or `:` - it is the same
97
+ * parser `new URL` uses, so the two cannot disagree.
98
+ */
99
+ private urlFor;
100
+ /** `serialised` is what a `headerFactory` signs, and is `''` for no body. */
101
+ private bodyFor;
102
+ private send;
103
+ }
104
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@dunx/http/client` - the outbound half.
3
+ *
4
+ * A subpath rather than the root barrel: `HttpFactory` there is the inbound
5
+ * direction, and `HttpModule` next to it would read as either. Importing
6
+ * `@dunx/http` does not load any of this.
7
+ */
8
+ export { FetchError, FetchTransportError } from './client/errors.js';
9
+ export { isJsonBody, isPlainObject, safeStringify } from './client/json.js';
10
+ export { DEFAULT_REQUEST_ID_HEADER, HttpClientOptions, type HttpClientOptionsInit, } from './client/options.js';
11
+ export { backoffDelay, executeWithRetry, isRetryableStatus, retryAfterMs, type BackoffOptions, type RetryOptions, } from './client/retry.js';
12
+ export { httpClient, HttpModule } from './client/module.js';
13
+ export { HttpService, type HeaderFactory, type RequestConfig, type RequestMethod, } from './client/service.js';