@dunx/http 2.3.1 → 2.5.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.
@@ -105,7 +105,60 @@ var HttpStatusCode = Object.freeze({
105
105
  GATEWAY_TIMEOUT: 504
106
106
  });
107
107
 
108
- export { __privateGet, __privateAdd, __decoratorStart, __decoratorMetadata, __runInitializers, __decorateElement, HttpStatusCode };
108
+ // src/server/trace-context.ts
109
+ var TRACEPARENT_HEADER = "traceparent";
110
+ var TRACESTATE_HEADER = "tracestate";
111
+ var HEX_32 = /^[0-9a-f]{32}$/;
112
+ var HEX_16 = /^[0-9a-f]{16}$/;
113
+ var HEX_2 = /^[0-9a-f]{2}$/;
114
+ var ZERO_TRACE = "0".repeat(32);
115
+ var ZERO_SPAN = "0".repeat(16);
116
+ var SAMPLED = 1;
117
+ var TRACE = Symbol.for("dunx.http.trace");
118
+ var mintSpanId = () => Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString("hex");
109
119
 
110
- //# debugId=463DAE47FB741BC964756E2164756E21
111
- //# sourceMappingURL=chunk-sz4pvqxy.js.map
120
+ class TraceContext {
121
+ static adopt(req, requestId) {
122
+ const inbound = TraceContext.#parse(req.headers.get(TRACEPARENT_HEADER));
123
+ const state = req.headers.get(TRACESTATE_HEADER);
124
+ const trace = {
125
+ traceId: inbound?.traceId ?? requestId.replaceAll("-", ""),
126
+ spanId: mintSpanId(),
127
+ ...inbound === undefined ? {} : { parentSpanId: inbound.spanId },
128
+ flags: inbound?.flags ?? "01",
129
+ ...inbound !== undefined && state !== null ? { state } : {}
130
+ };
131
+ req[TRACE] = trace;
132
+ return trace;
133
+ }
134
+ static of(req) {
135
+ return req[TRACE];
136
+ }
137
+ static header(trace) {
138
+ return `00-${trace.traceId}-${trace.spanId}-${trace.flags}`;
139
+ }
140
+ static sampled(trace) {
141
+ return (Number.parseInt(trace.flags, 16) & SAMPLED) === SAMPLED;
142
+ }
143
+ static #parse(header) {
144
+ if (header === null)
145
+ return;
146
+ const parts = header.split("-");
147
+ if (parts.length < 4)
148
+ return;
149
+ const [version, traceId, spanId, flags] = parts;
150
+ if (!HEX_2.test(version) || version === "ff")
151
+ return;
152
+ if (version === "00" && parts.length !== 4)
153
+ return;
154
+ if (!HEX_32.test(traceId) || traceId === ZERO_TRACE)
155
+ return;
156
+ if (!HEX_16.test(spanId) || spanId === ZERO_SPAN)
157
+ return;
158
+ if (!HEX_2.test(flags))
159
+ return;
160
+ return { traceId, spanId, flags };
161
+ }
162
+ }
163
+
164
+ export { __privateGet, __privateAdd, __decoratorStart, __decoratorMetadata, __runInitializers, __decorateElement, HttpStatusCode, TRACEPARENT_HEADER, TRACESTATE_HEADER, TraceContext };
@@ -24,6 +24,17 @@ export interface HttpClientOptionsInit {
24
24
  * @default true
25
25
  */
26
26
  readonly propagateRequestId?: boolean | string;
27
+ /**
28
+ * Forward W3C Trace Context upstream as `traceparent`, so the callee's spans
29
+ * join this request's trace.
30
+ *
31
+ * Read from `RequestContext`, so it only carries when a trace is in scope -
32
+ * which means `requestLogging: { trace: true }` on the inbound side. With that
33
+ * off there is nothing to send and this costs one property read.
34
+ *
35
+ * @default true
36
+ */
37
+ readonly propagateTrace?: boolean;
27
38
  /** Bound as its own token, so a second client can be injected by name. */
28
39
  readonly name?: string;
29
40
  /**
@@ -51,6 +62,7 @@ export declare class HttpClientOptions {
51
62
  readonly headers: Readonly<Record<string, string>>;
52
63
  readonly retry: RetryOptions<unknown>;
53
64
  readonly requestIdHeader: string | undefined;
65
+ readonly propagateTrace: boolean;
54
66
  readonly name: string | undefined;
55
67
  readonly fetchOptions: Readonly<Record<string, unknown>>;
56
68
  constructor(init?: HttpClientOptionsInit);
package/dist/client.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // @bun
2
2
  import {
3
- HttpStatusCode
4
- } from "./chunk-sz4pvqxy.js";
3
+ HttpStatusCode,
4
+ TRACEPARENT_HEADER,
5
+ TraceContext
6
+ } from "./chunk-jh7jk0bn.js";
5
7
 
6
8
  // src/client/errors.ts
7
9
  import { AppError } from "@dunx/core";
@@ -75,6 +77,7 @@ class HttpClientOptions {
75
77
  headers;
76
78
  retry;
77
79
  requestIdHeader;
80
+ propagateTrace;
78
81
  name;
79
82
  fetchOptions;
80
83
  constructor(init = {}) {
@@ -83,6 +86,7 @@ class HttpClientOptions {
83
86
  this.headers = init.headers ?? {};
84
87
  this.retry = init.retry ?? {};
85
88
  this.name = init.name;
89
+ this.propagateTrace = init.propagateTrace ?? true;
86
90
  const propagate = init.propagateRequestId ?? true;
87
91
  this.requestIdHeader = propagate === false ? undefined : propagate === true ? DEFAULT_REQUEST_ID_HEADER : propagate;
88
92
  this.fetchOptions = Object.fromEntries([
@@ -328,11 +332,19 @@ class HttpService extends UrlHelper {
328
332
  }
329
333
  async send(config, url, body, serialised, accept = "application/json") {
330
334
  const requestId = this.options.requestIdHeader === undefined ? undefined : this.requestContext.getContext().requestId;
335
+ const trace = this.options.propagateTrace ? this.requestContext.getContext() : undefined;
331
336
  const headers = {
332
337
  accept,
333
338
  ...serialised === "" ? {} : { "content-type": "application/json" },
334
339
  ...this.options.headers,
335
340
  ...requestId === undefined || this.options.requestIdHeader === undefined ? {} : { [this.options.requestIdHeader]: requestId },
341
+ ...typeof trace?.traceId === "string" && typeof trace.spanId === "string" ? {
342
+ [TRACEPARENT_HEADER]: TraceContext.header({
343
+ traceId: trace.traceId,
344
+ spanId: trace.spanId,
345
+ flags: "01"
346
+ })
347
+ } : {},
336
348
  ...config.headerFactory?.({
337
349
  timestamp: Math.floor(Date.now() / 1000),
338
350
  method: config.method,
@@ -460,6 +472,3 @@ export {
460
472
  retryAfterMs,
461
473
  safeStringify
462
474
  };
463
-
464
- //# debugId=9448CCC7AF6FC82264756E2164756E21
465
- //# sourceMappingURL=client.js.map
@@ -0,0 +1,30 @@
1
+ import type { BunRequest } from 'bun';
2
+ import type { RouteContext } from '../server/context.js';
3
+ import type { Middleware, Next } from '../server/middleware.js';
4
+ import { CompressionOptions } from './options.js';
5
+ /**
6
+ * Response compression, on Bun's own compressors.
7
+ *
8
+ * **Not installed by default, and registered by the app rather than by a module:**
9
+ *
10
+ * ```ts
11
+ * const app = await HttpFactory.create(AppModule, { imports: [CompressionModule.forRoot()] });
12
+ * app.use(Compression);
13
+ * ```
14
+ *
15
+ * An app that never registers it pays nothing - there is no branch in the request
16
+ * path to skip. Position is the app's decision for the same reason `StaticFiles`
17
+ * leaves it open: compression belongs inside request logging, so the logged status
18
+ * is the real one, and outside anything that wants to read the body it produced.
19
+ *
20
+ * Two encoders rather than one. A body whose `content-length` is known and under
21
+ * `BUFFER_LIMIT` goes through `Bun.zstdCompressSync`/`gzipSync`, which is faster
22
+ * than the stream and leaves an accurate `content-length` on the response; a
23
+ * streamed or oversized body goes through `CompressionStream` and loses the header,
24
+ * as it must.
25
+ */
26
+ export declare class Compression implements Middleware {
27
+ #private;
28
+ constructor(options: CompressionOptions);
29
+ handle(req: BunRequest, _ctx: RouteContext, next: Next): Promise<Response>;
30
+ }
@@ -0,0 +1,20 @@
1
+ import { type Deps, type DynamicModule, type FactoryProvider } from '@dunx/core';
2
+ import { type CompressionOptionsInit } from './options.js';
3
+ /**
4
+ * Binds `Compression` and its options. Like `StaticModule`, importing it does not
5
+ * install anything - the app decides where in the chain it goes:
6
+ *
7
+ * ```ts
8
+ * const app = await HttpFactory.create(AppModule, {
9
+ * imports: [CompressionModule.forRoot({ threshold: 2048 })],
10
+ * });
11
+ * app.use(Compression);
12
+ * ```
13
+ */
14
+ export declare class CompressionModule {
15
+ static forRoot(init?: CompressionOptionsInit): DynamicModule;
16
+ /** `forRoot` with the options read off the container - a config value, usually. */
17
+ static forRootAsync<const D extends Deps>(config: FactoryProvider<CompressionOptionsInit, D> & {
18
+ readonly imports?: DynamicModule['imports'];
19
+ }): DynamicModule;
20
+ }
@@ -0,0 +1,12 @@
1
+ import type { CompressionEncoding } from './options.js';
2
+ /**
3
+ * The coding to encode with, or `undefined` for none.
4
+ *
5
+ * `offered` is the server's preference order and breaks a tie, which is what
6
+ * makes `['zstd', 'gzip']` meaningful against a browser that sends both at the
7
+ * same q. An explicit `q=0` refuses a coding, `*` supplies a default for the ones
8
+ * not named, and an absent header means the client said nothing - answered with
9
+ * no encoding, because a client that cannot decode is worse than one that reads
10
+ * a few more bytes.
11
+ */
12
+ export declare const negotiate: (header: string | null, offered: readonly CompressionEncoding[]) => CompressionEncoding | undefined;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The content codings this package produces.
3
+ *
4
+ * **Brotli is absent, and that is a measurement rather than an oversight.** Bun
5
+ * implements it - `new CompressionStream('brotli')` works - but at 6,344 us to
6
+ * encode a 6.4 KB JSON body against gzip's 23 us and zstd's 24 us, roughly 275x.
7
+ * The `level` argument that would fix it is accepted and ignored: `{ level: 4 }`
8
+ * encodes in 6,345 us and produces the same 339 bytes as the default. Brotli
9
+ * belongs on a build artefact compressed once, not on a response encoded per
10
+ * request. Note also that the `CompressionStream` format is spelled `brotli`
11
+ * while the HTTP token is `br`, so the two never lined up anyway.
12
+ *
13
+ * **`deflate` is absent for a second and worse reason: Bun's two encoders disagree
14
+ * about what it means.** `Bun.deflateSync` emits raw DEFLATE (first bytes
15
+ * `cb 48`), while `CompressionStream('deflate')` emits zlib (`78 9c`), which is
16
+ * what `Content-Encoding: deflate` is defined as. No option reconciles them -
17
+ * `library`, `windowBits` and `level` all leave `deflateSync` raw - so offering
18
+ * the coding would flip wire format at the buffering threshold and serve bytes a
19
+ * strict client rejects. `gzip` is taken by everything that would have accepted
20
+ * `deflate`. Measured on Bun 1.4.0; see docs/bun-apis.md.
21
+ */
22
+ export declare const CompressionEncoding: Readonly<{
23
+ readonly ZSTD: 'zstd';
24
+ readonly GZIP: 'gzip';
25
+ }>;
26
+ export type CompressionEncoding = (typeof CompressionEncoding)[keyof typeof CompressionEncoding];
27
+ /**
28
+ * Whether a `content-type` is worth encoding.
29
+ *
30
+ * An already-compressed payload - a JPEG, an MP4, a zip - comes out of a second
31
+ * pass slightly larger, having spent the CPU to get there. The default answers
32
+ * no to anything it does not recognise, so a new binary type is skipped rather
33
+ * than wasted on.
34
+ */
35
+ export declare const isCompressibleType: (contentType: string | null) => boolean;
36
+ export interface CompressionOptionsInit {
37
+ /**
38
+ * The codings offered, most preferred first. A tie in the client's q-values is
39
+ * broken by this order, so it is a real preference and not just a filter.
40
+ *
41
+ * The default puts `zstd` first for speed. On a 6.4 KB JSON body zstd encodes to
42
+ * 372 bytes in 7.7 us where gzip takes 16.1 us to reach 576; on a 116 KB OpenAPI
43
+ * document the two land within 0.2% of each other (9,603 against 9,587), so the
44
+ * size advantage narrows with the body while the time one does not. A client
45
+ * that does not send `zstd` in `accept-encoding` gets gzip, so the order costs
46
+ * nothing to state.
47
+ *
48
+ * @default ['zstd', 'gzip']
49
+ */
50
+ readonly encodings?: readonly CompressionEncoding[];
51
+ /**
52
+ * Bodies below this many bytes are sent as they are.
53
+ *
54
+ * Only applied when the response declares a `content-length`. A short JSON body
55
+ * grows under gzip - the header and trailer alone are 18 bytes - and the round
56
+ * trip through a compressor is time spent to send more.
57
+ *
58
+ * @default 1024
59
+ */
60
+ readonly threshold?: number;
61
+ /** Which `content-type`s to encode. @default isCompressibleType */
62
+ readonly filter?: (contentType: string | null) => boolean;
63
+ }
64
+ /**
65
+ * A class, not an interface, so it is a runtime value `@dunx/transform` can record
66
+ * at an injection site - the same reason `StaticOptions` and `ThrottleOptions` are.
67
+ */
68
+ export declare class CompressionOptions {
69
+ readonly encodings: readonly CompressionEncoding[];
70
+ readonly threshold: number;
71
+ readonly filter: (contentType: string | null) => boolean;
72
+ constructor(init?: CompressionOptionsInit);
73
+ }
@@ -3,9 +3,9 @@
3
3
  * finishes every `onInit` before `listen()` binds, so a connection refused *is* "not
4
4
  * started yet" and a third endpoint would restate it.
5
5
  *
6
- * `@Public()` because a probe has no credentials, and `@ApiHidden()` because these
7
- * are for the orchestrator rather than for an API consumer. Both are the existing
8
- * route metadata; there is nothing health-specific about either.
6
+ * `@Public()` because a probe has no credentials. Both routes are documented, under
7
+ * the `Health` tag; `HealthModule.forRoot({ documented: false })` mounts
8
+ * {@link HiddenHealthController} instead.
9
9
  */
10
10
  export declare class HealthController {
11
11
  #private;
@@ -19,3 +19,15 @@ export declare class HealthController {
19
19
  /** Should the process receive traffic. Fails from the moment the drain starts. */
20
20
  ready(): Promise<Response>;
21
21
  }
22
+ /**
23
+ * The same two routes, kept out of the OpenAPI document.
24
+ *
25
+ * A subclass rather than a flag read when the module is registered, because
26
+ * `@ApiHidden()` writes to the class and a class is shared by every app in the
27
+ * process: `examples/full` boots a second container to demonstrate the websocket
28
+ * relay, so setting the flag at `forRoot` time would leak into the other one. The
29
+ * prefix and both handlers resolve through the prototype chain, so this is the
30
+ * whole implementation.
31
+ */
32
+ export declare class HiddenHealthController extends HealthController {
33
+ }
@@ -28,13 +28,14 @@ export declare class HealthModule {
28
28
  * });
29
29
  * ```
30
30
  *
31
- * `routes` is read from the init here too, but the controller is mounted from the
32
- * static shape rather than from the awaited options: a route table is folded into
33
- * one closure per route when the server binds, so it cannot wait on a factory.
34
- * Pass `routes: false` and mount your own if that matters.
31
+ * `routes` and `documented` are read from the init here too, but the controller is
32
+ * mounted from the static shape rather than from the awaited options: a route
33
+ * table is folded into one closure per route when the server binds, so it cannot
34
+ * wait on a factory. Pass `routes: false` and mount your own if that matters.
35
35
  */
36
36
  static forRootAsync<const D extends Deps>(config: FactoryProvider<HealthOptionsInit, D> & {
37
37
  readonly imports?: DynamicModule['imports'];
38
38
  readonly routes?: boolean;
39
+ readonly documented?: boolean;
39
40
  }): DynamicModule;
40
41
  }
@@ -28,6 +28,8 @@ export declare class HealthOptions {
28
28
  readonly timeoutMs: number;
29
29
  /** Mount `/health/live` and `/health/ready`. Default `true`. */
30
30
  readonly routes: boolean;
31
+ /** Include the two routes in the OpenAPI document. Default `true`. */
32
+ readonly documented: boolean;
31
33
  /** How long to fail readiness before the server stops accepting. Default `0`. */
32
34
  readonly drainDelayMs: number;
33
35
  constructor(init?: HealthOptionsInit);
@@ -40,6 +42,12 @@ export interface HealthOptionsInit {
40
42
  /** Per-indicator budget. Default `2000`. */
41
43
  readonly timeoutMs?: number;
42
44
  readonly routes?: boolean;
45
+ /**
46
+ * `false` mounts `HiddenHealthController`, so the probes are served and left out
47
+ * of the document. They are documented by default: the paths and the report are
48
+ * worth finding in the reference, and an orchestrator reads neither.
49
+ */
50
+ readonly documented?: boolean;
43
51
  /** Passed through to {@link ReadinessOptions}. */
44
52
  readonly drainDelayMs?: number;
45
53
  }
@@ -0,0 +1,14 @@
1
+ import type { JsonSchema } from '../route/schema.js';
2
+ /**
3
+ * `HealthReport`, as a JSON Schema literal.
4
+ *
5
+ * A hand-written schema rather than a zod one, because `RouteSchemas.response`
6
+ * accepts either and this package has no validator dependency to spend on
7
+ * documenting two routes. `$id` hoists it into `components/schemas` once, so both
8
+ * probes and both statuses reference the same definition.
9
+ *
10
+ * It restates the `HealthReport` and `HealthCheckReport` interfaces, which is the
11
+ * one duplicate here: a type is erased and a document needs the shape at runtime.
12
+ * `health.test.ts` asserts the two agree.
13
+ */
14
+ export declare const HEALTH_REPORT_SCHEMA: JsonSchema;
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { Controller, Delete, Get, Patch, Post, Put, } from './route/decorators.js';
2
2
  export { discoverRoutes, joinPath, type DiscoveredRoute, } from './route/discover.js';
3
- export type { HttpMethod, RouteMeta, RoutePath } from './route/marker.js';
3
+ export { defaultStatusFor, type DefaultStatus, type HttpMethod, type RouteMeta, type RoutePath, } from './route/marker.js';
4
4
  export { ApiHidden, guardsOf, HIDDEN, meta, metaKey, metaOf, mergeMeta, Public, PUBLIC, Roles, ROLES, UNMATCHED, UseGuards, type MetaKey, type MetaRecord, } from './route/metadata.js';
5
- export type { InferOutput, Input, RouteInput, RouteSchemas, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, } from './route/schema.js';
5
+ export type { InferOutput, Input, JsonSchema, ResponseMap, Returns, RouteInput, RouteSchemas, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, } from './route/schema.js';
6
6
  export { gatewaysOf, routesOf, type GatewayHandler, type GatewayNode, type RouteInputs, type RouteNode, } from './inspect.js';
7
7
  export { ClientAddress } from './server/client-address.js';
8
8
  export { buildContext, type RouteContext } from './server/context.js';
@@ -10,6 +10,7 @@ export { preflight, withCors, type CorsOptions, type CorsOrigin, } from './serve
10
10
  export { defaultErrorMapper, ErrorFilter, errorMapper, HttpError, isErrorFilter, toErrorMapper, ValidationError, type ErrorHandler, type ErrorMapper, type HttpErrorOptions, type InputSource, type ValidationIssue, } from './server/errors.js';
11
11
  export { HttpFactory, type HttpApp, type HttpOptions, } from './server/factory.js';
12
12
  export { REQUEST_ID_HEADER } from './server/request-id.js';
13
+ export { TRACEPARENT_HEADER, TRACESTATE_HEADER, TraceContext, type Trace, } from './server/trace-context.js';
13
14
  export { RequestLoggingMiddleware, type RequestLoggingOptions, } from './server/request-logging.js';
14
15
  export { compose, type Middleware, type Next, type RouteHandler, } from './server/middleware.js';
15
16
  export { assertNoCollisions, assertNoGatewayCollisions, buildRoutes, withUpgradeRoutes, type BunRoutes, type GuardResolver, type RouteMethod, type ServeRoutes, } from './server/routes.js';
@@ -17,6 +18,10 @@ export type { AppSettings } from './server/settings.js';
17
18
  export { StaticFiles } from './static/files.js';
18
19
  export { StaticModule } from './static/module.js';
19
20
  export { normalizePrefix, StaticOptions, type StaticOptionsInit, } from './static/options.js';
21
+ export { Compression } from './compression/compression.js';
22
+ export { CompressionModule } from './compression/module.js';
23
+ export { negotiate } from './compression/negotiate.js';
24
+ export { CompressionEncoding, CompressionOptions, isCompressibleType, type CompressionOptionsInit, } from './compression/options.js';
20
25
  export { SKIP_THROTTLE, SkipThrottle, THROTTLE, Throttle, type ThrottleLimit, } from './throttle/decorators.js';
21
26
  export { ThrottleGuard } from './throttle/guard.js';
22
27
  export { ThrottleModule } from './throttle/module.js';
@@ -36,8 +41,9 @@ export { decodeRelay, DEFAULT_RELAY_CHANNEL, encodeRelay, type PubSubRelay, type
36
41
  export { buildGateways, buildRuntime, type GatewayRuntime, } from './ws/runtime.js';
37
42
  export type { Socket, SocketData, SocketErrorHandler, SocketOptions, } from './ws/socket.js';
38
43
  export { HealthIndicator, PingProbe, QueryProbe, type ProbeResult, type ProbeState, } from './health/contracts.js';
39
- export { HealthController } from './health/controller.js';
44
+ export { HealthController, HiddenHealthController, } from './health/controller.js';
40
45
  export { DatabaseIndicator, DiskIndicator, DiskOptions, MemoryIndicator, MemoryOptions, RedisIndicator, type DiskOptionsInit, type MemoryOptionsInit, } from './health/indicators.js';
41
46
  export { HealthModule } from './health/module.js';
47
+ export { HEALTH_REPORT_SCHEMA } from './health/report-schema.js';
42
48
  export { Readiness, ReadinessOptions } from './health/readiness.js';
43
49
  export { HealthOptions, HealthRegistry, type HealthCheckReport, type HealthOptionsInit, type HealthReport, } from './health/registry.js';