@rotorsoft/act-http 1.6.0 → 1.7.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.
@@ -36,4 +36,5 @@
36
36
  export type { ActorExtractor } from "./actor.js";
37
37
  export { type ApiError, ERROR_MAP, type ErrorMapEntry, toApiError, } from "./errors.js";
38
38
  export { type IdempotencyResult, withIdempotency } from "./idempotency.js";
39
+ export { DEFAULT_SSE_HEARTBEAT_MS, DEFAULT_SSE_MAX_CONNECTIONS, fireAndForget, resolveSseConfig, runSseSubscription, type SseAccounting, type SseConfig, SseConnectionCounter, type SseOptions, type SseSubscriptionFrame, } from "./sse-wiring.js";
39
40
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/api/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,YAAY,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EACL,KAAK,QAAQ,EACb,SAAS,EACT,KAAK,aAAa,EAClB,UAAU,GACX,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,KAAK,iBAAiB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/api/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,YAAY,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EACL,KAAK,QAAQ,EACb,SAAS,EACT,KAAK,aAAa,EAClB,UAAU,GACX,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,KAAK,iBAAiB,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC3E,OAAO,EACL,wBAAwB,EACxB,2BAA2B,EAC3B,aAAa,EACb,gBAAgB,EAChB,kBAAkB,EAClB,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,oBAAoB,EACpB,KAAK,UAAU,EACf,KAAK,oBAAoB,GAC1B,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,166 @@
1
+ import type { BroadcastChannel } from "../sse/broadcast.js";
2
+ import type { BroadcastState } from "../sse/types.js";
3
+ /**
4
+ * SSE wiring options shared by the auto-generated `trpc` and `hono`
5
+ * transports. When supplied, each transport emits one streaming
6
+ * subscription per registered state name, all reading from the same
7
+ * host-supplied {@link BroadcastChannel}.
8
+ *
9
+ * The host owns publication: every `app.do(...)` call site must
10
+ * forward the resulting snapshots to `channel.publish(...)` for
11
+ * subscribers to see updates. The framework deliberately doesn't
12
+ * auto-publish because the derived state usually needs app-specific
13
+ * overlays (presence, computed fields) that only the host can supply.
14
+ *
15
+ * Defaults are sized for typical business-app dashboards (dozens to
16
+ * hundreds of human viewers per process). Both numeric knobs are
17
+ * validated at transport-function construction; out-of-range values
18
+ * throw `RangeError` immediately so misconfiguration surfaces at
19
+ * startup instead of at first connection.
20
+ */
21
+ export type SseOptions = {
22
+ /**
23
+ * The {@link BroadcastChannel} the generator's subscriptions hook
24
+ * into. Host owns its lifecycle; the generator does not construct
25
+ * one for you because the channel's `<S>` shape is app-specific and
26
+ * passing it in keeps the type chain typed end-to-end at the host.
27
+ */
28
+ readonly channel: BroadcastChannel<any>;
29
+ /**
30
+ * Hard cap on concurrent open subscriptions per generator
31
+ * instance. The 501st simultaneous open returns `503` (Hono) or
32
+ * throws `TOO_MANY_REQUESTS` (tRPC) — never a silent stall.
33
+ *
34
+ * Default `500`. Validated range `[1, 10_000]`. Above 10k the
35
+ * framework refuses to construct: at that point the FD ceiling
36
+ * and per-connection memory force horizontal scaling regardless
37
+ * of tuning, so the right move is sticky LB + per-process cap,
38
+ * not a higher single-process limit.
39
+ */
40
+ readonly maxConnections?: number;
41
+ /**
42
+ * Keep-alive cadence in milliseconds. Most reverse proxies idle
43
+ * connections out after ~60 seconds; the default `30_000` stays
44
+ * comfortably under.
45
+ *
46
+ * Validated range `[15_000, 300_000]`. Under 15 s wastes
47
+ * bandwidth on a business workload; over 5 min risks proxy drops.
48
+ */
49
+ readonly heartbeatMs?: number;
50
+ };
51
+ /**
52
+ * Resolved SSE configuration after validation and default expansion.
53
+ * Internal — the resolved values are what every transport actually
54
+ * runs against.
55
+ *
56
+ * @internal
57
+ */
58
+ export type SseConfig = {
59
+ readonly channel: BroadcastChannel<BroadcastState>;
60
+ readonly maxConnections: number;
61
+ readonly heartbeatMs: number;
62
+ };
63
+ /**
64
+ * Default cap on concurrent open subscriptions per generator
65
+ * instance. Sized for typical business-app dashboards (dozens to a
66
+ * few hundred viewers); high enough not to surprise small
67
+ * deployments, low enough not to mask a missing horizontal-scale
68
+ * strategy on big ones.
69
+ */
70
+ export declare const DEFAULT_SSE_MAX_CONNECTIONS = 500;
71
+ /**
72
+ * Default keep-alive cadence (30 s). Sits comfortably under the
73
+ * 60 s idle timeout most reverse proxies impose.
74
+ */
75
+ export declare const DEFAULT_SSE_HEARTBEAT_MS = 30000;
76
+ /**
77
+ * Validate and apply defaults to host-supplied {@link SseOptions}.
78
+ *
79
+ * Throws `RangeError` when either numeric knob is outside its
80
+ * documented range. Pure: same input → same output, no side
81
+ * effects, no I/O. Each transport calls this once at the start of
82
+ * `hono(...)` / `trpc(...)`.
83
+ */
84
+ export declare function resolveSseConfig(options: SseOptions): SseConfig;
85
+ /**
86
+ * Per-generator concurrent-open counter. Constructed once inside
87
+ * `hono(...)` / `trpc(...)` and consulted at every subscription
88
+ * open: failure to acquire means the cap has been hit and the
89
+ * transport reports `503` / `TOO_MANY_REQUESTS` to the caller.
90
+ *
91
+ * The counter is intentionally process-local. SSE doesn't multiplex
92
+ * across processes; operators wanting more than `maxConnections`
93
+ * viewers run multiple processes behind a sticky load balancer.
94
+ */
95
+ export declare class SseConnectionCounter {
96
+ readonly limit: number;
97
+ private _open;
98
+ constructor(limit: number);
99
+ /**
100
+ * Reserve one slot. Returns `true` when accepted (caller must
101
+ * eventually {@link release} the slot, even on error paths) or
102
+ * `false` when the cap is already reached.
103
+ */
104
+ acquire(): boolean;
105
+ /**
106
+ * Free a previously-{@link acquire}d slot. Always runs from a
107
+ * `finally` block in the transport's subscription handler so
108
+ * crashed handlers don't leak count.
109
+ */
110
+ release(): void;
111
+ /** Currently-open count. Test/observability hook. */
112
+ get open(): number;
113
+ }
114
+ /**
115
+ * Wrap a fire-and-forget Promise-returning operation so its
116
+ * rejection is swallowed in place of leaking an unhandled
117
+ * rejection. Used by the Hono heartbeat tick (the interval callback
118
+ * isn't `await`ed, so its rejection has nowhere to go) and by any
119
+ * other "best-effort write" the transports run.
120
+ *
121
+ * Pure: same input → same output, no side effects on the caller.
122
+ * Returns the chained Promise so the caller can also `await`
123
+ * completion if they want to (the Hono heartbeat doesn't).
124
+ */
125
+ export declare function fireAndForget<T>(op: () => Promise<T>): Promise<T | undefined>;
126
+ /**
127
+ * Frame yielded by the per-state SSE subscription generator. The
128
+ * initial cached state (when present) arrives as `kind: "state"`;
129
+ * every subsequent broadcast publication arrives as `kind: "patch"`
130
+ * with the channel's `PatchMessage` payload.
131
+ */
132
+ export type SseSubscriptionFrame<S> = {
133
+ readonly kind: "state";
134
+ readonly data: S;
135
+ } | {
136
+ readonly kind: "patch";
137
+ readonly data: unknown;
138
+ };
139
+ /**
140
+ * Subscription accounting hook. Implementations have a chance to
141
+ * acquire a slot before the loop starts and to release it once the
142
+ * loop's `finally` block runs — used by the tRPC side to enforce
143
+ * `maxConnections` from inside the generator, where it can throw
144
+ * `TRPCError` cleanly. Hono enforces the cap outside the loop (so a
145
+ * full counter returns `503` *before* streamSSE writes headers), then
146
+ * passes `undefined` here.
147
+ */
148
+ export type SseAccounting = {
149
+ acquire(): boolean;
150
+ release(): void;
151
+ };
152
+ /**
153
+ * The shared subscription loop both transports run. Optionally
154
+ * acquires one slot on the supplied {@link SseAccounting}, yields the
155
+ * cached state (when present), then forwards every channel
156
+ * publication as a `kind: "patch"` frame until either the consumer
157
+ * breaks (calling `iter.return()`) or the supplied abort signal fires.
158
+ *
159
+ * Exported as the testable seam — the Hono and tRPC sibling
160
+ * generators wrap this so the signal-driven cleanup and counter
161
+ * accounting live in one place.
162
+ */
163
+ export declare function runSseSubscription<S extends {
164
+ _v: number;
165
+ } = any>(channel: BroadcastChannel<S>, stream_id: string, accounting: SseAccounting | undefined, signal: AbortSignal | undefined, on_cap_exceeded?: () => never): AsyncGenerator<SseSubscriptionFrame<S>>;
166
+ //# sourceMappingURL=sse-wiring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse-wiring.d.ts","sourceRoot":"","sources":["../../../src/api/sse-wiring.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB;;;;;OAKG;IAEH,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC,GAAG,CAAC,CAAC;IACxC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;OAOG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,SAAS,GAAG;IACtB,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC,cAAc,CAAC,CAAC;IACnD,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;GAGG;AACH,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAO/C;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,UAAU,GAAG,SAAS,CA0B/D;AAED;;;;;;;;;GASG;AACH,qBAAa,oBAAoB;IAC/B,SAAgB,KAAK,EAAE,MAAM,CAAC;IAC9B,OAAO,CAAC,KAAK,CAAK;gBAEN,KAAK,EAAE,MAAM;IAIzB;;;;OAIG;IACH,OAAO,IAAI,OAAO;IAMlB;;;;OAIG;IACH,OAAO,IAAI,IAAI;IAIf,qDAAqD;IACrD,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAE7E;AAED;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,CAAC,CAAC,IAC9B;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;CAAE,GAC5C;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAEvD;;;;;;;;GAQG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,IAAI,OAAO,CAAC;IACnB,OAAO,IAAI,IAAI,CAAC;CACjB,CAAC;AAEF;;;;;;;;;;GAUG;AAEH,wBAAuB,kBAAkB,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,GAAG,EACtE,OAAO,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,aAAa,GAAG,SAAS,EACrC,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,eAAe,CAAC,EAAE,MAAM,KAAK,GAC5B,cAAc,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAgCzC"}
@@ -2,7 +2,7 @@ import type { Actor, Target } from "@rotorsoft/act";
2
2
  import type { IdempotencyStore } from "@rotorsoft/act-ops/idempotency";
3
3
  import type { Context, MiddlewareHandler } from "hono";
4
4
  import { Hono } from "hono";
5
- import { type ActorExtractor } from "../api/index.js";
5
+ import { type ActorExtractor, type SseOptions } from "../api/index.js";
6
6
  /**
7
7
  * Hono context variables this subpath contributes. The generic on
8
8
  * the returned middleware threads it through so routes downstream
@@ -46,6 +46,23 @@ export type HonoOptions = {
46
46
  readonly store: IdempotencyStore;
47
47
  readonly keyFrom?: (c: Context) => string | undefined;
48
48
  };
49
+ /**
50
+ * Optional SSE wiring. When set, the generator emits one
51
+ * `GET <basePath>/sse/<stateName>?stream=<streamId>` per unique
52
+ * state name in the registry. The endpoint runs the host
53
+ * {@link actor} extractor, looks up the streamId from
54
+ * `?stream=...`, opens a `text/event-stream`, yields the cached
55
+ * state (if any), and forwards every patch published to the
56
+ * shared {@link SseOptions.channel}. A heartbeat keeps proxies
57
+ * from idling the connection out; the per-process connection cap
58
+ * returns `503 Service Unavailable` (with `Retry-After: 1`) when
59
+ * full so operators never see silent stalls.
60
+ *
61
+ * Off by default — most APIs don't expose live state to every
62
+ * client, and opening one is widening both the auth and cost
63
+ * surface.
64
+ */
65
+ readonly sse?: SseOptions;
49
66
  readonly basePath?: string;
50
67
  };
51
68
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hono/index.ts"],"names":[],"mappings":"AAgDA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACvD,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EACL,KAAK,cAAc,EAIpB,MAAM,iBAAiB,CAAC;AAEzB;;;;;GAKG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,KAAK,EAAE,KAAK,CAAC;CACd,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,CACf,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,OAAO,KACP,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,eAAe,CAAC,EAAE,CACzB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,OAAO,KACP,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACtD,QAAQ,CAAC,WAAW,CAAC,EAAE;QACrB,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;QACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;KACvD,CAAC;IACF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,cAAc,GACxB,iBAAiB,CAAC;IAAE,SAAS,EAAE,sBAAsB,CAAA;CAAE,CAAC,CAe1D;AAKD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH;;;;;;;;GAQG;AACH,KAAK,UAAU,GAAG;IAChB,QAAQ,CAAC,QAAQ,EAAE;QACjB,OAAO,EAAE,MAAM,CACb,MAAM,EACN;YACE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;YACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;SAC3C,CACF,CAAC;KACH,CAAC;IACF,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACxE,CAAC;AAEF,wBAAgB,IAAI,CAAC,IAAI,SAAS,UAAU,GAAG,UAAU,EACvD,GAAG,EAAE,IAAI,EACT,OAAO,EAAE,WAAW,GACnB,IAAI,CAAC;IAAE,SAAS,EAAE,sBAAsB,CAAA;CAAE,CAAC,CAyE7C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hono/index.ts"],"names":[],"mappings":"AAgDA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACvD,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EACL,KAAK,cAAc,EAMnB,KAAK,UAAU,EAGhB,MAAM,iBAAiB,CAAC;AAEzB;;;;;GAKG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,KAAK,EAAE,KAAK,CAAC;CACd,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,CACf,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,OAAO,KACP,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,eAAe,CAAC,EAAE,CACzB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,OAAO,EACd,CAAC,EAAE,OAAO,KACP,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACtD,QAAQ,CAAC,WAAW,CAAC,EAAE;QACrB,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;QACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;KACvD,CAAC;IACF;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,cAAc,GACxB,iBAAiB,CAAC;IAAE,SAAS,EAAE,sBAAsB,CAAA;CAAE,CAAC,CAe1D;AAKD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH;;;;;;;;GAQG;AACH,KAAK,UAAU,GAAG;IAChB,QAAQ,CAAC,QAAQ,EAAE;QACjB,OAAO,EAAE,MAAM,CACb,MAAM,EACN;YACE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;YACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;SAC3C,CACF,CAAC;KACH,CAAC;IACF,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACxE,CAAC;AAEF,wBAAgB,IAAI,CAAC,IAAI,SAAS,UAAU,GAAG,UAAU,EACvD,GAAG,EAAE,IAAI,EACT,OAAO,EAAE,WAAW,GACnB,IAAI,CAAC;IAAE,SAAS,EAAE,sBAAsB,CAAA;CAAE,CAAC,CA0I7C"}
@@ -53,7 +53,7 @@
53
53
  import type { Target } from "@rotorsoft/act";
54
54
  import type { IdempotencyStore } from "@rotorsoft/act-ops/idempotency";
55
55
  import { type AnyTRPCMiddlewareFunction, type AnyTRPCMutationProcedure, type AnyTRPCRootTypes, type TRPCBuiltRouter } from "@trpc/server";
56
- import { type ActorExtractor } from "../api/index.js";
56
+ import { type ActorExtractor, type SseOptions } from "../api/index.js";
57
57
  /**
58
58
  * Per-call options for {@link trpc}. The host supplies the three
59
59
  * seams the package can't make decisions on:
@@ -95,6 +95,22 @@ export type TrpcOptions<Ctx> = {
95
95
  readonly store: IdempotencyStore;
96
96
  readonly keyFrom: (ctx: Ctx) => string | undefined;
97
97
  };
98
+ /**
99
+ * Optional SSE wiring. When set, the generator emits one
100
+ * subscription per unique state name in the registry, grouped on
101
+ * the returned router as
102
+ * `router.subscribe.<stateName>.useSubscription({ stream })`.
103
+ * Each subscription yields `{ kind: "state", data }` once with
104
+ * the cached state (when present) and then `{ kind: "patch",
105
+ * data }` for every patch the shared
106
+ * {@link SseOptions.channel} publishes. The per-process
107
+ * connection cap surfaces as a `TOO_MANY_REQUESTS` `TRPCError`.
108
+ *
109
+ * Off by default — most APIs don't expose live state to every
110
+ * client, and opening one is widening both the auth and cost
111
+ * surface.
112
+ */
113
+ readonly sse?: SseOptions;
98
114
  };
99
115
  /**
100
116
  * Build a tRPC middleware that runs an {@link ActorExtractor} once
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/trpc/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,OAAO,KAAK,EAAS,MAAM,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EACL,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,KAAK,gBAAgB,EAErB,KAAK,eAAe,EAErB,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,KAAK,cAAc,EAGpB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,MAAM,MAAM,WAAW,CAAC,GAAG,IAAI;IAC7B,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,CACf,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,GAAG,KACL,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,eAAe,CAAC,EAAE,CACzB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,GAAG,KACL,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACtD,QAAQ,CAAC,WAAW,CAAC,EAAE;QACrB,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;QACjC,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,MAAM,GAAG,SAAS,CAAC;KACpD,CAAC;CACH,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,cAAc,GACxB,yBAAyB,CAmB3B;AA+CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH;;;;;;;;;;GAUG;AACH,KAAK,UAAU,GAAG;IAChB,QAAQ,CAAC,QAAQ,EAAE;QACjB,OAAO,EAAE,MAAM,CACb,MAAM,EACN;YACE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;YACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;SAC3C,CACF,CAAC;KACH,CAAC;IACF,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACxE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,KAAK,eAAe,CAAC,IAAI,IAAI,eAAe,CAC1C,gBAAgB,EAChB,IAAI,SAAS;IAAE,QAAQ,EAAE;QAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;KAAE,CAAA;CAAE,GAClD;KAAG,CAAC,IAAI,MAAM,QAAQ,GAAG,wBAAwB;CAAE,GACnD,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAC7C,CAAC;AAEF,wBAAgB,IAAI,CAClB,GAAG,SAAS,MAAM,GAAG,MAAM,EAC3B,IAAI,SAAS,UAAU,GAAG,UAAU,EACpC,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,CAAC,GAAG,CAAC,GAAG,eAAe,CAAC,IAAI,CAAC,CAyE7D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/trpc/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,OAAO,KAAK,EAAS,MAAM,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EACL,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,KAAK,gBAAgB,EAErB,KAAK,eAAe,EAErB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,KAAK,cAAc,EAInB,KAAK,UAAU,EAGhB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,MAAM,MAAM,WAAW,CAAC,GAAG,IAAI;IAC7B,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,CACf,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,GAAG,KACL,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,eAAe,CAAC,EAAE,CACzB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,GAAG,KACL,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACtD,QAAQ,CAAC,WAAW,CAAC,EAAE;QACrB,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;QACjC,QAAQ,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,MAAM,GAAG,SAAS,CAAC;KACpD,CAAC;IACF;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,UAAU,CAAC;CAC3B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,cAAc,GACxB,yBAAyB,CAmB3B;AA+CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH;;;;;;;;;;GAUG;AACH,KAAK,UAAU,GAAG;IAChB,QAAQ,CAAC,QAAQ,EAAE;QACjB,OAAO,EAAE,MAAM,CACb,MAAM,EACN;YACE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;YACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;SAC3C,CACF,CAAC;KACH,CAAC;IACF,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACxE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,KAAK,eAAe,CAAC,IAAI,IAAI,eAAe,CAC1C,gBAAgB,EAChB,IAAI,SAAS;IAAE,QAAQ,EAAE;QAAE,OAAO,EAAE,MAAM,QAAQ,CAAA;KAAE,CAAA;CAAE,GAClD;KAAG,CAAC,IAAI,MAAM,QAAQ,GAAG,wBAAwB;CAAE,GACnD,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAC7C,CAAC;AAEF,wBAAgB,IAAI,CAClB,GAAG,SAAS,MAAM,GAAG,MAAM,EAC3B,IAAI,SAAS,UAAU,GAAG,UAAU,EACpC,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,CAAC,GAAG,CAAC,GAAG,eAAe,CAAC,IAAI,CAAC,CAsG7D"}
@@ -20,7 +20,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/api/index.ts
21
21
  var api_exports = {};
22
22
  __export(api_exports, {
23
+ DEFAULT_SSE_HEARTBEAT_MS: () => DEFAULT_SSE_HEARTBEAT_MS,
24
+ DEFAULT_SSE_MAX_CONNECTIONS: () => DEFAULT_SSE_MAX_CONNECTIONS,
23
25
  ERROR_MAP: () => ERROR_MAP,
26
+ SseConnectionCounter: () => SseConnectionCounter,
27
+ fireAndForget: () => fireAndForget,
28
+ resolveSseConfig: () => resolveSseConfig,
29
+ runSseSubscription: () => runSseSubscription,
24
30
  toApiError: () => toApiError,
25
31
  withIdempotency: () => withIdempotency
26
32
  });
@@ -76,9 +82,107 @@ async function withIdempotency(store, key, handler) {
76
82
  }
77
83
  return { deduped: false, result: await handler() };
78
84
  }
85
+
86
+ // src/api/sse-wiring.ts
87
+ var DEFAULT_SSE_MAX_CONNECTIONS = 500;
88
+ var DEFAULT_SSE_HEARTBEAT_MS = 3e4;
89
+ var MAX_CONNECTIONS_MIN = 1;
90
+ var MAX_CONNECTIONS_MAX = 1e4;
91
+ var HEARTBEAT_MS_MIN = 15e3;
92
+ var HEARTBEAT_MS_MAX = 3e5;
93
+ function resolveSseConfig(options) {
94
+ const max_connections = options.maxConnections ?? DEFAULT_SSE_MAX_CONNECTIONS;
95
+ const heartbeat_ms = options.heartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS;
96
+ if (!Number.isFinite(max_connections) || max_connections < MAX_CONNECTIONS_MIN || max_connections > MAX_CONNECTIONS_MAX) {
97
+ throw new RangeError(
98
+ `sse.maxConnections must be in [${MAX_CONNECTIONS_MIN}, ${MAX_CONNECTIONS_MAX}], got ${max_connections}`
99
+ );
100
+ }
101
+ if (!Number.isFinite(heartbeat_ms) || heartbeat_ms < HEARTBEAT_MS_MIN || heartbeat_ms > HEARTBEAT_MS_MAX) {
102
+ throw new RangeError(
103
+ `sse.heartbeatMs must be in [${HEARTBEAT_MS_MIN}, ${HEARTBEAT_MS_MAX}], got ${heartbeat_ms}`
104
+ );
105
+ }
106
+ return {
107
+ channel: options.channel,
108
+ maxConnections: max_connections,
109
+ heartbeatMs: heartbeat_ms
110
+ };
111
+ }
112
+ var SseConnectionCounter = class {
113
+ limit;
114
+ _open = 0;
115
+ constructor(limit) {
116
+ this.limit = limit;
117
+ }
118
+ /**
119
+ * Reserve one slot. Returns `true` when accepted (caller must
120
+ * eventually {@link release} the slot, even on error paths) or
121
+ * `false` when the cap is already reached.
122
+ */
123
+ acquire() {
124
+ if (this._open >= this.limit) return false;
125
+ this._open++;
126
+ return true;
127
+ }
128
+ /**
129
+ * Free a previously-{@link acquire}d slot. Always runs from a
130
+ * `finally` block in the transport's subscription handler so
131
+ * crashed handlers don't leak count.
132
+ */
133
+ release() {
134
+ if (this._open > 0) this._open--;
135
+ }
136
+ /** Currently-open count. Test/observability hook. */
137
+ get open() {
138
+ return this._open;
139
+ }
140
+ };
141
+ function fireAndForget(op) {
142
+ return op().catch(() => void 0);
143
+ }
144
+ async function* runSseSubscription(channel, stream_id, accounting, signal, on_cap_exceeded) {
145
+ if (accounting && !accounting.acquire() && on_cap_exceeded) {
146
+ on_cap_exceeded();
147
+ }
148
+ const pending = [];
149
+ let resolve_wait = null;
150
+ const unsubscribe = channel.subscribe(stream_id, (msg) => {
151
+ pending.push(msg);
152
+ resolve_wait?.();
153
+ });
154
+ const on_abort = () => resolve_wait?.();
155
+ signal?.addEventListener("abort", on_abort);
156
+ try {
157
+ const cached = channel.get_state(stream_id);
158
+ if (cached !== void 0) {
159
+ yield { kind: "state", data: cached };
160
+ }
161
+ while (!signal?.aborted) {
162
+ if (pending.length === 0) {
163
+ await new Promise((resolve) => {
164
+ resolve_wait = resolve;
165
+ });
166
+ if (signal?.aborted) break;
167
+ }
168
+ const msg = pending.shift();
169
+ yield { kind: "patch", data: msg };
170
+ }
171
+ } finally {
172
+ signal?.removeEventListener("abort", on_abort);
173
+ unsubscribe();
174
+ accounting?.release();
175
+ }
176
+ }
79
177
  // Annotate the CommonJS export names for ESM import in node:
80
178
  0 && (module.exports = {
179
+ DEFAULT_SSE_HEARTBEAT_MS,
180
+ DEFAULT_SSE_MAX_CONNECTIONS,
81
181
  ERROR_MAP,
182
+ SseConnectionCounter,
183
+ fireAndForget,
184
+ resolveSseConfig,
185
+ runSseSubscription,
82
186
  toApiError,
83
187
  withIdempotency
84
188
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/index.ts","../../src/api/errors.ts","../../src/api/idempotency.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/api\n *\n * Shared utilities for the act-http auto-generated API surfaces.\n * Three concerns that every transport (tRPC, Hono, OpenAPI) has to\n * address — actor extraction, error envelope mapping,\n * `Idempotency-Key` wiring — defined once here and composed by each\n * transport sibling subpath.\n *\n * - {@link ActorExtractor} — the host-supplied closure that resolves\n * an `Actor` from an incoming request. Auth (JWT, session, API\n * key) stays in the host; the package only asks for this one\n * function.\n * - {@link ApiError}, {@link ERROR_MAP}, {@link toApiError} — the\n * uniform error envelope and the status/code mapping every\n * transport uses. Cross-transport consistency by construction.\n * - {@link withIdempotency} — the helper that wraps action handlers\n * in an `Idempotency-Key` claim. Reuses the\n * `@rotorsoft/act-ops/idempotency` contract that\n * `@rotorsoft/act-http/receiver` already speaks, so receivers and\n * generated APIs share one `IdempotencyStore` implementation.\n *\n * Sibling subpaths in the same package consume the utilities here:\n *\n * - `@rotorsoft/act-http/trpc` — tRPC adapter (#843).\n * - `@rotorsoft/act-http/hono` — Hono adapter (#844).\n * - `@rotorsoft/act-http/openapi` — OpenAPI emitter (#845).\n *\n * Existing siblings unrelated to the generated-API work:\n *\n * - `@rotorsoft/act-http/webhook` — outbound POST delivery.\n * - `@rotorsoft/act-http/sse` — incremental state broadcast.\n * - `@rotorsoft/act-http/receiver` — inbound webhook ingestion.\n */\n\nexport type { ActorExtractor } from \"./actor.js\";\nexport {\n type ApiError,\n ERROR_MAP,\n type ErrorMapEntry,\n toApiError,\n} from \"./errors.js\";\nexport { type IdempotencyResult, withIdempotency } from \"./idempotency.js\";\n","import {\n ConcurrencyError,\n InvariantError,\n NonRetryableError,\n StreamClosedError,\n ValidationError,\n} from \"@rotorsoft/act\";\n\n/**\n * Uniform error envelope shipped over the wire by every act-http\n * transport. Hosts get the same shape from REST, tRPC, and OpenAPI —\n * a client that talks to two transports doesn't have to invent two\n * error parsers.\n *\n * - `error` — the framework error name (`\"ValidationError\"`,\n * `\"InvariantError\"`, …). Stable identifier, safe to switch on.\n * - `detail` — the framework's message text. Human-readable; not\n * parsed by clients.\n * - `code` — a machine-readable status code from {@link ERROR_MAP}\n * for clients that prefer enum-style branching over name strings.\n */\nexport type ApiError = {\n error: string;\n detail?: string;\n code?: string;\n};\n\n/**\n * Status + code pair for one known framework error.\n */\nexport type ErrorMapEntry = {\n status: number;\n code: string;\n};\n\n/**\n * The single table that maps framework error types to HTTP status\n * codes and machine-readable codes. One table, three consumers\n * (Hono, tRPC, OpenAPI) — cross-transport consistency by\n * construction.\n *\n * Operators wanting different mappings wrap the generated transport\n * rather than mutating this — the consistency is the load-bearing\n * property, not the specific status codes.\n */\nexport const ERROR_MAP = {\n ValidationError: { status: 422, code: \"VALIDATION\" },\n InvariantError: { status: 409, code: \"INVARIANT\" },\n ConcurrencyError: { status: 412, code: \"CONCURRENCY\" },\n StreamClosedError: { status: 410, code: \"STREAM_CLOSED\" },\n NonRetryableError: { status: 400, code: \"NON_RETRYABLE\" },\n} as const satisfies Record<string, ErrorMapEntry>;\n\nconst lookup_known = (\n err: unknown\n): { name: keyof typeof ERROR_MAP } | null => {\n if (err instanceof ValidationError) return { name: \"ValidationError\" };\n if (err instanceof InvariantError) return { name: \"InvariantError\" };\n if (err instanceof ConcurrencyError) return { name: \"ConcurrencyError\" };\n if (err instanceof StreamClosedError) return { name: \"StreamClosedError\" };\n if (err instanceof NonRetryableError) return { name: \"NonRetryableError\" };\n return null;\n};\n\n/**\n * Translate an unknown thrown value into the canonical\n * {@link ApiError} envelope plus HTTP status. Each transport's error\n * boundary calls this once and forwards the result to the wire.\n *\n * Known framework errors map per {@link ERROR_MAP}. Everything else\n * surfaces as a 500 with `code: \"INTERNAL\"`; the `detail` field is\n * populated when the throw was an `Error` instance, omitted\n * otherwise (a thrown string or object doesn't get to leak its\n * payload to the client).\n */\nexport function toApiError(err: unknown): { status: number; body: ApiError } {\n const known = lookup_known(err);\n if (known) {\n const entry = ERROR_MAP[known.name];\n return {\n status: entry.status,\n body: {\n error: known.name,\n detail: (err as Error).message,\n code: entry.code,\n },\n };\n }\n if (err instanceof Error) {\n return {\n status: 500,\n body: { error: \"InternalError\", detail: err.message, code: \"INTERNAL\" },\n };\n }\n return {\n status: 500,\n body: { error: \"InternalError\", code: \"INTERNAL\" },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * Result of a {@link withIdempotency} call.\n *\n * - `{ deduped: false, result }` — the claim was fresh; the handler\n * ran and produced `result`.\n * - `{ deduped: true }` — the claim was already taken; the handler\n * was *not* invoked. The caller decides how to respond (typically\n * a 2xx with no body, matching the receiver-side convention).\n *\n * Note: the contract does not cache the previous response. A\n * duplicate call returns the deduped marker only — replaying the\n * original handler's output would require a response-caching\n * adapter, which is out of scope here. The receiver-side convention\n * (and the convention the generated transports follow) is \"ack the\n * duplicate; do nothing else.\"\n */\nexport type IdempotencyResult<T> =\n | { deduped: false; result: T }\n | { deduped: true };\n\n/**\n * Wrap an action handler so the framework honors `Idempotency-Key`\n * dedup. Acquires the key via {@link IdempotencyStore.claim}, runs\n * the handler exactly when the claim was fresh, and skips the\n * handler entirely on a duplicate.\n *\n * Reuses the contract `@rotorsoft/act-ops/idempotency` already\n * defines for the receiver-side `Idempotency-Key` story. A single\n * `IdempotencyStore` implementation covers both halves of the \"Act\n * over the wire\" surface — receiver and generated API.\n */\nexport async function withIdempotency<T>(\n store: IdempotencyStore,\n key: string,\n handler: () => Promise<T>\n): Promise<IdempotencyResult<T>> {\n const fresh = await store.claim(key);\n if (!fresh) {\n return { deduped: true };\n }\n return { deduped: false, result: await handler() };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,iBAMO;AAuCA,IAAM,YAAY;AAAA,EACvB,iBAAiB,EAAE,QAAQ,KAAK,MAAM,aAAa;AAAA,EACnD,gBAAgB,EAAE,QAAQ,KAAK,MAAM,YAAY;AAAA,EACjD,kBAAkB,EAAE,QAAQ,KAAK,MAAM,cAAc;AAAA,EACrD,mBAAmB,EAAE,QAAQ,KAAK,MAAM,gBAAgB;AAAA,EACxD,mBAAmB,EAAE,QAAQ,KAAK,MAAM,gBAAgB;AAC1D;AAEA,IAAM,eAAe,CACnB,QAC4C;AAC5C,MAAI,eAAe,2BAAiB,QAAO,EAAE,MAAM,kBAAkB;AACrE,MAAI,eAAe,0BAAgB,QAAO,EAAE,MAAM,iBAAiB;AACnE,MAAI,eAAe,4BAAkB,QAAO,EAAE,MAAM,mBAAmB;AACvE,MAAI,eAAe,6BAAmB,QAAO,EAAE,MAAM,oBAAoB;AACzE,MAAI,eAAe,6BAAmB,QAAO,EAAE,MAAM,oBAAoB;AACzE,SAAO;AACT;AAaO,SAAS,WAAW,KAAkD;AAC3E,QAAM,QAAQ,aAAa,GAAG;AAC9B,MAAI,OAAO;AACT,UAAM,QAAQ,UAAU,MAAM,IAAI;AAClC,WAAO;AAAA,MACL,QAAQ,MAAM;AAAA,MACd,MAAM;AAAA,QACJ,OAAO,MAAM;AAAA,QACb,QAAS,IAAc;AAAA,QACvB,MAAM,MAAM;AAAA,MACd;AAAA,IACF;AAAA,EACF;AACA,MAAI,eAAe,OAAO;AACxB,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,MAAM,EAAE,OAAO,iBAAiB,QAAQ,IAAI,SAAS,MAAM,WAAW;AAAA,IACxE;AAAA,EACF;AACA,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,MAAM,EAAE,OAAO,iBAAiB,MAAM,WAAW;AAAA,EACnD;AACF;;;ACjEA,eAAsB,gBACpB,OACA,KACA,SAC+B;AAC/B,QAAM,QAAQ,MAAM,MAAM,MAAM,GAAG;AACnC,MAAI,CAAC,OAAO;AACV,WAAO,EAAE,SAAS,KAAK;AAAA,EACzB;AACA,SAAO,EAAE,SAAS,OAAO,QAAQ,MAAM,QAAQ,EAAE;AACnD;","names":[]}
1
+ {"version":3,"sources":["../../src/api/index.ts","../../src/api/errors.ts","../../src/api/idempotency.ts","../../src/api/sse-wiring.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/api\n *\n * Shared utilities for the act-http auto-generated API surfaces.\n * Three concerns that every transport (tRPC, Hono, OpenAPI) has to\n * address — actor extraction, error envelope mapping,\n * `Idempotency-Key` wiring — defined once here and composed by each\n * transport sibling subpath.\n *\n * - {@link ActorExtractor} — the host-supplied closure that resolves\n * an `Actor` from an incoming request. Auth (JWT, session, API\n * key) stays in the host; the package only asks for this one\n * function.\n * - {@link ApiError}, {@link ERROR_MAP}, {@link toApiError} — the\n * uniform error envelope and the status/code mapping every\n * transport uses. Cross-transport consistency by construction.\n * - {@link withIdempotency} — the helper that wraps action handlers\n * in an `Idempotency-Key` claim. Reuses the\n * `@rotorsoft/act-ops/idempotency` contract that\n * `@rotorsoft/act-http/receiver` already speaks, so receivers and\n * generated APIs share one `IdempotencyStore` implementation.\n *\n * Sibling subpaths in the same package consume the utilities here:\n *\n * - `@rotorsoft/act-http/trpc` — tRPC adapter (#843).\n * - `@rotorsoft/act-http/hono` — Hono adapter (#844).\n * - `@rotorsoft/act-http/openapi` — OpenAPI emitter (#845).\n *\n * Existing siblings unrelated to the generated-API work:\n *\n * - `@rotorsoft/act-http/webhook` — outbound POST delivery.\n * - `@rotorsoft/act-http/sse` — incremental state broadcast.\n * - `@rotorsoft/act-http/receiver` — inbound webhook ingestion.\n */\n\nexport type { ActorExtractor } from \"./actor.js\";\nexport {\n type ApiError,\n ERROR_MAP,\n type ErrorMapEntry,\n toApiError,\n} from \"./errors.js\";\nexport { type IdempotencyResult, withIdempotency } from \"./idempotency.js\";\nexport {\n DEFAULT_SSE_HEARTBEAT_MS,\n DEFAULT_SSE_MAX_CONNECTIONS,\n fireAndForget,\n resolveSseConfig,\n runSseSubscription,\n type SseAccounting,\n type SseConfig,\n SseConnectionCounter,\n type SseOptions,\n type SseSubscriptionFrame,\n} from \"./sse-wiring.js\";\n","import {\n ConcurrencyError,\n InvariantError,\n NonRetryableError,\n StreamClosedError,\n ValidationError,\n} from \"@rotorsoft/act\";\n\n/**\n * Uniform error envelope shipped over the wire by every act-http\n * transport. Hosts get the same shape from REST, tRPC, and OpenAPI —\n * a client that talks to two transports doesn't have to invent two\n * error parsers.\n *\n * - `error` — the framework error name (`\"ValidationError\"`,\n * `\"InvariantError\"`, …). Stable identifier, safe to switch on.\n * - `detail` — the framework's message text. Human-readable; not\n * parsed by clients.\n * - `code` — a machine-readable status code from {@link ERROR_MAP}\n * for clients that prefer enum-style branching over name strings.\n */\nexport type ApiError = {\n error: string;\n detail?: string;\n code?: string;\n};\n\n/**\n * Status + code pair for one known framework error.\n */\nexport type ErrorMapEntry = {\n status: number;\n code: string;\n};\n\n/**\n * The single table that maps framework error types to HTTP status\n * codes and machine-readable codes. One table, three consumers\n * (Hono, tRPC, OpenAPI) — cross-transport consistency by\n * construction.\n *\n * Operators wanting different mappings wrap the generated transport\n * rather than mutating this — the consistency is the load-bearing\n * property, not the specific status codes.\n */\nexport const ERROR_MAP = {\n ValidationError: { status: 422, code: \"VALIDATION\" },\n InvariantError: { status: 409, code: \"INVARIANT\" },\n ConcurrencyError: { status: 412, code: \"CONCURRENCY\" },\n StreamClosedError: { status: 410, code: \"STREAM_CLOSED\" },\n NonRetryableError: { status: 400, code: \"NON_RETRYABLE\" },\n} as const satisfies Record<string, ErrorMapEntry>;\n\nconst lookup_known = (\n err: unknown\n): { name: keyof typeof ERROR_MAP } | null => {\n if (err instanceof ValidationError) return { name: \"ValidationError\" };\n if (err instanceof InvariantError) return { name: \"InvariantError\" };\n if (err instanceof ConcurrencyError) return { name: \"ConcurrencyError\" };\n if (err instanceof StreamClosedError) return { name: \"StreamClosedError\" };\n if (err instanceof NonRetryableError) return { name: \"NonRetryableError\" };\n return null;\n};\n\n/**\n * Translate an unknown thrown value into the canonical\n * {@link ApiError} envelope plus HTTP status. Each transport's error\n * boundary calls this once and forwards the result to the wire.\n *\n * Known framework errors map per {@link ERROR_MAP}. Everything else\n * surfaces as a 500 with `code: \"INTERNAL\"`; the `detail` field is\n * populated when the throw was an `Error` instance, omitted\n * otherwise (a thrown string or object doesn't get to leak its\n * payload to the client).\n */\nexport function toApiError(err: unknown): { status: number; body: ApiError } {\n const known = lookup_known(err);\n if (known) {\n const entry = ERROR_MAP[known.name];\n return {\n status: entry.status,\n body: {\n error: known.name,\n detail: (err as Error).message,\n code: entry.code,\n },\n };\n }\n if (err instanceof Error) {\n return {\n status: 500,\n body: { error: \"InternalError\", detail: err.message, code: \"INTERNAL\" },\n };\n }\n return {\n status: 500,\n body: { error: \"InternalError\", code: \"INTERNAL\" },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * Result of a {@link withIdempotency} call.\n *\n * - `{ deduped: false, result }` — the claim was fresh; the handler\n * ran and produced `result`.\n * - `{ deduped: true }` — the claim was already taken; the handler\n * was *not* invoked. The caller decides how to respond (typically\n * a 2xx with no body, matching the receiver-side convention).\n *\n * Note: the contract does not cache the previous response. A\n * duplicate call returns the deduped marker only — replaying the\n * original handler's output would require a response-caching\n * adapter, which is out of scope here. The receiver-side convention\n * (and the convention the generated transports follow) is \"ack the\n * duplicate; do nothing else.\"\n */\nexport type IdempotencyResult<T> =\n | { deduped: false; result: T }\n | { deduped: true };\n\n/**\n * Wrap an action handler so the framework honors `Idempotency-Key`\n * dedup. Acquires the key via {@link IdempotencyStore.claim}, runs\n * the handler exactly when the claim was fresh, and skips the\n * handler entirely on a duplicate.\n *\n * Reuses the contract `@rotorsoft/act-ops/idempotency` already\n * defines for the receiver-side `Idempotency-Key` story. A single\n * `IdempotencyStore` implementation covers both halves of the \"Act\n * over the wire\" surface — receiver and generated API.\n */\nexport async function withIdempotency<T>(\n store: IdempotencyStore,\n key: string,\n handler: () => Promise<T>\n): Promise<IdempotencyResult<T>> {\n const fresh = await store.claim(key);\n if (!fresh) {\n return { deduped: true };\n }\n return { deduped: false, result: await handler() };\n}\n","import type { BroadcastChannel } from \"../sse/broadcast.js\";\nimport type { BroadcastState } from \"../sse/types.js\";\n\n/**\n * SSE wiring options shared by the auto-generated `trpc` and `hono`\n * transports. When supplied, each transport emits one streaming\n * subscription per registered state name, all reading from the same\n * host-supplied {@link BroadcastChannel}.\n *\n * The host owns publication: every `app.do(...)` call site must\n * forward the resulting snapshots to `channel.publish(...)` for\n * subscribers to see updates. The framework deliberately doesn't\n * auto-publish because the derived state usually needs app-specific\n * overlays (presence, computed fields) that only the host can supply.\n *\n * Defaults are sized for typical business-app dashboards (dozens to\n * hundreds of human viewers per process). Both numeric knobs are\n * validated at transport-function construction; out-of-range values\n * throw `RangeError` immediately so misconfiguration surfaces at\n * startup instead of at first connection.\n */\nexport type SseOptions = {\n /**\n * The {@link BroadcastChannel} the generator's subscriptions hook\n * into. Host owns its lifecycle; the generator does not construct\n * one for you because the channel's `<S>` shape is app-specific and\n * passing it in keeps the type chain typed end-to-end at the host.\n */\n // biome-ignore lint/suspicious/noExplicitAny: the channel's BroadcastState shape is opaque to the generator — narrowing it would force every state into one structural bound that doesn't fit real apps\n readonly channel: BroadcastChannel<any>;\n /**\n * Hard cap on concurrent open subscriptions per generator\n * instance. The 501st simultaneous open returns `503` (Hono) or\n * throws `TOO_MANY_REQUESTS` (tRPC) — never a silent stall.\n *\n * Default `500`. Validated range `[1, 10_000]`. Above 10k the\n * framework refuses to construct: at that point the FD ceiling\n * and per-connection memory force horizontal scaling regardless\n * of tuning, so the right move is sticky LB + per-process cap,\n * not a higher single-process limit.\n */\n readonly maxConnections?: number;\n /**\n * Keep-alive cadence in milliseconds. Most reverse proxies idle\n * connections out after ~60 seconds; the default `30_000` stays\n * comfortably under.\n *\n * Validated range `[15_000, 300_000]`. Under 15 s wastes\n * bandwidth on a business workload; over 5 min risks proxy drops.\n */\n readonly heartbeatMs?: number;\n};\n\n/**\n * Resolved SSE configuration after validation and default expansion.\n * Internal — the resolved values are what every transport actually\n * runs against.\n *\n * @internal\n */\nexport type SseConfig = {\n readonly channel: BroadcastChannel<BroadcastState>;\n readonly maxConnections: number;\n readonly heartbeatMs: number;\n};\n\n/**\n * Default cap on concurrent open subscriptions per generator\n * instance. Sized for typical business-app dashboards (dozens to a\n * few hundred viewers); high enough not to surprise small\n * deployments, low enough not to mask a missing horizontal-scale\n * strategy on big ones.\n */\nexport const DEFAULT_SSE_MAX_CONNECTIONS = 500;\n\n/**\n * Default keep-alive cadence (30 s). Sits comfortably under the\n * 60 s idle timeout most reverse proxies impose.\n */\nexport const DEFAULT_SSE_HEARTBEAT_MS = 30_000;\n\nconst MAX_CONNECTIONS_MIN = 1;\nconst MAX_CONNECTIONS_MAX = 10_000;\nconst HEARTBEAT_MS_MIN = 15_000;\nconst HEARTBEAT_MS_MAX = 300_000;\n\n/**\n * Validate and apply defaults to host-supplied {@link SseOptions}.\n *\n * Throws `RangeError` when either numeric knob is outside its\n * documented range. Pure: same input → same output, no side\n * effects, no I/O. Each transport calls this once at the start of\n * `hono(...)` / `trpc(...)`.\n */\nexport function resolveSseConfig(options: SseOptions): SseConfig {\n const max_connections = options.maxConnections ?? DEFAULT_SSE_MAX_CONNECTIONS;\n const heartbeat_ms = options.heartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS;\n if (\n !Number.isFinite(max_connections) ||\n max_connections < MAX_CONNECTIONS_MIN ||\n max_connections > MAX_CONNECTIONS_MAX\n ) {\n throw new RangeError(\n `sse.maxConnections must be in [${MAX_CONNECTIONS_MIN}, ${MAX_CONNECTIONS_MAX}], got ${max_connections}`\n );\n }\n if (\n !Number.isFinite(heartbeat_ms) ||\n heartbeat_ms < HEARTBEAT_MS_MIN ||\n heartbeat_ms > HEARTBEAT_MS_MAX\n ) {\n throw new RangeError(\n `sse.heartbeatMs must be in [${HEARTBEAT_MS_MIN}, ${HEARTBEAT_MS_MAX}], got ${heartbeat_ms}`\n );\n }\n return {\n channel: options.channel,\n maxConnections: max_connections,\n heartbeatMs: heartbeat_ms,\n };\n}\n\n/**\n * Per-generator concurrent-open counter. Constructed once inside\n * `hono(...)` / `trpc(...)` and consulted at every subscription\n * open: failure to acquire means the cap has been hit and the\n * transport reports `503` / `TOO_MANY_REQUESTS` to the caller.\n *\n * The counter is intentionally process-local. SSE doesn't multiplex\n * across processes; operators wanting more than `maxConnections`\n * viewers run multiple processes behind a sticky load balancer.\n */\nexport class SseConnectionCounter {\n public readonly limit: number;\n private _open = 0;\n\n constructor(limit: number) {\n this.limit = limit;\n }\n\n /**\n * Reserve one slot. Returns `true` when accepted (caller must\n * eventually {@link release} the slot, even on error paths) or\n * `false` when the cap is already reached.\n */\n acquire(): boolean {\n if (this._open >= this.limit) return false;\n this._open++;\n return true;\n }\n\n /**\n * Free a previously-{@link acquire}d slot. Always runs from a\n * `finally` block in the transport's subscription handler so\n * crashed handlers don't leak count.\n */\n release(): void {\n if (this._open > 0) this._open--;\n }\n\n /** Currently-open count. Test/observability hook. */\n get open(): number {\n return this._open;\n }\n}\n\n/**\n * Wrap a fire-and-forget Promise-returning operation so its\n * rejection is swallowed in place of leaking an unhandled\n * rejection. Used by the Hono heartbeat tick (the interval callback\n * isn't `await`ed, so its rejection has nowhere to go) and by any\n * other \"best-effort write\" the transports run.\n *\n * Pure: same input → same output, no side effects on the caller.\n * Returns the chained Promise so the caller can also `await`\n * completion if they want to (the Hono heartbeat doesn't).\n */\nexport function fireAndForget<T>(op: () => Promise<T>): Promise<T | undefined> {\n return op().catch(() => undefined);\n}\n\n/**\n * Frame yielded by the per-state SSE subscription generator. The\n * initial cached state (when present) arrives as `kind: \"state\"`;\n * every subsequent broadcast publication arrives as `kind: \"patch\"`\n * with the channel's `PatchMessage` payload.\n */\nexport type SseSubscriptionFrame<S> =\n | { readonly kind: \"state\"; readonly data: S }\n | { readonly kind: \"patch\"; readonly data: unknown };\n\n/**\n * Subscription accounting hook. Implementations have a chance to\n * acquire a slot before the loop starts and to release it once the\n * loop's `finally` block runs — used by the tRPC side to enforce\n * `maxConnections` from inside the generator, where it can throw\n * `TRPCError` cleanly. Hono enforces the cap outside the loop (so a\n * full counter returns `503` *before* streamSSE writes headers), then\n * passes `undefined` here.\n */\nexport type SseAccounting = {\n acquire(): boolean;\n release(): void;\n};\n\n/**\n * The shared subscription loop both transports run. Optionally\n * acquires one slot on the supplied {@link SseAccounting}, yields the\n * cached state (when present), then forwards every channel\n * publication as a `kind: \"patch\"` frame until either the consumer\n * breaks (calling `iter.return()`) or the supplied abort signal fires.\n *\n * Exported as the testable seam — the Hono and tRPC sibling\n * generators wrap this so the signal-driven cleanup and counter\n * accounting live in one place.\n */\n// biome-ignore lint/suspicious/noExplicitAny: the channel's BroadcastState shape is opaque to the loop — narrowing it would force every state into one structural bound that doesn't fit real apps\nexport async function* runSseSubscription<S extends { _v: number } = any>(\n channel: BroadcastChannel<S>,\n stream_id: string,\n accounting: SseAccounting | undefined,\n signal: AbortSignal | undefined,\n on_cap_exceeded?: () => never\n): AsyncGenerator<SseSubscriptionFrame<S>> {\n if (accounting && !accounting.acquire() && on_cap_exceeded) {\n on_cap_exceeded();\n }\n const pending: unknown[] = [];\n let resolve_wait: (() => void) | null = null;\n const unsubscribe = channel.subscribe(stream_id, (msg) => {\n pending.push(msg);\n resolve_wait?.();\n });\n const on_abort = () => resolve_wait?.();\n signal?.addEventListener(\"abort\", on_abort);\n try {\n const cached = channel.get_state(stream_id);\n if (cached !== undefined) {\n yield { kind: \"state\", data: cached };\n }\n while (!signal?.aborted) {\n if (pending.length === 0) {\n await new Promise<void>((resolve) => {\n resolve_wait = resolve;\n });\n if (signal?.aborted) break;\n }\n const msg = pending.shift();\n yield { kind: \"patch\", data: msg };\n }\n } finally {\n signal?.removeEventListener(\"abort\", on_abort);\n unsubscribe();\n accounting?.release();\n }\n}\n"],"mappings":"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","names":[]}
package/dist/api/index.js CHANGED
@@ -1,11 +1,23 @@
1
1
  import {
2
+ DEFAULT_SSE_HEARTBEAT_MS,
3
+ DEFAULT_SSE_MAX_CONNECTIONS,
2
4
  ERROR_MAP,
5
+ SseConnectionCounter,
6
+ fireAndForget,
7
+ resolveSseConfig,
8
+ runSseSubscription,
3
9
  toApiError,
4
10
  withIdempotency
5
- } from "../chunk-Z6FVXDDU.js";
11
+ } from "../chunk-QOHCL5GN.js";
6
12
  import "../chunk-PZ5AY32C.js";
7
13
  export {
14
+ DEFAULT_SSE_HEARTBEAT_MS,
15
+ DEFAULT_SSE_MAX_CONNECTIONS,
8
16
  ERROR_MAP,
17
+ SseConnectionCounter,
18
+ fireAndForget,
19
+ resolveSseConfig,
20
+ runSseSubscription,
9
21
  toApiError,
10
22
  withIdempotency
11
23
  };
@@ -0,0 +1,161 @@
1
+ // src/api/errors.ts
2
+ import {
3
+ ConcurrencyError,
4
+ InvariantError,
5
+ NonRetryableError,
6
+ StreamClosedError,
7
+ ValidationError
8
+ } from "@rotorsoft/act";
9
+ var ERROR_MAP = {
10
+ ValidationError: { status: 422, code: "VALIDATION" },
11
+ InvariantError: { status: 409, code: "INVARIANT" },
12
+ ConcurrencyError: { status: 412, code: "CONCURRENCY" },
13
+ StreamClosedError: { status: 410, code: "STREAM_CLOSED" },
14
+ NonRetryableError: { status: 400, code: "NON_RETRYABLE" }
15
+ };
16
+ var lookup_known = (err) => {
17
+ if (err instanceof ValidationError) return { name: "ValidationError" };
18
+ if (err instanceof InvariantError) return { name: "InvariantError" };
19
+ if (err instanceof ConcurrencyError) return { name: "ConcurrencyError" };
20
+ if (err instanceof StreamClosedError) return { name: "StreamClosedError" };
21
+ if (err instanceof NonRetryableError) return { name: "NonRetryableError" };
22
+ return null;
23
+ };
24
+ function toApiError(err) {
25
+ const known = lookup_known(err);
26
+ if (known) {
27
+ const entry = ERROR_MAP[known.name];
28
+ return {
29
+ status: entry.status,
30
+ body: {
31
+ error: known.name,
32
+ detail: err.message,
33
+ code: entry.code
34
+ }
35
+ };
36
+ }
37
+ if (err instanceof Error) {
38
+ return {
39
+ status: 500,
40
+ body: { error: "InternalError", detail: err.message, code: "INTERNAL" }
41
+ };
42
+ }
43
+ return {
44
+ status: 500,
45
+ body: { error: "InternalError", code: "INTERNAL" }
46
+ };
47
+ }
48
+
49
+ // src/api/idempotency.ts
50
+ async function withIdempotency(store, key, handler) {
51
+ const fresh = await store.claim(key);
52
+ if (!fresh) {
53
+ return { deduped: true };
54
+ }
55
+ return { deduped: false, result: await handler() };
56
+ }
57
+
58
+ // src/api/sse-wiring.ts
59
+ var DEFAULT_SSE_MAX_CONNECTIONS = 500;
60
+ var DEFAULT_SSE_HEARTBEAT_MS = 3e4;
61
+ var MAX_CONNECTIONS_MIN = 1;
62
+ var MAX_CONNECTIONS_MAX = 1e4;
63
+ var HEARTBEAT_MS_MIN = 15e3;
64
+ var HEARTBEAT_MS_MAX = 3e5;
65
+ function resolveSseConfig(options) {
66
+ const max_connections = options.maxConnections ?? DEFAULT_SSE_MAX_CONNECTIONS;
67
+ const heartbeat_ms = options.heartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS;
68
+ if (!Number.isFinite(max_connections) || max_connections < MAX_CONNECTIONS_MIN || max_connections > MAX_CONNECTIONS_MAX) {
69
+ throw new RangeError(
70
+ `sse.maxConnections must be in [${MAX_CONNECTIONS_MIN}, ${MAX_CONNECTIONS_MAX}], got ${max_connections}`
71
+ );
72
+ }
73
+ if (!Number.isFinite(heartbeat_ms) || heartbeat_ms < HEARTBEAT_MS_MIN || heartbeat_ms > HEARTBEAT_MS_MAX) {
74
+ throw new RangeError(
75
+ `sse.heartbeatMs must be in [${HEARTBEAT_MS_MIN}, ${HEARTBEAT_MS_MAX}], got ${heartbeat_ms}`
76
+ );
77
+ }
78
+ return {
79
+ channel: options.channel,
80
+ maxConnections: max_connections,
81
+ heartbeatMs: heartbeat_ms
82
+ };
83
+ }
84
+ var SseConnectionCounter = class {
85
+ limit;
86
+ _open = 0;
87
+ constructor(limit) {
88
+ this.limit = limit;
89
+ }
90
+ /**
91
+ * Reserve one slot. Returns `true` when accepted (caller must
92
+ * eventually {@link release} the slot, even on error paths) or
93
+ * `false` when the cap is already reached.
94
+ */
95
+ acquire() {
96
+ if (this._open >= this.limit) return false;
97
+ this._open++;
98
+ return true;
99
+ }
100
+ /**
101
+ * Free a previously-{@link acquire}d slot. Always runs from a
102
+ * `finally` block in the transport's subscription handler so
103
+ * crashed handlers don't leak count.
104
+ */
105
+ release() {
106
+ if (this._open > 0) this._open--;
107
+ }
108
+ /** Currently-open count. Test/observability hook. */
109
+ get open() {
110
+ return this._open;
111
+ }
112
+ };
113
+ function fireAndForget(op) {
114
+ return op().catch(() => void 0);
115
+ }
116
+ async function* runSseSubscription(channel, stream_id, accounting, signal, on_cap_exceeded) {
117
+ if (accounting && !accounting.acquire() && on_cap_exceeded) {
118
+ on_cap_exceeded();
119
+ }
120
+ const pending = [];
121
+ let resolve_wait = null;
122
+ const unsubscribe = channel.subscribe(stream_id, (msg) => {
123
+ pending.push(msg);
124
+ resolve_wait?.();
125
+ });
126
+ const on_abort = () => resolve_wait?.();
127
+ signal?.addEventListener("abort", on_abort);
128
+ try {
129
+ const cached = channel.get_state(stream_id);
130
+ if (cached !== void 0) {
131
+ yield { kind: "state", data: cached };
132
+ }
133
+ while (!signal?.aborted) {
134
+ if (pending.length === 0) {
135
+ await new Promise((resolve) => {
136
+ resolve_wait = resolve;
137
+ });
138
+ if (signal?.aborted) break;
139
+ }
140
+ const msg = pending.shift();
141
+ yield { kind: "patch", data: msg };
142
+ }
143
+ } finally {
144
+ signal?.removeEventListener("abort", on_abort);
145
+ unsubscribe();
146
+ accounting?.release();
147
+ }
148
+ }
149
+
150
+ export {
151
+ ERROR_MAP,
152
+ toApiError,
153
+ withIdempotency,
154
+ DEFAULT_SSE_MAX_CONNECTIONS,
155
+ DEFAULT_SSE_HEARTBEAT_MS,
156
+ resolveSseConfig,
157
+ SseConnectionCounter,
158
+ fireAndForget,
159
+ runSseSubscription
160
+ };
161
+ //# sourceMappingURL=chunk-QOHCL5GN.js.map