@dunx/http 2.4.0 → 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
+ }
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, JsonSchema, 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';