@dunx/http 3.8.1 → 3.8.3

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
@@ -95,6 +95,10 @@ from, and it may change in any release.
95
95
  otherwise silently keep one.
96
96
  - Handlers may return a `Response`, any JSON-serialisable value, or `undefined`
97
97
  for a 204.
98
+ - `Authorize` and `gate()` are the contract an ops surface gates itself with:
99
+ raw request in, 404 on refusal, and a returned `Response` sent as written for a
100
+ browser that needs a sign-in page. `@dunx/dashboard` and `@dunx/openapi` both
101
+ take one, so an app writes the policy once and hands it to both.
98
102
  - Schemas, parsers and the status resolve at boot into the same closure the
99
103
  middleware chain folds into. A request reads no metadata and does no lookup.
100
104
  - Every request adopts W3C Trace Context, so `traceId`, `spanId`, `parentSpanId`
package/dist/client.js CHANGED
@@ -41,7 +41,7 @@ class FetchTransportError extends AppError {
41
41
  this.aborted = aborted;
42
42
  }
43
43
  }
44
- Object.defineProperty(FetchTransportError, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "readonly response: { readonly method: string; readonly url: string }" }, { unresolved: "readonly aborted: boolean" }, ErrorOptions] });
44
+ Object.defineProperty(FetchTransportError, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "readonly response: { readonly method: string; readonly url: string }" }, { unresolved: "readonly aborted: boolean" }, typeof ErrorOptions === "undefined" ? { unresolved: "options?: ErrorOptions" } : ErrorOptions] });
45
45
  // src/client/options.ts
46
46
  class HttpClientOptions {
47
47
  baseUrl;
@@ -54,3 +54,9 @@ export declare abstract class PingProbe {
54
54
  export declare abstract class QueryProbe {
55
55
  abstract ping(): Promise<void>;
56
56
  }
57
+ /** Enough of an object store to answer "can I reach it", which `Storage` from
58
+ * `@dunx/infra/files` is on either backend. `exists` and not a read or a write:
59
+ * one `HEAD` against S3, one `stat` locally, and nothing left behind. */
60
+ export declare abstract class StorageProbe {
61
+ abstract exists(key: string): Promise<boolean>;
62
+ }
@@ -1,16 +1,47 @@
1
- import { HealthIndicator, type PingProbe, type ProbeResult, type QueryProbe } from './contracts.js';
1
+ import { HealthIndicator, type PingProbe, type ProbeResult, type QueryProbe, type StorageProbe } from './contracts.js';
2
+ /** Up on a completed round trip, down with the thrown message, detail the
3
+ * latency. Subclass it with a `name` for anything else answering a `ping()`. */
4
+ export declare abstract class RoundTripIndicator extends HealthIndicator {
5
+ private readonly probe;
6
+ constructor(probe: QueryProbe);
7
+ check(): Promise<ProbeResult>;
8
+ }
2
9
  /** Redis is up if it answers `PING`. */
3
- export declare class RedisIndicator extends HealthIndicator {
4
- private readonly redis;
10
+ export declare class RedisIndicator extends RoundTripIndicator {
5
11
  readonly name = "redis";
6
12
  constructor(redis: PingProbe);
7
- check(): Promise<ProbeResult>;
8
13
  }
9
14
  /** The database is up if a round trip completes. */
10
- export declare class DatabaseIndicator extends HealthIndicator {
11
- private readonly db;
15
+ export declare class DatabaseIndicator extends RoundTripIndicator {
12
16
  readonly name = "database";
13
- constructor(db: QueryProbe);
17
+ }
18
+ /**
19
+ * The AMQP broker, up while the connection is established and unblocked, and
20
+ * satisfied by `AmqpConnection` from `@dunx/infra/amqp`. The first probe on an
21
+ * unopened connection waits `readyTimeoutMs`, whose 5 s default outruns
22
+ * `timeoutMs` at 2 s and so reports `unknown`; the rest never wait.
23
+ */
24
+ export declare class AmqpIndicator extends RoundTripIndicator {
25
+ readonly name = "amqp";
26
+ }
27
+ export interface StorageProbeOptionsInit {
28
+ readonly key?: string;
29
+ }
30
+ export declare class StorageProbeOptions {
31
+ readonly key: string;
32
+ constructor(init?: StorageProbeOptionsInit);
33
+ }
34
+ /**
35
+ * The configured object store, which is what `DiskIndicator` stops measuring the
36
+ * moment an app moves to `S3Storage`. Whether the key exists is no part of the
37
+ * signal and the answer is discarded: a store that replies is up, one that throws
38
+ * is down with the message expired credentials and a missing bucket arrive as.
39
+ */
40
+ export declare class StorageIndicator extends HealthIndicator {
41
+ private readonly storage;
42
+ private readonly options;
43
+ readonly name = "storage";
44
+ constructor(storage: StorageProbe, options?: StorageProbeOptions);
14
45
  check(): Promise<ProbeResult>;
15
46
  }
16
47
  export interface MemoryOptionsInit {
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { Controller, Delete, Get, Patch, Post, Put, } from './route/decorators.j
2
2
  export type { HttpMethod, RoutePath } from './route/marker.js';
3
3
  export { ApiHidden, HIDDEN, meta, metaKey, metaOf, mergeMeta, Public, PUBLIC, Roles, ROLES, STREAMS, UNMATCHED, UseGuards, type MetaKey, type MetaRecord, } from './route/metadata.js';
4
4
  export type { InferOutput, Input, JsonSchema, ResponseMap, Returns, RouteInput, RouteSchemas, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, } from './route/schema.js';
5
+ export { gate, type Authorize, type AuthorizeDecision, } from './server/authorize.js';
5
6
  export { ClientAddress } from './server/client-address.js';
6
7
  export type { RouteContext } from './server/context.js';
7
8
  export type { CorsOptions, CorsOrigin } from './server/cors.js';
@@ -32,15 +33,17 @@ export { Gateway, OnClose, OnDrain, OnMessage, OnOpen, OnPing, OnPong, OnUpgrade
32
33
  export type { Envelope } from './ws/envelope.js';
33
34
  export type { SocketContext, SocketDispatch, SocketFrame, SocketMiddleware, SocketNext, } from './ws/middleware.js';
34
35
  export { SocketLoggingMiddleware, type SocketLoggingOptions, } from './ws/logging.js';
36
+ export { SocketObserver, type SocketOutcome } from './ws/observer.js';
35
37
  export { PubSub } from './ws/pubsub.js';
36
38
  export { PostgresRelay, type PostgresRelayOptions, } from './ws/postgres-relay.js';
37
39
  export { RedisRelay, type RedisRelayOptions } from './ws/redis-relay.js';
38
40
  export { PostgresRelayConnectionOptions, RelayConnectionOptions, WsRelayModule, } from './ws/relay-module.js';
41
+ export { RelayPublisher, type RelayPublisherInit, } from './ws/relay-publisher.js';
39
42
  export { DEFAULT_RELAY_CHANNEL, WsRelay, type PubSubRelay, type RelayOptions, } from './ws/relay.js';
40
43
  export type { Socket, SocketData, SocketErrorHandler, SocketOptions, } from './ws/socket.js';
41
- export { HealthIndicator, PingProbe, QueryProbe, type ProbeResult, type ProbeState, } from './health/contracts.js';
44
+ export { HealthIndicator, PingProbe, QueryProbe, StorageProbe, type ProbeResult, type ProbeState, } from './health/contracts.js';
42
45
  export { HealthController } from './health/controller.js';
43
- export { DatabaseIndicator, DiskIndicator, DiskOptions, MemoryIndicator, MemoryOptions, RedisIndicator, type DiskOptionsInit, type MemoryOptionsInit, } from './health/indicators.js';
46
+ export { AmqpIndicator, DatabaseIndicator, DiskIndicator, DiskOptions, MemoryIndicator, MemoryOptions, RedisIndicator, RoundTripIndicator, StorageIndicator, StorageProbeOptions, type DiskOptionsInit, type MemoryOptionsInit, type StorageProbeOptionsInit, } from './health/indicators.js';
44
47
  export { HealthModule } from './health/module.js';
45
48
  export { HEALTH_REPORT_SCHEMA } from './health/report-schema.js';
46
49
  export { Readiness, ReadinessOptions } from './health/readiness.js';
package/dist/index.js CHANGED
@@ -62,6 +62,17 @@ var Post = verb("POST");
62
62
  var Put = verb("PUT");
63
63
  var Patch = verb("PATCH");
64
64
  var Delete = verb("DELETE");
65
+ // src/server/authorize.ts
66
+ var gate = async (authorize, req) => {
67
+ if (authorize === undefined)
68
+ return;
69
+ const decision = await authorize(req);
70
+ if (decision === true)
71
+ return;
72
+ if (decision instanceof Response)
73
+ return decision;
74
+ return Response.json({ error: "NOT_FOUND", status: 404 }, { status: 404 });
75
+ };
65
76
  // src/server/client-address.ts
66
77
  import { AppError } from "@dunx/core";
67
78
  var trustedHops = (setting) => {
@@ -299,19 +310,19 @@ var observe = (next, done) => {
299
310
  try {
300
311
  result = next();
301
312
  } catch (error) {
302
- done(error, undefined);
313
+ done(error, undefined, false);
303
314
  throw error;
304
315
  }
305
316
  if (result instanceof Promise) {
306
317
  return result.then((value) => {
307
- done(undefined, value);
318
+ done(undefined, value, true);
308
319
  return value;
309
320
  }, (error) => {
310
- done(error, undefined);
321
+ done(error, undefined, false);
311
322
  throw error;
312
323
  });
313
324
  }
314
- done(undefined, result);
325
+ done(undefined, result, true);
315
326
  return result;
316
327
  };
317
328
 
@@ -2671,6 +2682,19 @@ var OnMessage = (event) => (value) => {
2671
2682
  markHandler(value, { kind: HandlerKind.MESSAGE, event });
2672
2683
  return value;
2673
2684
  };
2685
+ // src/ws/observer.ts
2686
+ class SocketObserver {
2687
+ reportsErrors = false;
2688
+ handle(frame, ctx, next) {
2689
+ return observe(next, (error, value, ok) => {
2690
+ try {
2691
+ this.settled(ok ? { ok, value } : { ok, error }, frame, ctx);
2692
+ } catch (failure) {
2693
+ console.error(`[dunx/http] ${this.constructor.name}.settled threw, which cannot ` + "change the outcome it was watching:", failure);
2694
+ }
2695
+ });
2696
+ }
2697
+ }
2674
2698
  // src/ws/postgres-relay.ts
2675
2699
  var PROTOCOLS = ["postgres:", "postgresql:"];
2676
2700
  var defaultPostgresRelayUrl = () => process.env["POSTGRES_URL"] ?? process.env["DATABASE_URL"] ?? "postgres://localhost:5432";
@@ -2800,6 +2824,50 @@ Object.defineProperty(RedisRelay, Symbol.for("dunx.deps"), { value: () => [{ unr
2800
2824
  import {
2801
2825
  provide as provide5
2802
2826
  } from "@dunx/core";
2827
+
2828
+ // src/ws/relay-publisher.ts
2829
+ class RelayPublisher {
2830
+ relay;
2831
+ #origin = `worker:${Bun.randomUUIDv7()}`;
2832
+ #channel;
2833
+ #onError;
2834
+ constructor(relay, init = {}) {
2835
+ this.relay = relay;
2836
+ this.#channel = init.channel ?? DEFAULT_RELAY_CHANNEL;
2837
+ this.#onError = init.onError ?? defaultRelayError;
2838
+ }
2839
+ get origin() {
2840
+ return this.#origin;
2841
+ }
2842
+ get channel() {
2843
+ return this.#channel;
2844
+ }
2845
+ publishEvent(topic, event, data) {
2846
+ this.publish(topic, encode(event, data));
2847
+ }
2848
+ publish(topic, data) {
2849
+ try {
2850
+ const result = this.relay.publish(this.#channel, encodeRelay(this.#origin, topic, data));
2851
+ if (result instanceof Promise) {
2852
+ result.catch((error) => {
2853
+ this.#report(error);
2854
+ });
2855
+ }
2856
+ } catch (error) {
2857
+ this.#report(error);
2858
+ }
2859
+ }
2860
+ #report(error) {
2861
+ try {
2862
+ this.#onError(error, "publish");
2863
+ } catch (failure) {
2864
+ console.error("[dunx/http] a RelayPublisher onError handler threw:", failure);
2865
+ }
2866
+ }
2867
+ }
2868
+ Object.defineProperty(RelayPublisher, Symbol.for("dunx.deps"), { value: () => [WsRelay, { unresolved: "init: RelayPublisherInit = {}", optional: true }] });
2869
+
2870
+ // src/ws/relay-module.ts
2803
2871
  class RelayConnectionOptions {
2804
2872
  url;
2805
2873
  maxRetries;
@@ -2850,7 +2918,12 @@ class RelayLifecycle {
2850
2918
  }
2851
2919
  }
2852
2920
  Object.defineProperty(RelayLifecycle, Symbol.for("dunx.deps"), { value: () => [WsRelay] });
2853
- var redisBindings = (options) => [
2921
+ var publisherBinding = (init) => provide5(RelayPublisher, {
2922
+ useFactory: (relay) => new RelayPublisher(relay, init),
2923
+ inject: [WsRelay]
2924
+ });
2925
+ var redisBindings = (options, publisher) => [
2926
+ publisherBinding(publisher),
2854
2927
  provide5(RelayConnectionOptions, options),
2855
2928
  provide5(RedisRelay, {
2856
2929
  useFactory: (settings) => new RedisRelay(settings.toInit()),
@@ -2865,7 +2938,8 @@ var redisBindings = (options) => [
2865
2938
  inject: [WsRelay]
2866
2939
  })
2867
2940
  ];
2868
- var postgresBindings = (options) => [
2941
+ var postgresBindings = (options, publisher) => [
2942
+ publisherBinding(publisher),
2869
2943
  provide5(PostgresRelayConnectionOptions, options),
2870
2944
  provide5(PostgresRelay, {
2871
2945
  useFactory: (settings) => new PostgresRelay(settings.toInit()),
@@ -2882,48 +2956,58 @@ var postgresBindings = (options) => [
2882
2956
  ];
2883
2957
 
2884
2958
  class WsRelayModule {
2885
- static forRoot(init = {}) {
2959
+ static forRoot(init = {}, publisher = {}) {
2886
2960
  return {
2887
2961
  module: WsRelayModule,
2888
- exports: [WsRelay, RedisRelay, RelayConnectionOptions],
2962
+ exports: [WsRelay, RedisRelay, RelayConnectionOptions, RelayPublisher],
2889
2963
  providers: redisBindings({
2890
2964
  useFactory: () => new RelayConnectionOptions(init),
2891
2965
  inject: []
2892
- })
2966
+ }, publisher)
2893
2967
  };
2894
2968
  }
2895
- static forRootAsync(config) {
2969
+ static forRootAsync(config, publisher = {}) {
2896
2970
  const useFactory = async (...deps) => new RelayConnectionOptions(await config.useFactory(...deps));
2897
2971
  return {
2898
2972
  module: WsRelayModule,
2899
2973
  ...config.imports === undefined ? {} : { imports: config.imports },
2900
- exports: [WsRelay, RedisRelay, RelayConnectionOptions],
2974
+ exports: [WsRelay, RedisRelay, RelayConnectionOptions, RelayPublisher],
2901
2975
  providers: redisBindings({
2902
2976
  useFactory,
2903
2977
  inject: config.inject ?? []
2904
- })
2978
+ }, publisher)
2905
2979
  };
2906
2980
  }
2907
- static forPostgres(init = {}) {
2981
+ static forPostgres(init = {}, publisher = {}) {
2908
2982
  return {
2909
2983
  module: WsRelayModule,
2910
- exports: [WsRelay, PostgresRelay, PostgresRelayConnectionOptions],
2984
+ exports: [
2985
+ WsRelay,
2986
+ PostgresRelay,
2987
+ PostgresRelayConnectionOptions,
2988
+ RelayPublisher
2989
+ ],
2911
2990
  providers: postgresBindings({
2912
2991
  useFactory: () => new PostgresRelayConnectionOptions(init),
2913
2992
  inject: []
2914
- })
2993
+ }, publisher)
2915
2994
  };
2916
2995
  }
2917
- static forPostgresAsync(config) {
2996
+ static forPostgresAsync(config, publisher = {}) {
2918
2997
  const useFactory = async (...deps) => new PostgresRelayConnectionOptions(await config.useFactory(...deps));
2919
2998
  return {
2920
2999
  module: WsRelayModule,
2921
3000
  ...config.imports === undefined ? {} : { imports: config.imports },
2922
- exports: [WsRelay, PostgresRelay, PostgresRelayConnectionOptions],
3001
+ exports: [
3002
+ WsRelay,
3003
+ PostgresRelay,
3004
+ PostgresRelayConnectionOptions,
3005
+ RelayPublisher
3006
+ ],
2923
3007
  providers: postgresBindings({
2924
3008
  useFactory,
2925
3009
  inject: config.inject ?? []
2926
- })
3010
+ }, publisher)
2927
3011
  };
2928
3012
  }
2929
3013
  }
@@ -2937,6 +3021,9 @@ class PingProbe {
2937
3021
 
2938
3022
  class QueryProbe {
2939
3023
  }
3024
+
3025
+ class StorageProbe {
3026
+ }
2940
3027
  // src/health/controller.ts
2941
3028
  import { inject } from "@dunx/core";
2942
3029
 
@@ -3138,35 +3225,64 @@ let _HiddenHealthController = HiddenHealthController;
3138
3225
  import { statfs } from "fs/promises";
3139
3226
  var ms = (started) => Math.round(performance.now() - started);
3140
3227
 
3141
- class RedisIndicator extends HealthIndicator {
3142
- redis;
3143
- name = "redis";
3144
- constructor(redis) {
3228
+ class RoundTripIndicator extends HealthIndicator {
3229
+ probe;
3230
+ constructor(probe) {
3145
3231
  super();
3146
- this.redis = redis;
3232
+ this.probe = probe;
3147
3233
  }
3148
3234
  async check() {
3149
3235
  const started = performance.now();
3150
- await this.redis.ping();
3236
+ await this.probe.ping();
3151
3237
  return { state: "up", detail: `${ms(started)} ms` };
3152
3238
  }
3153
3239
  }
3154
- Object.defineProperty(RedisIndicator, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "private readonly redis: PingProbe", typeOnly: "PingProbe" }] });
3240
+ Object.defineProperty(RoundTripIndicator, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "private readonly probe: QueryProbe", typeOnly: "QueryProbe" }] });
3241
+
3242
+ class RedisIndicator extends RoundTripIndicator {
3243
+ name = "redis";
3244
+ constructor(redis) {
3245
+ super({
3246
+ ping: async () => {
3247
+ await redis.ping();
3248
+ }
3249
+ });
3250
+ }
3251
+ }
3252
+ Object.defineProperty(RedisIndicator, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "redis: PingProbe", typeOnly: "PingProbe" }] });
3155
3253
 
3156
- class DatabaseIndicator extends HealthIndicator {
3157
- db;
3254
+ class DatabaseIndicator extends RoundTripIndicator {
3158
3255
  name = "database";
3159
- constructor(db) {
3256
+ }
3257
+
3258
+ class AmqpIndicator extends RoundTripIndicator {
3259
+ name = "amqp";
3260
+ }
3261
+
3262
+ class StorageProbeOptions {
3263
+ key;
3264
+ constructor(init = {}) {
3265
+ this.key = init.key ?? ".dunx-health";
3266
+ }
3267
+ }
3268
+ Object.defineProperty(StorageProbeOptions, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "init: StorageProbeOptionsInit = {}", optional: true }] });
3269
+
3270
+ class StorageIndicator extends HealthIndicator {
3271
+ storage;
3272
+ options;
3273
+ name = "storage";
3274
+ constructor(storage, options = new StorageProbeOptions) {
3160
3275
  super();
3161
- this.db = db;
3276
+ this.storage = storage;
3277
+ this.options = options;
3162
3278
  }
3163
3279
  async check() {
3164
3280
  const started = performance.now();
3165
- await this.db.ping();
3281
+ await this.storage.exists(this.options.key);
3166
3282
  return { state: "up", detail: `${ms(started)} ms` };
3167
3283
  }
3168
3284
  }
3169
- Object.defineProperty(DatabaseIndicator, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "private readonly db: QueryProbe", typeOnly: "QueryProbe" }] });
3285
+ Object.defineProperty(StorageIndicator, Symbol.for("dunx.deps"), { value: () => [{ unresolved: "private readonly storage: StorageProbe", typeOnly: "StorageProbe" }, StorageProbeOptions] });
3170
3286
 
3171
3287
  class MemoryOptions {
3172
3288
  maxRssBytes;
@@ -3320,6 +3436,7 @@ __runInitializers(_init, 1, HealthModule);
3320
3436
  __decoratorMetadata(_init, HealthModule);
3321
3437
  let _HealthModule = HealthModule;
3322
3438
  export {
3439
+ AmqpIndicator,
3323
3440
  ApiHidden2 as ApiHidden,
3324
3441
  ClientAddress,
3325
3442
  Compression,
@@ -3375,18 +3492,24 @@ export {
3375
3492
  RedisRelay,
3376
3493
  RedisThrottleStore,
3377
3494
  RelayConnectionOptions,
3495
+ RelayPublisher,
3378
3496
  RequestLoggingMiddleware,
3379
3497
  RequestMetrics,
3380
3498
  Roles2 as Roles,
3499
+ RoundTripIndicator,
3381
3500
  SKIP_THROTTLE,
3382
3501
  STREAMS2 as STREAMS,
3383
3502
  SkipThrottle,
3384
3503
  SocketLoggingMiddleware,
3504
+ SocketObserver,
3385
3505
  Sse,
3386
3506
  SseStream,
3387
3507
  StaticFiles2 as StaticFiles,
3388
3508
  StaticModule,
3389
3509
  StaticOptions2 as StaticOptions,
3510
+ StorageIndicator,
3511
+ StorageProbe,
3512
+ StorageProbeOptions,
3390
3513
  THROTTLE,
3391
3514
  TRACEPARENT_HEADER2 as TRACEPARENT_HEADER,
3392
3515
  TRACERESPONSE_HEADER2 as TRACERESPONSE_HEADER,
@@ -3405,6 +3528,7 @@ export {
3405
3528
  WsRelayModule,
3406
3529
  defaultErrorMapper,
3407
3530
  errorMapper,
3531
+ gate,
3408
3532
  mergeMeta2 as mergeMeta,
3409
3533
  meta2 as meta,
3410
3534
  metaKey2 as metaKey,
@@ -0,0 +1,18 @@
1
+ import type { BunRequest } from 'bun';
2
+ /**
3
+ * What an {@link Authorize} answers. `true` passes and `false` refuses with a
4
+ * 404; a `Response` refuses with itself, which is what a browser needs.
5
+ */
6
+ export type AuthorizeDecision = boolean | Response;
7
+ /**
8
+ * Decides whether a request may see an ops surface at all - the dashboard page,
9
+ * the OpenAPI explorer. Both sit outside the app's session guard, so this gets
10
+ * the raw `BunRequest` and asks the auth library. Refusal is 404, not 403.
11
+ */
12
+ export type Authorize = (req: BunRequest) => AuthorizeDecision | Promise<AuthorizeDecision>;
13
+ /**
14
+ * Runs the gate: `undefined` to carry on, or the response to answer with. Only
15
+ * `true` admits and only a `Response` replaces the refusal, so an `authorize`
16
+ * that falls off the end of a branch closes rather than opens.
17
+ */
18
+ export declare const gate: (authorize: Authorize | undefined, req: BunRequest) => Promise<Response | undefined>;
@@ -79,8 +79,11 @@ export declare const composeSocket: (middleware: readonly SocketMiddleware[], ct
79
79
  * Calls `next()` and reports how it went, on whichever channel it went out on,
80
80
  * leaving the result untouched.
81
81
  *
82
- * `error` is `undefined` on success. A synchronous throw and a rejection both
83
- * reach `done` and are then rethrown, so a middleware that only observes cannot
84
- * accidentally swallow a failure.
82
+ * A synchronous throw and a rejection both reach `done` and are then rethrown, so
83
+ * a middleware that only observes cannot accidentally swallow a failure.
84
+ *
85
+ * `ok` says which happened, and it is not `error === undefined`: a handler may
86
+ * `throw undefined` or reject with it, and that reads as a success that returned
87
+ * nothing to anything comparing the value.
85
88
  */
86
- export declare const observe: (next: SocketNext, done: (error: unknown, value: unknown) => void) => unknown;
89
+ export declare const observe: (next: SocketNext, done: (error: unknown, value: unknown, ok: boolean) => void) => unknown;
@@ -0,0 +1,46 @@
1
+ import { type SocketContext, type SocketFrame, type SocketMiddleware, type SocketNext } from './middleware.js';
2
+ /**
3
+ * How a handler went. A union rather than an `error` that is `undefined` on
4
+ * success: a handler may `throw undefined`, and `error !== undefined` reads that
5
+ * as a success.
6
+ */
7
+ export type SocketOutcome = {
8
+ readonly ok: true;
9
+ readonly value: unknown;
10
+ } | {
11
+ readonly ok: false;
12
+ readonly error: unknown;
13
+ };
14
+ /**
15
+ * A {@link SocketMiddleware} that watches a frame and changes nothing about it.
16
+ *
17
+ * A gateway handler may return a value or a promise, so a middleware wanting the
18
+ * outcome has to handle a throw and a rejection and rethrow both; getting it
19
+ * wrong silently swallows a failure. Extend this and implement {@link settled}
20
+ * instead - the result reaches the caller untouched, `settled` throwing
21
+ * included.
22
+ *
23
+ * ```ts
24
+ * export class Reporter extends SocketObserver {
25
+ * override readonly reportsErrors = true;
26
+ *
27
+ * protected override settled(outcome: SocketOutcome, _f: SocketFrame, ctx: SocketContext): void {
28
+ * if (outcome.ok) return;
29
+ * this.logger.error('socket handler failed', { event: ctx.event, err: outcome.error });
30
+ * }
31
+ * }
32
+ * ```
33
+ *
34
+ * One that answers or refuses a frame is not this: implement `SocketMiddleware`.
35
+ */
36
+ export declare abstract class SocketObserver implements SocketMiddleware {
37
+ /**
38
+ * Override to `true` when {@link settled} reports a failure somewhere. See
39
+ * {@link SocketMiddleware.reportsErrors} - a middleware that ignores a throw
40
+ * and claims otherwise turns error reporting off for the whole server.
41
+ */
42
+ readonly reportsErrors: boolean;
43
+ handle(frame: SocketFrame, ctx: SocketContext, next: SocketNext): unknown;
44
+ /** How the handler went, once per frame, on whichever channel it used. */
45
+ protected abstract settled(outcome: SocketOutcome, frame: SocketFrame, ctx: SocketContext): void;
46
+ }
@@ -1,6 +1,7 @@
1
1
  import { type AsyncModuleConfig, type Deps, type DynamicModule } from '@dunx/core';
2
2
  import { type PostgresRelayOptions } from './postgres-relay.js';
3
3
  import { type RedisRelayOptions } from './redis-relay.js';
4
+ import { type RelayPublisherInit } from './relay-publisher.js';
4
5
  /**
5
6
  * The Redis relay's connection settings, as a class so a factory can bind them.
6
7
  *
@@ -48,19 +49,19 @@ export declare class PostgresRelayConnectionOptions {
48
49
  */
49
50
  export declare class WsRelayModule {
50
51
  /** Redis or Valkey, over `Bun.RedisClient`. */
51
- static forRoot(init?: RedisRelayOptions): DynamicModule;
52
+ static forRoot(init?: RedisRelayOptions, publisher?: RelayPublisherInit): DynamicModule;
52
53
  /**
53
54
  * The same bindings with the settings behind a factory, so the url can come off
54
55
  * `ConfigService`. `imports` reaches that factory; importing the module
55
56
  * alongside does not, since a dynamic module is its own scope.
56
57
  */
57
- static forRootAsync<const D extends Deps>(config: AsyncModuleConfig<RedisRelayOptions, D>): DynamicModule;
58
+ static forRootAsync<const D extends Deps>(config: AsyncModuleConfig<RedisRelayOptions, D>, publisher?: RelayPublisherInit): DynamicModule;
58
59
  /**
59
60
  * Postgres, over `Bun.SQL`'s `LISTEN`/`NOTIFY`, for an app that already has a
60
61
  * database and would rather not run a broker. A frame over about 7.9 KB is
61
62
  * refused; see {@link PostgresRelay}.
62
63
  */
63
- static forPostgres(init?: PostgresRelayOptions): DynamicModule;
64
+ static forPostgres(init?: PostgresRelayOptions, publisher?: RelayPublisherInit): DynamicModule;
64
65
  /** `forPostgres` with the settings behind a factory. */
65
- static forPostgresAsync<const D extends Deps>(config: AsyncModuleConfig<PostgresRelayOptions, D>): DynamicModule;
66
+ static forPostgresAsync<const D extends Deps>(config: AsyncModuleConfig<PostgresRelayOptions, D>, publisher?: RelayPublisherInit): DynamicModule;
66
67
  }
@@ -0,0 +1,50 @@
1
+ import { WsRelay, type RelayOptions } from './relay.js';
2
+ /** What a publisher needs beyond the relay itself. */
3
+ export interface RelayPublisherInit {
4
+ /**
5
+ * The broker channel, which **must** be the one the servers listen on
6
+ * (`HttpOptionsProvider.relayChannel`). A mismatch is silent - the broker
7
+ * accepts the publish and delivers it to nobody - so read both from one
8
+ * constant rather than writing the name twice.
9
+ */
10
+ readonly channel?: string;
11
+ /** Reports a publish the broker refused. Defaults to a `console.warn`. */
12
+ readonly onError?: RelayOptions['onError'];
13
+ }
14
+ /**
15
+ * Publishes a socket frame from a process that has no server.
16
+ *
17
+ * `PubSub` needs a live `Bun.serve` for the local half of its fan-out, which a
18
+ * queue worker or a forked job child has none of. This is the other half alone:
19
+ * every frame goes to the broker and comes back out of the servers listening on
20
+ * the channel, with no local delivery and no subscribe.
21
+ *
22
+ * `WsRelayModule` binds it, so it is a constructor parameter:
23
+ *
24
+ * ```ts
25
+ * export class RoundAnnouncer {
26
+ * constructor(private readonly frames: RelayPublisher) {}
27
+ *
28
+ * announce(round: string): void {
29
+ * this.frames.publishEvent('lobby', 'round.started', { round });
30
+ * }
31
+ * }
32
+ * ```
33
+ */
34
+ export declare class RelayPublisher {
35
+ #private;
36
+ private readonly relay;
37
+ constructor(relay: WsRelay, init?: RelayPublisherInit);
38
+ /** This process's id on the relay channel. Stable for its lifetime. */
39
+ get origin(): string;
40
+ /** The channel frames go out on. */
41
+ get channel(): string;
42
+ /** The same envelope `@OnMessage(event)` reads, published to a topic. */
43
+ publishEvent(topic: string, event: string, data?: unknown): void;
44
+ /**
45
+ * **Never throws.** A job handler that failed here would be retried and would
46
+ * repeat its side effects to deliver a frame nobody awaited. Failures go to
47
+ * `onError`.
48
+ */
49
+ publish(topic: string, data: string | Bun.BufferSource): void;
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/http",
3
- "version": "3.8.1",
3
+ "version": "3.8.3",
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.8.1",
79
+ "@dunx/core": "^3.8.3",
80
80
  "@types/bun": ">=1.4.1"
81
81
  },
82
82
  "peerDependenciesMeta": {