@rotorsoft/act-http 1.11.2 → 1.11.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/dist/api/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  runSseSubscription,
10
10
  toApiError,
11
11
  withIdempotency
12
- } from "../chunk-75DA7NJI.js";
12
+ } from "../chunk-YEDAY2PC.js";
13
13
  import "../chunk-WZ6QI2OI.js";
14
14
  import "../chunk-BOJG4555.js";
15
15
  import "../chunk-PZ5AY32C.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  withIdempotency
3
- } from "./chunk-75DA7NJI.js";
3
+ } from "./chunk-YEDAY2PC.js";
4
4
  import {
5
5
  track_in_flight
6
6
  } from "./chunk-CVCTDLJA.js";
@@ -25,4 +25,4 @@ function make_dispatch(app, resolvers, store) {
25
25
  export {
26
26
  make_dispatch
27
27
  };
28
- //# sourceMappingURL=chunk-QM6YP2XH.js.map
28
+ //# sourceMappingURL=chunk-QSH2Y3YR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/api/dispatch.ts"],"sourcesContent":["import type {\n Act,\n Actor,\n Registry,\n Schema,\n Schemas,\n Snapshot,\n Target,\n} from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport type { ZodType } from \"zod\";\nimport { withIdempotency } from \"./idempotency.js\";\nimport { track_in_flight } from \"./in-flight.js\";\n\n/**\n * The Act surface the generated transports walk: the registry's action\n * name → owning-state map and `do(...)`. A structural bound lets `TApp`\n * infer to the caller's concrete `Act<...>` without leaking the variance of\n * its nested types, and without `any`.\n */\nexport type ActSurface = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n /** The state's schema and its events', which the OpenAPI doc describes responses with. */\n readonly state?: ZodType;\n readonly events?: Readonly<Record<string, ZodType>>;\n }\n >;\n };\n do(action: string, target: Target, payload: unknown): Promise<unknown>;\n};\n\n/**\n * The registry types of a built app, read back so a generated API keeps\n * them: each action's payload, the state it leaves, the events, and the\n * state names. A bare {@link ActSurface} has none, so its actions take and\n * return `unknown`.\n */\ntype AppRegistry<TApp> = TApp extends {\n readonly registry: Registry<infer S, infer E, infer A, infer N>;\n}\n ? { states: S; events: E; actions: A; names: N }\n : {\n states: Record<string, Schema>;\n events: Schemas;\n actions: Record<string, unknown>;\n names: string;\n };\n\n/** The payload each action of `TApp` takes, by action name. */\nexport type AppActions<TApp> = AppRegistry<TApp>[\"actions\"];\n\n/** The state names of `TApp`. */\nexport type AppStateNames<TApp> = AppRegistry<TApp>[\"names\"];\n\n/** The state named `N` in `TApp`, as the SSE channel carries it. */\nexport type AppState<TApp, N> = (TApp extends Act<any, any, any, infer M, any>\n ? N extends keyof M\n ? M[N]\n : Schema\n : Schema) & { _v: number };\n\n/** What `app.do` returns for action `K` of `TApp`. */\nexport type ActionResult<TApp, K> = Snapshot<\n K extends keyof AppRegistry<TApp>[\"states\"]\n ? AppRegistry<TApp>[\"states\"][K]\n : Schema,\n AppRegistry<TApp>[\"events\"]\n>[];\n\n/**\n * What a `stream` or `expectedVersion` resolver receives: the action's\n * name, its parsed payload and the transport's context, as one tuple per\n * action, so checking the name narrows the payload.\n */\nexport type ResolverArgs<TApp, C> = {\n [K in keyof AppActions<TApp> & string]: [\n action_name: K,\n input: AppActions<TApp>[K],\n ctx: C,\n ];\n}[keyof AppActions<TApp> & string];\n\n/** The target resolvers a generated transport takes, over its own context. */\ntype TargetResolvers<C> = {\n readonly stream: (\n action_name: string,\n input: unknown,\n ctx: C\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n ctx: C\n ) => number | undefined | Promise<number | undefined>;\n};\n\n/** What one generated action call came to; each transport maps the refusals. */\nexport type Dispatched =\n | { kind: \"done\"; result: unknown }\n | { kind: \"missing-key\" }\n | { kind: \"in-flight\" }\n | { kind: \"conflict\" };\n\n/**\n * One generated action call: resolve the target from the validated input,\n * then run `app.do` on the raw payload (it parses again itself, and a second\n * parse of the parsed value would run every `.transform()` twice), under\n * `Idempotency-Key` dedup when a store is set. A duplicate of a key still\n * running is `in-flight`, since the original may yet fail and release it;\n * one already committed is `conflict`.\n */\nexport function make_dispatch<C>(\n app: ActSurface,\n resolvers: TargetResolvers<C>,\n store: IdempotencyStore | undefined\n) {\n // Counts claims per key, to tell the two kinds of duplicate apart.\n const tracker = store && track_in_flight(store);\n return async (\n action_name: string,\n input: unknown,\n payload: unknown,\n ctx: C,\n actor: Actor,\n key: () => string | undefined\n ): Promise<Dispatched> => {\n const stream = await resolvers.stream(action_name, input, ctx);\n const expected = await resolvers.expectedVersion?.(action_name, input, ctx);\n const target =\n expected === undefined\n ? { stream, actor }\n : { stream, actor, expectedVersion: expected };\n const run = () => app.do(action_name, target, payload);\n if (!tracker) return { kind: \"done\", result: await run() };\n const k = key();\n if (!k) return { kind: \"missing-key\" };\n const outcome = await withIdempotency(tracker.store, k, run);\n if (!outcome.deduped) return { kind: \"done\", result: outcome.result };\n return { kind: tracker.in_flight(k) ? \"in-flight\" : \"conflict\" };\n };\n}\n"],"mappings":";;;;;;;;AAoHO,SAAS,cACd,KACA,WACA,OACA;AAEA,QAAM,UAAU,SAAS,gBAAgB,KAAK;AAC9C,SAAO,OACL,aACA,OACA,SACA,KACA,OACA,QACwB;AACxB,UAAM,SAAS,MAAM,UAAU,OAAO,aAAa,OAAO,GAAG;AAC7D,UAAM,WAAW,MAAM,UAAU,kBAAkB,aAAa,OAAO,GAAG;AAC1E,UAAM,SACJ,aAAa,SACT,EAAE,QAAQ,MAAM,IAChB,EAAE,QAAQ,OAAO,iBAAiB,SAAS;AACjD,UAAM,MAAM,MAAM,IAAI,GAAG,aAAa,QAAQ,OAAO;AACrD,QAAI,CAAC,QAAS,QAAO,EAAE,MAAM,QAAQ,QAAQ,MAAM,IAAI,EAAE;AACzD,UAAM,IAAI,IAAI;AACd,QAAI,CAAC,EAAG,QAAO,EAAE,MAAM,cAAc;AACrC,UAAM,UAAU,MAAM,gBAAgB,QAAQ,OAAO,GAAG,GAAG;AAC3D,QAAI,CAAC,QAAQ,QAAS,QAAO,EAAE,MAAM,QAAQ,QAAQ,QAAQ,OAAO;AACpE,WAAO,EAAE,MAAM,QAAQ,UAAU,CAAC,IAAI,cAAc,WAAW;AAAA,EACjE;AACF;","names":[]}
@@ -149,9 +149,7 @@ async function* runSseSubscription(channel, stream_id, accounting, signal, optio
149
149
  pending.push(msg);
150
150
  return;
151
151
  }
152
- const versioned = pending.findIndex(
153
- (m) => !is_version_neutral(m)
154
- );
152
+ const versioned = pending.findIndex((m) => !is_version_neutral(m));
155
153
  if (versioned >= 0) {
156
154
  pending.splice(versioned, 1);
157
155
  pending.push(msg);
@@ -196,4 +194,4 @@ export {
196
194
  fireAndForget,
197
195
  runSseSubscription
198
196
  };
199
- //# sourceMappingURL=chunk-75DA7NJI.js.map
197
+ //# sourceMappingURL=chunk-YEDAY2PC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/api/errors.ts","../src/api/idempotency.ts","../src/api/sse-wiring.ts"],"sourcesContent":["import {\n ConcurrencyError,\n InvariantError,\n NonRetryableError,\n PreconditionError,\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 * `\"PreconditionError\"`, …). 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 PreconditionError: { status: 409, code: \"PRECONDITION\" },\n /** @deprecated Thrown only by the deprecated `.given` rules. Removed in 2.0. */\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 // Before `InvariantError`, which it extends.\n if (err instanceof PreconditionError) return { name: \"PreconditionError\" };\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} (a\n * *tentative* reservation), runs the handler exactly when the claim\n * was fresh, and skips the handler entirely on a duplicate.\n *\n * The claim is finalized two-phase: on handler success the key is\n * {@link IdempotencyStore.commit committed} so later retries dedup;\n * on handler failure it is {@link IdempotencyStore.release released}\n * and the rejection re-thrown, so the sender's retry re-processes\n * instead of the delivery being permanently dropped. The tentative\n * claim still dedups a concurrent duplicate mid-flight.\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 let result: T;\n try {\n result = await handler();\n } catch (err) {\n await store.release(key);\n throw err;\n }\n await store.commit(key);\n return { deduped: false, result };\n}\n","import { z } from \"zod\";\nimport type { BroadcastChannel } from \"../sse/broadcast.js\";\nimport {\n type BroadcastState,\n is_version_neutral,\n type PatchMessage,\n resync_frame,\n} 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 `ZodError` immediately so misconfiguration surfaces at startup\n * instead of at first connection (the framework-wide convention for\n * config bags — see .claude/rules/config-validation.md).\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 // `any`: one channel carries every state; the generated subscriptions type each state's frames from the app\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 * Hard cap on the per-connection in-memory backlog of undelivered\n * broadcast frames. A consumer that stalls (slow client, paused\n * tab) can't drain frames as fast as a busy stream publishes them;\n * without a bound the backlog grows until the process runs out of\n * memory. When the backlog is full the oldest **version-keyed** frame\n * is dropped to make room for the newest: the consumer sees the\n * version gap, reports `behind`, and refetches, so it still converges\n * on current state having skipped intermediate versions it was too\n * slow to see.\n *\n * Version-neutral frames (`_overlay`, `_resync`) are never dropped\n * silently — they create no gap to notice, so a backlog made only of\n * them collapses into a single `_resync` instead.\n *\n * Default `256`. Validated range `[1, 100_000]`.\n */\n readonly maxPendingPerConnection?: 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 readonly maxPendingPerConnection: 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\n/**\n * Default cap on the per-connection undelivered-frame backlog. Sized\n * so a briefly-stalled consumer on a busy stream keeps recent history\n * without letting a permanently-wedged client pin unbounded memory.\n * At overflow the oldest version-keyed frame is dropped: it is a full\n * version-keyed patch, so the consumer sees the gap and converges on\n * current state regardless of which intermediate versions it missed.\n * Version-neutral frames create no gap, so they collapse into a\n * `_resync` rather than being dropped silently.\n */\nexport const DEFAULT_SSE_MAX_PENDING_PER_CONNECTION = 256;\n\n/**\n * Zod schema for the numeric knobs on {@link SseOptions}. Defaults,\n * ranges, and (eventual) integer constraints live in one place — same\n * declarative-validation pattern the rest of the framework uses for\n * configuration shapes.\n *\n * @internal\n */\nconst SseOptionsSchema = z.object({\n maxConnections: z\n .number()\n .int()\n .min(1)\n .max(10_000)\n .default(DEFAULT_SSE_MAX_CONNECTIONS),\n heartbeatMs: z\n .number()\n .int()\n .min(15_000)\n .max(300_000)\n .default(DEFAULT_SSE_HEARTBEAT_MS),\n maxPendingPerConnection: z\n .number()\n .int()\n .min(1)\n .max(100_000)\n .default(DEFAULT_SSE_MAX_PENDING_PER_CONNECTION),\n});\n\n/**\n * Validate and apply defaults to host-supplied {@link SseOptions}.\n *\n * Out-of-range values throw at the call site — each transport calls\n * this once at the start of `hono(...)` / `trpc(...)` so\n * misconfiguration surfaces at construction, not at first\n * connection.\n */\nexport function resolveSseConfig(options: SseOptions): SseConfig {\n const parsed = SseOptionsSchema.parse({\n maxConnections: options.maxConnections,\n heartbeatMs: options.heartbeatMs,\n maxPendingPerConnection: options.maxPendingPerConnection,\n });\n return {\n channel: options.channel,\n maxConnections: parsed.maxConnections,\n heartbeatMs: parsed.heartbeatMs,\n maxPendingPerConnection: parsed.maxPendingPerConnection,\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 | {\n readonly kind: \"patch\";\n readonly data: S extends BroadcastState ? PatchMessage<S> : unknown;\n };\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 * Per-subscription knobs for {@link runSseSubscription}, bundled so the\n * two transports (and their tests) pass one options object rather than\n * a growing positional tail.\n */\nexport type RunSseOptions = {\n /**\n * Invoked when {@link SseAccounting.acquire} returns `false` (the\n * connection cap is full). tRPC throws `TRPCError` here to reject the\n * subscription cleanly from inside the generator; Hono enforces the\n * cap outside the loop and omits this.\n */\n readonly on_cap_exceeded?: () => never;\n /**\n * Upper bound on the per-connection undelivered-frame backlog. When\n * the backlog reaches this many frames the oldest is dropped to make\n * room for the newest (drop-oldest). Defaults to\n * {@link DEFAULT_SSE_MAX_PENDING_PER_CONNECTION} when omitted.\n */\n readonly maxPending?: number;\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// `any`: 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 options?: RunSseOptions\n): AsyncGenerator<SseSubscriptionFrame<S>> {\n // Track whether we actually reserved a slot. Releasing in `finally`\n // unconditionally would decrement the counter below the true open\n // count when acquire() fails without an `on_cap_exceeded` handler —\n // inflating the effective cap over time. Only release what we\n // acquired.\n let acquired = false;\n if (accounting) {\n acquired = accounting.acquire();\n if (!acquired && options?.on_cap_exceeded) {\n options.on_cap_exceeded();\n }\n }\n const max_pending =\n options?.maxPending ?? DEFAULT_SSE_MAX_PENDING_PER_CONNECTION;\n const pending: PatchMessage<S>[] = [];\n let resolve_wait: (() => void) | null = null;\n const unsubscribe = channel.subscribe(stream_id, (msg) => {\n enqueue(msg);\n resolve_wait?.();\n });\n /**\n * Bound the per-connection backlog: a stalled consumer (slow client,\n * paused tab) can't drain as fast as a busy stream publishes, so a frame\n * has to go.\n *\n * Which frame is not arbitrary. A version-keyed patch can be dropped\n * safely — the client sees the version gap, reports `behind`, and\n * refetches. A version-neutral frame (`_overlay`, `_resync`) carries no\n * versions at all, so dropping one leaves no gap to notice and the update\n * is lost silently. So: evict the oldest version-keyed frame if\n * there is one, and only if the whole backlog is version-neutral collapse\n * it into the single frame that says \"you missed something\".\n */\n const enqueue = (msg: PatchMessage<S>) => {\n if (pending.length < max_pending) {\n pending.push(msg);\n return;\n }\n const versioned = pending.findIndex((m) => !is_version_neutral(m));\n if (versioned >= 0) {\n pending.splice(versioned, 1);\n pending.push(msg);\n return;\n }\n // Every pending frame is version-neutral. Collapsing them into one\n // resync loses no signal — a resync supersedes anything it replaces,\n // including `msg` when the cap leaves no room for it.\n pending.splice(0, pending.length, resync_frame<S>());\n if (pending.length < max_pending) pending.push(msg);\n };\n\n const on_abort = () => resolve_wait?.();\n signal?.addEventListener(\"abort\", on_abort);\n try {\n const cached = channel.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() as PatchMessage<S>;\n // The frame type resolves `PatchMessage<S>` only once `S` is known.\n yield { kind: \"patch\", data: msg as never };\n }\n } finally {\n signal?.removeEventListener(\"abort\", on_abort);\n unsubscribe();\n if (acquired) accounting?.release();\n }\n}\n"],"mappings":"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","names":[]}
@@ -19988,9 +19988,7 @@ async function* runSseSubscription(channel, stream_id, accounting, signal, optio
19988
19988
  pending.push(msg);
19989
19989
  return;
19990
19990
  }
19991
- const versioned = pending.findIndex(
19992
- (m) => !is_version_neutral(m)
19993
- );
19991
+ const versioned = pending.findIndex((m) => !is_version_neutral(m));
19994
19992
  if (versioned >= 0) {
19995
19993
  pending.splice(versioned, 1);
19996
19994
  pending.push(msg);