@dunx/http 3.7.0 → 3.8.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 CHANGED
@@ -108,8 +108,8 @@ from, and it may change in any release.
108
108
  `grpc-status` in an HTTP trailer and `Bun.serve` sends no trailers, so a request
109
109
  with `content-type: application/grpc` gets a 415 that says so.
110
110
  `@connectrpc/connect` and `@bufbuild/protobuf` are optional peers, and the
111
- `.proto` toolchain stays yours. `ThrottleGuard` does not cover an RPC: it skips
112
- every unmatched path, and an RPC path is in no route table.
111
+ `.proto` toolchain stays yours. `ThrottleGuard` covers an RPC: it skips an
112
+ unmatched path nobody claims, and an RPC path is claimed.
113
113
 
114
114
  ## License
115
115
 
@@ -0,0 +1,56 @@
1
+ // @bun
2
+ // src/server/trace-context.ts
3
+ import {
4
+ DEFAULT_TRACE_FLAGS,
5
+ formatTraceparent,
6
+ isSampled,
7
+ mintSpanId,
8
+ mintTraceId,
9
+ parseTraceparent,
10
+ TRACEPARENT_HEADER as TRACEPARENT_HEADER2,
11
+ TRACESTATE_HEADER as TRACESTATE_HEADER2
12
+ } from "@dunx/core";
13
+ var TRACERESPONSE_HEADER2 = "traceresponse";
14
+ var TRACE = Symbol.for("dunx.http.trace");
15
+ var EXPOSE = Symbol.for("dunx.http.trace.expose");
16
+
17
+ class TraceContext2 {
18
+ static adopt(req, expose = true) {
19
+ const inbound = parseTraceparent(req.headers.get(TRACEPARENT_HEADER2));
20
+ const state = req.headers.get(TRACESTATE_HEADER2);
21
+ const trace = inbound === undefined ? {
22
+ traceId: mintTraceId(),
23
+ spanId: mintSpanId(),
24
+ flags: DEFAULT_TRACE_FLAGS
25
+ } : {
26
+ traceId: inbound.traceId,
27
+ spanId: mintSpanId(),
28
+ parentSpanId: inbound.spanId,
29
+ flags: inbound.flags,
30
+ ...state === null ? {} : { state }
31
+ };
32
+ req[TRACE] = trace;
33
+ if (expose)
34
+ req[EXPOSE] = true;
35
+ return trace;
36
+ }
37
+ static of(req) {
38
+ return req[TRACE];
39
+ }
40
+ static header(trace) {
41
+ return formatTraceparent(trace);
42
+ }
43
+ static stamp(response, req) {
44
+ const traced = req;
45
+ const trace = traced[TRACE];
46
+ if (trace !== undefined && traced[EXPOSE] === true) {
47
+ response.headers.set(TRACERESPONSE_HEADER2, TraceContext2.header(trace));
48
+ }
49
+ return response;
50
+ }
51
+ static sampled(trace) {
52
+ return isSampled(trace);
53
+ }
54
+ }
55
+
56
+ export { TRACEPARENT_HEADER2, TRACESTATE_HEADER2, TRACERESPONSE_HEADER2, TraceContext2 };
@@ -13,9 +13,10 @@ export interface SseMessage {
13
13
  * Every event of a server-sent-events body, in order, ending with the stream or
14
14
  * with `[DONE]`. One still being read when the body ends is dropped, per spec.
15
15
  *
16
- * Async iteration rather than `getReader()`, which releases the reader on
17
- * completion, on a consumer `break` and on the `[DONE]` return. Hand-rolled:
18
- * Bun exposes no `EventSource` global and no SSE parser, measured not assumed.
16
+ * `getReader()` rather than async iteration, so the last read is told apart from
17
+ * a chunk boundary: a trailing `\r` is half a `\r\n` in one and a line ending in
18
+ * the other. `releaseLock` in a `finally` covers a `break` and `[DONE]`.
19
+ * Hand-rolled: Bun exposes no `EventSource` and no SSE parser, measured.
19
20
  */
20
21
  export declare function sseMessages(body: ReadableStream<Uint8Array>): AsyncGenerator<SseMessage>;
21
22
  /**
package/dist/client.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  TRACEPARENT_HEADER2,
7
7
  TRACESTATE_HEADER2,
8
8
  TraceContext2
9
- } from "./chunk-gmtwad7f.js";
9
+ } from "./chunk-cx4btdwe.js";
10
10
 
11
11
  // src/client/errors.ts
12
12
  import { AppError } from "@dunx/core";
@@ -174,44 +174,61 @@ async function* sseMessages(body) {
174
174
  let event;
175
175
  let id;
176
176
  let retry;
177
- for await (const chunk of body) {
178
- buffer += decoder.decode(chunk, { stream: true });
179
- let end = LINE.exec(buffer);
180
- while (end !== null) {
181
- const line = buffer.slice(0, end.index);
182
- buffer = buffer.slice(end.index + end[0].length);
183
- end = LINE.exec(buffer);
184
- if (line === "") {
185
- const payload = data.join(`
177
+ const reader = body.getReader();
178
+ let done = false;
179
+ try {
180
+ for (;; ) {
181
+ let end = LINE.exec(buffer);
182
+ while (end !== null) {
183
+ if (!done && end[0] === "\r" && end.index + 1 === buffer.length)
184
+ break;
185
+ const line = buffer.slice(0, end.index);
186
+ buffer = buffer.slice(end.index + end[0].length);
187
+ end = LINE.exec(buffer);
188
+ if (line === "") {
189
+ const seen = data.length > 0;
190
+ const payload = data.join(`
186
191
  `);
187
- data = [];
188
- if (payload === "") {
192
+ data = [];
193
+ if (!seen) {
194
+ event = undefined;
195
+ continue;
196
+ }
197
+ if (payload === "[DONE]")
198
+ return;
199
+ yield {
200
+ data: payload,
201
+ ...event === undefined ? {} : { event },
202
+ ...id === undefined ? {} : { id },
203
+ ...retry === undefined ? {} : { retry }
204
+ };
189
205
  event = undefined;
190
206
  continue;
191
207
  }
192
- if (payload === "[DONE]")
193
- return;
194
- yield {
195
- data: payload,
196
- ...event === undefined ? {} : { event },
197
- ...id === undefined ? {} : { id },
198
- ...retry === undefined ? {} : { retry }
199
- };
200
- event = undefined;
201
- continue;
208
+ if (line.startsWith(":"))
209
+ continue;
210
+ const [field, value] = split(line);
211
+ if (field === "data")
212
+ data.push(value);
213
+ else if (field === "event")
214
+ event = value;
215
+ else if (field === "id" && !value.includes("\x00"))
216
+ id = value;
217
+ else if (field === "retry" && /^\d+$/.test(value))
218
+ retry = Number(value);
202
219
  }
203
- if (line.startsWith(":"))
220
+ if (done)
221
+ return;
222
+ const next = await reader.read();
223
+ if (next.done) {
224
+ buffer += decoder.decode();
225
+ done = true;
204
226
  continue;
205
- const [field, value] = split(line);
206
- if (field === "data")
207
- data.push(value);
208
- else if (field === "event")
209
- event = value;
210
- else if (field === "id" && !value.includes("\x00"))
211
- id = value;
212
- else if (field === "retry" && /^\d+$/.test(value))
213
- retry = Number(value);
227
+ }
228
+ buffer += decoder.decode(next.value, { stream: true });
214
229
  }
230
+ } finally {
231
+ reader.releaseLock();
215
232
  }
216
233
  }
217
234
  class ConnectDeadline {
@@ -12,14 +12,15 @@ import { ConnectRegistry } from './registry.js';
12
12
  * first is what leaves a matched route paying nothing. Anything outside the
13
13
  * registered paths falls through untouched.
14
14
  *
15
- * `ThrottleGuard` is the one that does **not** cover an RPC: it returns early on
16
- * every unmatched path so a burst of 404s cannot spend a caller's budget, and an
17
- * RPC is unmatched. See docs/guide/27-rpc.md.
15
+ * `ThrottleGuard` covers an RPC: its early return is for an unmatched path
16
+ * nobody claims, and a claimed path is served. See docs/guide/27-rpc.md.
18
17
  */
19
18
  export declare class ConnectMiddleware implements Middleware, ClaimsPaths {
20
19
  #private;
21
20
  constructor(registry: ConnectRegistry);
22
21
  /** Every mounted RPC path, so a controller cannot shadow one unnoticed. */
23
22
  claimedPaths(): readonly string[];
23
+ /** Connect and gRPC-Web both POST. Connect's GET form is not served here. */
24
+ claimedMethods(): readonly string[];
24
25
  handle(req: BunRequest, ctx: RouteContext, next: Next): Promise<Response>;
25
26
  }
@@ -1,6 +1,6 @@
1
1
  import type { DescService } from '@bufbuild/protobuf';
2
2
  import type { ConnectRouterOptions, ServiceImpl } from '@connectrpc/connect';
3
- import type { Ctor, ModuleRef } from '@dunx/core';
3
+ import { type Ctor, type ModuleRef } from '@dunx/core';
4
4
  /**
5
5
  * Everything `createConnectRouter` takes except the protocol switches and
6
6
  * `shutdownSignal`, which the container owns. `interceptors`, `contextValues`,
@@ -43,9 +43,8 @@ export interface ConnectOptionsInit extends ConnectRouterSettings {
43
43
  * the deadline for the whole call, which is the default because a gap between
44
44
  * messages is the protocol rather than a symptom.
45
45
  *
46
- * Worth setting on a public mount: `ThrottleGuard` returns early on every
47
- * unmatched path and an RPC is unmatched, so nothing else here bounds how long
48
- * or how many streams one caller holds open.
46
+ * Worth setting on a public mount: `ThrottleGuard` limits the calls, and this
47
+ * limits how long one that is already streaming may idle.
49
48
  *
50
49
  * @default 0
51
50
  */
package/dist/connect.js CHANGED
@@ -16,12 +16,14 @@ import {
16
16
  } from "./chunk-08k9vq31.js";
17
17
 
18
18
  // src/connect/registry.ts
19
+ import { AppError as AppError2 } from "@dunx/core";
19
20
  import {
20
21
  createConnectRouter
21
22
  } from "@connectrpc/connect";
22
23
  import { createFetchHandler } from "@connectrpc/connect/protocol";
23
24
 
24
25
  // src/connect/options.ts
26
+ import { AppError } from "@dunx/core";
25
27
  var connectService = (service, useClass) => ({ service, useClass });
26
28
 
27
29
  class ConnectOptions {
@@ -48,14 +50,14 @@ class ConnectOptions {
48
50
  this.grpcWeb = grpcWeb ?? true;
49
51
  this.streamTimeout = streamTimeout ?? 0;
50
52
  if (!Number.isFinite(this.streamTimeout) || this.streamTimeout < 0) {
51
- throw new Error(`ConnectModule streamTimeout must be a non-negative number of seconds, got ${String(streamTimeout)}.`);
53
+ throw new AppError(`ConnectModule streamTimeout must be a non-negative number of seconds, got ${String(streamTimeout)}.`);
52
54
  }
53
55
  this.router = router;
54
56
  if (!this.connect && !this.grpcWeb) {
55
- throw new Error("ConnectModule needs at least one protocol, and both connect and " + "grpcWeb are false. Native gRPC is not a third option here: it " + "carries grpc-status in an HTTP trailer and Bun.serve sends none.");
57
+ throw new AppError("ConnectModule needs at least one protocol, and both connect and " + "grpcWeb are false. Native gRPC is not a third option here: it " + "carries grpc-status in an HTTP trailer and Bun.serve sends none.");
56
58
  }
57
59
  if (services.length === 0) {
58
- throw new Error("ConnectModule.forRoot was given no services. Pass at least one " + "connectService(Desc, Impl), or drop the module.");
60
+ throw new AppError("ConnectModule.forRoot was given no services. Pass at least one " + "connectService(Desc, Impl), or drop the module.");
59
61
  }
60
62
  }
61
63
  }
@@ -82,7 +84,7 @@ class ConnectRegistry {
82
84
  options.services.forEach((registration, index) => {
83
85
  const implementation = implementations[index];
84
86
  if (implementation === undefined) {
85
- throw new Error(`No instance was resolved for ${registration.useClass.name}, which ` + `serves ${registration.service.typeName}.`);
87
+ throw new AppError2(`No instance was resolved for ${registration.useClass.name}, which ` + `serves ${registration.service.typeName}.`);
86
88
  }
87
89
  router.service(registration.service, implementation);
88
90
  });
@@ -131,6 +133,9 @@ class ConnectMiddleware {
131
133
  claimedPaths() {
132
134
  return this.#registry.paths;
133
135
  }
136
+ claimedMethods() {
137
+ return ["POST"];
138
+ }
134
139
  handle(req, ctx, next) {
135
140
  if (ctx.get(UNMATCHED2) !== true)
136
141
  return next();
package/dist/index.js CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  TRACESTATE_HEADER2,
38
38
  TRACERESPONSE_HEADER2,
39
39
  TraceContext2
40
- } from "./chunk-gmtwad7f.js";
40
+ } from "./chunk-cx4btdwe.js";
41
41
  import {
42
42
  __privateGet,
43
43
  __privateAdd,
@@ -189,16 +189,21 @@ var seriesFor = (route, method) => ({
189
189
  class RequestMetrics {
190
190
  #series = new Map;
191
191
  #unmatched = new Map;
192
+ #claimed = new Map;
193
+ #claimedPaths = new Set;
192
194
  #since = new Date;
193
195
  #server;
194
196
  observe(ctx, status, durationNs, traceId) {
195
197
  let series = this.#series.get(ctx);
196
198
  if (series === undefined) {
197
199
  if (ctx.get(UNMATCHED2) === true) {
198
- series = this.#unmatched.get(ctx.method);
200
+ const claimed = this.#claimedPaths.has(ctx.path);
201
+ const key = claimed ? `${ctx.method} ${ctx.path}` : ctx.method;
202
+ const bucket = claimed ? this.#claimed : this.#unmatched;
203
+ series = bucket.get(key);
199
204
  if (series === undefined) {
200
- series = seriesFor(UNMATCHED_ROUTE, ctx.method);
201
- this.#unmatched.set(ctx.method, series);
205
+ series = seriesFor(claimed ? ctx.path : UNMATCHED_ROUTE, ctx.method);
206
+ bucket.set(key, series);
202
207
  }
203
208
  } else {
204
209
  series = seriesFor(ctx.path, ctx.method);
@@ -218,6 +223,7 @@ class RequestMetrics {
218
223
  const routes = [];
219
224
  for (const series of [
220
225
  ...this.#series.values(),
226
+ ...this.#claimed.values(),
221
227
  ...this.#unmatched.values()
222
228
  ]) {
223
229
  routes.push({
@@ -238,9 +244,13 @@ class RequestMetrics {
238
244
  }
239
245
  reset() {
240
246
  this.#series.clear();
247
+ this.#claimed.clear();
241
248
  this.#unmatched.clear();
242
249
  this.#since = new Date;
243
250
  }
251
+ claim(paths) {
252
+ this.#claimedPaths = new Set(paths);
253
+ }
244
254
  attach(server) {
245
255
  this.#server = server;
246
256
  }
@@ -1366,7 +1376,22 @@ var buildFallback = (middleware = [], onError = defaultErrorMapper, cors, notFou
1366
1376
  const miss = () => {
1367
1377
  throw new HttpError(HttpStatusCode2.NOT_FOUND, "NOT_FOUND");
1368
1378
  };
1379
+ const claimedPreflight = new Map;
1380
+ if (cors) {
1381
+ for (const entry of middleware) {
1382
+ if (!hasClaimedPaths(entry))
1383
+ continue;
1384
+ const answer = preflight(cors, entry.claimedMethods());
1385
+ for (const path of entry.claimedPaths())
1386
+ claimedPreflight.set(path, answer);
1387
+ }
1388
+ }
1369
1389
  const run = async (req, server) => {
1390
+ if (req.method === "OPTIONS") {
1391
+ const answer = claimedPreflight.get(new URL(req.url).pathname);
1392
+ if (answer !== undefined)
1393
+ return answer(req);
1394
+ }
1370
1395
  try {
1371
1396
  return await compose(middleware, unmatchedContext(req, notFound === "public", server), miss)(req);
1372
1397
  } catch (error) {
@@ -1645,7 +1670,9 @@ class HttpApplication extends ShutdownAware {
1645
1670
  if (ws && !this.#split)
1646
1671
  assertNoGatewayCollisions(prefixed, ws.paths);
1647
1672
  assertNoShadowedClaims(prefixed, middleware);
1648
- this.#app.get(ClaimedRoutes).attach(middleware.flatMap((entry) => hasClaimedPaths(entry) ? [...entry.claimedPaths()] : []));
1673
+ const claimed = middleware.flatMap((entry) => hasClaimedPaths(entry) ? [...entry.claimedPaths()] : []);
1674
+ this.#app.get(ClaimedRoutes).attach(claimed);
1675
+ this.#app.get(RequestMetrics).claim(claimed);
1649
1676
  const fetch = buildFallback(middleware, this.#onError, this.#cors, this.#notFound);
1650
1677
  const bound = this.#binding.bind({ port, routes, fetch, websocket: ws });
1651
1678
  attachAddressSource(this.#app.get(ClientAddress), {
@@ -2240,6 +2267,8 @@ var frameEvent = (event) => {
2240
2267
  const data = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
2241
2268
  for (const line of lines(data ?? ""))
2242
2269
  fields.push(`data: ${line}`);
2270
+ } else if (event.event !== undefined || event.id !== undefined) {
2271
+ fields.push("data: ");
2243
2272
  }
2244
2273
  return `${fields.join(`
2245
2274
  `)}
@@ -2266,11 +2295,17 @@ class SseStream {
2266
2295
  #timer;
2267
2296
  #lastEventId;
2268
2297
  #closed = false;
2298
+ #gone = new AbortController;
2299
+ #drained;
2269
2300
  constructor(options = {}) {
2270
2301
  this.#body = new ReadableStream({
2271
2302
  start: (controller) => {
2272
2303
  this.#controller = controller;
2273
2304
  },
2305
+ pull: () => {
2306
+ this.#drained?.();
2307
+ this.#drained = undefined;
2308
+ },
2274
2309
  cancel: () => {
2275
2310
  this.#stop();
2276
2311
  }
@@ -2292,6 +2327,7 @@ class SseStream {
2292
2327
  if (stream.#closed)
2293
2328
  break;
2294
2329
  stream.send(event);
2330
+ await stream.#backpressure();
2295
2331
  }
2296
2332
  stream.close();
2297
2333
  } catch (error) {
@@ -2300,6 +2336,9 @@ class SseStream {
2300
2336
  })();
2301
2337
  return stream;
2302
2338
  }
2339
+ get signal() {
2340
+ return this.#gone.signal;
2341
+ }
2303
2342
  get lastEventId() {
2304
2343
  return this.#lastEventId;
2305
2344
  }
@@ -2321,9 +2360,11 @@ class SseStream {
2321
2360
  this.#controller?.close();
2322
2361
  }
2323
2362
  toResponse(headers = {}) {
2324
- return new Response(this.#body, {
2325
- headers: { ...headers, ...SSE_HEADERS }
2326
- });
2363
+ const merged = new Headers(headers);
2364
+ for (const [name, value] of Object.entries(SSE_HEADERS)) {
2365
+ merged.set(name, value);
2366
+ }
2367
+ return new Response(this.#body, { headers: merged });
2327
2368
  }
2328
2369
  #write(text) {
2329
2370
  if (this.#closed)
@@ -2336,8 +2377,20 @@ class SseStream {
2336
2377
  this.#stop();
2337
2378
  this.#controller?.error(error);
2338
2379
  }
2380
+ #backpressure() {
2381
+ const wanted = this.#controller?.desiredSize;
2382
+ if (this.#closed || wanted === undefined || wanted === null || wanted > 0) {
2383
+ return Promise.resolve();
2384
+ }
2385
+ return new Promise((resolve) => {
2386
+ this.#drained = resolve;
2387
+ });
2388
+ }
2339
2389
  #stop() {
2340
2390
  this.#closed = true;
2391
+ this.#gone.abort();
2392
+ this.#drained?.();
2393
+ this.#drained = undefined;
2341
2394
  if (this.#timer !== undefined)
2342
2395
  clearInterval(this.#timer);
2343
2396
  this.#timer = undefined;
@@ -2512,7 +2565,8 @@ class ThrottleGuard {
2512
2565
  }
2513
2566
  #key(req, ctx) {
2514
2567
  const subject = (this.options.subject ?? ((request) => this.address.of(request)))(req, ctx) ?? "anonymous";
2515
- return `${this.options.prefix}:throttle:${ctx.controller}:${ctx.handler}:${subject}`;
2568
+ const scope = ctx.get(UNMATCHED2) === true ? ctx.path : `${ctx.controller}:${ctx.handler}`;
2569
+ return `${this.options.prefix}:throttle:${scope}:${subject}`;
2516
2570
  }
2517
2571
  async#hit(key, windowSeconds) {
2518
2572
  try {
@@ -54,6 +54,8 @@ export declare class RequestMetrics {
54
54
  * reflecting a deploy three days ago; who calls this is the app's decision.
55
55
  */
56
56
  reset(): void;
57
+ /** Internal: the paths a middleware answers off the fallback. */
58
+ claim(paths: Iterable<string>): void;
57
59
  /**
58
60
  * Internal: `listen()` hands the bound server's counters to the resolved
59
61
  * singleton. Structural, so an app serving from two ports can hand over the
@@ -28,5 +28,10 @@ export declare const compose: (middleware: readonly Middleware[], ctx: RouteCont
28
28
  */
29
29
  export interface ClaimsPaths {
30
30
  claimedPaths(): readonly string[];
31
+ /**
32
+ * The methods those paths answer. `preflight` is mounted over the route table,
33
+ * which a claimed path is not in, so an `OPTIONS` would reach the 404.
34
+ */
35
+ claimedMethods(): readonly string[];
31
36
  }
32
37
  export declare const hasClaimedPaths: (value: object) => value is ClaimsPaths;
@@ -1,5 +1,5 @@
1
- export declare const TRACEPARENT_HEADER = "traceparent";
2
- export declare const TRACESTATE_HEADER = "tracestate";
1
+ import { TRACEPARENT_HEADER, TRACESTATE_HEADER, type TraceIds } from '@dunx/core';
2
+ export { TRACEPARENT_HEADER, TRACESTATE_HEADER };
3
3
  /**
4
4
  * The span that answered, sent back so a caller can record which of the callee's
5
5
  * spans its own span points at. Same four fields as `traceparent`, and the
@@ -12,15 +12,14 @@ export declare const TRACESTATE_HEADER = "tracestate";
12
12
  * and adoption is thin, so treat a caller reading it as a bonus.
13
13
  */
14
14
  export declare const TRACERESPONSE_HEADER = "traceresponse";
15
- export interface Trace {
16
- /** 32 hex digits, shared by every span in the trace. */
17
- readonly traceId: string;
18
- /** 16 hex digits identifying this server's work on this request. */
19
- readonly spanId: string;
15
+ /**
16
+ * This server's view of a trace: the {@link TraceIds} on the wire plus the
17
+ * caller's span and any vendor `tracestate`, neither of which a `traceparent`
18
+ * carries on its own.
19
+ */
20
+ export interface Trace extends TraceIds {
20
21
  /** The caller's span, when one arrived in `traceparent`. */
21
22
  readonly parentSpanId?: string;
22
- /** Two hex digits. Bit 0 is `sampled`. */
23
- readonly flags: string;
24
23
  /** `tracestate` verbatim, when one arrived. Vendor data this server does not read. */
25
24
  readonly state?: string;
26
25
  }
@@ -41,7 +40,6 @@ export interface Trace {
41
40
  * request carries no correlation id at all.
42
41
  */
43
42
  export declare class TraceContext {
44
- #private;
45
43
  /**
46
44
  * The inbound `traceparent`, or a fresh trace. A malformed header is discarded
47
45
  * rather than repaired, as the standard requires. Version `ff` is invalid; a
@@ -63,7 +61,7 @@ export declare class TraceContext {
63
61
  * The `traceparent` to send upstream. This server's span becomes the callee's
64
62
  * parent, so the two link without inventing a span nothing logged.
65
63
  */
66
- static header(trace: Pick<Trace, 'traceId' | 'spanId' | 'flags'>): string;
64
+ static header(trace: TraceIds): string;
67
65
  /**
68
66
  * The response, carrying `traceresponse` if this request adopted a trace.
69
67
  *
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * One server-sent event. Every field is optional: `retry` alone changes the
3
- * reconnection delay, `event` alone fires a named event with no payload.
3
+ * reconnection delay, and `event` or `id` alone carries an empty `data:` so it
4
+ * dispatches, which a frame with no data field at all does not.
4
5
  */
5
6
  export interface SseEvent {
6
7
  /** A string as it is, anything else through `JSON.stringify`. Each line of it
@@ -20,6 +20,9 @@ export declare class SseStream {
20
20
  * terminal chunk and an `EventSource` reconnects.
21
21
  */
22
22
  static from(events: AsyncIterable<SseEvent>, options?: SseStreamOptions): SseStream;
23
+ /** Aborts when the client goes away, for a handler that waits between events
24
+ * to race: `for await` only sees a disconnect between yields. */
25
+ get signal(): AbortSignal;
23
26
  /** The `id` of the last event sent, or `undefined` if none carried one. */
24
27
  get lastEventId(): string | undefined;
25
28
  get closed(): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/http",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "Bun.serve adapter for the dunx framework: controllers, middleware and WebSocket gateways",
5
5
  "keywords": [
6
6
  "bun",
@@ -76,7 +76,7 @@
76
76
  "peerDependencies": {
77
77
  "@bufbuild/protobuf": "^2.15.0",
78
78
  "@connectrpc/connect": "^2.2.0",
79
- "@dunx/core": "^3.7.0",
79
+ "@dunx/core": "^3.8.0",
80
80
  "@types/bun": ">=1.4.1"
81
81
  },
82
82
  "peerDependenciesMeta": {
@@ -1,71 +0,0 @@
1
- // @bun
2
- // src/server/trace-context.ts
3
- var TRACEPARENT_HEADER2 = "traceparent";
4
- var TRACESTATE_HEADER2 = "tracestate";
5
- var TRACERESPONSE_HEADER2 = "traceresponse";
6
- var HEX_32 = /^[0-9a-f]{32}$/;
7
- var HEX_16 = /^[0-9a-f]{16}$/;
8
- var HEX_2 = /^[0-9a-f]{2}$/;
9
- var ZERO_TRACE = "0".repeat(32);
10
- var ZERO_SPAN = "0".repeat(16);
11
- var SAMPLED = 1;
12
- var DEFAULT_FLAGS = "01";
13
- var TRACE = Symbol.for("dunx.http.trace");
14
- var EXPOSE = Symbol.for("dunx.http.trace.expose");
15
- var mint = (bytes) => crypto.getRandomValues(new Uint8Array(bytes)).toHex();
16
-
17
- class TraceContext2 {
18
- static adopt(req, expose = true) {
19
- const inbound = TraceContext2.#parse(req.headers.get(TRACEPARENT_HEADER2));
20
- const state = req.headers.get(TRACESTATE_HEADER2);
21
- const trace = inbound === undefined ? { traceId: mint(16), spanId: mint(8), flags: DEFAULT_FLAGS } : {
22
- traceId: inbound.traceId,
23
- spanId: mint(8),
24
- parentSpanId: inbound.spanId,
25
- flags: inbound.flags,
26
- ...state === null ? {} : { state }
27
- };
28
- req[TRACE] = trace;
29
- if (expose)
30
- req[EXPOSE] = true;
31
- return trace;
32
- }
33
- static of(req) {
34
- return req[TRACE];
35
- }
36
- static header(trace) {
37
- return `00-${trace.traceId}-${trace.spanId}-${trace.flags}`;
38
- }
39
- static stamp(response, req) {
40
- const traced = req;
41
- const trace = traced[TRACE];
42
- if (trace !== undefined && traced[EXPOSE] === true) {
43
- response.headers.set(TRACERESPONSE_HEADER2, TraceContext2.header(trace));
44
- }
45
- return response;
46
- }
47
- static sampled(trace) {
48
- return (Number.parseInt(trace.flags, 16) & SAMPLED) === SAMPLED;
49
- }
50
- static #parse(header) {
51
- if (header === null)
52
- return;
53
- const parts = header.split("-");
54
- if (parts.length < 4)
55
- return;
56
- const [version, traceId, spanId, flags] = parts;
57
- if (!HEX_2.test(version) || version === "ff")
58
- return;
59
- if (version === "00" && parts.length !== 4)
60
- return;
61
- if (!HEX_32.test(traceId) || traceId === ZERO_TRACE)
62
- return;
63
- if (!HEX_16.test(spanId) || spanId === ZERO_SPAN)
64
- return;
65
- if (!HEX_2.test(flags))
66
- return;
67
- return { traceId, spanId, flags };
68
- }
69
- }
70
-
71
- export { TRACEPARENT_HEADER2, TRACESTATE_HEADER2, TRACERESPONSE_HEADER2, TraceContext2 };