@rotorsoft/act-http 1.10.22 → 1.10.24

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/openapi/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/openapi\n *\n * OpenAPI subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and returns a\n * valid OpenAPI 3.1 document describing the same action surface the\n * Hono sibling at `@rotorsoft/act-http/hono` serves. The doc is\n * suitable for cross-language client codegen (Go, Python, Swift,\n * Kotlin, .NET), for serving over `/openapi.json` alongside a live\n * API, and for the broader OpenAPI tooling ecosystem (Postman,\n * Swagger UI, Redoc, schemathesis, API gateways).\n *\n * Usage:\n *\n * ```ts\n * import { openapi } from \"@rotorsoft/act-http/openapi\";\n *\n * const doc = openapi(app, {\n * info: { title: \"Wolfdesk API\", version: \"1.0.0\" },\n * servers: [{ url: \"https://api.example.com\" }],\n * });\n * // doc: OpenAPIDocument — valid OpenAPI 3.1 object\n * ```\n *\n * **Pure data emit.** No runtime dep on Hono or tRPC. The subpath is\n * consumable standalone for hosts that serve their API some other\n * way (Fastify, Lambda, edge) or that only want the doc for client\n * codegen. The emit walks the registry once at function call; cost\n * is linear in `N actions × M Zod fields each`, single-digit\n * milliseconds for a 100-endpoint registry.\n *\n * **Determinism.** Output is stable across runs given the same\n * registry — entries land in `Object.entries(app.registry.actions)`\n * iteration order, schemas come from the action's Zod definition,\n * and the rest of the doc is fixed content. CI can snapshot the\n * result to catch unintended API surface changes.\n *\n * The same shared utilities at `@rotorsoft/act-http/api` underwrite\n * cross-transport consistency: the `ApiError` envelope is referenced\n * once from `components.schemas`, every error response points at it,\n * and the same shape ships from the tRPC and Hono siblings at\n * runtime. A client speaking the doc and the live REST API sees one\n * envelope per framework error.\n *\n * **The doc describes the Hono surface, not tRPC.** tRPC's URL\n * shape (`POST /trpc/<procedure>`, JSON-RPC-style body framing,\n * batching) doesn't model cleanly as OpenAPI operations — and tRPC\n * consumers already share types directly via `typeof router`, so\n * OpenAPI buys them nothing. The path shape this emitter produces\n * (`POST <basePath>/actions/<name>`) matches the\n * `@rotorsoft/act-http/hono` sibling exactly: same default\n * `basePath`, same request/response shapes, same envelope. If the\n * operator overrides `basePath` on the Hono adapter, they pass the\n * same override here so the doc keeps matching the live routes by\n * construction. tRPC and Hono can run side-by-side at different\n * mount points on the same Act instance; the OpenAPI doc covers\n * the REST half only.\n */\nimport { pii_fields } from \"@rotorsoft/act\";\nimport { z } from \"zod\";\n\n/**\n * Minimal OpenAPI 3.1 document type. The emitter targets the 3.1\n * spec; the type is intentionally a structural subset rather than\n * the full surface from `openapi-types` so callers can post-process\n * without fighting the type system. Cast to a fuller type from\n * `openapi-types` when downstream consumers need it.\n */\nexport type OpenAPIDocument = {\n readonly openapi: \"3.1.0\";\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly paths: Record<string, OpenAPIPathItem>;\n readonly components: OpenAPIComponents;\n};\n\n/**\n * Per-call options for {@link openapi}. The host supplies the\n * document-shape fields the registry can't derive (title, version,\n * servers) and toggles for the cross-cutting headers that the live\n * REST API may accept.\n *\n * - `info` — required. `title` and `version` must be non-empty;\n * `info` may carry any other top-level OpenAPI info fields.\n * - `servers` — optional. Each entry's `url` may contain\n * `{variable}` template syntax; bare URLs are validated through\n * `URL`'s parser and reject malformed inputs.\n * - `basePath` — optional, default `/api`. Mirrors the Hono\n * sibling's default so the doc describes the same paths the\n * generated REST surface serves.\n * - `idempotency` — optional, default `false`. When `true`, every\n * mutation operation documents a **required** `Idempotency-Key`\n * request header (the generated route fail-closes with 400 when the\n * header is absent, so the doc marks it required, not optional).\n * - `expectedVersion` — optional, default `false`. When `true`,\n * every mutation operation documents an optional `If-Match`\n * request header carrying the expected stream version.\n */\nexport type OpenAPIOptions = {\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly basePath?: string;\n readonly idempotency?: boolean;\n readonly expectedVersion?: boolean;\n};\n\nexport type OpenAPIInfo = {\n readonly title: string;\n readonly version: string;\n readonly description?: string;\n readonly summary?: string;\n readonly termsOfService?: string;\n readonly contact?: {\n readonly name?: string;\n readonly url?: string;\n readonly email?: string;\n };\n readonly license?: {\n readonly name: string;\n readonly identifier?: string;\n readonly url?: string;\n };\n};\n\nexport type OpenAPIServer = {\n readonly url: string;\n readonly description?: string;\n readonly variables?: Record<\n string,\n {\n readonly default: string;\n readonly enum?: ReadonlyArray<string>;\n readonly description?: string;\n }\n >;\n};\n\nexport type OpenAPIPathItem = {\n readonly post?: OpenAPIOperation;\n};\n\nexport type OpenAPIOperation = {\n readonly operationId: string;\n readonly summary: string;\n readonly tags: ReadonlyArray<string>;\n readonly parameters?: ReadonlyArray<OpenAPIParameter>;\n readonly requestBody?: OpenAPIRequestBody;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nexport type OpenAPIParameter = {\n readonly name: string;\n readonly in: \"header\" | \"query\" | \"path\" | \"cookie\";\n readonly required?: boolean;\n readonly description?: string;\n readonly schema?: Record<string, unknown>;\n};\n\nexport type OpenAPIRequestBody = {\n readonly required?: boolean;\n readonly content: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n};\n\nexport type OpenAPIResponse =\n | {\n readonly description: string;\n readonly content?: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n }\n | { readonly $ref: string };\n\nexport type OpenAPIComponents = {\n readonly schemas: Record<string, Record<string, unknown>>;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nconst DEFAULT_BASE_PATH = \"/api\";\n\n/**\n * The shared `ApiError` envelope schema, ref'd by every error\n * response. Matches the runtime envelope at\n * `@rotorsoft/act-http/api`'s `toApiError` exactly — keep in sync if\n * the envelope shape evolves.\n */\nconst API_ERROR_SCHEMA: Record<string, unknown> = {\n type: \"object\",\n properties: {\n error: { type: \"string\" },\n detail: { type: \"string\" },\n code: { type: \"string\" },\n },\n required: [\"error\"],\n};\n\n/**\n * The generic snapshot-array response schema returned on a\n * successful mutation. Each action commits one or more events; the\n * shape here is `Snapshot[]` with the per-action state and event\n * data left as open `object` (deeper per-action typing would need\n * to walk `state.events[*]` and emit a discriminated union — a\n * future refinement once the doc consumers ask for it).\n */\nconst SNAPSHOT_ARRAY_SCHEMA: Record<string, unknown> = {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n state: { type: \"object\" },\n event: { type: \"object\" },\n version: { type: \"integer\" },\n patches: { type: \"integer\" },\n snaps: { type: \"integer\" },\n cache_hit: { type: \"boolean\" },\n replayed: { type: \"integer\" },\n },\n required: [\"state\", \"version\", \"patches\", \"snaps\", \"cache_hit\", \"replayed\"],\n },\n};\n\n/**\n * The error-response shape every operation references via\n * `$ref: \"#/components/responses/ApiError\"`. Single source of\n * truth — bug fixes to the envelope flow to every operation\n * automatically.\n */\nconst API_ERROR_RESPONSE: OpenAPIResponse = {\n description: \"Error envelope per @rotorsoft/act-http/api's toApiError.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/ApiError\" },\n },\n },\n};\n\n/**\n * Server URL refinement: OpenAPI permits `{variable}` template syntax\n * inside server URLs, so we substitute each capture with `x` before\n * parsing. The character class forbids both `{` and `}` inside the\n * template, matching OpenAPI's variable-name grammar exactly and\n * eliminating the catastrophic-backtracking surface CodeQL flagged\n * on `[^}]+`.\n *\n * @internal\n */\nfunction is_valid_server_url(url: string): boolean {\n try {\n new URL(url.replace(/\\{[^{}]+\\}/g, \"x\"));\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Zod schema for the minimum required shape of {@link OpenAPIOptions}.\n * Same declarative-validation pattern the rest of the framework uses;\n * the OpenAPI surface is mostly free-form (`description`, `contact`,\n * `license` — the spec lets through anything that JSON-stringifies),\n * so only the load-bearing fields land here. Hosts overriding into\n * unusual shapes still get the rest of the doc emitted; this schema\n * only fails the construction call when `info.title` / `info.version`\n * is missing-or-empty or a `servers[].url` won't parse.\n *\n * @internal\n */\nconst OpenAPIOptionsSchema = z.object({\n info: z.object({\n title: z\n .string({ message: \"openapi: info.title is required (non-empty string)\" })\n .trim()\n .min(1, \"openapi: info.title is required (non-empty string)\"),\n version: z\n .string({\n message: \"openapi: info.version is required (non-empty string)\",\n })\n .trim()\n .min(1, \"openapi: info.version is required (non-empty string)\"),\n }),\n servers: z\n .array(\n z.object({\n url: z.string().refine(is_valid_server_url, {\n message: \"openapi: invalid server url\",\n }),\n })\n )\n .optional(),\n});\n\nfunction validate_options(options: OpenAPIOptions): void {\n OpenAPIOptionsSchema.parse({\n info: { title: options.info?.title, version: options.info?.version },\n servers: options.servers,\n });\n}\n\nfunction strip_json_schema_meta(\n schema: Record<string, unknown>\n): Record<string, unknown> {\n // `$schema` at the top of an inline schema is harmless in OpenAPI\n // 3.1 but adds noise to the doc. Drop it.\n const { $schema: _, ...rest } = schema;\n return rest;\n}\n\n/**\n * Annotate the emitted request-body schema's sensitive properties so\n * codegen / Swagger UI treat them as secrets rather than echoing them\n * freely. `z.toJSONSchema` has no knowledge of the sensitive registry\n * (`sensitive()` marks schemas out-of-band in a WeakMap), so we walk the\n * action's declared sensitive fields — via `pii_fields`, the same lookup\n * the orchestrator uses — and mark each matching property `writeOnly:\n * true` + `format: password`. Non-object schemas and actions with no\n * sensitive fields pass through untouched (the zero-cost common path).\n *\n * `pii_fields` only reports top-level keys of a `z.object`, and Zod's\n * JSON Schema emit always renders those as members of `properties` — so\n * once `fields` is non-empty, `body_schema.properties[field]` is\n * guaranteed present. No defensive fallback needed.\n *\n * @internal\n */\nfunction mark_sensitive_fields(\n body_schema: Record<string, unknown>,\n zod_schema: z.ZodType\n): Record<string, unknown> {\n const fields = pii_fields(zod_schema);\n if (fields.length === 0) return body_schema;\n const properties = body_schema.properties as Record<\n string,\n Record<string, unknown>\n >;\n const next_properties: Record<string, Record<string, unknown>> = {\n ...properties,\n };\n for (const field of fields) {\n next_properties[field] = {\n ...properties[field],\n writeOnly: true,\n format: \"password\",\n };\n }\n return { ...body_schema, properties: next_properties };\n}\n\nfunction build_operation(\n action_name: string,\n body_schema: Record<string, unknown>,\n options: OpenAPIOptions\n): OpenAPIOperation {\n const parameters: OpenAPIParameter[] = [];\n if (options.idempotency) {\n parameters.push({\n name: \"Idempotency-Key\",\n in: \"header\",\n // Required: the generated route fail-closes with 400 when idempotency is\n // enabled and the header is absent, so the doc must not advertise it as\n // optional (#1287).\n required: true,\n description:\n \"Required idempotency token. A duplicate of a request that already succeeded produces a 409 Conflict (the first result is not cached on the server). A duplicate that arrives while the original is still running produces a retryable 503 with Retry-After, because the original may yet fail.\",\n schema: { type: \"string\" },\n });\n }\n if (options.expectedVersion) {\n parameters.push({\n name: \"If-Match\",\n in: \"header\",\n required: false,\n description:\n \"Expected stream version for optimistic concurrency. Mismatch produces a 412 Precondition Failed via ConcurrencyError.\",\n schema: { type: \"string\" },\n });\n }\n\n return {\n operationId: action_name,\n summary: `Commit the ${action_name} action`,\n tags: [\"Actions\"],\n ...(parameters.length > 0 ? { parameters } : {}),\n requestBody: {\n required: true,\n content: {\n \"application/json\": { schema: body_schema },\n },\n },\n responses: {\n \"200\": {\n description: \"Action committed; returns the snapshot array.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/SnapshotArray\" },\n },\n },\n },\n \"400\": { $ref: \"#/components/responses/ApiError\" },\n \"409\": { $ref: \"#/components/responses/ApiError\" },\n \"410\": { $ref: \"#/components/responses/ApiError\" },\n \"412\": { $ref: \"#/components/responses/ApiError\" },\n \"422\": { $ref: \"#/components/responses/ApiError\" },\n \"500\": { $ref: \"#/components/responses/ApiError\" },\n ...(options.idempotency\n ? { \"503\": { $ref: \"#/components/responses/ApiError\" } }\n : {}),\n },\n };\n}\n\n/**\n * Structural shape of the Act surface this emitter walks. Letting\n * TApp infer to the caller's concrete `Act<TSchemaReg, ...>` instead\n * of forcing it to fit a narrow framework-typed upper bound keeps\n * the caller's variance from leaking — and avoids `any` in the\n * signature.\n *\n * @internal\n */\ntype ActRegistryView = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n }\n >;\n };\n};\n\n/**\n * Build an OpenAPI 3.1 document describing a built `Act` instance's\n * action surface.\n *\n * Walks `app.registry.actions` once and emits a `POST\n * <basePath>/actions/<actionName>` operation per action, deriving\n * the request-body schema from each action's Zod definition via\n * `z.toJSONSchema` (Zod 4's native JSON Schema 2020-12 emitter,\n * which is the OpenAPI 3.1 schema dialect — no conversion layer\n * needed). Error responses reference the shared\n * `#/components/responses/ApiError` so a single envelope shape\n * covers every error path.\n *\n * @param app A built `Act` orchestrator. Required.\n * @param options Document-shape fields (`info.title`, `info.version`,\n * servers, base path) and toggles for the cross-cutting headers\n * the live REST API may accept (`Idempotency-Key`, `If-Match`).\n * @returns A valid OpenAPI 3.1 document object — serve as\n * `/openapi.json`, ship to a CDN, write to disk during CI, or\n * pipe to a codegen tool.\n *\n * @throws Error if `info.title` / `info.version` is missing or\n * empty, or if any `servers[].url` fails URL parsing after\n * `{variable}` substitution.\n */\nexport function openapi<TApp extends ActRegistryView>(\n app: TApp,\n options: OpenAPIOptions\n): OpenAPIDocument {\n validate_options(options);\n const base_path = options.basePath ?? DEFAULT_BASE_PATH;\n const paths: Record<string, OpenAPIPathItem> = {};\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const zod_schema = state.actions[action_name] as z.ZodType;\n const body_schema = mark_sensitive_fields(\n strip_json_schema_meta(\n // `unrepresentable: \"any\"` emits an open (`{}`) schema for a Zod type\n // with no JSON-Schema form (z.date/z.bigint/z.map/z.set/...) instead of\n // Zod 4's default `\"throw\"`, which would abort the WHOLE document over\n // one such field — while hono() serves the same action fine (#1328).\n // `io: \"input\"` describes what a client must SEND: a `.default()`\n // field is optional, extra keys are not rejected (Zod strips them),\n // and a `.pipe()`/`.transform()` documents its input type. Zod's\n // default, `\"output\"`, describes the parsed result instead.\n z.toJSONSchema(zod_schema, {\n unrepresentable: \"any\",\n io: \"input\",\n }) as Record<string, unknown>\n ),\n zod_schema\n );\n paths[`${base_path}/actions/${action_name}`] = {\n post: build_operation(action_name, body_schema, options),\n };\n }\n\n return {\n openapi: \"3.1.0\",\n info: options.info,\n ...(options.servers ? { servers: options.servers } : {}),\n paths,\n components: {\n schemas: {\n ApiError: API_ERROR_SCHEMA,\n SnapshotArray: SNAPSHOT_ARRAY_SCHEMA,\n },\n responses: {\n ApiError: API_ERROR_RESPONSE,\n },\n },\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/openapi/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/openapi\n *\n * OpenAPI subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and returns a\n * valid OpenAPI 3.1 document describing the same action surface the\n * Hono sibling at `@rotorsoft/act-http/hono` serves. The doc is\n * suitable for cross-language client codegen (Go, Python, Swift,\n * Kotlin, .NET), for serving over `/openapi.json` alongside a live\n * API, and for the broader OpenAPI tooling ecosystem (Postman,\n * Swagger UI, Redoc, schemathesis, API gateways).\n *\n * Usage:\n *\n * ```ts\n * import { openapi } from \"@rotorsoft/act-http/openapi\";\n *\n * const doc = openapi(app, {\n * info: { title: \"Wolfdesk API\", version: \"1.0.0\" },\n * servers: [{ url: \"https://api.example.com\" }],\n * });\n * // doc: OpenAPIDocument — valid OpenAPI 3.1 object\n * ```\n *\n * **Pure data emit.** No runtime dep on Hono or tRPC. The subpath is\n * consumable standalone for hosts that serve their API some other\n * way (Fastify, Lambda, edge) or that only want the doc for client\n * codegen. The emit walks the registry once at function call; cost\n * is linear in `N actions × M Zod fields each`, single-digit\n * milliseconds for a 100-endpoint registry.\n *\n * **Determinism.** Output is stable across runs given the same\n * registry — entries land in `Object.entries(app.registry.actions)`\n * iteration order, schemas come from the action's Zod definition,\n * and the rest of the doc is fixed content. CI can snapshot the\n * result to catch unintended API surface changes.\n *\n * The same shared utilities at `@rotorsoft/act-http/api` underwrite\n * cross-transport consistency: the `ApiError` envelope is referenced\n * once from `components.schemas`, every error response points at it,\n * and the same shape ships from the tRPC and Hono siblings at\n * runtime. A client speaking the doc and the live REST API sees one\n * envelope per framework error.\n *\n * **The doc describes the Hono surface, not tRPC.** tRPC's URL\n * shape (`POST /trpc/<procedure>`, JSON-RPC-style body framing,\n * batching) doesn't model cleanly as OpenAPI operations — and tRPC\n * consumers already share types directly via `typeof router`, so\n * OpenAPI buys them nothing. The path shape this emitter produces\n * (`POST <basePath>/actions/<name>`) matches the\n * `@rotorsoft/act-http/hono` sibling exactly: same default\n * `basePath`, same request/response shapes, same envelope. If the\n * operator overrides `basePath` on the Hono adapter, they pass the\n * same override here so the doc keeps matching the live routes by\n * construction. tRPC and Hono can run side-by-side at different\n * mount points on the same Act instance; the OpenAPI doc covers\n * the REST half only.\n */\nimport { pii_fields } from \"@rotorsoft/act\";\nimport { z } from \"zod\";\nimport type { ActSurface } from \"../api/dispatch.js\";\n\n/**\n * Minimal OpenAPI 3.1 document type. The emitter targets the 3.1\n * spec; the type is intentionally a structural subset rather than\n * the full surface from `openapi-types` so callers can post-process\n * without fighting the type system. Cast to a fuller type from\n * `openapi-types` when downstream consumers need it.\n */\nexport type OpenAPIDocument = {\n readonly openapi: \"3.1.0\";\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly paths: Record<string, OpenAPIPathItem>;\n readonly components: OpenAPIComponents;\n};\n\n/**\n * Per-call options for {@link openapi}. The host supplies the\n * document-shape fields the registry can't derive (title, version,\n * servers) and toggles for the cross-cutting headers that the live\n * REST API may accept.\n *\n * - `info` — required. `title` and `version` must be non-empty;\n * `info` may carry any other top-level OpenAPI info fields.\n * - `servers` — optional. Each entry's `url` may contain\n * `{variable}` template syntax; bare URLs are validated through\n * `URL`'s parser and reject malformed inputs.\n * - `basePath` — optional, default `/api`. Mirrors the Hono\n * sibling's default so the doc describes the same paths the\n * generated REST surface serves.\n * - `idempotency` — optional, default `false`. When `true`, every\n * mutation operation documents a **required** `Idempotency-Key`\n * request header (the generated route fail-closes with 400 when the\n * header is absent, so the doc marks it required, not optional).\n * - `expectedVersion` — optional, default `false`. When `true`,\n * every mutation operation documents an optional `If-Match`\n * request header carrying the expected stream version.\n */\nexport type OpenAPIOptions = {\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly basePath?: string;\n readonly idempotency?: boolean;\n readonly expectedVersion?: boolean;\n};\n\nexport type OpenAPIInfo = {\n readonly title: string;\n readonly version: string;\n readonly description?: string;\n readonly summary?: string;\n readonly termsOfService?: string;\n readonly contact?: {\n readonly name?: string;\n readonly url?: string;\n readonly email?: string;\n };\n readonly license?: {\n readonly name: string;\n readonly identifier?: string;\n readonly url?: string;\n };\n};\n\nexport type OpenAPIServer = {\n readonly url: string;\n readonly description?: string;\n readonly variables?: Record<\n string,\n {\n readonly default: string;\n readonly enum?: ReadonlyArray<string>;\n readonly description?: string;\n }\n >;\n};\n\nexport type OpenAPIPathItem = {\n readonly post?: OpenAPIOperation;\n};\n\nexport type OpenAPIOperation = {\n readonly operationId: string;\n readonly summary: string;\n readonly tags: ReadonlyArray<string>;\n readonly parameters?: ReadonlyArray<OpenAPIParameter>;\n readonly requestBody?: OpenAPIRequestBody;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nexport type OpenAPIParameter = {\n readonly name: string;\n readonly in: \"header\" | \"query\" | \"path\" | \"cookie\";\n readonly required?: boolean;\n readonly description?: string;\n readonly schema?: Record<string, unknown>;\n};\n\nexport type OpenAPIRequestBody = {\n readonly required?: boolean;\n readonly content: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n};\n\nexport type OpenAPIResponse =\n | {\n readonly description: string;\n readonly content?: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n }\n | { readonly $ref: string };\n\nexport type OpenAPIComponents = {\n readonly schemas: Record<string, Record<string, unknown>>;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nconst DEFAULT_BASE_PATH = \"/api\";\n\n/**\n * The shared `ApiError` envelope schema, ref'd by every error\n * response. Matches the runtime envelope at\n * `@rotorsoft/act-http/api`'s `toApiError` exactly — keep in sync if\n * the envelope shape evolves.\n */\nconst API_ERROR_SCHEMA: Record<string, unknown> = {\n type: \"object\",\n properties: {\n error: { type: \"string\" },\n detail: { type: \"string\" },\n code: { type: \"string\" },\n },\n required: [\"error\"],\n};\n\n/**\n * The generic snapshot-array response schema returned on a\n * successful mutation. Each action commits one or more events; the\n * shape here is `Snapshot[]` with the per-action state and event\n * data left as open `object` (deeper per-action typing would need\n * to walk `state.events[*]` and emit a discriminated union — a\n * future refinement once the doc consumers ask for it).\n */\nconst SNAPSHOT_ARRAY_SCHEMA: Record<string, unknown> = {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n state: { type: \"object\" },\n event: { type: \"object\" },\n version: { type: \"integer\" },\n patches: { type: \"integer\" },\n snaps: { type: \"integer\" },\n cache_hit: { type: \"boolean\" },\n replayed: { type: \"integer\" },\n },\n required: [\"state\", \"version\", \"patches\", \"snaps\", \"cache_hit\", \"replayed\"],\n },\n};\n\n/**\n * The error-response shape every operation references via\n * `$ref: \"#/components/responses/ApiError\"`. Single source of\n * truth — bug fixes to the envelope flow to every operation\n * automatically.\n */\nconst API_ERROR_RESPONSE: OpenAPIResponse = {\n description: \"Error envelope per @rotorsoft/act-http/api's toApiError.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/ApiError\" },\n },\n },\n};\n\n/**\n * Server URL refinement: OpenAPI permits `{variable}` template syntax\n * inside server URLs, so we substitute each capture with `x` before\n * parsing. The character class forbids both `{` and `}` inside the\n * template, matching OpenAPI's variable-name grammar exactly and\n * eliminating the catastrophic-backtracking surface CodeQL flagged\n * on `[^}]+`.\n *\n * @internal\n */\nfunction is_valid_server_url(url: string): boolean {\n try {\n new URL(url.replace(/\\{[^{}]+\\}/g, \"x\"));\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Zod schema for the minimum required shape of {@link OpenAPIOptions}.\n * Same declarative-validation pattern the rest of the framework uses;\n * the OpenAPI surface is mostly free-form (`description`, `contact`,\n * `license` — the spec lets through anything that JSON-stringifies),\n * so only the load-bearing fields land here. Hosts overriding into\n * unusual shapes still get the rest of the doc emitted; this schema\n * only fails the construction call when `info.title` / `info.version`\n * is missing-or-empty or a `servers[].url` won't parse.\n *\n * @internal\n */\nconst OpenAPIOptionsSchema = z.object({\n info: z.object({\n title: z\n .string({ message: \"openapi: info.title is required (non-empty string)\" })\n .trim()\n .min(1, \"openapi: info.title is required (non-empty string)\"),\n version: z\n .string({\n message: \"openapi: info.version is required (non-empty string)\",\n })\n .trim()\n .min(1, \"openapi: info.version is required (non-empty string)\"),\n }),\n servers: z\n .array(\n z.object({\n url: z.string().refine(is_valid_server_url, {\n message: \"openapi: invalid server url\",\n }),\n })\n )\n .optional(),\n});\n\nfunction validate_options(options: OpenAPIOptions): void {\n OpenAPIOptionsSchema.parse({\n info: { title: options.info?.title, version: options.info?.version },\n servers: options.servers,\n });\n}\n\nfunction strip_json_schema_meta(\n schema: Record<string, unknown>\n): Record<string, unknown> {\n // `$schema` at the top of an inline schema is harmless in OpenAPI\n // 3.1 but adds noise to the doc. Drop it.\n const { $schema: _, ...rest } = schema;\n return rest;\n}\n\n/**\n * Annotate the emitted request-body schema's sensitive properties so\n * codegen / Swagger UI treat them as secrets rather than echoing them\n * freely. `z.toJSONSchema` has no knowledge of the sensitive registry\n * (`sensitive()` marks schemas out-of-band in a WeakMap), so we walk the\n * action's declared sensitive fields — via `pii_fields`, the same lookup\n * the orchestrator uses — and mark each matching property `writeOnly:\n * true` + `format: password`. Non-object schemas and actions with no\n * sensitive fields pass through untouched (the zero-cost common path).\n *\n * `pii_fields` only reports top-level keys of a `z.object`, and Zod's\n * JSON Schema emit always renders those as members of `properties` — so\n * once `fields` is non-empty, `body_schema.properties[field]` is\n * guaranteed present. No defensive fallback needed.\n *\n * @internal\n */\nfunction mark_sensitive_fields(\n body_schema: Record<string, unknown>,\n zod_schema: z.ZodType\n): Record<string, unknown> {\n const fields = pii_fields(zod_schema);\n if (fields.length === 0) return body_schema;\n const properties = body_schema.properties as Record<\n string,\n Record<string, unknown>\n >;\n const next_properties: Record<string, Record<string, unknown>> = {\n ...properties,\n };\n for (const field of fields) {\n next_properties[field] = {\n ...properties[field],\n writeOnly: true,\n format: \"password\",\n };\n }\n return { ...body_schema, properties: next_properties };\n}\n\nfunction build_operation(\n action_name: string,\n body_schema: Record<string, unknown>,\n options: OpenAPIOptions\n): OpenAPIOperation {\n const parameters: OpenAPIParameter[] = [];\n if (options.idempotency) {\n parameters.push({\n name: \"Idempotency-Key\",\n in: \"header\",\n // Required: the generated route fail-closes with 400 when idempotency is\n // enabled and the header is absent, so the doc must not advertise it as\n // optional (#1287).\n required: true,\n description:\n \"Required idempotency token. A duplicate of a request that already succeeded produces a 409 Conflict (the first result is not cached on the server). A duplicate that arrives while the original is still running produces a retryable 503 with Retry-After, because the original may yet fail.\",\n schema: { type: \"string\" },\n });\n }\n if (options.expectedVersion) {\n parameters.push({\n name: \"If-Match\",\n in: \"header\",\n required: false,\n description:\n \"Expected stream version for optimistic concurrency. Mismatch produces a 412 Precondition Failed via ConcurrencyError.\",\n schema: { type: \"string\" },\n });\n }\n\n return {\n operationId: action_name,\n summary: `Commit the ${action_name} action`,\n tags: [\"Actions\"],\n ...(parameters.length > 0 ? { parameters } : {}),\n requestBody: {\n required: true,\n content: {\n \"application/json\": { schema: body_schema },\n },\n },\n responses: {\n \"200\": {\n description: \"Action committed; returns the snapshot array.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/SnapshotArray\" },\n },\n },\n },\n \"400\": { $ref: \"#/components/responses/ApiError\" },\n \"409\": { $ref: \"#/components/responses/ApiError\" },\n \"410\": { $ref: \"#/components/responses/ApiError\" },\n \"412\": { $ref: \"#/components/responses/ApiError\" },\n \"422\": { $ref: \"#/components/responses/ApiError\" },\n \"500\": { $ref: \"#/components/responses/ApiError\" },\n ...(options.idempotency\n ? { \"503\": { $ref: \"#/components/responses/ApiError\" } }\n : {}),\n },\n };\n}\n\n/**\n * Build an OpenAPI 3.1 document describing a built `Act` instance's\n * action surface.\n *\n * Walks `app.registry.actions` once and emits a `POST\n * <basePath>/actions/<actionName>` operation per action, deriving\n * the request-body schema from each action's Zod definition via\n * `z.toJSONSchema` (Zod 4's native JSON Schema 2020-12 emitter,\n * which is the OpenAPI 3.1 schema dialect — no conversion layer\n * needed). Error responses reference the shared\n * `#/components/responses/ApiError` so a single envelope shape\n * covers every error path.\n *\n * @param app A built `Act` orchestrator. Required.\n * @param options Document-shape fields (`info.title`, `info.version`,\n * servers, base path) and toggles for the cross-cutting headers\n * the live REST API may accept (`Idempotency-Key`, `If-Match`).\n * @returns A valid OpenAPI 3.1 document object — serve as\n * `/openapi.json`, ship to a CDN, write to disk during CI, or\n * pipe to a codegen tool.\n *\n * @throws Error if `info.title` / `info.version` is missing or\n * empty, or if any `servers[].url` fails URL parsing after\n * `{variable}` substitution.\n */\nexport function openapi<TApp extends Pick<ActSurface, \"registry\">>(\n app: TApp,\n options: OpenAPIOptions\n): OpenAPIDocument {\n validate_options(options);\n const base_path = options.basePath ?? DEFAULT_BASE_PATH;\n const paths: Record<string, OpenAPIPathItem> = {};\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const zod_schema = state.actions[action_name] as z.ZodType;\n const body_schema = mark_sensitive_fields(\n strip_json_schema_meta(\n // `unrepresentable: \"any\"` emits an open (`{}`) schema for a Zod type\n // with no JSON-Schema form (z.date/z.bigint/z.map/z.set/...) instead of\n // Zod 4's default `\"throw\"`, which would abort the WHOLE document over\n // one such field — while hono() serves the same action fine (#1328).\n // `io: \"input\"` describes what a client must SEND: a `.default()`\n // field is optional, extra keys are not rejected (Zod strips them),\n // and a `.pipe()`/`.transform()` documents its input type. Zod's\n // default, `\"output\"`, describes the parsed result instead.\n z.toJSONSchema(zod_schema, {\n unrepresentable: \"any\",\n io: \"input\",\n }) as Record<string, unknown>\n ),\n zod_schema\n );\n paths[`${base_path}/actions/${action_name}`] = {\n post: build_operation(action_name, body_schema, options),\n };\n }\n\n return {\n openapi: \"3.1.0\",\n info: options.info,\n ...(options.servers ? { servers: options.servers } : {}),\n paths,\n components: {\n schemas: {\n ApiError: API_ERROR_SCHEMA,\n SnapshotArray: SNAPSHOT_ARRAY_SCHEMA,\n },\n responses: {\n ApiError: API_ERROR_RESPONSE,\n },\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,9 +1,7 @@
1
1
  "use strict";
2
- var __create = Object.create;
3
2
  var __defProp = Object.defineProperty;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
6
  var __export = (target, all) => {
9
7
  for (var name in all)
@@ -17,14 +15,6 @@ var __copyProps = (to, from, except, desc) => {
17
15
  }
18
16
  return to;
19
17
  };
20
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
- // If the importer is in node compatibility mode or this is not an ESM
22
- // file that has been converted to a CommonJS file using a Babel-
23
- // compatible transform (i.e. "__esModule" has not been set), then set
24
- // "default" to the CommonJS "module.exports" for node compatibility.
25
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
- mod
27
- ));
28
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
19
 
30
20
  // src/sse/index.ts
@@ -157,9 +147,7 @@ var wire_safe = (patch2) => normalize(patch2, true);
157
147
  var cache_safe = (state) => normalize(state, false);
158
148
  var OVERLAY_KEYS = /* @__PURE__ */ Symbol("act.sse.overlay_keys");
159
149
  var default_subscriber_error = (error, streamId) => {
160
- void import("@rotorsoft/act").then(
161
- ({ log }) => log().error(error, `sse subscriber threw for "${streamId}"`)
162
- );
150
+ console.error(`sse subscriber threw for "${streamId}"`, error);
163
151
  };
164
152
  var tag_overlay_keys = (state, prev, keys) => {
165
153
  const carried = prev?.[OVERLAY_KEYS];
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/types.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // Distinguish an *absent* baseline from a real version-0 baseline (#1346).\n // Act assigns the first event of any stream `version = 0`, so a fresh client\n // (no cache) must resume from a pre-baseline watermark of -1 — otherwise the\n // `?? 0` default collides with a genuine version-0 genesis patch and drops\n // it as \"stale\". A present baseline keeps its own `_v`.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else — a resync frame carries no versions, and the empty-frame\n // branch below would otherwise report it as `stale`, which is the one\n // answer that does NOT refetch (#1423).\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply. For a client that already has a\n // baseline that is genuinely `stale` — a no-op, don't refetch. For one with\n // NO baseline there is nothing to be stale relative to, and `stale` is the\n // one answer that does not refetch, so it would strand the client (#1474).\n // Both the doc-comment below and `real-time.md` already say a fresh client\n // is never stale; this is the branch that made that false.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled EXHAUSTIVELY here — an overlay must never reach\n // the contiguous fold below, which would apply its payload as if it were\n // that version's domain patch. At exactly `cachedV + 1` that fold made the\n // client adopt presence data as version N, stamp itself caught up, and\n // never refetch — the real update for N lost permanently (#1419).\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport {\n type BroadcastState,\n type PatchMessage,\n resync_frame,\n type Subscriber,\n} from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\n/**\n * Deliver a frame to every subscriber, containing each one individually.\n *\n * SSE subscribers are the least-trusted callbacks in the system — one per\n * connection, driven by network state. The unguarded loop this replaces let\n * the first thrower abort iteration, so every later subscriber lost the\n * frame and the exception escaped into the host's commit path (#1423). Same\n * containment principle the orchestrator applies to lifecycle listeners.\n * Guarding each callback rather than the loop is the point: one guard around\n * the whole loop would still let the first throw suppress the rest.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite `undefined`-valued keys to `null`, recursively.\n *\n * `@rotorsoft/act-patch` treats `undefined` and `null` as the same delete\n * signal, but `JSON.stringify` drops `undefined`-valued keys entirely — and\n * every SSE transport serializes frames as JSON. A reducer that clears a\n * field the idiomatic way (`{ left: undefined }`) therefore produced a frame\n * with no mention of `left` at all, so a live client kept the stale value\n * while stamping the new version — believing itself caught up, and never\n * refetching (#1471).\n *\n * `null` reaches the same `delete` branch in the patch applicator and\n * survives JSON, so normalizing here makes the two spellings equivalent on\n * the wire as they already are in memory.\n *\n * One walker serves both destinations. The frame and the cached state\n * agree on every encoding except the delete: see `wire_safe` /\n * `cache_safe` below.\n */\nconst normalize = <T>(value: T, nulls: boolean): T => {\n if (value === null || typeof value !== \"object\") return value;\n if (Array.isArray(value)) return value.map((v) => normalize(v, nulls)) as T;\n // A Set has exactly one sensible JSON encoding, and `JSON.stringify` gives\n // it the wrong one: `{}`. The framework's own `PresenceTracker.online()`\n // returns a Set and the presence recipe feeds it straight to `overlay()`,\n // so the documented way to broadcast presence shipped an empty object to\n // every client — and then froze, because a client holding `{}` treats\n // every later empty patch as a no-op (#1472).\n if (value instanceof Set)\n return [...value].map((v) => normalize(v, nulls)) as T;\n // Everything else non-plain (Date, Map, class instances) is left to\n // whatever the host's serializer already does with it. `Date` has a\n // defined encoding; `Map` does not have an unambiguous one (entries or\n // object?), so guessing would trade a visible bug for a silent choice.\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>))\n out[k] = v === undefined && nulls ? null : normalize(v, nulls);\n return out as T;\n};\n\n/**\n * Frame-bound normalization: Sets become arrays, and `undefined` becomes\n * `null` so a delete survives `JSON.stringify` (#1471).\n */\nconst wire_safe = <T>(patch: T): T => normalize(patch, true);\n\n/**\n * Cache-bound normalization: Sets become arrays, `undefined` is left alone.\n *\n * The cached state is what a reconnecting client reseeds from, and #1471\n * settled that it must keep a cleared key *absent* rather than `null`, so\n * the wire's delete encoding is exactly the part the cache must not adopt.\n * That single difference is why `nulls` is a parameter instead of two\n * separate walkers — a second copy would drift the moment either side grew\n * a case.\n *\n * Non-enumerable properties are invisible to `Object.entries`, so the\n * `OVERLAY_KEYS` marker is neither copied nor clobbered here; callers tag\n * after normalizing.\n */\nconst cache_safe = <T>(state: T): T => normalize(state, false);\n\n/**\n * Keys an `overlay()` contributed to a stream's cached state, carried on the\n * cached object itself.\n *\n * `publish()` replaces the cache entry with host-derived state, so overlay\n * data — presence, computed fields — vanished from the cache while live\n * clients, which had already applied the overlay frame, kept it. A\n * reconnecting client then reseeded WITHOUT it and had no way to notice: its\n * `_v` matches the server's, so nothing classifies as `behind` (#1473).\n *\n * A symbol keeps this out of `Object.keys`, `JSON.stringify` and therefore\n * off the wire; living on the cached object means it is evicted with the\n * entry rather than needing a parallel structure to prune.\n */\nconst OVERLAY_KEYS = Symbol(\"act.sse.overlay_keys\");\n\ntype WithOverlayKeys = { [OVERLAY_KEYS]?: ReadonlySet<string> };\n\n/**\n * Default `onSubscriberError` — routes through the framework logger, so a\n * throwing subscriber lands wherever the host already sends framework logs.\n *\n * The logger is reached through a dynamic import on purpose. This module sits\n * in the `sse` subpath alongside `applyPatchMessage` and the wire types, which\n * browser code imports; a static `import { log } from \"@rotorsoft/act\"` puts\n * the whole framework in that bundle, and the framework builds an\n * `AsyncLocalStorage` the moment it loads — a Node API the browser stubs out\n * and throws on. Only a server ever constructs a `BroadcastChannel`, so this\n * path never runs client-side, and the import stays out of the static graph\n * where the bundler would follow it.\n */\nconst default_subscriber_error = (error: unknown, streamId: string): void => {\n void import(\"@rotorsoft/act\").then(({ log }) =>\n log().error(error, `sse subscriber threw for \"${streamId}\"`)\n );\n};\n\n/** Record which keys an overlay owns, accumulating across overlays. */\nconst tag_overlay_keys = <S extends object>(\n state: S,\n prev: S | undefined,\n keys: readonly string[]\n): S => {\n const carried = (prev as WithOverlayKeys | undefined)?.[OVERLAY_KEYS];\n Object.defineProperty(state, OVERLAY_KEYS, {\n value: new Set([...(carried ?? []), ...keys]),\n enumerable: false,\n configurable: true,\n });\n return state;\n};\n\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n /**\n * @param options.cacheSize - Max number of stream states kept in the LRU\n * cache (default 50).\n */\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n cacheSize?: number;\n /**\n * Called when overlay-contributed state is lost, on either of the two\n * paths that can lose it: `overlay()` finding no cached baseline, and\n * the LRU evicting an entry that carried overlay keys — the latter from\n * inside `publish()`, for a stream other than the one being published.\n *\n * Live subscribers of the affected stream are sent a resync frame, so\n * they refetch rather than keep showing state the server has forgotten.\n * The hook is observability on top of that recovery: a steady stream of\n * calls means `cacheSize` is too small for the working set.\n *\n * A throw is caught and routed to `onSubscriberError` — the resync has\n * already gone out, and a host bug must not cost the publishing stream\n * its own frame. Defaults to a no-op.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher (#1423). Defaults to the\n * framework's `log()` port, so it lands wherever the host already\n * routes framework logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50,\n (streamId, dropped) => this.on_cache_evict(streamId, dropped)\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ?? default_subscriber_error;\n }\n\n /**\n * Fan a resync out to a stream's live subscribers. `applyPatchMessage`\n * always reports `behind` for it, so they refetch.\n */\n private broadcast_resync(streamId: string): void {\n fan_out(this.channels.get(streamId), resync_frame<S>(), (error) =>\n this.on_subscriber_error(error, streamId)\n );\n }\n\n /**\n * The LRU dropped an entry. If it carried overlay-contributed keys, that\n * data existed only in the cache and is now gone: a later `publish()` has\n * no baseline to carry it from, and the reseed a reconnecting client gets\n * would silently lack presence a live client is still showing (#1648).\n *\n * `overlay()` was hardened for the same loss on its own path (#1423);\n * catching it here covers `publish()` too, and does it at the moment the\n * data is lost rather than at a later commit that may never come. Reading\n * the marker off the entry being evicted is what avoids the parallel\n * bookkeeping structure the `OVERLAY_KEYS` design explicitly rejected.\n */\n private on_cache_evict(streamId: string, dropped: S): void {\n if (!(dropped as S & WithOverlayKeys)[OVERLAY_KEYS]?.size) return;\n this.report_overlay_miss(streamId);\n }\n\n /**\n * Overlay-contributed state for `streamId` is gone. Recover first, report\n * second.\n *\n * The resync goes out before the host hook runs, and the hook is contained\n * the way `fan_out` contains subscribers: a bad consumer must not break the\n * publisher (#1423), and the same applies to a bad observer. Without this,\n * a throwing `onOverlayMiss` escapes on the eviction path — which runs\n * inside `publish()`, before that stream's own `fan_out` — so one host bug\n * costs the evicted stream its recovery frame *and* an unrelated stream the\n * frame for a version already written to the cache. The evicted stream's\n * clients never self-heal from that, which is the silent loss #1648 exists\n * to end.\n *\n * Routing the throw to `onSubscriberError` rather than swallowing it keeps\n * it wherever the host already reads framework delivery failures.\n */\n private report_overlay_miss(streamId: string): void {\n this.broadcast_resync(streamId);\n try {\n this.on_overlay_miss(streamId);\n } catch (error) {\n this.on_subscriber_error(error, streamId);\n }\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n // Carry overlay-contributed keys across the commit (#1473). Only keys\n // an `overlay()` actually owns, and only when the new domain state does\n // not speak to them — so a publisher that drops or overwrites a key\n // still wins, and presence survives a commit as the docs imply.\n // Normalize once, up front: the cache entry and the frame are both\n // derived from this, so the reseed a reconnecting client gets cannot\n // disagree with what a live client applied (#1646). `overlay()` already\n // normalized before its own cache write; `publish()` did not, so a Set\n // shipped as an array live and as `{}` on reseed — and then froze.\n const safe_state = cache_safe(state);\n const prev = this.state_cache.get(streamId) as\n | (S & WithOverlayKeys)\n | undefined;\n const overlay_keys = prev?.[OVERLAY_KEYS];\n let cached = safe_state;\n if (overlay_keys?.size && prev) {\n const carried = { ...safe_state } as S;\n const kept: string[] = [];\n for (const key of overlay_keys)\n if (!(key in safe_state) && key in prev) {\n (carried as Record<string, unknown>)[key] = (\n prev as Record<string, unknown>\n )[key];\n kept.push(key);\n }\n cached = tag_overlay_keys(carried, undefined, kept);\n }\n this.state_cache.set(streamId, cached);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to read-modify-write, so nothing is broadcast — and\n // because no frame is emitted there is nothing for a live client to\n // classify as `behind`, so it never refetches either. Unlike a domain\n // commit (which repopulates the cache via `publish`), an overlay-only\n // stream never recovers. The defaults make this reachable rather than\n // exotic: `cacheSize` is 50 while a host may hold far more live\n // subscriptions, and cache promotion happens at connect time, so a\n // busy-but-not-committing stream ages out while fully subscribed.\n //\n // Emit a resync frame so live subscribers refetch instead of silently\n // missing the update forever (#1423). The host hook still fires so a\n // host can count these — a steady stream of them means `cacheSize` is\n // too small for the working set.\n this.report_overlay_miss(streamId);\n // Still `undefined`: no overlay state was produced, and callers use the\n // return value as \"the patch I broadcast\", which a resync is not.\n return undefined;\n }\n\n // Normalize BEFORE applying to the cache, so a reconnecting client's\n // reseed and a live client's frame agree (#1472). `apply_patch` treats\n // `null` and `undefined` as the same delete signal, so normalizing first\n // does not change the delete semantics — it only fixes the encodings\n // that would not survive JSON.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(\n streamId,\n tag_overlay_keys(state, prev, Object.keys(safe_patch as object))\n );\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n private on_evict: (key: string, state: S) => void;\n\n /**\n * @param maxSize - Max entries before the LRU drops its oldest.\n * @param onEvict - Called with the entry the LRU just dropped. Eviction is\n * the moment cached-only data is lost, so it is the only place a caller\n * can still see what went (#1648).\n */\n constructor(\n maxSize = 50,\n onEvict: (key: string, state: S) => void = () => {}\n ) {\n this.maxSize = maxSize;\n this.on_evict = onEvict;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n const oldest = this.cache.keys().next().value!;\n const dropped = this.cache.get(oldest)!;\n this.cache.delete(oldest);\n this.on_evict(oldest, dropped);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","import type { DeepPartial } from \"@rotorsoft/act-patch\";\n\n/**\n * Base constraint for state objects managed by the broadcast system.\n * Apps extend this with their own domain state shape.\n */\nexport type BroadcastState = Record<string, unknown> & {\n /** Event store stream version — set by the broadcast layer from snap.event.version */\n _v: number;\n};\n\n/**\n * SSE message: version-keyed domain patches.\n * Keys are stringified version numbers, values are domain patches (deep partials).\n * Multi-event commits produce multiple version-keyed entries.\n *\n * The optional `_overlay` marker flags a version-neutral update (presence,\n * computed-field refresh) emitted by {@link BroadcastChannel.overlay} — a\n * single entry keyed at the *current* version. It tells `applyPatchMessage`\n * to merge the entry on top of the client's caught-up state instead of\n * rejecting it as stale (a same-version patch WITHOUT the marker stays\n * stale). Ordinary version-bumping patches from `publish()` omit it.\n *\n * `_resync` carries no versions at all. The server emits it when it cannot\n * construct a patch — today, when `overlay()` finds the stream's baseline\n * evicted from the LRU. A client that receives one always reports `behind`\n * and refetches. Without it the server had nothing to say in that case, so\n * live subscribers silently stopped receiving presence updates and had no\n * way to notice (#1423).\n */\nexport type PatchMessage<S extends BroadcastState = BroadcastState> = Record<\n number,\n DeepPartial<S>\n> & { readonly _overlay?: true; readonly _resync?: true };\n\n/**\n * Subscriber callback — receives version-keyed patch messages.\n */\nexport type Subscriber<S extends BroadcastState = BroadcastState> = (\n msg: PatchMessage<S>\n) => void;\n\n/**\n * The frame that tells a client to refetch: no versions, so\n * `applyPatchMessage` always reports `behind`.\n *\n * Every producer of a resync uses this — `overlay()` on a missing baseline,\n * the cache on evicting overlay state, and the SSE backlog when it cannot\n * drop a frame without losing it silently. One factory so the shape cannot\n * drift between them.\n */\nexport const resync_frame = <S extends BroadcastState>(): PatchMessage<S> =>\n ({ _resync: true }) as PatchMessage<S>;\n\n/**\n * Whether a frame carries no versions, so `applyPatchMessage` can never\n * classify it as a gap.\n *\n * A version-keyed patch that goes missing is self-announcing: the next frame\n * skips a version and the client refetches. An `_overlay` or `_resync` frame\n * is not, which is why anything that drops frames has to tell them apart.\n */\nexport const is_version_neutral = <S extends BroadcastState>(\n msg: PatchMessage<S>\n): boolean => msg._overlay === true || msg._resync === true;\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["import_act_patch","deep_merge","import_act_patch","patch","apply_patch"]}
1
+ {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/types.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // Distinguish an *absent* baseline from a real version-0 baseline (#1346).\n // Act assigns the first event of any stream `version = 0`, so a fresh client\n // (no cache) must resume from a pre-baseline watermark of -1 — otherwise the\n // `?? 0` default collides with a genuine version-0 genesis patch and drops\n // it as \"stale\". A present baseline keeps its own `_v`.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else — a resync frame carries no versions, and the empty-frame\n // branch below would otherwise report it as `stale`, which is the one\n // answer that does NOT refetch (#1423).\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply. For a client that already has a\n // baseline that is genuinely `stale` — a no-op, don't refetch. For one with\n // NO baseline there is nothing to be stale relative to, and `stale` is the\n // one answer that does not refetch, so it would strand the client (#1474).\n // Both the doc-comment below and `real-time.md` already say a fresh client\n // is never stale; this is the branch that made that false.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled EXHAUSTIVELY here — an overlay must never reach\n // the contiguous fold below, which would apply its payload as if it were\n // that version's domain patch. At exactly `cachedV + 1` that fold made the\n // client adopt presence data as version N, stamp itself caught up, and\n // never refetch — the real update for N lost permanently (#1419).\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport {\n type BroadcastState,\n type PatchMessage,\n resync_frame,\n type Subscriber,\n} from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\n/**\n * Deliver a frame to every subscriber, containing each one individually.\n *\n * SSE subscribers are the least-trusted callbacks in the system — one per\n * connection, driven by network state. The unguarded loop this replaces let\n * the first thrower abort iteration, so every later subscriber lost the\n * frame and the exception escaped into the host's commit path (#1423). Same\n * containment principle the orchestrator applies to lifecycle listeners.\n * Guarding each callback rather than the loop is the point: one guard around\n * the whole loop would still let the first throw suppress the rest.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite `undefined`-valued keys to `null`, recursively.\n *\n * `@rotorsoft/act-patch` treats `undefined` and `null` as the same delete\n * signal, but `JSON.stringify` drops `undefined`-valued keys entirely — and\n * every SSE transport serializes frames as JSON. A reducer that clears a\n * field the idiomatic way (`{ left: undefined }`) therefore produced a frame\n * with no mention of `left` at all, so a live client kept the stale value\n * while stamping the new version — believing itself caught up, and never\n * refetching (#1471).\n *\n * `null` reaches the same `delete` branch in the patch applicator and\n * survives JSON, so normalizing here makes the two spellings equivalent on\n * the wire as they already are in memory.\n *\n * One walker serves both destinations. The frame and the cached state\n * agree on every encoding except the delete: see `wire_safe` /\n * `cache_safe` below.\n */\nconst normalize = <T>(value: T, nulls: boolean): T => {\n if (value === null || typeof value !== \"object\") return value;\n if (Array.isArray(value)) return value.map((v) => normalize(v, nulls)) as T;\n // A Set has exactly one sensible JSON encoding, and `JSON.stringify` gives\n // it the wrong one: `{}`. The framework's own `PresenceTracker.online()`\n // returns a Set and the presence recipe feeds it straight to `overlay()`,\n // so the documented way to broadcast presence shipped an empty object to\n // every client — and then froze, because a client holding `{}` treats\n // every later empty patch as a no-op (#1472).\n if (value instanceof Set)\n return [...value].map((v) => normalize(v, nulls)) as T;\n // Everything else non-plain (Date, Map, class instances) is left to\n // whatever the host's serializer already does with it. `Date` has a\n // defined encoding; `Map` does not have an unambiguous one (entries or\n // object?), so guessing would trade a visible bug for a silent choice.\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>))\n out[k] = v === undefined && nulls ? null : normalize(v, nulls);\n return out as T;\n};\n\n/**\n * Frame-bound normalization: Sets become arrays, and `undefined` becomes\n * `null` so a delete survives `JSON.stringify` (#1471).\n */\nconst wire_safe = <T>(patch: T): T => normalize(patch, true);\n\n/**\n * Cache-bound normalization: Sets become arrays, `undefined` is left alone.\n *\n * The cached state is what a reconnecting client reseeds from, and #1471\n * settled that it must keep a cleared key *absent* rather than `null`, so\n * the wire's delete encoding is exactly the part the cache must not adopt.\n * That single difference is why `nulls` is a parameter instead of two\n * separate walkers — a second copy would drift the moment either side grew\n * a case.\n *\n * Non-enumerable properties are invisible to `Object.entries`, so the\n * `OVERLAY_KEYS` marker is neither copied nor clobbered here; callers tag\n * after normalizing.\n */\nconst cache_safe = <T>(state: T): T => normalize(state, false);\n\n/**\n * Keys an `overlay()` contributed to a stream's cached state, carried on the\n * cached object itself.\n *\n * `publish()` replaces the cache entry with host-derived state, so overlay\n * data — presence, computed fields — vanished from the cache while live\n * clients, which had already applied the overlay frame, kept it. A\n * reconnecting client then reseeded WITHOUT it and had no way to notice: its\n * `_v` matches the server's, so nothing classifies as `behind` (#1473).\n *\n * A symbol keeps this out of `Object.keys`, `JSON.stringify` and therefore\n * off the wire; living on the cached object means it is evicted with the\n * entry rather than needing a parallel structure to prune.\n */\nconst OVERLAY_KEYS = Symbol(\"act.sse.overlay_keys\");\n\ntype WithOverlayKeys = { [OVERLAY_KEYS]?: ReadonlySet<string> };\n\n/**\n * Default `onSubscriberError`. This module shares the `sse` subpath with\n * `applyPatchMessage`, which browser code imports, so it never imports the\n * framework: a bundler would follow that into a browser build. Pass\n * `onSubscriberError` to send these to your logger.\n */\nconst default_subscriber_error = (error: unknown, streamId: string): void => {\n // biome-ignore lint/suspicious/noConsole: the default can't reach the framework logger (see above)\n console.error(`sse subscriber threw for \"${streamId}\"`, error);\n};\n\n/** Record which keys an overlay owns, accumulating across overlays. */\nconst tag_overlay_keys = <S extends object>(\n state: S,\n prev: S | undefined,\n keys: readonly string[]\n): S => {\n const carried = (prev as WithOverlayKeys | undefined)?.[OVERLAY_KEYS];\n Object.defineProperty(state, OVERLAY_KEYS, {\n value: new Set([...(carried ?? []), ...keys]),\n enumerable: false,\n configurable: true,\n });\n return state;\n};\n\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n /**\n * @param options.cacheSize - Max number of stream states kept in the LRU\n * cache (default 50).\n */\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n cacheSize?: number;\n /**\n * Called when overlay-contributed state is lost, on either of the two\n * paths that can lose it: `overlay()` finding no cached baseline, and\n * the LRU evicting an entry that carried overlay keys — the latter from\n * inside `publish()`, for a stream other than the one being published.\n *\n * Live subscribers of the affected stream are sent a resync frame, so\n * they refetch rather than keep showing state the server has forgotten.\n * The hook is observability on top of that recovery: a steady stream of\n * calls means `cacheSize` is too small for the working set.\n *\n * A throw is caught and routed to `onSubscriberError` — the resync has\n * already gone out, and a host bug must not cost the publishing stream\n * its own frame. Defaults to a no-op.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher. Defaults to `console.error`;\n * pass your logger here to route it with the rest of your logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50,\n (streamId, dropped) => this.on_cache_evict(streamId, dropped)\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ?? default_subscriber_error;\n }\n\n /**\n * Fan a resync out to a stream's live subscribers. `applyPatchMessage`\n * always reports `behind` for it, so they refetch.\n */\n private broadcast_resync(streamId: string): void {\n fan_out(this.channels.get(streamId), resync_frame<S>(), (error) =>\n this.on_subscriber_error(error, streamId)\n );\n }\n\n /**\n * The LRU dropped an entry. If it carried overlay-contributed keys, that\n * data existed only in the cache and is now gone: a later `publish()` has\n * no baseline to carry it from, and the reseed a reconnecting client gets\n * would silently lack presence a live client is still showing (#1648).\n *\n * `overlay()` was hardened for the same loss on its own path (#1423);\n * catching it here covers `publish()` too, and does it at the moment the\n * data is lost rather than at a later commit that may never come. Reading\n * the marker off the entry being evicted is what avoids the parallel\n * bookkeeping structure the `OVERLAY_KEYS` design explicitly rejected.\n */\n private on_cache_evict(streamId: string, dropped: S): void {\n if (!(dropped as S & WithOverlayKeys)[OVERLAY_KEYS]?.size) return;\n this.report_overlay_miss(streamId);\n }\n\n /**\n * Overlay-contributed state for `streamId` is gone. Recover first, report\n * second.\n *\n * The resync goes out before the host hook runs, and the hook is contained\n * the way `fan_out` contains subscribers: a bad consumer must not break the\n * publisher (#1423), and the same applies to a bad observer. Without this,\n * a throwing `onOverlayMiss` escapes on the eviction path — which runs\n * inside `publish()`, before that stream's own `fan_out` — so one host bug\n * costs the evicted stream its recovery frame *and* an unrelated stream the\n * frame for a version already written to the cache. The evicted stream's\n * clients never self-heal from that, which is the silent loss #1648 exists\n * to end.\n *\n * Routing the throw to `onSubscriberError` rather than swallowing it keeps\n * it wherever the host already reads framework delivery failures.\n */\n private report_overlay_miss(streamId: string): void {\n this.broadcast_resync(streamId);\n try {\n this.on_overlay_miss(streamId);\n } catch (error) {\n this.on_subscriber_error(error, streamId);\n }\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n // Carry overlay-contributed keys across the commit (#1473). Only keys\n // an `overlay()` actually owns, and only when the new domain state does\n // not speak to them — so a publisher that drops or overwrites a key\n // still wins, and presence survives a commit as the docs imply.\n // Normalize once, up front: the cache entry and the frame are both\n // derived from this, so the reseed a reconnecting client gets cannot\n // disagree with what a live client applied (#1646). `overlay()` already\n // normalized before its own cache write; `publish()` did not, so a Set\n // shipped as an array live and as `{}` on reseed — and then froze.\n const safe_state = cache_safe(state);\n const prev = this.state_cache.get(streamId) as\n | (S & WithOverlayKeys)\n | undefined;\n const overlay_keys = prev?.[OVERLAY_KEYS];\n let cached = safe_state;\n if (overlay_keys?.size && prev) {\n const carried = { ...safe_state } as S;\n const kept: string[] = [];\n for (const key of overlay_keys)\n if (!(key in safe_state) && key in prev) {\n (carried as Record<string, unknown>)[key] = (\n prev as Record<string, unknown>\n )[key];\n kept.push(key);\n }\n cached = tag_overlay_keys(carried, undefined, kept);\n }\n this.state_cache.set(streamId, cached);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to read-modify-write, so nothing is broadcast — and\n // because no frame is emitted there is nothing for a live client to\n // classify as `behind`, so it never refetches either. Unlike a domain\n // commit (which repopulates the cache via `publish`), an overlay-only\n // stream never recovers. The defaults make this reachable rather than\n // exotic: `cacheSize` is 50 while a host may hold far more live\n // subscriptions, and cache promotion happens at connect time, so a\n // busy-but-not-committing stream ages out while fully subscribed.\n //\n // Emit a resync frame so live subscribers refetch instead of silently\n // missing the update forever (#1423). The host hook still fires so a\n // host can count these — a steady stream of them means `cacheSize` is\n // too small for the working set.\n this.report_overlay_miss(streamId);\n // Still `undefined`: no overlay state was produced, and callers use the\n // return value as \"the patch I broadcast\", which a resync is not.\n return undefined;\n }\n\n // Normalize BEFORE applying to the cache, so a reconnecting client's\n // reseed and a live client's frame agree (#1472). `apply_patch` treats\n // `null` and `undefined` as the same delete signal, so normalizing first\n // does not change the delete semantics — it only fixes the encodings\n // that would not survive JSON.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(\n streamId,\n tag_overlay_keys(state, prev, Object.keys(safe_patch as object))\n );\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n private on_evict: (key: string, state: S) => void;\n\n /**\n * @param maxSize - Max entries before the LRU drops its oldest.\n * @param onEvict - Called with the entry the LRU just dropped. Eviction is\n * the moment cached-only data is lost, so it is the only place a caller\n * can still see what went (#1648).\n */\n constructor(\n maxSize = 50,\n onEvict: (key: string, state: S) => void = () => {}\n ) {\n this.maxSize = maxSize;\n this.on_evict = onEvict;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n const oldest = this.cache.keys().next().value!;\n const dropped = this.cache.get(oldest)!;\n this.cache.delete(oldest);\n this.on_evict(oldest, dropped);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","import type { DeepPartial } from \"@rotorsoft/act-patch\";\n\n/**\n * Base constraint for state objects managed by the broadcast system.\n * Apps extend this with their own domain state shape.\n */\nexport type BroadcastState = Record<string, unknown> & {\n /** Event store stream version — set by the broadcast layer from snap.event.version */\n _v: number;\n};\n\n/**\n * SSE message: version-keyed domain patches.\n * Keys are stringified version numbers, values are domain patches (deep partials).\n * Multi-event commits produce multiple version-keyed entries.\n *\n * The optional `_overlay` marker flags a version-neutral update (presence,\n * computed-field refresh) emitted by {@link BroadcastChannel.overlay} — a\n * single entry keyed at the *current* version. It tells `applyPatchMessage`\n * to merge the entry on top of the client's caught-up state instead of\n * rejecting it as stale (a same-version patch WITHOUT the marker stays\n * stale). Ordinary version-bumping patches from `publish()` omit it.\n *\n * `_resync` carries no versions at all. The server emits it when it cannot\n * construct a patch — today, when `overlay()` finds the stream's baseline\n * evicted from the LRU. A client that receives one always reports `behind`\n * and refetches. Without it the server had nothing to say in that case, so\n * live subscribers silently stopped receiving presence updates and had no\n * way to notice (#1423).\n */\nexport type PatchMessage<S extends BroadcastState = BroadcastState> = Record<\n number,\n DeepPartial<S>\n> & { readonly _overlay?: true; readonly _resync?: true };\n\n/**\n * Subscriber callback — receives version-keyed patch messages.\n */\nexport type Subscriber<S extends BroadcastState = BroadcastState> = (\n msg: PatchMessage<S>\n) => void;\n\n/**\n * The frame that tells a client to refetch: no versions, so\n * `applyPatchMessage` always reports `behind`.\n *\n * Every producer of a resync uses this — `overlay()` on a missing baseline,\n * the cache on evicting overlay state, and the SSE backlog when it cannot\n * drop a frame without losing it silently. One factory so the shape cannot\n * drift between them.\n */\nexport const resync_frame = <S extends BroadcastState>(): PatchMessage<S> =>\n ({ _resync: true }) as PatchMessage<S>;\n\n/**\n * Whether a frame carries no versions, so `applyPatchMessage` can never\n * classify it as a gap.\n *\n * A version-keyed patch that goes missing is self-announcing: the next frame\n * skips a version and the client refetches. An `_overlay` or `_resync` frame\n * is not, which is why anything that drops frames has to tell them apart.\n */\nexport const is_version_neutral = <S extends BroadcastState>(\n msg: PatchMessage<S>\n): boolean => msg._overlay === true || msg._resync === true;\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["import_act_patch","deep_merge","import_act_patch","patch","apply_patch"]}
package/dist/sse/index.js CHANGED
@@ -121,9 +121,7 @@ var wire_safe = (patch2) => normalize(patch2, true);
121
121
  var cache_safe = (state) => normalize(state, false);
122
122
  var OVERLAY_KEYS = /* @__PURE__ */ Symbol("act.sse.overlay_keys");
123
123
  var default_subscriber_error = (error, streamId) => {
124
- void import("@rotorsoft/act").then(
125
- ({ log }) => log().error(error, `sse subscriber threw for "${streamId}"`)
126
- );
124
+ console.error(`sse subscriber threw for "${streamId}"`, error);
127
125
  };
128
126
  var tag_overlay_keys = (state, prev, keys) => {
129
127
  const carried = prev?.[OVERLAY_KEYS];
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // Distinguish an *absent* baseline from a real version-0 baseline (#1346).\n // Act assigns the first event of any stream `version = 0`, so a fresh client\n // (no cache) must resume from a pre-baseline watermark of -1 — otherwise the\n // `?? 0` default collides with a genuine version-0 genesis patch and drops\n // it as \"stale\". A present baseline keeps its own `_v`.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else — a resync frame carries no versions, and the empty-frame\n // branch below would otherwise report it as `stale`, which is the one\n // answer that does NOT refetch (#1423).\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply. For a client that already has a\n // baseline that is genuinely `stale` — a no-op, don't refetch. For one with\n // NO baseline there is nothing to be stale relative to, and `stale` is the\n // one answer that does not refetch, so it would strand the client (#1474).\n // Both the doc-comment below and `real-time.md` already say a fresh client\n // is never stale; this is the branch that made that false.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled EXHAUSTIVELY here — an overlay must never reach\n // the contiguous fold below, which would apply its payload as if it were\n // that version's domain patch. At exactly `cachedV + 1` that fold made the\n // client adopt presence data as version N, stamp itself caught up, and\n // never refetch — the real update for N lost permanently (#1419).\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport {\n type BroadcastState,\n type PatchMessage,\n resync_frame,\n type Subscriber,\n} from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\n/**\n * Deliver a frame to every subscriber, containing each one individually.\n *\n * SSE subscribers are the least-trusted callbacks in the system — one per\n * connection, driven by network state. The unguarded loop this replaces let\n * the first thrower abort iteration, so every later subscriber lost the\n * frame and the exception escaped into the host's commit path (#1423). Same\n * containment principle the orchestrator applies to lifecycle listeners.\n * Guarding each callback rather than the loop is the point: one guard around\n * the whole loop would still let the first throw suppress the rest.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite `undefined`-valued keys to `null`, recursively.\n *\n * `@rotorsoft/act-patch` treats `undefined` and `null` as the same delete\n * signal, but `JSON.stringify` drops `undefined`-valued keys entirely — and\n * every SSE transport serializes frames as JSON. A reducer that clears a\n * field the idiomatic way (`{ left: undefined }`) therefore produced a frame\n * with no mention of `left` at all, so a live client kept the stale value\n * while stamping the new version — believing itself caught up, and never\n * refetching (#1471).\n *\n * `null` reaches the same `delete` branch in the patch applicator and\n * survives JSON, so normalizing here makes the two spellings equivalent on\n * the wire as they already are in memory.\n *\n * One walker serves both destinations. The frame and the cached state\n * agree on every encoding except the delete: see `wire_safe` /\n * `cache_safe` below.\n */\nconst normalize = <T>(value: T, nulls: boolean): T => {\n if (value === null || typeof value !== \"object\") return value;\n if (Array.isArray(value)) return value.map((v) => normalize(v, nulls)) as T;\n // A Set has exactly one sensible JSON encoding, and `JSON.stringify` gives\n // it the wrong one: `{}`. The framework's own `PresenceTracker.online()`\n // returns a Set and the presence recipe feeds it straight to `overlay()`,\n // so the documented way to broadcast presence shipped an empty object to\n // every client — and then froze, because a client holding `{}` treats\n // every later empty patch as a no-op (#1472).\n if (value instanceof Set)\n return [...value].map((v) => normalize(v, nulls)) as T;\n // Everything else non-plain (Date, Map, class instances) is left to\n // whatever the host's serializer already does with it. `Date` has a\n // defined encoding; `Map` does not have an unambiguous one (entries or\n // object?), so guessing would trade a visible bug for a silent choice.\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>))\n out[k] = v === undefined && nulls ? null : normalize(v, nulls);\n return out as T;\n};\n\n/**\n * Frame-bound normalization: Sets become arrays, and `undefined` becomes\n * `null` so a delete survives `JSON.stringify` (#1471).\n */\nconst wire_safe = <T>(patch: T): T => normalize(patch, true);\n\n/**\n * Cache-bound normalization: Sets become arrays, `undefined` is left alone.\n *\n * The cached state is what a reconnecting client reseeds from, and #1471\n * settled that it must keep a cleared key *absent* rather than `null`, so\n * the wire's delete encoding is exactly the part the cache must not adopt.\n * That single difference is why `nulls` is a parameter instead of two\n * separate walkers — a second copy would drift the moment either side grew\n * a case.\n *\n * Non-enumerable properties are invisible to `Object.entries`, so the\n * `OVERLAY_KEYS` marker is neither copied nor clobbered here; callers tag\n * after normalizing.\n */\nconst cache_safe = <T>(state: T): T => normalize(state, false);\n\n/**\n * Keys an `overlay()` contributed to a stream's cached state, carried on the\n * cached object itself.\n *\n * `publish()` replaces the cache entry with host-derived state, so overlay\n * data — presence, computed fields — vanished from the cache while live\n * clients, which had already applied the overlay frame, kept it. A\n * reconnecting client then reseeded WITHOUT it and had no way to notice: its\n * `_v` matches the server's, so nothing classifies as `behind` (#1473).\n *\n * A symbol keeps this out of `Object.keys`, `JSON.stringify` and therefore\n * off the wire; living on the cached object means it is evicted with the\n * entry rather than needing a parallel structure to prune.\n */\nconst OVERLAY_KEYS = Symbol(\"act.sse.overlay_keys\");\n\ntype WithOverlayKeys = { [OVERLAY_KEYS]?: ReadonlySet<string> };\n\n/**\n * Default `onSubscriberError` — routes through the framework logger, so a\n * throwing subscriber lands wherever the host already sends framework logs.\n *\n * The logger is reached through a dynamic import on purpose. This module sits\n * in the `sse` subpath alongside `applyPatchMessage` and the wire types, which\n * browser code imports; a static `import { log } from \"@rotorsoft/act\"` puts\n * the whole framework in that bundle, and the framework builds an\n * `AsyncLocalStorage` the moment it loads — a Node API the browser stubs out\n * and throws on. Only a server ever constructs a `BroadcastChannel`, so this\n * path never runs client-side, and the import stays out of the static graph\n * where the bundler would follow it.\n */\nconst default_subscriber_error = (error: unknown, streamId: string): void => {\n void import(\"@rotorsoft/act\").then(({ log }) =>\n log().error(error, `sse subscriber threw for \"${streamId}\"`)\n );\n};\n\n/** Record which keys an overlay owns, accumulating across overlays. */\nconst tag_overlay_keys = <S extends object>(\n state: S,\n prev: S | undefined,\n keys: readonly string[]\n): S => {\n const carried = (prev as WithOverlayKeys | undefined)?.[OVERLAY_KEYS];\n Object.defineProperty(state, OVERLAY_KEYS, {\n value: new Set([...(carried ?? []), ...keys]),\n enumerable: false,\n configurable: true,\n });\n return state;\n};\n\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n /**\n * @param options.cacheSize - Max number of stream states kept in the LRU\n * cache (default 50).\n */\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n cacheSize?: number;\n /**\n * Called when overlay-contributed state is lost, on either of the two\n * paths that can lose it: `overlay()` finding no cached baseline, and\n * the LRU evicting an entry that carried overlay keys — the latter from\n * inside `publish()`, for a stream other than the one being published.\n *\n * Live subscribers of the affected stream are sent a resync frame, so\n * they refetch rather than keep showing state the server has forgotten.\n * The hook is observability on top of that recovery: a steady stream of\n * calls means `cacheSize` is too small for the working set.\n *\n * A throw is caught and routed to `onSubscriberError` — the resync has\n * already gone out, and a host bug must not cost the publishing stream\n * its own frame. Defaults to a no-op.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher (#1423). Defaults to the\n * framework's `log()` port, so it lands wherever the host already\n * routes framework logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50,\n (streamId, dropped) => this.on_cache_evict(streamId, dropped)\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ?? default_subscriber_error;\n }\n\n /**\n * Fan a resync out to a stream's live subscribers. `applyPatchMessage`\n * always reports `behind` for it, so they refetch.\n */\n private broadcast_resync(streamId: string): void {\n fan_out(this.channels.get(streamId), resync_frame<S>(), (error) =>\n this.on_subscriber_error(error, streamId)\n );\n }\n\n /**\n * The LRU dropped an entry. If it carried overlay-contributed keys, that\n * data existed only in the cache and is now gone: a later `publish()` has\n * no baseline to carry it from, and the reseed a reconnecting client gets\n * would silently lack presence a live client is still showing (#1648).\n *\n * `overlay()` was hardened for the same loss on its own path (#1423);\n * catching it here covers `publish()` too, and does it at the moment the\n * data is lost rather than at a later commit that may never come. Reading\n * the marker off the entry being evicted is what avoids the parallel\n * bookkeeping structure the `OVERLAY_KEYS` design explicitly rejected.\n */\n private on_cache_evict(streamId: string, dropped: S): void {\n if (!(dropped as S & WithOverlayKeys)[OVERLAY_KEYS]?.size) return;\n this.report_overlay_miss(streamId);\n }\n\n /**\n * Overlay-contributed state for `streamId` is gone. Recover first, report\n * second.\n *\n * The resync goes out before the host hook runs, and the hook is contained\n * the way `fan_out` contains subscribers: a bad consumer must not break the\n * publisher (#1423), and the same applies to a bad observer. Without this,\n * a throwing `onOverlayMiss` escapes on the eviction path — which runs\n * inside `publish()`, before that stream's own `fan_out` — so one host bug\n * costs the evicted stream its recovery frame *and* an unrelated stream the\n * frame for a version already written to the cache. The evicted stream's\n * clients never self-heal from that, which is the silent loss #1648 exists\n * to end.\n *\n * Routing the throw to `onSubscriberError` rather than swallowing it keeps\n * it wherever the host already reads framework delivery failures.\n */\n private report_overlay_miss(streamId: string): void {\n this.broadcast_resync(streamId);\n try {\n this.on_overlay_miss(streamId);\n } catch (error) {\n this.on_subscriber_error(error, streamId);\n }\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n // Carry overlay-contributed keys across the commit (#1473). Only keys\n // an `overlay()` actually owns, and only when the new domain state does\n // not speak to them — so a publisher that drops or overwrites a key\n // still wins, and presence survives a commit as the docs imply.\n // Normalize once, up front: the cache entry and the frame are both\n // derived from this, so the reseed a reconnecting client gets cannot\n // disagree with what a live client applied (#1646). `overlay()` already\n // normalized before its own cache write; `publish()` did not, so a Set\n // shipped as an array live and as `{}` on reseed — and then froze.\n const safe_state = cache_safe(state);\n const prev = this.state_cache.get(streamId) as\n | (S & WithOverlayKeys)\n | undefined;\n const overlay_keys = prev?.[OVERLAY_KEYS];\n let cached = safe_state;\n if (overlay_keys?.size && prev) {\n const carried = { ...safe_state } as S;\n const kept: string[] = [];\n for (const key of overlay_keys)\n if (!(key in safe_state) && key in prev) {\n (carried as Record<string, unknown>)[key] = (\n prev as Record<string, unknown>\n )[key];\n kept.push(key);\n }\n cached = tag_overlay_keys(carried, undefined, kept);\n }\n this.state_cache.set(streamId, cached);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to read-modify-write, so nothing is broadcast — and\n // because no frame is emitted there is nothing for a live client to\n // classify as `behind`, so it never refetches either. Unlike a domain\n // commit (which repopulates the cache via `publish`), an overlay-only\n // stream never recovers. The defaults make this reachable rather than\n // exotic: `cacheSize` is 50 while a host may hold far more live\n // subscriptions, and cache promotion happens at connect time, so a\n // busy-but-not-committing stream ages out while fully subscribed.\n //\n // Emit a resync frame so live subscribers refetch instead of silently\n // missing the update forever (#1423). The host hook still fires so a\n // host can count these — a steady stream of them means `cacheSize` is\n // too small for the working set.\n this.report_overlay_miss(streamId);\n // Still `undefined`: no overlay state was produced, and callers use the\n // return value as \"the patch I broadcast\", which a resync is not.\n return undefined;\n }\n\n // Normalize BEFORE applying to the cache, so a reconnecting client's\n // reseed and a live client's frame agree (#1472). `apply_patch` treats\n // `null` and `undefined` as the same delete signal, so normalizing first\n // does not change the delete semantics — it only fixes the encodings\n // that would not survive JSON.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(\n streamId,\n tag_overlay_keys(state, prev, Object.keys(safe_patch as object))\n );\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n private on_evict: (key: string, state: S) => void;\n\n /**\n * @param maxSize - Max entries before the LRU drops its oldest.\n * @param onEvict - Called with the entry the LRU just dropped. Eviction is\n * the moment cached-only data is lost, so it is the only place a caller\n * can still see what went (#1648).\n */\n constructor(\n maxSize = 50,\n onEvict: (key: string, state: S) => void = () => {}\n ) {\n this.maxSize = maxSize;\n this.on_evict = onEvict;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n const oldest = this.cache.keys().next().value!;\n const dropped = this.cache.get(oldest)!;\n this.cache.delete(oldest);\n this.on_evict(oldest, dropped);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["patch"]}
1
+ {"version":3,"sources":["../../src/sse/index.ts","../../src/sse/apply-patch.ts","../../src/sse/broadcast.ts","../../src/sse/state-cache.ts","../../src/sse/presence.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/sse\n *\n * Incremental state broadcast over SSE for act event-sourced apps.\n *\n * Server-side broadcast with domain patch forwarding, an LRU state cache,\n * presence tracking, and a client-side patch applicator with version\n * validation and resync detection.\n *\n * ## Architecture\n *\n * ```\n * app.do() → snapshots (each carries its domain patch)\n * │\n * ▼\n * deriveState(snap) ← app-specific (overlay presence, deadlines, etc.)\n * state._v = snap.event.version\n * │\n * ▼\n * broadcast.publish(streamId, state, patches)\n * │\n * ├── version-key each patch: { [baseV+1]: patch1, [baseV+2]: patch2 }\n * └── push to all SSE subscribers\n * │\n * ▼\n * Client: applyPatchMessage(msg, cached)\n * │\n * ├── contiguous → deep-merge patches in version order\n * ├── stale → skip (client already ahead)\n * └── behind → resync (client missed versions)\n * ```\n *\n * ## Version Contract\n *\n * `_v` is always the event store stream version (`snap.event.version`).\n * No separate version counters. The event store is the single source of truth.\n */\n\nexport { patch } from \"@rotorsoft/act-patch\";\nexport type { ApplyResult } from \"./apply-patch.js\";\nexport { applyPatchMessage } from \"./apply-patch.js\";\nexport { BroadcastChannel } from \"./broadcast.js\";\nexport { PresenceTracker } from \"./presence.js\";\nexport { StateCache } from \"./state-cache.js\";\nexport type { BroadcastState, PatchMessage, Subscriber } from \"./types.js\";\n","import { patch as deep_merge } from \"@rotorsoft/act-patch\";\nimport type { BroadcastState, PatchMessage } from \"./types.js\";\n\n/**\n * Result of applying a patch message to cached client state.\n */\nexport type ApplyResult<S extends BroadcastState = BroadcastState> =\n | { ok: true; state: S }\n | { ok: false; reason: \"stale\" | \"behind\" };\n\n/**\n * Apply a version-keyed patch message to the client's cached state.\n *\n * ## Version logic\n *\n * - All patches older than cached → \"stale\" (client already ahead)\n * - Gap between cached version and first patch → \"behind\" (client missed versions, must resync)\n * - Contiguous from cached version → apply in order\n * - No baseline (fresh client) → the genesis patch (version 0) folds onto init\n * state; a first patch at version ≥ 1 is \"behind\" (can't build from init)\n * - Overlay frame ({@link PatchMessage._overlay}) at the current version →\n * merged on top of cached state, `_v` unchanged (presence / computed\n * fields reach caught-up clients instead of being dropped as stale)\n *\n * ## Usage (React Query)\n *\n * ```typescript\n * onData: (msg) => {\n * const cached = utils.get_state.get_data({ streamId });\n * const result = applyPatchMessage(msg, cached);\n * if (result.ok) {\n * utils.get_state.setData({ streamId }, result.state);\n * } else if (result.reason === \"behind\") {\n * utils.get_state.invalidate({ streamId }); // trigger full refetch\n * }\n * // \"stale\" → no-op, client already has newer state\n * }\n * ```\n */\nexport function applyPatchMessage<S extends BroadcastState>(\n msg: PatchMessage<S>,\n cached: S | null | undefined\n): ApplyResult<S> {\n // Distinguish an *absent* baseline from a real version-0 baseline (#1346).\n // Act assigns the first event of any stream `version = 0`, so a fresh client\n // (no cache) must resume from a pre-baseline watermark of -1 — otherwise the\n // `?? 0` default collides with a genuine version-0 genesis patch and drops\n // it as \"stale\". A present baseline keeps its own `_v`.\n const hasBaseline = cached != null;\n const cachedV = cached?._v ?? -1;\n // `overlay` is a non-numeric marker key, not a version — exclude it.\n const versions = Object.keys(msg)\n .map(Number)\n .filter((v) => Number.isInteger(v))\n .sort((a, b) => a - b);\n\n // Server-forced resync: it could not construct a patch (e.g. `overlay()`\n // found the baseline evicted), so the client must refetch. Checked before\n // anything else — a resync frame carries no versions, and the empty-frame\n // branch below would otherwise report it as `stale`, which is the one\n // answer that does NOT refetch (#1423).\n if (msg._resync) return { ok: false, reason: \"behind\" };\n\n // An empty frame carries nothing to apply. For a client that already has a\n // baseline that is genuinely `stale` — a no-op, don't refetch. For one with\n // NO baseline there is nothing to be stale relative to, and `stale` is the\n // one answer that does not refetch, so it would strand the client (#1474).\n // Both the doc-comment below and `real-time.md` already say a fresh client\n // is never stale; this is the branch that made that false.\n if (!versions.length)\n return { ok: false, reason: hasBaseline ? \"stale\" : \"behind\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n // Overlay frame: a version-neutral update (presence, computed field) at the\n // current version. Handled EXHAUSTIVELY here — an overlay must never reach\n // the contiguous fold below, which would apply its payload as if it were\n // that version's domain patch. At exactly `cachedV + 1` that fold made the\n // client adopt presence data as version N, stamp itself caught up, and\n // never refetch — the real update for N lost permanently (#1419).\n if (msg._overlay) {\n // No baseline to merge onto: the client must fetch one.\n if (!cached) return { ok: false, reason: \"behind\" };\n // Caught up: merge on top, keeping _v (an overlay changes no version).\n if (maxV === cachedV)\n return {\n ok: true,\n state: { ...deep_merge(cached, msg[maxV]), _v: cachedV },\n };\n // Ahead by any amount: the client missed at least one domain patch.\n if (maxV > cachedV) return { ok: false, reason: \"behind\" };\n // Older than the baseline: genuinely stale.\n return { ok: false, reason: \"stale\" };\n }\n\n // A present baseline that already covers these versions is stale. A fresh\n // client (no baseline, cachedV = -1) can never be stale — its first\n // contiguous update is the genesis version 0.\n if (hasBaseline && maxV <= cachedV) return { ok: false, reason: \"stale\" };\n // A gap between the baseline (or the init floor -1) and the first patch means\n // missed versions — resync. For a fresh client this admits the genesis patch\n // (minV 0 === -1 + 1) but rejects a first patch at version >= 1, which cannot\n // be built from init state alone.\n if (minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n // No baseline → fold the genesis patch(es) onto init state ({}).\n let state = (cached ?? {}) as S;\n for (const v of versions) {\n if (v <= cachedV) continue;\n state = { ...deep_merge(state, msg[v]), _v: v } as S;\n }\n return { ok: true, state };\n}\n","import { patch as apply_patch } from \"@rotorsoft/act-patch\";\nimport { StateCache } from \"./state-cache.js\";\nimport {\n type BroadcastState,\n type PatchMessage,\n resync_frame,\n type Subscriber,\n} from \"./types.js\";\n\n/**\n * Server-side broadcast channel for incremental state sync over SSE.\n *\n * Manages per-stream subscriber sets and an LRU state cache. When state\n * changes, forwards domain patches (from event handlers) to all subscribers\n * as version-keyed messages.\n *\n * ## Usage\n *\n * ```typescript\n * const broadcast = new BroadcastChannel<MyState>();\n *\n * // After every app.do():\n * const snaps = await app.do(...);\n * const patches = snaps.map(s => s.patch).filter(Boolean);\n * const state = deriveState(snaps.at(-1));\n * broadcast.publish(streamId, state, patches);\n *\n * // In SSE subscription:\n * const cleanup = broadcast.subscribe(streamId, (msg) => {\n * pending = msg;\n * resolve?.();\n * });\n *\n * // Initial state for reconnects:\n * const cached = broadcast.state(streamId);\n * ```\n *\n * ## Version Contract\n *\n * The `_v` field on state MUST be set from `snap.event.version` (the event\n * store's monotonic stream version) BEFORE calling `publish()`. This is the\n * single source of truth for ordering — no separate version counters.\n */\n/**\n * Deliver a frame to every subscriber, containing each one individually.\n *\n * SSE subscribers are the least-trusted callbacks in the system — one per\n * connection, driven by network state. The unguarded loop this replaces let\n * the first thrower abort iteration, so every later subscriber lost the\n * frame and the exception escaped into the host's commit path (#1423). Same\n * containment principle the orchestrator applies to lifecycle listeners.\n * Guarding each callback rather than the loop is the point: one guard around\n * the whole loop would still let the first throw suppress the rest.\n *\n * @internal\n */\nfunction fan_out<S extends BroadcastState>(\n subs: Set<Subscriber<S>> | undefined,\n msg: PatchMessage<S>,\n on_error: (error: unknown) => void\n): void {\n if (!subs?.size) return;\n for (const cb of subs) {\n try {\n cb(msg);\n } catch (error) {\n on_error(error);\n }\n }\n}\n\n/**\n * Rewrite `undefined`-valued keys to `null`, recursively.\n *\n * `@rotorsoft/act-patch` treats `undefined` and `null` as the same delete\n * signal, but `JSON.stringify` drops `undefined`-valued keys entirely — and\n * every SSE transport serializes frames as JSON. A reducer that clears a\n * field the idiomatic way (`{ left: undefined }`) therefore produced a frame\n * with no mention of `left` at all, so a live client kept the stale value\n * while stamping the new version — believing itself caught up, and never\n * refetching (#1471).\n *\n * `null` reaches the same `delete` branch in the patch applicator and\n * survives JSON, so normalizing here makes the two spellings equivalent on\n * the wire as they already are in memory.\n *\n * One walker serves both destinations. The frame and the cached state\n * agree on every encoding except the delete: see `wire_safe` /\n * `cache_safe` below.\n */\nconst normalize = <T>(value: T, nulls: boolean): T => {\n if (value === null || typeof value !== \"object\") return value;\n if (Array.isArray(value)) return value.map((v) => normalize(v, nulls)) as T;\n // A Set has exactly one sensible JSON encoding, and `JSON.stringify` gives\n // it the wrong one: `{}`. The framework's own `PresenceTracker.online()`\n // returns a Set and the presence recipe feeds it straight to `overlay()`,\n // so the documented way to broadcast presence shipped an empty object to\n // every client — and then froze, because a client holding `{}` treats\n // every later empty patch as a no-op (#1472).\n if (value instanceof Set)\n return [...value].map((v) => normalize(v, nulls)) as T;\n // Everything else non-plain (Date, Map, class instances) is left to\n // whatever the host's serializer already does with it. `Date` has a\n // defined encoding; `Map` does not have an unambiguous one (entries or\n // object?), so guessing would trade a visible bug for a silent choice.\n const proto = Object.getPrototypeOf(value);\n if (proto !== Object.prototype && proto !== null) return value;\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(value as Record<string, unknown>))\n out[k] = v === undefined && nulls ? null : normalize(v, nulls);\n return out as T;\n};\n\n/**\n * Frame-bound normalization: Sets become arrays, and `undefined` becomes\n * `null` so a delete survives `JSON.stringify` (#1471).\n */\nconst wire_safe = <T>(patch: T): T => normalize(patch, true);\n\n/**\n * Cache-bound normalization: Sets become arrays, `undefined` is left alone.\n *\n * The cached state is what a reconnecting client reseeds from, and #1471\n * settled that it must keep a cleared key *absent* rather than `null`, so\n * the wire's delete encoding is exactly the part the cache must not adopt.\n * That single difference is why `nulls` is a parameter instead of two\n * separate walkers — a second copy would drift the moment either side grew\n * a case.\n *\n * Non-enumerable properties are invisible to `Object.entries`, so the\n * `OVERLAY_KEYS` marker is neither copied nor clobbered here; callers tag\n * after normalizing.\n */\nconst cache_safe = <T>(state: T): T => normalize(state, false);\n\n/**\n * Keys an `overlay()` contributed to a stream's cached state, carried on the\n * cached object itself.\n *\n * `publish()` replaces the cache entry with host-derived state, so overlay\n * data — presence, computed fields — vanished from the cache while live\n * clients, which had already applied the overlay frame, kept it. A\n * reconnecting client then reseeded WITHOUT it and had no way to notice: its\n * `_v` matches the server's, so nothing classifies as `behind` (#1473).\n *\n * A symbol keeps this out of `Object.keys`, `JSON.stringify` and therefore\n * off the wire; living on the cached object means it is evicted with the\n * entry rather than needing a parallel structure to prune.\n */\nconst OVERLAY_KEYS = Symbol(\"act.sse.overlay_keys\");\n\ntype WithOverlayKeys = { [OVERLAY_KEYS]?: ReadonlySet<string> };\n\n/**\n * Default `onSubscriberError`. This module shares the `sse` subpath with\n * `applyPatchMessage`, which browser code imports, so it never imports the\n * framework: a bundler would follow that into a browser build. Pass\n * `onSubscriberError` to send these to your logger.\n */\nconst default_subscriber_error = (error: unknown, streamId: string): void => {\n // biome-ignore lint/suspicious/noConsole: the default can't reach the framework logger (see above)\n console.error(`sse subscriber threw for \"${streamId}\"`, error);\n};\n\n/** Record which keys an overlay owns, accumulating across overlays. */\nconst tag_overlay_keys = <S extends object>(\n state: S,\n prev: S | undefined,\n keys: readonly string[]\n): S => {\n const carried = (prev as WithOverlayKeys | undefined)?.[OVERLAY_KEYS];\n Object.defineProperty(state, OVERLAY_KEYS, {\n value: new Set([...(carried ?? []), ...keys]),\n enumerable: false,\n configurable: true,\n });\n return state;\n};\n\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n /**\n * @param options.cacheSize - Max number of stream states kept in the LRU\n * cache (default 50).\n */\n private on_subscriber_error: (error: unknown, streamId: string) => void;\n private on_overlay_miss: (streamId: string) => void;\n\n constructor(options?: {\n cacheSize?: number;\n /**\n * Called when overlay-contributed state is lost, on either of the two\n * paths that can lose it: `overlay()` finding no cached baseline, and\n * the LRU evicting an entry that carried overlay keys — the latter from\n * inside `publish()`, for a stream other than the one being published.\n *\n * Live subscribers of the affected stream are sent a resync frame, so\n * they refetch rather than keep showing state the server has forgotten.\n * The hook is observability on top of that recovery: a steady stream of\n * calls means `cacheSize` is too small for the working set.\n *\n * A throw is caught and routed to `onSubscriberError` — the resync has\n * already gone out, and a host bug must not cost the publishing stream\n * its own frame. Defaults to a no-op.\n */\n onOverlayMiss?: (streamId: string) => void;\n /**\n * Called when a subscriber callback throws. The frame is still delivered\n * to every other subscriber and the publish still returns normally — a\n * bad consumer must not break the publisher. Defaults to `console.error`;\n * pass your logger here to route it with the rest of your logs.\n */\n onSubscriberError?: (error: unknown, streamId: string) => void;\n /**\n * Deprecated alias of `cacheSize` — removal in the next major. When\n * both are given, `cacheSize` wins.\n * @deprecated use `cacheSize`\n */\n cache_size?: number;\n }) {\n this.state_cache = new StateCache<S>(\n options?.cacheSize ?? options?.cache_size ?? 50,\n (streamId, dropped) => this.on_cache_evict(streamId, dropped)\n );\n this.on_overlay_miss = options?.onOverlayMiss ?? (() => {});\n this.on_subscriber_error =\n options?.onSubscriberError ?? default_subscriber_error;\n }\n\n /**\n * Fan a resync out to a stream's live subscribers. `applyPatchMessage`\n * always reports `behind` for it, so they refetch.\n */\n private broadcast_resync(streamId: string): void {\n fan_out(this.channels.get(streamId), resync_frame<S>(), (error) =>\n this.on_subscriber_error(error, streamId)\n );\n }\n\n /**\n * The LRU dropped an entry. If it carried overlay-contributed keys, that\n * data existed only in the cache and is now gone: a later `publish()` has\n * no baseline to carry it from, and the reseed a reconnecting client gets\n * would silently lack presence a live client is still showing (#1648).\n *\n * `overlay()` was hardened for the same loss on its own path (#1423);\n * catching it here covers `publish()` too, and does it at the moment the\n * data is lost rather than at a later commit that may never come. Reading\n * the marker off the entry being evicted is what avoids the parallel\n * bookkeeping structure the `OVERLAY_KEYS` design explicitly rejected.\n */\n private on_cache_evict(streamId: string, dropped: S): void {\n if (!(dropped as S & WithOverlayKeys)[OVERLAY_KEYS]?.size) return;\n this.report_overlay_miss(streamId);\n }\n\n /**\n * Overlay-contributed state for `streamId` is gone. Recover first, report\n * second.\n *\n * The resync goes out before the host hook runs, and the hook is contained\n * the way `fan_out` contains subscribers: a bad consumer must not break the\n * publisher (#1423), and the same applies to a bad observer. Without this,\n * a throwing `onOverlayMiss` escapes on the eviction path — which runs\n * inside `publish()`, before that stream's own `fan_out` — so one host bug\n * costs the evicted stream its recovery frame *and* an unrelated stream the\n * frame for a version already written to the cache. The evicted stream's\n * clients never self-heal from that, which is the silent loss #1648 exists\n * to end.\n *\n * Routing the throw to `onSubscriberError` rather than swallowing it keeps\n * it wherever the host already reads framework delivery failures.\n */\n private report_overlay_miss(streamId: string): void {\n this.broadcast_resync(streamId);\n try {\n this.on_overlay_miss(streamId);\n } catch (error) {\n this.on_subscriber_error(error, streamId);\n }\n }\n\n /**\n * Publish domain patches from a commit.\n * patches[i] corresponds to version baseV + i + 1.\n *\n * @param streamId - The event store stream ID\n * @param state - Full state with `_v` set from `snap.event.version`\n * @param patches - Array of domain patches, one per emitted event\n */\n publish(\n streamId: string,\n state: S,\n patches: Partial<S>[] = []\n ): PatchMessage<S> {\n // Carry overlay-contributed keys across the commit (#1473). Only keys\n // an `overlay()` actually owns, and only when the new domain state does\n // not speak to them — so a publisher that drops or overwrites a key\n // still wins, and presence survives a commit as the docs imply.\n // Normalize once, up front: the cache entry and the frame are both\n // derived from this, so the reseed a reconnecting client gets cannot\n // disagree with what a live client applied (#1646). `overlay()` already\n // normalized before its own cache write; `publish()` did not, so a Set\n // shipped as an array live and as `{}` on reseed — and then froze.\n const safe_state = cache_safe(state);\n const prev = this.state_cache.get(streamId) as\n | (S & WithOverlayKeys)\n | undefined;\n const overlay_keys = prev?.[OVERLAY_KEYS];\n let cached = safe_state;\n if (overlay_keys?.size && prev) {\n const carried = { ...safe_state } as S;\n const kept: string[] = [];\n for (const key of overlay_keys)\n if (!(key in safe_state) && key in prev) {\n (carried as Record<string, unknown>)[key] = (\n prev as Record<string, unknown>\n )[key];\n kept.push(key);\n }\n cached = tag_overlay_keys(carried, undefined, kept);\n }\n this.state_cache.set(streamId, cached);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = wire_safe(p);\n });\n\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Publish a state update that doesn't change the event version\n * (e.g. presence overlay, computed field refresh).\n * Uses the same version as the cached state, single entry.\n */\n overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) {\n // No baseline to read-modify-write, so nothing is broadcast — and\n // because no frame is emitted there is nothing for a live client to\n // classify as `behind`, so it never refetches either. Unlike a domain\n // commit (which repopulates the cache via `publish`), an overlay-only\n // stream never recovers. The defaults make this reachable rather than\n // exotic: `cacheSize` is 50 while a host may hold far more live\n // subscriptions, and cache promotion happens at connect time, so a\n // busy-but-not-committing stream ages out while fully subscribed.\n //\n // Emit a resync frame so live subscribers refetch instead of silently\n // missing the update forever (#1423). The host hook still fires so a\n // host can count these — a steady stream of them means `cacheSize` is\n // too small for the working set.\n this.report_overlay_miss(streamId);\n // Still `undefined`: no overlay state was produced, and callers use the\n // return value as \"the patch I broadcast\", which a resync is not.\n return undefined;\n }\n\n // Normalize BEFORE applying to the cache, so a reconnecting client's\n // reseed and a live client's frame agree (#1472). `apply_patch` treats\n // `null` and `undefined` as the same delete signal, so normalizing first\n // does not change the delete semantics — it only fixes the encodings\n // that would not survive JSON.\n const safe_patch = wire_safe(overlay_patch);\n const state = apply_patch(prev, safe_patch) as S;\n this.state_cache.set(\n streamId,\n tag_overlay_keys(state, prev, Object.keys(safe_patch as object))\n );\n\n // `_overlay: true` marks this as a version-neutral update so a caught-up\n // client applies it at the current version instead of dropping it as\n // stale (the key equals the client's cachedV). Ordinary patches omit it.\n const msg: PatchMessage<S> = {\n [state._v]: safe_patch,\n _overlay: true,\n };\n fan_out(this.channels.get(streamId), msg, (error) =>\n this.on_subscriber_error(error, streamId)\n );\n return msg;\n }\n\n /**\n * Subscribe to broadcast messages for a stream.\n * Returns a cleanup function that removes the subscription.\n */\n subscribe(streamId: string, cb: Subscriber<S>): () => void {\n if (!this.channels.has(streamId)) this.channels.set(streamId, new Set());\n this.channels.get(streamId)!.add(cb);\n return () => {\n this.channels.get(streamId)?.delete(cb);\n if (this.channels.get(streamId)?.size === 0) {\n this.channels.delete(streamId);\n }\n };\n }\n\n /** Get the number of subscribers for a stream. */\n subscriberCount(streamId: string): number {\n return this.channels.get(streamId)?.size ?? 0;\n }\n\n /** Get the cached state for a stream (for reconnects / initial SSE yield). */\n state(streamId: string): S | undefined {\n return this.state_cache.get(streamId);\n }\n\n /** @deprecated use `overlay` — removal in the next major */\n publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n return this.overlay(streamId, overlay_patch);\n }\n\n /** @deprecated use `subscriberCount` — removal in the next major */\n get_subscriber_count(streamId: string): number {\n return this.subscriberCount(streamId);\n }\n\n /** @deprecated use `state` — removal in the next major */\n get_state(streamId: string): S | undefined {\n return this.state(streamId);\n }\n\n /** Direct access to the state cache (for app-specific reads like presence). */\n get cache(): StateCache<S> {\n return this.state_cache;\n }\n}\n","import type { BroadcastState } from \"./types.js\";\n\n/**\n * Generic LRU cache for aggregate state objects.\n *\n * Keyed by stream ID. Each entry stores the full state (with `_v` from the\n * event store). Used as the \"previous state\" baseline for computing patches,\n * and as the fast path for reconnects.\n *\n * The cache is shared between the broadcast hot path and read queries.\n * Projections should maintain their own cache to avoid double-apply bugs.\n */\nexport class StateCache<S extends BroadcastState = BroadcastState> {\n private cache = new Map<string, S>();\n private maxSize: number;\n private on_evict: (key: string, state: S) => void;\n\n /**\n * @param maxSize - Max entries before the LRU drops its oldest.\n * @param onEvict - Called with the entry the LRU just dropped. Eviction is\n * the moment cached-only data is lost, so it is the only place a caller\n * can still see what went (#1648).\n */\n constructor(\n maxSize = 50,\n onEvict: (key: string, state: S) => void = () => {}\n ) {\n this.maxSize = maxSize;\n this.on_evict = onEvict;\n }\n\n /** Get a cached state, promoting it to MRU position. */\n get(key: string): S | undefined {\n const s = this.cache.get(key);\n if (s) {\n this.cache.delete(key);\n this.cache.set(key, s);\n }\n return s;\n }\n\n /** Set a cached state, evicting the LRU entry if at capacity. */\n set(key: string, state: S): void {\n this.cache.delete(key);\n this.cache.set(key, state);\n if (this.cache.size > this.maxSize) {\n const oldest = this.cache.keys().next().value!;\n const dropped = this.cache.get(oldest)!;\n this.cache.delete(oldest);\n this.on_evict(oldest, dropped);\n }\n }\n\n /** Remove a cached entry. */\n delete(key: string): void {\n this.cache.delete(key);\n }\n\n /** Check if a key exists in the cache. */\n has(key: string): boolean {\n return this.cache.has(key);\n }\n\n /** Current number of cached entries. */\n get size(): number {\n return this.cache.size;\n }\n\n /** Direct access to the underlying map (for iteration). */\n entries(): IterableIterator<[string, S]> {\n return this.cache.entries();\n }\n}\n","/**\n * Generic presence tracker — ref-counted online status per stream per identity.\n *\n * Supports multi-tab: each subscribe increments the ref count, each\n * unsubscribe decrements it. An identity is considered online when\n * ref count > 0.\n *\n * ## Usage\n *\n * ```typescript\n * const presence = new PresenceTracker();\n *\n * // On SSE connect:\n * presence.add(game_id, player_id);\n *\n * // On SSE disconnect:\n * presence.remove(game_id, player_id);\n *\n * // Query:\n * presence.online(game_id); // Set<string>\n * ```\n */\nexport class PresenceTracker {\n private streams = new Map<string, Map<string, number>>();\n\n /** Increment ref count for an identity on a stream. */\n add(streamId: string, identity_id: string): void {\n if (!this.streams.has(streamId)) this.streams.set(streamId, new Map());\n const counts = this.streams.get(streamId)!;\n counts.set(identity_id, (counts.get(identity_id) ?? 0) + 1);\n }\n\n /** Decrement ref count. Removes the identity when count reaches 0. */\n remove(streamId: string, identity_id: string): void {\n const counts = this.streams.get(streamId);\n if (!counts) return;\n const n = (counts.get(identity_id) ?? 1) - 1;\n if (n <= 0) counts.delete(identity_id);\n else counts.set(identity_id, n);\n if (counts.size === 0) this.streams.delete(streamId);\n }\n\n /** Get the set of online identity IDs for a stream. */\n online(streamId: string): Set<string> {\n const counts = this.streams.get(streamId);\n return counts ? new Set(counts.keys()) : new Set();\n }\n\n /** Check if a specific identity is online for a stream. */\n isOnline(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n\n /** @deprecated use `online` — removal in the next major */\n get_online(streamId: string): Set<string> {\n return this.online(streamId);\n }\n\n /** @deprecated use `isOnline` — removal in the next major */\n is_online(streamId: string, identity_id: string): boolean {\n return this.isOnline(streamId, identity_id);\n }\n}\n"],"mappings":"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","names":["patch"]}