@rotorsoft/act-http 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  checkWebhook
3
3
  } from "../../chunk-4CGAUB5H.js";
4
+ import "../../chunk-PZ5AY32C.js";
4
5
 
5
6
  // src/receiver/express/index.ts
6
7
  function webhookMiddleware(options) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/express/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped }` and calls `next()`.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n (\n req as Request & { idempotency: { key: string; deduped: boolean } }\n ).idempotency = {\n key: result.key,\n deduped: result.deduped,\n };\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n"],"mappings":";;;;;AAkDO,SAAS,kBACd,SACgB;AAChB,SAAO,eAAe,MACpB,KACA,KACA,MACe;AACf,UAAM,UAAU,iBAAiB,IAAI,IAAI;AACzC,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,UAAI,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AACvD;AAAA,IACF;AACA,IACE,IACA,cAAc;AAAA,MACd,KAAK,OAAO;AAAA,MACZ,SAAS,OAAO;AAAA,IAClB;AACA,SAAK;AAAA,EACP;AACF;AAEA,SAAS,iBAAiB,MAAuB;AAC/C,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,MAAI,gBAAgB,WAAY,QAAO,OAAO,KAAK,IAAI,EAAE,SAAS,MAAM;AACxE,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/express/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/express\n *\n * Express adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import express from \"express\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/express\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = express();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * // Raw body capture required when signing is enabled.\n * app.use(express.raw({ type: \"application/json\" }));\n *\n * app.post(\n * \"/webhooks/orders\",\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET }),\n * (req, res) => {\n * const { key, deduped } = (req as any).idempotency;\n * if (deduped) return res.json({ status: \"dedup-skipped\", key });\n * // ... process the inbound event ...\n * res.json({ status: \"processed\", key });\n * }\n * );\n * ```\n *\n * On failure: responds with the framework-idiomatic JSON shape\n * `{ error: <reason> }` at status 400 (missing-key) or 401\n * (verification failures), and does not call `next()`. On success:\n * attaches `req.idempotency = { key, deduped }` and calls `next()`.\n *\n * **Raw body requirement**: when `secret` is configured, mount\n * `express.raw({ type: \"application/json\" })` (or whatever\n * content-type your webhooks use) ahead of the receiver middleware.\n * The middleware reads `req.body` as a `Buffer | string` and converts\n * to a UTF-8 string for hashing. Skip when unsigned.\n */\nimport type { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\n/**\n * Build an Express middleware that verifies the request signature\n * (when `secret` is set), enforces `Idempotency-Key`, and claims the\n * key on the configured store. See the module-level docs for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): RequestHandler {\n return async function check(\n req: Request,\n res: Response,\n next: NextFunction\n ): Promise<void> {\n const rawBody = buffer_or_string(req.body);\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n res.status(result.status).json({ error: result.reason });\n return;\n }\n (\n req as Request & { idempotency: { key: string; deduped: boolean } }\n ).idempotency = {\n key: result.key,\n deduped: result.deduped,\n };\n next();\n };\n}\n\nfunction buffer_or_string(body: unknown): string {\n if (typeof body === \"string\") return body;\n if (body instanceof Uint8Array) return Buffer.from(body).toString(\"utf8\");\n return \"\";\n}\n"],"mappings":";;;;;;AAkDO,SAAS,kBACd,SACgB;AAChB,SAAO,eAAe,MACpB,KACA,KACA,MACe;AACf,UAAM,UAAU,iBAAiB,IAAI,IAAI;AACzC,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,UAAI,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AACvD;AAAA,IACF;AACA,IACE,IACA,cAAc;AAAA,MACd,KAAK,OAAO;AAAA,MACZ,SAAS,OAAO;AAAA,IAClB;AACA,SAAK;AAAA,EACP;AACF;AAEA,SAAS,iBAAiB,MAAuB;AAC/C,MAAI,OAAO,SAAS,SAAU,QAAO;AACrC,MAAI,gBAAgB,WAAY,QAAO,OAAO,KAAK,IAAI,EAAE,SAAS,MAAM;AACxE,SAAO;AACT;","names":[]}
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  checkWebhook
3
3
  } from "../../chunk-4CGAUB5H.js";
4
+ import "../../chunk-PZ5AY32C.js";
4
5
 
5
6
  // src/receiver/fastify/index.ts
6
7
  function webhookMiddleware(options) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/fastify/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped }` and lets the route handler\n * run.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: { key: string; deduped: boolean };\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n req.idempotency = { key: result.key, deduped: result.deduped };\n };\n}\n"],"mappings":";;;;;AA2DO,SAAS,kBACd,SACiE;AACjE,SAAO,eAAe,MACpB,SACA,OACe;AACf,UAAM,MAAM;AACZ,UAAM,UAAU,IAAI,WAAW;AAC/B,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,MAAM,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AAC/D;AAAA,IACF;AACA,QAAI,cAAc,EAAE,KAAK,OAAO,KAAK,SAAS,OAAO,QAAQ;AAAA,EAC/D;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/fastify/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/fastify\n *\n * Fastify adapter for the receiver-side webhook check.\n *\n * Usage:\n *\n * ```ts\n * import Fastify from \"fastify\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/fastify\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * const app = Fastify();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * app.post(\n * \"/webhooks/orders\",\n * {\n * preHandler: webhookMiddleware({\n * store: dedup,\n * secret: process.env.WEBHOOK_SECRET,\n * }),\n * },\n * async (request, reply) => {\n * const { key, deduped } = (request as any).idempotency;\n * if (deduped) return { status: \"dedup-skipped\", key };\n * // ... process the inbound event ...\n * return { status: \"processed\", key };\n * }\n * );\n * ```\n *\n * On failure: replies with `{ error: <reason> }` at status 400\n * (missing-key) or 401 (verification failures). On success: attaches\n * `request.idempotency = { key, deduped }` and lets the route handler\n * run.\n *\n * **Raw body requirement**: when `secret` is configured, register a\n * content-type parser that preserves the raw body string. Fastify's\n * default JSON parser eats the bytes — register a custom parser via\n * `app.addContentTypeParser(\"application/json\", { parseAs: \"string\" }, …)`\n * and stash the string on `request.rawBody` (Fastify pattern). The\n * middleware reads `request.rawBody` for hashing. Skip when unsigned.\n */\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\ntype WebhookRequest = FastifyRequest & {\n rawBody?: string;\n idempotency?: { key: string; deduped: boolean };\n};\n\n/**\n * Build a Fastify `preHandler` hook that verifies the request\n * signature (when `secret` is set), enforces `Idempotency-Key`, and\n * claims the key on the configured store. See the module-level docs\n * for usage.\n */\nexport function webhookMiddleware(\n options: CheckWebhookOptions\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async function check(\n request: FastifyRequest,\n reply: FastifyReply\n ): Promise<void> {\n const req = request as WebhookRequest;\n const rawBody = req.rawBody ?? \"\";\n const result = await checkWebhook(\n req.headers as Record<string, string | string[] | undefined>,\n rawBody,\n options\n );\n if (!result.ok) {\n await reply.status(result.status).send({ error: result.reason });\n return;\n }\n req.idempotency = { key: result.key, deduped: result.deduped };\n };\n}\n"],"mappings":";;;;;;AA2DO,SAAS,kBACd,SACiE;AACjE,SAAO,eAAe,MACpB,SACA,OACe;AACf,UAAM,MAAM;AACZ,UAAM,UAAU,IAAI,WAAW;AAC/B,UAAM,SAAS,MAAM;AAAA,MACnB,IAAI;AAAA,MACJ;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,MAAM,OAAO,OAAO,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,OAAO,CAAC;AAC/D;AAAA,IACF;AACA,QAAI,cAAc,EAAE,KAAK,OAAO,KAAK,SAAS,OAAO,QAAQ;AAAA,EAC/D;AACF;","names":[]}
@@ -2,6 +2,7 @@ import {
2
2
  webhookMiddleware
3
3
  } from "../../chunk-K4HAOBRF.js";
4
4
  import "../../chunk-4CGAUB5H.js";
5
+ import "../../chunk-PZ5AY32C.js";
5
6
  export {
6
7
  webhookMiddleware
7
8
  };
@@ -6,6 +6,7 @@ import {
6
6
  extractIdempotencyKey,
7
7
  verifyWebhook
8
8
  } from "../chunk-4CGAUB5H.js";
9
+ import "../chunk-PZ5AY32C.js";
9
10
 
10
11
  // src/receiver/start.ts
11
12
  import { Hono } from "hono";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/receiver/start.ts"],"sourcesContent":["import type {\n Receiver,\n ReceiverBuilder,\n ReceiverContext,\n ReceiverOptions,\n Validator,\n} from \"@rotorsoft/act-ops/receiver\";\nimport { Hono } from \"hono\";\nimport { webhookMiddleware } from \"./hono/index.js\";\n\n/**\n * Recommended factory for \"I want to receive webhooks.\" Returns a\n * {@link ReceiverBuilder} the operator configures fluently:\n *\n * ```ts\n * import { receiver } from \"@rotorsoft/act-http/receiver\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n * import { z } from \"zod\";\n *\n * const r = receiver({\n * port: 4001,\n * store: new InMemoryIdempotencyStore(),\n * secret: process.env.WEBHOOK_SECRET,\n * })\n * .on(\"OrderConfirmed\", z.object({\n * orderId: z.string(),\n * total: z.number(),\n * }), async (event, ctx) => {\n * // event.orderId and event.total are typed\n * // ctx.key is the deduplicated Idempotency-Key\n * await process_order(event.orderId, event.total);\n * })\n * .build();\n *\n * await r.listen();\n * ```\n *\n * Matches Act's builder pattern: `receiver(...)` is the factory,\n * `.on()` registers handlers fluently, `.build()` finalizes and\n * produces an immutable {@link Receiver} — at which point the type\n * loses `.on()` and gains the runtime methods (`listen` / `close` /\n * `fetch`). The lifecycle phases are split at the type level.\n *\n * Internally uses Hono for routing — the universal-runtime choice\n * that gives one code path coverage across Node, AWS Lambda,\n * Cloudflare Workers, Vercel Edge, Bun, and Deno. For operators\n * with an existing tRPC / Express / Fastify / Hono app who need to\n * compose the receiver with their own middleware stack, the\n * lower-level `webhookMiddleware` from\n * `@rotorsoft/act-http/receiver/<framework>` is the escape hatch.\n *\n * `@hono/node-server` is imported lazily inside `.listen()` so\n * Lambda / edge consumers (who never call `.listen()`) don't need\n * it installed.\n */\nexport function receiver(options: ReceiverOptions): ReceiverBuilder {\n const app = new Hono<{\n Variables: { idempotency: { key: string; deduped: boolean } };\n }>();\n\n const middleware = webhookMiddleware({\n store: options.store,\n secret: options.secret,\n });\n\n let built = false;\n\n const builder: ReceiverBuilder = {\n on<T>(\n name: string,\n schema: Validator<T>,\n handler: (event: T, ctx: ReceiverContext) => Promise<void>\n ): ReceiverBuilder {\n if (built) {\n throw new Error(\n `Cannot register handler \"${name}\" after .build() — handlers are frozen once the receiver is built.`\n );\n }\n\n app.post(`/${name}`, middleware, async (c) => {\n let validated: T;\n try {\n const body = await c.req.json();\n validated = schema.parse(body);\n } catch (err) {\n return c.json(\n {\n error: \"validation-failed\",\n detail: (err as Error).message,\n },\n 422\n );\n }\n\n const idem = c.get(\"idempotency\");\n if (!idem.deduped) {\n try {\n await handler(validated, { key: idem.key });\n } catch (err) {\n return c.json(\n {\n error: \"handler-failed\",\n detail: (err as Error).message,\n },\n 500\n );\n }\n }\n\n return c.body(null, 204);\n });\n\n return builder;\n },\n\n build(): Receiver {\n built = true;\n\n // biome-ignore lint/suspicious/noExplicitAny: server lifecycle handle from @hono/node-server\n let server: any | undefined;\n\n return {\n async listen(): Promise<void> {\n const { serve } = await import(\"@hono/node-server\");\n const launched = serve({ fetch: app.fetch, port: options.port });\n server = launched;\n await new Promise<void>((resolve) => {\n launched.once(\"listening\", () => resolve());\n });\n },\n\n async close(): Promise<void> {\n if (!server) return;\n const s = server;\n server = undefined;\n await new Promise<void>((resolve) => s.close(() => resolve()));\n },\n\n async fetch(request: Request): Promise<Response> {\n return app.fetch(request);\n },\n };\n },\n };\n\n return builder;\n}\n"],"mappings":";;;;;;;;;;AAOA,SAAS,YAAY;AAgDd,SAAS,SAAS,SAA2C;AAClE,QAAM,MAAM,IAAI,KAEb;AAEH,QAAM,aAAa,kBAAkB;AAAA,IACnC,OAAO,QAAQ;AAAA,IACf,QAAQ,QAAQ;AAAA,EAClB,CAAC;AAED,MAAI,QAAQ;AAEZ,QAAM,UAA2B;AAAA,IAC/B,GACE,MACA,QACA,SACiB;AACjB,UAAI,OAAO;AACT,cAAM,IAAI;AAAA,UACR,4BAA4B,IAAI;AAAA,QAClC;AAAA,MACF;AAEA,UAAI,KAAK,IAAI,IAAI,IAAI,YAAY,OAAO,MAAM;AAC5C,YAAI;AACJ,YAAI;AACF,gBAAM,OAAO,MAAM,EAAE,IAAI,KAAK;AAC9B,sBAAY,OAAO,MAAM,IAAI;AAAA,QAC/B,SAAS,KAAK;AACZ,iBAAO,EAAE;AAAA,YACP;AAAA,cACE,OAAO;AAAA,cACP,QAAS,IAAc;AAAA,YACzB;AAAA,YACA;AAAA,UACF;AAAA,QACF;AAEA,cAAM,OAAO,EAAE,IAAI,aAAa;AAChC,YAAI,CAAC,KAAK,SAAS;AACjB,cAAI;AACF,kBAAM,QAAQ,WAAW,EAAE,KAAK,KAAK,IAAI,CAAC;AAAA,UAC5C,SAAS,KAAK;AACZ,mBAAO,EAAE;AAAA,cACP;AAAA,gBACE,OAAO;AAAA,gBACP,QAAS,IAAc;AAAA,cACzB;AAAA,cACA;AAAA,YACF;AAAA,UACF;AAAA,QACF;AAEA,eAAO,EAAE,KAAK,MAAM,GAAG;AAAA,MACzB,CAAC;AAED,aAAO;AAAA,IACT;AAAA,IAEA,QAAkB;AAChB,cAAQ;AAGR,UAAI;AAEJ,aAAO;AAAA,QACL,MAAM,SAAwB;AAC5B,gBAAM,EAAE,MAAM,IAAI,MAAM,OAAO,mBAAmB;AAClD,gBAAM,WAAW,MAAM,EAAE,OAAO,IAAI,OAAO,MAAM,QAAQ,KAAK,CAAC;AAC/D,mBAAS;AACT,gBAAM,IAAI,QAAc,CAAC,YAAY;AACnC,qBAAS,KAAK,aAAa,MAAM,QAAQ,CAAC;AAAA,UAC5C,CAAC;AAAA,QACH;AAAA,QAEA,MAAM,QAAuB;AAC3B,cAAI,CAAC,OAAQ;AACb,gBAAM,IAAI;AACV,mBAAS;AACT,gBAAM,IAAI,QAAc,CAAC,YAAY,EAAE,MAAM,MAAM,QAAQ,CAAC,CAAC;AAAA,QAC/D;AAAA,QAEA,MAAM,MAAM,SAAqC;AAC/C,iBAAO,IAAI,MAAM,OAAO;AAAA,QAC1B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/receiver/start.ts"],"sourcesContent":["import type {\n Receiver,\n ReceiverBuilder,\n ReceiverContext,\n ReceiverOptions,\n Validator,\n} from \"@rotorsoft/act-ops/receiver\";\nimport { Hono } from \"hono\";\nimport { webhookMiddleware } from \"./hono/index.js\";\n\n/**\n * Recommended factory for \"I want to receive webhooks.\" Returns a\n * {@link ReceiverBuilder} the operator configures fluently:\n *\n * ```ts\n * import { receiver } from \"@rotorsoft/act-http/receiver\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n * import { z } from \"zod\";\n *\n * const r = receiver({\n * port: 4001,\n * store: new InMemoryIdempotencyStore(),\n * secret: process.env.WEBHOOK_SECRET,\n * })\n * .on(\"OrderConfirmed\", z.object({\n * orderId: z.string(),\n * total: z.number(),\n * }), async (event, ctx) => {\n * // event.orderId and event.total are typed\n * // ctx.key is the deduplicated Idempotency-Key\n * await process_order(event.orderId, event.total);\n * })\n * .build();\n *\n * await r.listen();\n * ```\n *\n * Matches Act's builder pattern: `receiver(...)` is the factory,\n * `.on()` registers handlers fluently, `.build()` finalizes and\n * produces an immutable {@link Receiver} — at which point the type\n * loses `.on()` and gains the runtime methods (`listen` / `close` /\n * `fetch`). The lifecycle phases are split at the type level.\n *\n * Internally uses Hono for routing — the universal-runtime choice\n * that gives one code path coverage across Node, AWS Lambda,\n * Cloudflare Workers, Vercel Edge, Bun, and Deno. For operators\n * with an existing tRPC / Express / Fastify / Hono app who need to\n * compose the receiver with their own middleware stack, the\n * lower-level `webhookMiddleware` from\n * `@rotorsoft/act-http/receiver/<framework>` is the escape hatch.\n *\n * `@hono/node-server` is imported lazily inside `.listen()` so\n * Lambda / edge consumers (who never call `.listen()`) don't need\n * it installed.\n */\nexport function receiver(options: ReceiverOptions): ReceiverBuilder {\n const app = new Hono<{\n Variables: { idempotency: { key: string; deduped: boolean } };\n }>();\n\n const middleware = webhookMiddleware({\n store: options.store,\n secret: options.secret,\n });\n\n let built = false;\n\n const builder: ReceiverBuilder = {\n on<T>(\n name: string,\n schema: Validator<T>,\n handler: (event: T, ctx: ReceiverContext) => Promise<void>\n ): ReceiverBuilder {\n if (built) {\n throw new Error(\n `Cannot register handler \"${name}\" after .build() — handlers are frozen once the receiver is built.`\n );\n }\n\n app.post(`/${name}`, middleware, async (c) => {\n let validated: T;\n try {\n const body = await c.req.json();\n validated = schema.parse(body);\n } catch (err) {\n return c.json(\n {\n error: \"validation-failed\",\n detail: (err as Error).message,\n },\n 422\n );\n }\n\n const idem = c.get(\"idempotency\");\n if (!idem.deduped) {\n try {\n await handler(validated, { key: idem.key });\n } catch (err) {\n return c.json(\n {\n error: \"handler-failed\",\n detail: (err as Error).message,\n },\n 500\n );\n }\n }\n\n return c.body(null, 204);\n });\n\n return builder;\n },\n\n build(): Receiver {\n built = true;\n\n // biome-ignore lint/suspicious/noExplicitAny: server lifecycle handle from @hono/node-server\n let server: any | undefined;\n\n return {\n async listen(): Promise<void> {\n const { serve } = await import(\"@hono/node-server\");\n const launched = serve({ fetch: app.fetch, port: options.port });\n server = launched;\n await new Promise<void>((resolve) => {\n launched.once(\"listening\", () => resolve());\n });\n },\n\n async close(): Promise<void> {\n if (!server) return;\n const s = server;\n server = undefined;\n await new Promise<void>((resolve) => s.close(() => resolve()));\n },\n\n async fetch(request: Request): Promise<Response> {\n return app.fetch(request);\n },\n };\n },\n };\n\n return builder;\n}\n"],"mappings":";;;;;;;;;;;AAOA,SAAS,YAAY;AAgDd,SAAS,SAAS,SAA2C;AAClE,QAAM,MAAM,IAAI,KAEb;AAEH,QAAM,aAAa,kBAAkB;AAAA,IACnC,OAAO,QAAQ;AAAA,IACf,QAAQ,QAAQ;AAAA,EAClB,CAAC;AAED,MAAI,QAAQ;AAEZ,QAAM,UAA2B;AAAA,IAC/B,GACE,MACA,QACA,SACiB;AACjB,UAAI,OAAO;AACT,cAAM,IAAI;AAAA,UACR,4BAA4B,IAAI;AAAA,QAClC;AAAA,MACF;AAEA,UAAI,KAAK,IAAI,IAAI,IAAI,YAAY,OAAO,MAAM;AAC5C,YAAI;AACJ,YAAI;AACF,gBAAM,OAAO,MAAM,EAAE,IAAI,KAAK;AAC9B,sBAAY,OAAO,MAAM,IAAI;AAAA,QAC/B,SAAS,KAAK;AACZ,iBAAO,EAAE;AAAA,YACP;AAAA,cACE,OAAO;AAAA,cACP,QAAS,IAAc;AAAA,YACzB;AAAA,YACA;AAAA,UACF;AAAA,QACF;AAEA,cAAM,OAAO,EAAE,IAAI,aAAa;AAChC,YAAI,CAAC,KAAK,SAAS;AACjB,cAAI;AACF,kBAAM,QAAQ,WAAW,EAAE,KAAK,KAAK,IAAI,CAAC;AAAA,UAC5C,SAAS,KAAK;AACZ,mBAAO,EAAE;AAAA,cACP;AAAA,gBACE,OAAO;AAAA,gBACP,QAAS,IAAc;AAAA,cACzB;AAAA,cACA;AAAA,YACF;AAAA,UACF;AAAA,QACF;AAEA,eAAO,EAAE,KAAK,MAAM,GAAG;AAAA,MACzB,CAAC;AAED,aAAO;AAAA,IACT;AAAA,IAEA,QAAkB;AAChB,cAAQ;AAGR,UAAI;AAEJ,aAAO;AAAA,QACL,MAAM,SAAwB;AAC5B,gBAAM,EAAE,MAAM,IAAI,MAAM,OAAO,mBAAmB;AAClD,gBAAM,WAAW,MAAM,EAAE,OAAO,IAAI,OAAO,MAAM,QAAQ,KAAK,CAAC;AAC/D,mBAAS;AACT,gBAAM,IAAI,QAAc,CAAC,YAAY;AACnC,qBAAS,KAAK,aAAa,MAAM,QAAQ,CAAC;AAAA,UAC5C,CAAC;AAAA,QACH;AAAA,QAEA,MAAM,QAAuB;AAC3B,cAAI,CAAC,OAAQ;AACb,gBAAM,IAAI;AACV,mBAAS;AACT,gBAAM,IAAI,QAAc,CAAC,YAAY,EAAE,MAAM,MAAM,QAAQ,CAAC,CAAC;AAAA,QAC/D;AAAA,QAEA,MAAM,MAAM,SAAqC;AAC/C,iBAAO,IAAI,MAAM,OAAO;AAAA,QAC1B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  checkWebhook
3
3
  } from "../../chunk-4CGAUB5H.js";
4
+ import "../../chunk-PZ5AY32C.js";
4
5
 
5
6
  // src/receiver/trpc/index.ts
6
7
  import { TRPCError } from "@trpc/server";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/receiver/trpc/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/trpc\n *\n * tRPC adapter for the receiver-side webhook check. Composes\n * `extractIdempotencyKey` + `verifyWebhook` + `IdempotencyStore.claim`\n * into a single middleware factory.\n *\n * Usage:\n *\n * ```ts\n * import { initTRPC, TRPCError } from \"@trpc/server\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/trpc\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * type Ctx = {\n * headers: Record<string, string | string[] | undefined>;\n * rawBody: string;\n * };\n *\n * const t = initTRPC.context<Ctx>().create();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * const idempotent = t.procedure.use(\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET })\n * );\n * ```\n *\n * The middleware throws a `TRPCError` with `BAD_REQUEST` for\n * `missing-key` and `UNAUTHORIZED` for any verification failure.\n * On success it injects `{ key, deduped }` into the request context\n * under the `idempotency` property.\n *\n * **Raw body requirement**: when `secret` is configured, the middleware\n * needs the raw request bytes for HMAC verification. Capture them in\n * `createContext` — most tRPC HTTP adapters expose the raw stream;\n * read it into a string and stash it on `ctx.rawBody`. Skip when\n * unsigned (no `secret`) — the middleware never reads `rawBody` in\n * that mode.\n */\nimport { TRPCError } from \"@trpc/server\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\n/**\n * Build a tRPC middleware that verifies the request signature (when\n * `secret` is set), enforces `Idempotency-Key`, and claims the key on\n * the configured store. See the module-level docs for usage.\n *\n * The returned function uses permissive `any` typing because tRPC's\n * `MiddlewareFunction` type lives in `unstable-core-do-not-import`\n * (internal namespace, not for external import). Type-safety at the\n * call site comes from `t.procedure.use(...)` validating the\n * middleware shape against the procedure's context — the operator's\n * tRPC context must include `headers` and `rawBody`, and downstream\n * handlers see `ctx.idempotency = { key, deduped }`.\n */\n// biome-ignore lint/suspicious/noExplicitAny: tRPC's internal middleware shape\nexport function webhookMiddleware(options: CheckWebhookOptions): any {\n return async function check(opts: {\n ctx: {\n headers: Record<string, string | string[] | undefined>;\n rawBody: string;\n };\n // biome-ignore lint/suspicious/noExplicitAny: see above\n next: (next: { ctx: any }) => Promise<any>;\n }) {\n const result = await checkWebhook(\n opts.ctx.headers,\n opts.ctx.rawBody,\n options\n );\n if (!result.ok) {\n throw new TRPCError({\n code: result.status === 400 ? \"BAD_REQUEST\" : \"UNAUTHORIZED\",\n message: result.reason,\n });\n }\n return opts.next({\n ctx: {\n ...opts.ctx,\n idempotency: { key: result.key, deduped: result.deduped },\n },\n });\n };\n}\n"],"mappings":";;;;;AAwCA,SAAS,iBAAiB;AAiBnB,SAAS,kBAAkB,SAAmC;AACnE,SAAO,eAAe,MAAM,MAOzB;AACD,UAAM,SAAS,MAAM;AAAA,MACnB,KAAK,IAAI;AAAA,MACT,KAAK,IAAI;AAAA,MACT;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,IAAI,UAAU;AAAA,QAClB,MAAM,OAAO,WAAW,MAAM,gBAAgB;AAAA,QAC9C,SAAS,OAAO;AAAA,MAClB,CAAC;AAAA,IACH;AACA,WAAO,KAAK,KAAK;AAAA,MACf,KAAK;AAAA,QACH,GAAG,KAAK;AAAA,QACR,aAAa,EAAE,KAAK,OAAO,KAAK,SAAS,OAAO,QAAQ;AAAA,MAC1D;AAAA,IACF,CAAC;AAAA,EACH;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/receiver/trpc/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/receiver/trpc\n *\n * tRPC adapter for the receiver-side webhook check. Composes\n * `extractIdempotencyKey` + `verifyWebhook` + `IdempotencyStore.claim`\n * into a single middleware factory.\n *\n * Usage:\n *\n * ```ts\n * import { initTRPC, TRPCError } from \"@trpc/server\";\n * import { webhookMiddleware } from \"@rotorsoft/act-http/receiver/trpc\";\n * import { InMemoryIdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n *\n * type Ctx = {\n * headers: Record<string, string | string[] | undefined>;\n * rawBody: string;\n * };\n *\n * const t = initTRPC.context<Ctx>().create();\n * const dedup = new InMemoryIdempotencyStore();\n *\n * const idempotent = t.procedure.use(\n * webhookMiddleware({ store: dedup, secret: process.env.WEBHOOK_SECRET })\n * );\n * ```\n *\n * The middleware throws a `TRPCError` with `BAD_REQUEST` for\n * `missing-key` and `UNAUTHORIZED` for any verification failure.\n * On success it injects `{ key, deduped }` into the request context\n * under the `idempotency` property.\n *\n * **Raw body requirement**: when `secret` is configured, the middleware\n * needs the raw request bytes for HMAC verification. Capture them in\n * `createContext` — most tRPC HTTP adapters expose the raw stream;\n * read it into a string and stash it on `ctx.rawBody`. Skip when\n * unsigned (no `secret`) — the middleware never reads `rawBody` in\n * that mode.\n */\nimport { TRPCError } from \"@trpc/server\";\nimport { type CheckWebhookOptions, checkWebhook } from \"../check.js\";\n\n/**\n * Build a tRPC middleware that verifies the request signature (when\n * `secret` is set), enforces `Idempotency-Key`, and claims the key on\n * the configured store. See the module-level docs for usage.\n *\n * The returned function uses permissive `any` typing because tRPC's\n * `MiddlewareFunction` type lives in `unstable-core-do-not-import`\n * (internal namespace, not for external import). Type-safety at the\n * call site comes from `t.procedure.use(...)` validating the\n * middleware shape against the procedure's context — the operator's\n * tRPC context must include `headers` and `rawBody`, and downstream\n * handlers see `ctx.idempotency = { key, deduped }`.\n */\n// biome-ignore lint/suspicious/noExplicitAny: tRPC's internal middleware shape\nexport function webhookMiddleware(options: CheckWebhookOptions): any {\n return async function check(opts: {\n ctx: {\n headers: Record<string, string | string[] | undefined>;\n rawBody: string;\n };\n // biome-ignore lint/suspicious/noExplicitAny: see above\n next: (next: { ctx: any }) => Promise<any>;\n }) {\n const result = await checkWebhook(\n opts.ctx.headers,\n opts.ctx.rawBody,\n options\n );\n if (!result.ok) {\n throw new TRPCError({\n code: result.status === 400 ? \"BAD_REQUEST\" : \"UNAUTHORIZED\",\n message: result.reason,\n });\n }\n return opts.next({\n ctx: {\n ...opts.ctx,\n idempotency: { key: result.key, deduped: result.deduped },\n },\n });\n };\n}\n"],"mappings":";;;;;;AAwCA,SAAS,iBAAiB;AAiBnB,SAAS,kBAAkB,SAAmC;AACnE,SAAO,eAAe,MAAM,MAOzB;AACD,UAAM,SAAS,MAAM;AAAA,MACnB,KAAK,IAAI;AAAA,MACT,KAAK,IAAI;AAAA,MACT;AAAA,IACF;AACA,QAAI,CAAC,OAAO,IAAI;AACd,YAAM,IAAI,UAAU;AAAA,QAClB,MAAM,OAAO,WAAW,MAAM,gBAAgB;AAAA,QAC9C,SAAS,OAAO;AAAA,MAClB,CAAC;AAAA,IACH;AACA,WAAO,KAAK,KAAK;AAAA,MACf,KAAK;AAAA,QACH,GAAG,KAAK;AAAA,QACR,aAAa,EAAE,KAAK,OAAO,KAAK,SAAS,OAAO,QAAQ;AAAA,MAC1D;AAAA,IACF,CAAC;AAAA,EACH;AACF;","names":[]}
package/dist/sse/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import "../chunk-PZ5AY32C.js";
2
+
1
3
  // src/sse/index.ts
2
4
  import { patch } from "@rotorsoft/act-patch";
3
5
 
@@ -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 *\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 const cachedV = cached?._v ?? 0;\n const versions = Object.keys(msg)\n .map(Number)\n .sort((a, b) => a - b);\n\n if (!versions.length) return { ok: false, reason: \"stale\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n if (maxV <= cachedV) return { ok: false, reason: \"stale\" };\n if (!cached || minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n let state = cached;\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 type { BroadcastState, PatchMessage, Subscriber } 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.get_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 */\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n constructor(options?: { cache_size?: number }) {\n this.state_cache = new StateCache<S>(options?.cache_size ?? 50);\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 this.state_cache.set(streamId, state);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = p;\n });\n\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\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 publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) return undefined;\n\n const state = apply_patch(prev, overlay_patch) as S;\n this.state_cache.set(streamId, state);\n\n const msg: PatchMessage<S> = { [state._v]: overlay_patch };\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\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 get_subscriber_count(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 get_state(streamId: string): S | undefined {\n return this.state_cache.get(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\n constructor(maxSize = 50) {\n this.maxSize = maxSize;\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 this.cache.delete(this.cache.keys().next().value!);\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.get_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 get_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 is_online(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n}\n"],"mappings":"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","names":[]}
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 *\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 const cachedV = cached?._v ?? 0;\n const versions = Object.keys(msg)\n .map(Number)\n .sort((a, b) => a - b);\n\n if (!versions.length) return { ok: false, reason: \"stale\" };\n\n const minV = versions[0];\n const maxV = versions[versions.length - 1];\n\n if (maxV <= cachedV) return { ok: false, reason: \"stale\" };\n if (!cached || minV > cachedV + 1) return { ok: false, reason: \"behind\" };\n\n let state = cached;\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 type { BroadcastState, PatchMessage, Subscriber } 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.get_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 */\nexport class BroadcastChannel<S extends BroadcastState = BroadcastState> {\n private channels = new Map<string, Set<Subscriber<S>>>();\n private state_cache: StateCache<S>;\n\n constructor(options?: { cache_size?: number }) {\n this.state_cache = new StateCache<S>(options?.cache_size ?? 50);\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 this.state_cache.set(streamId, state);\n\n const baseV = state._v - patches.length;\n const msg: PatchMessage<S> = {};\n patches.forEach((p, i) => {\n msg[baseV + i + 1] = p;\n });\n\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\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 publish_overlay(\n streamId: string,\n overlay_patch: Partial<S>\n ): PatchMessage<S> | undefined {\n const prev = this.state_cache.get(streamId);\n if (!prev) return undefined;\n\n const state = apply_patch(prev, overlay_patch) as S;\n this.state_cache.set(streamId, state);\n\n const msg: PatchMessage<S> = { [state._v]: overlay_patch };\n const subs = this.channels.get(streamId);\n if (subs?.size) {\n for (const cb of subs) cb(msg);\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 get_subscriber_count(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 get_state(streamId: string): S | undefined {\n return this.state_cache.get(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\n constructor(maxSize = 50) {\n this.maxSize = maxSize;\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 this.cache.delete(this.cache.keys().next().value!);\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.get_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 get_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 is_online(streamId: string, identity_id: string): boolean {\n return (this.streams.get(streamId)?.get(identity_id) ?? 0) > 0;\n }\n}\n"],"mappings":"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","names":[]}
@@ -79,13 +79,14 @@ async function withIdempotency(store, key, handler) {
79
79
 
80
80
  // src/trpc/index.ts
81
81
  function authenticated(extractor) {
82
- return async function actor_middleware({
82
+ const middleware = async function actor_middleware({
83
83
  ctx,
84
84
  next
85
85
  }) {
86
86
  const actor = await extractor(ctx);
87
87
  return next({ ctx: { ...ctx, actor } });
88
88
  };
89
+ return middleware;
89
90
  }
90
91
  function to_trpc_error(err) {
91
92
  const { status, body } = toApiError(err);
@@ -114,19 +115,15 @@ function status_to_trpc_code(status) {
114
115
  }
115
116
  function trpc(app, options) {
116
117
  const t = import_server.initTRPC.context().create();
117
- const authed = t.procedure.use(authenticated(options.actor));
118
118
  const handlers = {};
119
119
  for (const [action_name, state] of Object.entries(app.registry.actions)) {
120
- handlers[action_name] = authed.input(state.actions[action_name]).mutation(async ({ input, ctx }) => {
120
+ handlers[action_name] = t.procedure.input(state.actions[action_name]).mutation(async ({ input, ctx }) => {
121
121
  const host_ctx = ctx;
122
122
  try {
123
+ const actor = await options.actor(host_ctx);
123
124
  const stream = await options.stream(action_name, input, host_ctx);
124
125
  const expected_version = options.expectedVersion ? await options.expectedVersion(action_name, input, host_ctx) : void 0;
125
- const target = expected_version === void 0 ? { stream, actor: host_ctx.actor } : {
126
- stream,
127
- actor: host_ctx.actor,
128
- expectedVersion: expected_version
129
- };
126
+ const target = expected_version === void 0 ? { stream, actor } : { stream, actor, expectedVersion: expected_version };
130
127
  if (options.idempotency) {
131
128
  const key = options.idempotency.keyFrom(host_ctx);
132
129
  if (!key) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/trpc/index.ts","../../src/api/errors.ts","../../src/api/idempotency.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/trpc\n *\n * tRPC subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a typed\n * router that exposes every registered action as a flat `mutation`,\n * matching the shape hand-written tRPC routers in the Act ecosystem\n * already use.\n *\n * Usage:\n *\n * ```ts\n * import { trpc } from \"@rotorsoft/act-http/trpc\";\n *\n * const router = trpc(app, {\n * actor: (ctx) => ({ id: ctx.user.id, name: ctx.user.name }),\n * stream: (action, input, ctx) => input.stream ?? ctx.tenant,\n * });\n *\n * // Client sees:\n * // appRouter.PressKey.mutate({ key: \"5\" })\n * // appRouter.OpenTicket.mutate({ title: \"...\" })\n * ```\n *\n * The route shape is flat: one procedure per registered action at\n * the top level. Action names are the unique identifiers across\n * the registry (the framework already enforces no duplicates), so\n * an extra namespace tier would be overhead in the common case.\n * Pure mutations for this slice; query/projection wiring lives in\n * a follow-up. See `libs/act-http/README.md` for the full surface\n * walkthrough.\n *\n * **Actor lives on the context.** The generator wires an internal\n * tRPC middleware that runs the host's\n * {@link TrpcOptions.actor | actor extractor} once per call and\n * injects the resolved {@link Actor} onto the downstream context as\n * `ctx.actor`. Every generated mutation reads from there — auth\n * resolves at the procedure boundary, not inside each action body,\n * and any composed middleware (logging, tracing) sees the same\n * `ctx.actor`. The middleware factory is also exported as\n * {@link authenticated} for hosts that need to compose it into\n * their own procedure chain.\n *\n * Composes the shared utilities at `@rotorsoft/act-http/api`:\n * {@link ActorExtractor} for the auth seam, {@link toApiError} for\n * the uniform error→status/code mapping, {@link withIdempotency}\n * for `Idempotency-Key` dedup. Three transports (this one, Hono,\n * OpenAPI) ride the same utilities so a client talking to two\n * transports never sees two error shapes for the same framework\n * error.\n */\nimport type { Act, Actor, Schema, Schemas } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n type ActorExtractor,\n toApiError,\n withIdempotency,\n} from \"../api/index.js\";\n\n/**\n * Per-call options for {@link trpc}. The host supplies the three\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the tRPC context, returns the\n * `Actor` that flows onto `ctx.actor` for every generated\n * procedure (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated action input, and the original tRPC context; returns\n * the target stream. Singleton aggregates return a constant;\n * per-tenant aggregates pull from input or context.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * When set, the procedure threads the resolved value through\n * `target.expectedVersion` so `app.do` refuses to commit if the\n * stream has moved. Hosts typically read it from an `If-Match`\n * header on the underlying HTTP request, or pull it from the\n * client's last-known snapshot. Returning `undefined` skips the\n * check for that call — handy when only some actions are\n * concurrency-sensitive.\n * - `idempotency` (optional) — when set, the procedure honors\n * `Idempotency-Key` via the shared\n * {@link withIdempotency} helper. The host supplies the\n * `IdempotencyStore` and a `keyFrom` extractor that reads the key\n * out of the tRPC context (typically a header). On a duplicate\n * claim, the procedure throws a `CONFLICT` `TRPCError` — the\n * receiver-side convention of \"ack the duplicate\" doesn't carry\n * over because the contract intentionally does not cache the\n * original handler's result.\n *\n * @template Ctx The host's tRPC context shape. Flows end-to-end —\n * procedures see this exact context type (plus `actor`) at\n * runtime.\n */\nexport type TrpcOptions<Ctx> = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom: (ctx: Ctx) => string | undefined;\n };\n};\n\n/**\n * Build a tRPC middleware that runs an {@link ActorExtractor} once\n * per call and forwards the resolved {@link Actor} on the\n * downstream context as `ctx.actor`. The generator at\n * {@link trpc} uses this internally; it's also exported so hosts\n * that want to compose their own procedure chain (logging,\n * tracing, custom auth flavors) can wire it directly:\n *\n * ```ts\n * import { initTRPC } from \"@trpc/server\";\n * import { authenticated } from \"@rotorsoft/act-http/trpc\";\n *\n * type Ctx = { user?: { id: string; name: string } };\n * const t = initTRPC.context<Ctx>().create();\n *\n * const authed = t.procedure.use(\n * authenticated((ctx) => {\n * if (!ctx.user) throw new Error(\"not authenticated\");\n * return { id: ctx.user.id, name: ctx.user.name };\n * })\n * );\n *\n * // Inside any procedure built off `authed`, `ctx.actor: Actor`.\n * ```\n *\n * The middleware is unaware of the host's `t` instance — it returns\n * a structurally-typed function that any tRPC `procedure.use(...)`\n * accepts. Errors thrown by the extractor surface unchanged\n * (typically wrap them as `TRPCError({ code: \"UNAUTHORIZED\" })`\n * in the extractor body).\n */\n// biome-ignore lint/suspicious/noExplicitAny: tRPC's internal middleware shape lives behind `unstable-core-do-not-import`; structural typing carries the host's `t` validation.\nexport function authenticated(extractor: ActorExtractor): any {\n return async function actor_middleware({\n ctx,\n next,\n }: {\n ctx: object;\n next: (opts: { ctx: object }) => Promise<unknown>;\n }) {\n const actor = await extractor(ctx);\n return next({ ctx: { ...ctx, actor } });\n };\n}\n\n/**\n * Map a thrown framework error onto a `TRPCError`. Known errors get\n * their conventional tRPC code (`ConcurrencyError` → `CONFLICT`,\n * `ValidationError` → `UNPROCESSABLE_CONTENT`, etc.); unknown throws\n * surface as `INTERNAL_SERVER_ERROR`. Every transport in the\n * act-http epic passes through the same {@link toApiError} table,\n * so a client talking to both this subpath and the REST sibling\n * sees one envelope per framework error.\n *\n * @internal\n */\nfunction to_trpc_error(err: unknown): TRPCError {\n const { status, body } = toApiError(err);\n const code = status_to_trpc_code(status);\n return new TRPCError({\n code,\n message: body.detail ?? body.error,\n cause: err instanceof Error ? err : undefined,\n });\n}\n\n/**\n * Minimal status → tRPC code map. Only the statuses\n * {@link ERROR_MAP} produces (plus 500) appear here; anything else\n * collapses to `INTERNAL_SERVER_ERROR`.\n *\n * @internal\n */\nfunction status_to_trpc_code(status: number): TRPCError[\"code\"] {\n switch (status) {\n case 400:\n return \"BAD_REQUEST\";\n case 409:\n return \"CONFLICT\";\n case 410:\n return \"PRECONDITION_FAILED\";\n case 412:\n return \"CONFLICT\";\n case 422:\n return \"UNPROCESSABLE_CONTENT\";\n default:\n return \"INTERNAL_SERVER_ERROR\";\n }\n}\n\n/**\n * Build a typed tRPC router from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one flat mutation per\n * action under `router.<actionName>`. The framework already enforces\n * no duplicate action names across states, so a flat tier reads\n * cleanly at the client and matches the hand-written router shape\n * adopters already have. Each mutation:\n *\n * 1. Runs the internal {@link authenticated} — `options.actor`\n * resolves the {@link Actor} once and injects it onto\n * `ctx.actor`.\n * 2. Resolves the target stream via `options.stream(name, input, ctx)`.\n * 3. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — throws `CONFLICT` on duplicate.\n * 4. Calls `app.do(name, { stream, actor: ctx.actor }, input)`.\n * 5. Maps any thrown framework error onto a `TRPCError` via\n * {@link toApiError}.\n *\n * The returned router's procedure inputs come straight from each\n * action's Zod schema; outputs are the framework's\n * `Snapshot<TState, TEvents>[]` shape. Strong typing flows\n * end-to-end via tRPC's normal inference path — the client sees\n * `appRouter.Calculator.PressKey.mutate({ key: \"5\" })` as a typed\n * call without any codegen.\n *\n * @template Ctx The tRPC context shape the host's runtime feeds in.\n * Flows through `actor`, `stream`, and `idempotency.keyFrom` so\n * the operator's existing context types just work. The internal\n * middleware augments `Ctx` with `{ actor: Actor }` on the\n * downstream procedure context.\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, and (optional) idempotency seams.\n * @returns A tRPC router covering every registered action, grouped\n * by owning state.\n */\nexport function trpc<Ctx extends object = object>(\n // biome-ignore lint/suspicious/noExplicitAny: erased — the generator walks the registry at runtime, not the typed surface\n app: Act<any, Schemas, Schemas, Record<string, Schema>>,\n options: TrpcOptions<Ctx>\n) {\n const t = initTRPC.context<Ctx>().create();\n const authed = t.procedure.use(authenticated(options.actor));\n\n const handlers: Record<string, ReturnType<typeof authed.mutation>> = {};\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n handlers[action_name] = authed\n .input(state.actions[action_name] as never)\n .mutation(async ({ input, ctx }) => {\n // tRPC's internal `Simplify<Ctx>` transform produces a type\n // that's structurally identical to `Ctx & { actor }` but not\n // assignable to the generic parameter. The cast restores the\n // link; the runtime ctx IS the augmented context.\n const host_ctx = ctx as unknown as Ctx & { actor: Actor };\n try {\n const stream = await options.stream(action_name, input, host_ctx);\n const expected_version = options.expectedVersion\n ? await options.expectedVersion(action_name, input, host_ctx)\n : undefined;\n const target =\n expected_version === undefined\n ? { stream, actor: host_ctx.actor }\n : {\n stream,\n actor: host_ctx.actor,\n expectedVersion: expected_version,\n };\n\n if (options.idempotency) {\n const key = options.idempotency.keyFrom(host_ctx);\n if (!key) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Idempotency-Key required\",\n });\n }\n const outcome = await withIdempotency(\n options.idempotency.store,\n key,\n () =>\n app.do(action_name as never, target as never, input as never)\n );\n if (outcome.deduped) {\n throw new TRPCError({\n code: \"CONFLICT\",\n message:\n \"Idempotency-Key already used; original result not cached\",\n });\n }\n return outcome.result;\n }\n\n return await app.do(\n action_name as never,\n target as never,\n input as never\n );\n } catch (err) {\n if (err instanceof TRPCError) throw err;\n throw to_trpc_error(err);\n }\n });\n }\n\n return t.router(handlers as never);\n}\n","import {\n ConcurrencyError,\n InvariantError,\n NonRetryableError,\n StreamClosedError,\n ValidationError,\n} from \"@rotorsoft/act\";\n\n/**\n * Uniform error envelope shipped over the wire by every act-http\n * transport. Hosts get the same shape from REST, tRPC, and OpenAPI —\n * a client that talks to two transports doesn't have to invent two\n * error parsers.\n *\n * - `error` — the framework error name (`\"ValidationError\"`,\n * `\"InvariantError\"`, …). Stable identifier, safe to switch on.\n * - `detail` — the framework's message text. Human-readable; not\n * parsed by clients.\n * - `code` — a machine-readable status code from {@link ERROR_MAP}\n * for clients that prefer enum-style branching over name strings.\n */\nexport type ApiError = {\n error: string;\n detail?: string;\n code?: string;\n};\n\n/**\n * Status + code pair for one known framework error.\n */\nexport type ErrorMapEntry = {\n status: number;\n code: string;\n};\n\n/**\n * The single table that maps framework error types to HTTP status\n * codes and machine-readable codes. One table, three consumers\n * (Hono, tRPC, OpenAPI) — cross-transport consistency by\n * construction.\n *\n * Operators wanting different mappings wrap the generated transport\n * rather than mutating this — the consistency is the load-bearing\n * property, not the specific status codes.\n */\nexport const ERROR_MAP = {\n ValidationError: { status: 422, code: \"VALIDATION\" },\n InvariantError: { status: 409, code: \"INVARIANT\" },\n ConcurrencyError: { status: 412, code: \"CONCURRENCY\" },\n StreamClosedError: { status: 410, code: \"STREAM_CLOSED\" },\n NonRetryableError: { status: 400, code: \"NON_RETRYABLE\" },\n} as const satisfies Record<string, ErrorMapEntry>;\n\nconst lookup_known = (\n err: unknown\n): { name: keyof typeof ERROR_MAP } | null => {\n if (err instanceof ValidationError) return { name: \"ValidationError\" };\n if (err instanceof InvariantError) return { name: \"InvariantError\" };\n if (err instanceof ConcurrencyError) return { name: \"ConcurrencyError\" };\n if (err instanceof StreamClosedError) return { name: \"StreamClosedError\" };\n if (err instanceof NonRetryableError) return { name: \"NonRetryableError\" };\n return null;\n};\n\n/**\n * Translate an unknown thrown value into the canonical\n * {@link ApiError} envelope plus HTTP status. Each transport's error\n * boundary calls this once and forwards the result to the wire.\n *\n * Known framework errors map per {@link ERROR_MAP}. Everything else\n * surfaces as a 500 with `code: \"INTERNAL\"`; the `detail` field is\n * populated when the throw was an `Error` instance, omitted\n * otherwise (a thrown string or object doesn't get to leak its\n * payload to the client).\n */\nexport function toApiError(err: unknown): { status: number; body: ApiError } {\n const known = lookup_known(err);\n if (known) {\n const entry = ERROR_MAP[known.name];\n return {\n status: entry.status,\n body: {\n error: known.name,\n detail: (err as Error).message,\n code: entry.code,\n },\n };\n }\n if (err instanceof Error) {\n return {\n status: 500,\n body: { error: \"InternalError\", detail: err.message, code: \"INTERNAL\" },\n };\n }\n return {\n status: 500,\n body: { error: \"InternalError\", code: \"INTERNAL\" },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * Result of a {@link withIdempotency} call.\n *\n * - `{ deduped: false, result }` — the claim was fresh; the handler\n * ran and produced `result`.\n * - `{ deduped: true }` — the claim was already taken; the handler\n * was *not* invoked. The caller decides how to respond (typically\n * a 2xx with no body, matching the receiver-side convention).\n *\n * Note: the contract does not cache the previous response. A\n * duplicate call returns the deduped marker only — replaying the\n * original handler's output would require a response-caching\n * adapter, which is out of scope here. The receiver-side convention\n * (and the convention the generated transports follow) is \"ack the\n * duplicate; do nothing else.\"\n */\nexport type IdempotencyResult<T> =\n | { deduped: false; result: T }\n | { deduped: true };\n\n/**\n * Wrap an action handler so the framework honors `Idempotency-Key`\n * dedup. Acquires the key via {@link IdempotencyStore.claim}, runs\n * the handler exactly when the claim was fresh, and skips the\n * handler entirely on a duplicate.\n *\n * Reuses the contract `@rotorsoft/act-ops/idempotency` already\n * defines for the receiver-side `Idempotency-Key` story. A single\n * `IdempotencyStore` implementation covers both halves of the \"Act\n * over the wire\" surface — receiver and generated API.\n */\nexport async function withIdempotency<T>(\n store: IdempotencyStore,\n key: string,\n handler: () => Promise<T>\n): Promise<IdempotencyResult<T>> {\n const fresh = await store.claim(key);\n if (!fresh) {\n return { deduped: true };\n }\n return { deduped: false, result: await handler() };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/trpc/index.ts","../../src/api/errors.ts","../../src/api/idempotency.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/trpc\n *\n * tRPC subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a typed\n * router that exposes every registered action as a flat `mutation`,\n * matching the shape hand-written tRPC routers in the Act ecosystem\n * already use.\n *\n * Usage:\n *\n * ```ts\n * import { trpc } from \"@rotorsoft/act-http/trpc\";\n *\n * const router = trpc(app, {\n * actor: (ctx) => ({ id: ctx.user.id, name: ctx.user.name }),\n * stream: (action, input, ctx) => input.stream ?? ctx.tenant,\n * });\n *\n * // Client sees:\n * // appRouter.PressKey.mutate({ key: \"5\" })\n * // appRouter.OpenTicket.mutate({ title: \"...\" })\n * ```\n *\n * The route shape is flat: one procedure per registered action at\n * the top level. Action names are the unique identifiers across\n * the registry (the framework already enforces no duplicates), so\n * an extra namespace tier would be overhead in the common case.\n * Pure mutations for this slice; query/projection wiring lives in\n * a follow-up. See `libs/act-http/README.md` for the full surface\n * walkthrough.\n *\n * **Actor lives on the context.** The generator wires an internal\n * tRPC middleware that runs the host's\n * {@link TrpcOptions.actor | actor extractor} once per call and\n * injects the resolved {@link Actor} onto the downstream context as\n * `ctx.actor`. Every generated mutation reads from there — auth\n * resolves at the procedure boundary, not inside each action body,\n * and any composed middleware (logging, tracing) sees the same\n * `ctx.actor`. The middleware factory is also exported as\n * {@link authenticated} for hosts that need to compose it into\n * their own procedure chain.\n *\n * Composes the shared utilities at `@rotorsoft/act-http/api`:\n * {@link ActorExtractor} for the auth seam, {@link toApiError} for\n * the uniform error→status/code mapping, {@link withIdempotency}\n * for `Idempotency-Key` dedup. Three transports (this one, Hono,\n * OpenAPI) ride the same utilities so a client talking to two\n * transports never sees two error shapes for the same framework\n * error.\n */\nimport type { Actor, Target } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport {\n type AnyTRPCMiddlewareFunction,\n type AnyTRPCMutationProcedure,\n type AnyTRPCRootTypes,\n initTRPC,\n type TRPCBuiltRouter,\n TRPCError,\n} from \"@trpc/server\";\nimport {\n type ActorExtractor,\n toApiError,\n withIdempotency,\n} from \"../api/index.js\";\n\n/**\n * Per-call options for {@link trpc}. The host supplies the three\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the tRPC context, returns the\n * `Actor` that flows onto `ctx.actor` for every generated\n * procedure (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated action input, and the original tRPC context; returns\n * the target stream. Singleton aggregates return a constant;\n * per-tenant aggregates pull from input or context.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * When set, the procedure threads the resolved value through\n * `target.expectedVersion` so `app.do` refuses to commit if the\n * stream has moved. Hosts typically read it from an `If-Match`\n * header on the underlying HTTP request, or pull it from the\n * client's last-known snapshot. Returning `undefined` skips the\n * check for that call — handy when only some actions are\n * concurrency-sensitive.\n * - `idempotency` (optional) — when set, the procedure honors\n * `Idempotency-Key` via the shared\n * {@link withIdempotency} helper. The host supplies the\n * `IdempotencyStore` and a `keyFrom` extractor that reads the key\n * out of the tRPC context (typically a header). On a duplicate\n * claim, the procedure throws a `CONFLICT` `TRPCError` — the\n * receiver-side convention of \"ack the duplicate\" doesn't carry\n * over because the contract intentionally does not cache the\n * original handler's result.\n *\n * @template Ctx The host's tRPC context shape. Flows end-to-end —\n * procedures see this exact context type (plus `actor`) at\n * runtime.\n */\nexport type TrpcOptions<Ctx> = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom: (ctx: Ctx) => string | undefined;\n };\n};\n\n/**\n * Build a tRPC middleware that runs an {@link ActorExtractor} once\n * per call and forwards the resolved {@link Actor} on the\n * downstream context as `ctx.actor`. The generator at\n * {@link trpc} uses this internally; it's also exported so hosts\n * that want to compose their own procedure chain (logging,\n * tracing, custom auth flavors) can wire it directly:\n *\n * ```ts\n * import { initTRPC } from \"@trpc/server\";\n * import { authenticated } from \"@rotorsoft/act-http/trpc\";\n *\n * type Ctx = { user?: { id: string; name: string } };\n * const t = initTRPC.context<Ctx>().create();\n *\n * const authed = t.procedure.use(\n * authenticated((ctx) => {\n * if (!ctx.user) throw new Error(\"not authenticated\");\n * return { id: ctx.user.id, name: ctx.user.name };\n * })\n * );\n *\n * // Inside any procedure built off `authed`, `ctx.actor: Actor`.\n * ```\n *\n * The middleware is unaware of the host's `t` instance — it returns\n * a structurally-typed function that any tRPC `procedure.use(...)`\n * accepts. Errors thrown by the extractor surface unchanged\n * (typically wrap them as `TRPCError({ code: \"UNAUTHORIZED\" })`\n * in the extractor body).\n */\nexport function authenticated(\n extractor: ActorExtractor\n): AnyTRPCMiddlewareFunction {\n // tRPC's middleware function type lives behind\n // `unstable-core-do-not-import`; we satisfy it structurally with the\n // shape `procedure.use(...)` actually requires (an `{ ctx, next }`\n // bag whose `next` callback is invoked once with the augmented ctx)\n // and cast the structurally-typed result up to the named export. The\n // type cast carries no `any`; both sides are structurally compatible\n // at the call site.\n const middleware = async function actor_middleware({\n ctx,\n next,\n }: {\n ctx: object;\n next: (opts: { ctx: object }) => Promise<unknown>;\n }) {\n const actor = await extractor(ctx);\n return next({ ctx: { ...ctx, actor } });\n };\n return middleware as unknown as AnyTRPCMiddlewareFunction;\n}\n\n/**\n * Map a thrown framework error onto a `TRPCError`. Known errors get\n * their conventional tRPC code (`ConcurrencyError` → `CONFLICT`,\n * `ValidationError` → `UNPROCESSABLE_CONTENT`, etc.); unknown throws\n * surface as `INTERNAL_SERVER_ERROR`. Every transport in the\n * act-http epic passes through the same {@link toApiError} table,\n * so a client talking to both this subpath and the REST sibling\n * sees one envelope per framework error.\n *\n * @internal\n */\nfunction to_trpc_error(err: unknown): TRPCError {\n const { status, body } = toApiError(err);\n const code = status_to_trpc_code(status);\n return new TRPCError({\n code,\n message: body.detail ?? body.error,\n cause: err instanceof Error ? err : undefined,\n });\n}\n\n/**\n * Minimal status → tRPC code map. Only the statuses\n * {@link ERROR_MAP} produces (plus 500) appear here; anything else\n * collapses to `INTERNAL_SERVER_ERROR`.\n *\n * @internal\n */\nfunction status_to_trpc_code(status: number): TRPCError[\"code\"] {\n switch (status) {\n case 400:\n return \"BAD_REQUEST\";\n case 409:\n return \"CONFLICT\";\n case 410:\n return \"PRECONDITION_FAILED\";\n case 412:\n return \"CONFLICT\";\n case 422:\n return \"UNPROCESSABLE_CONTENT\";\n default:\n return \"INTERNAL_SERVER_ERROR\";\n }\n}\n\n/**\n * Build a typed tRPC router from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one flat mutation per\n * action under `router.<actionName>`. The framework already enforces\n * no duplicate action names across states, so a flat tier reads\n * cleanly at the client and matches the hand-written router shape\n * adopters already have. Each mutation:\n *\n * 1. Runs the internal {@link authenticated} — `options.actor`\n * resolves the {@link Actor} once and injects it onto\n * `ctx.actor`.\n * 2. Resolves the target stream via `options.stream(name, input, ctx)`.\n * 3. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — throws `CONFLICT` on duplicate.\n * 4. Calls `app.do(name, { stream, actor: ctx.actor }, input)`.\n * 5. Maps any thrown framework error onto a `TRPCError` via\n * {@link toApiError}.\n *\n * The returned router's procedure inputs come straight from each\n * action's Zod schema; outputs are the framework's\n * `Snapshot<TState, TEvents>[]` shape. Strong typing flows\n * end-to-end via tRPC's normal inference path — the client sees\n * `appRouter.Calculator.PressKey.mutate({ key: \"5\" })` as a typed\n * call without any codegen.\n *\n * @template Ctx The tRPC context shape the host's runtime feeds in.\n * Flows through `actor`, `stream`, and `idempotency.keyFrom` so\n * the operator's existing context types just work. The internal\n * middleware augments `Ctx` with `{ actor: Actor }` on the\n * downstream procedure context.\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, and (optional) idempotency seams.\n * @returns A tRPC router covering every registered action, grouped\n * by owning state.\n */\n/**\n * Structural shape of the Act surface this generator walks at\n * runtime — the registry's action-name → owning-state map plus the\n * `do(...)` dispatch. Letting TApp infer to the caller's concrete\n * `Act<...>` against this structural bound (instead of forcing it\n * to fit a narrow framework-typed upper bound) keeps the variance\n * of nested types like `PatchHandler` from leaking — and avoids\n * `any` in the signature.\n *\n * @internal\n */\ntype ActSurface = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n }\n >;\n };\n do(action: string, target: Target, payload: unknown): Promise<unknown>;\n};\n\n/**\n * Generated router type — one mutation procedure per action name in\n * `TApp`'s registry. Mapped over the concrete action keys so\n * `createTRPCReact<typeof router>()` sees specific procedure names\n * (`PressKey`, `Clear`, …) instead of a wide `Record<string, ...>`\n * index signature — which is what trips tRPC React's\n * \"useContext / Provider / useUtils collides\" check on widely-typed\n * routers.\n *\n * @internal\n */\ntype GeneratedRouter<TApp> = TRPCBuiltRouter<\n AnyTRPCRootTypes,\n TApp extends { registry: { actions: infer TActions } }\n ? { [K in keyof TActions]: AnyTRPCMutationProcedure }\n : Record<string, AnyTRPCMutationProcedure>\n>;\n\nexport function trpc<\n Ctx extends object = object,\n TApp extends ActSurface = ActSurface,\n>(app: TApp, options: TrpcOptions<Ctx>): GeneratedRouter<TApp> {\n const t = initTRPC.context<Ctx>().create();\n\n // Resolve the actor inline per mutation instead of via\n // `t.procedure.use(authenticated(...))`. The middleware path threads\n // tRPC's internal `Unwrap` type into each procedure's inferred shape,\n // which the d.ts emitter can't name portably (`unstable-core-do-not-\n // import` has a hashed file name that varies per build). Inlining\n // sidesteps the issue entirely — the standalone `authenticated`\n // export still works for hosts who want middleware composition.\n const handlers: Record<\n string,\n ReturnType<ReturnType<typeof t.procedure.input>[\"mutation\"]>\n > = {};\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n handlers[action_name] = t.procedure\n .input(state.actions[action_name] as never)\n .mutation(async ({ input, ctx }) => {\n const host_ctx = ctx as unknown as Ctx;\n try {\n const actor = await options.actor(host_ctx);\n const stream = await options.stream(action_name, input, host_ctx);\n const expected_version = options.expectedVersion\n ? await options.expectedVersion(action_name, input, host_ctx)\n : undefined;\n const target =\n expected_version === undefined\n ? { stream, actor }\n : { stream, actor, expectedVersion: expected_version };\n\n if (options.idempotency) {\n const key = options.idempotency.keyFrom(host_ctx);\n if (!key) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Idempotency-Key required\",\n });\n }\n const outcome = await withIdempotency(\n options.idempotency.store,\n key,\n () =>\n app.do(action_name as never, target as never, input as never)\n );\n if (outcome.deduped) {\n throw new TRPCError({\n code: \"CONFLICT\",\n message:\n \"Idempotency-Key already used; original result not cached\",\n });\n }\n return outcome.result;\n }\n\n return await app.do(\n action_name as never,\n target as never,\n input as never\n );\n } catch (err) {\n if (err instanceof TRPCError) throw err;\n throw to_trpc_error(err);\n }\n });\n }\n\n // The runtime router IS structurally compatible with\n // `GeneratedRouter<TApp>` (one mutation per action key), but tRPC's\n // `BuiltRouter` carries an internal `Unwrap<Ctx>` that the d.ts\n // emitter can't name portably. Bridging through `unknown` keeps the\n // caller-visible type clean (specific procedure names, no\n // index-signature widening that trips React tRPC's collision check).\n return t.router(handlers) as unknown as GeneratedRouter<TApp>;\n}\n","import {\n ConcurrencyError,\n InvariantError,\n NonRetryableError,\n StreamClosedError,\n ValidationError,\n} from \"@rotorsoft/act\";\n\n/**\n * Uniform error envelope shipped over the wire by every act-http\n * transport. Hosts get the same shape from REST, tRPC, and OpenAPI —\n * a client that talks to two transports doesn't have to invent two\n * error parsers.\n *\n * - `error` — the framework error name (`\"ValidationError\"`,\n * `\"InvariantError\"`, …). Stable identifier, safe to switch on.\n * - `detail` — the framework's message text. Human-readable; not\n * parsed by clients.\n * - `code` — a machine-readable status code from {@link ERROR_MAP}\n * for clients that prefer enum-style branching over name strings.\n */\nexport type ApiError = {\n error: string;\n detail?: string;\n code?: string;\n};\n\n/**\n * Status + code pair for one known framework error.\n */\nexport type ErrorMapEntry = {\n status: number;\n code: string;\n};\n\n/**\n * The single table that maps framework error types to HTTP status\n * codes and machine-readable codes. One table, three consumers\n * (Hono, tRPC, OpenAPI) — cross-transport consistency by\n * construction.\n *\n * Operators wanting different mappings wrap the generated transport\n * rather than mutating this — the consistency is the load-bearing\n * property, not the specific status codes.\n */\nexport const ERROR_MAP = {\n ValidationError: { status: 422, code: \"VALIDATION\" },\n InvariantError: { status: 409, code: \"INVARIANT\" },\n ConcurrencyError: { status: 412, code: \"CONCURRENCY\" },\n StreamClosedError: { status: 410, code: \"STREAM_CLOSED\" },\n NonRetryableError: { status: 400, code: \"NON_RETRYABLE\" },\n} as const satisfies Record<string, ErrorMapEntry>;\n\nconst lookup_known = (\n err: unknown\n): { name: keyof typeof ERROR_MAP } | null => {\n if (err instanceof ValidationError) return { name: \"ValidationError\" };\n if (err instanceof InvariantError) return { name: \"InvariantError\" };\n if (err instanceof ConcurrencyError) return { name: \"ConcurrencyError\" };\n if (err instanceof StreamClosedError) return { name: \"StreamClosedError\" };\n if (err instanceof NonRetryableError) return { name: \"NonRetryableError\" };\n return null;\n};\n\n/**\n * Translate an unknown thrown value into the canonical\n * {@link ApiError} envelope plus HTTP status. Each transport's error\n * boundary calls this once and forwards the result to the wire.\n *\n * Known framework errors map per {@link ERROR_MAP}. Everything else\n * surfaces as a 500 with `code: \"INTERNAL\"`; the `detail` field is\n * populated when the throw was an `Error` instance, omitted\n * otherwise (a thrown string or object doesn't get to leak its\n * payload to the client).\n */\nexport function toApiError(err: unknown): { status: number; body: ApiError } {\n const known = lookup_known(err);\n if (known) {\n const entry = ERROR_MAP[known.name];\n return {\n status: entry.status,\n body: {\n error: known.name,\n detail: (err as Error).message,\n code: entry.code,\n },\n };\n }\n if (err instanceof Error) {\n return {\n status: 500,\n body: { error: \"InternalError\", detail: err.message, code: \"INTERNAL\" },\n };\n }\n return {\n status: 500,\n body: { error: \"InternalError\", code: \"INTERNAL\" },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * Result of a {@link withIdempotency} call.\n *\n * - `{ deduped: false, result }` — the claim was fresh; the handler\n * ran and produced `result`.\n * - `{ deduped: true }` — the claim was already taken; the handler\n * was *not* invoked. The caller decides how to respond (typically\n * a 2xx with no body, matching the receiver-side convention).\n *\n * Note: the contract does not cache the previous response. A\n * duplicate call returns the deduped marker only — replaying the\n * original handler's output would require a response-caching\n * adapter, which is out of scope here. The receiver-side convention\n * (and the convention the generated transports follow) is \"ack the\n * duplicate; do nothing else.\"\n */\nexport type IdempotencyResult<T> =\n | { deduped: false; result: T }\n | { deduped: true };\n\n/**\n * Wrap an action handler so the framework honors `Idempotency-Key`\n * dedup. Acquires the key via {@link IdempotencyStore.claim}, runs\n * the handler exactly when the claim was fresh, and skips the\n * handler entirely on a duplicate.\n *\n * Reuses the contract `@rotorsoft/act-ops/idempotency` already\n * defines for the receiver-side `Idempotency-Key` story. A single\n * `IdempotencyStore` implementation covers both halves of the \"Act\n * over the wire\" surface — receiver and generated API.\n */\nexport async function withIdempotency<T>(\n store: IdempotencyStore,\n key: string,\n handler: () => Promise<T>\n): Promise<IdempotencyResult<T>> {\n const fresh = await store.claim(key);\n if (!fresh) {\n return { deduped: true };\n }\n return { deduped: false, result: await handler() };\n}\n"],"mappings":"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","names":[]}
@@ -2,17 +2,22 @@ import {
2
2
  toApiError,
3
3
  withIdempotency
4
4
  } from "../chunk-Z6FVXDDU.js";
5
+ import "../chunk-PZ5AY32C.js";
5
6
 
6
7
  // src/trpc/index.ts
7
- import { initTRPC, TRPCError } from "@trpc/server";
8
+ import {
9
+ initTRPC,
10
+ TRPCError
11
+ } from "@trpc/server";
8
12
  function authenticated(extractor) {
9
- return async function actor_middleware({
13
+ const middleware = async function actor_middleware({
10
14
  ctx,
11
15
  next
12
16
  }) {
13
17
  const actor = await extractor(ctx);
14
18
  return next({ ctx: { ...ctx, actor } });
15
19
  };
20
+ return middleware;
16
21
  }
17
22
  function to_trpc_error(err) {
18
23
  const { status, body } = toApiError(err);
@@ -41,19 +46,15 @@ function status_to_trpc_code(status) {
41
46
  }
42
47
  function trpc(app, options) {
43
48
  const t = initTRPC.context().create();
44
- const authed = t.procedure.use(authenticated(options.actor));
45
49
  const handlers = {};
46
50
  for (const [action_name, state] of Object.entries(app.registry.actions)) {
47
- handlers[action_name] = authed.input(state.actions[action_name]).mutation(async ({ input, ctx }) => {
51
+ handlers[action_name] = t.procedure.input(state.actions[action_name]).mutation(async ({ input, ctx }) => {
48
52
  const host_ctx = ctx;
49
53
  try {
54
+ const actor = await options.actor(host_ctx);
50
55
  const stream = await options.stream(action_name, input, host_ctx);
51
56
  const expected_version = options.expectedVersion ? await options.expectedVersion(action_name, input, host_ctx) : void 0;
52
- const target = expected_version === void 0 ? { stream, actor: host_ctx.actor } : {
53
- stream,
54
- actor: host_ctx.actor,
55
- expectedVersion: expected_version
56
- };
57
+ const target = expected_version === void 0 ? { stream, actor } : { stream, actor, expectedVersion: expected_version };
57
58
  if (options.idempotency) {
58
59
  const key = options.idempotency.keyFrom(host_ctx);
59
60
  if (!key) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/trpc/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/trpc\n *\n * tRPC subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a typed\n * router that exposes every registered action as a flat `mutation`,\n * matching the shape hand-written tRPC routers in the Act ecosystem\n * already use.\n *\n * Usage:\n *\n * ```ts\n * import { trpc } from \"@rotorsoft/act-http/trpc\";\n *\n * const router = trpc(app, {\n * actor: (ctx) => ({ id: ctx.user.id, name: ctx.user.name }),\n * stream: (action, input, ctx) => input.stream ?? ctx.tenant,\n * });\n *\n * // Client sees:\n * // appRouter.PressKey.mutate({ key: \"5\" })\n * // appRouter.OpenTicket.mutate({ title: \"...\" })\n * ```\n *\n * The route shape is flat: one procedure per registered action at\n * the top level. Action names are the unique identifiers across\n * the registry (the framework already enforces no duplicates), so\n * an extra namespace tier would be overhead in the common case.\n * Pure mutations for this slice; query/projection wiring lives in\n * a follow-up. See `libs/act-http/README.md` for the full surface\n * walkthrough.\n *\n * **Actor lives on the context.** The generator wires an internal\n * tRPC middleware that runs the host's\n * {@link TrpcOptions.actor | actor extractor} once per call and\n * injects the resolved {@link Actor} onto the downstream context as\n * `ctx.actor`. Every generated mutation reads from there — auth\n * resolves at the procedure boundary, not inside each action body,\n * and any composed middleware (logging, tracing) sees the same\n * `ctx.actor`. The middleware factory is also exported as\n * {@link authenticated} for hosts that need to compose it into\n * their own procedure chain.\n *\n * Composes the shared utilities at `@rotorsoft/act-http/api`:\n * {@link ActorExtractor} for the auth seam, {@link toApiError} for\n * the uniform error→status/code mapping, {@link withIdempotency}\n * for `Idempotency-Key` dedup. Three transports (this one, Hono,\n * OpenAPI) ride the same utilities so a client talking to two\n * transports never sees two error shapes for the same framework\n * error.\n */\nimport type { Act, Actor, Schema, Schemas } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n type ActorExtractor,\n toApiError,\n withIdempotency,\n} from \"../api/index.js\";\n\n/**\n * Per-call options for {@link trpc}. The host supplies the three\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the tRPC context, returns the\n * `Actor` that flows onto `ctx.actor` for every generated\n * procedure (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated action input, and the original tRPC context; returns\n * the target stream. Singleton aggregates return a constant;\n * per-tenant aggregates pull from input or context.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * When set, the procedure threads the resolved value through\n * `target.expectedVersion` so `app.do` refuses to commit if the\n * stream has moved. Hosts typically read it from an `If-Match`\n * header on the underlying HTTP request, or pull it from the\n * client's last-known snapshot. Returning `undefined` skips the\n * check for that call — handy when only some actions are\n * concurrency-sensitive.\n * - `idempotency` (optional) — when set, the procedure honors\n * `Idempotency-Key` via the shared\n * {@link withIdempotency} helper. The host supplies the\n * `IdempotencyStore` and a `keyFrom` extractor that reads the key\n * out of the tRPC context (typically a header). On a duplicate\n * claim, the procedure throws a `CONFLICT` `TRPCError` — the\n * receiver-side convention of \"ack the duplicate\" doesn't carry\n * over because the contract intentionally does not cache the\n * original handler's result.\n *\n * @template Ctx The host's tRPC context shape. Flows end-to-end —\n * procedures see this exact context type (plus `actor`) at\n * runtime.\n */\nexport type TrpcOptions<Ctx> = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom: (ctx: Ctx) => string | undefined;\n };\n};\n\n/**\n * Build a tRPC middleware that runs an {@link ActorExtractor} once\n * per call and forwards the resolved {@link Actor} on the\n * downstream context as `ctx.actor`. The generator at\n * {@link trpc} uses this internally; it's also exported so hosts\n * that want to compose their own procedure chain (logging,\n * tracing, custom auth flavors) can wire it directly:\n *\n * ```ts\n * import { initTRPC } from \"@trpc/server\";\n * import { authenticated } from \"@rotorsoft/act-http/trpc\";\n *\n * type Ctx = { user?: { id: string; name: string } };\n * const t = initTRPC.context<Ctx>().create();\n *\n * const authed = t.procedure.use(\n * authenticated((ctx) => {\n * if (!ctx.user) throw new Error(\"not authenticated\");\n * return { id: ctx.user.id, name: ctx.user.name };\n * })\n * );\n *\n * // Inside any procedure built off `authed`, `ctx.actor: Actor`.\n * ```\n *\n * The middleware is unaware of the host's `t` instance — it returns\n * a structurally-typed function that any tRPC `procedure.use(...)`\n * accepts. Errors thrown by the extractor surface unchanged\n * (typically wrap them as `TRPCError({ code: \"UNAUTHORIZED\" })`\n * in the extractor body).\n */\n// biome-ignore lint/suspicious/noExplicitAny: tRPC's internal middleware shape lives behind `unstable-core-do-not-import`; structural typing carries the host's `t` validation.\nexport function authenticated(extractor: ActorExtractor): any {\n return async function actor_middleware({\n ctx,\n next,\n }: {\n ctx: object;\n next: (opts: { ctx: object }) => Promise<unknown>;\n }) {\n const actor = await extractor(ctx);\n return next({ ctx: { ...ctx, actor } });\n };\n}\n\n/**\n * Map a thrown framework error onto a `TRPCError`. Known errors get\n * their conventional tRPC code (`ConcurrencyError` → `CONFLICT`,\n * `ValidationError` → `UNPROCESSABLE_CONTENT`, etc.); unknown throws\n * surface as `INTERNAL_SERVER_ERROR`. Every transport in the\n * act-http epic passes through the same {@link toApiError} table,\n * so a client talking to both this subpath and the REST sibling\n * sees one envelope per framework error.\n *\n * @internal\n */\nfunction to_trpc_error(err: unknown): TRPCError {\n const { status, body } = toApiError(err);\n const code = status_to_trpc_code(status);\n return new TRPCError({\n code,\n message: body.detail ?? body.error,\n cause: err instanceof Error ? err : undefined,\n });\n}\n\n/**\n * Minimal status → tRPC code map. Only the statuses\n * {@link ERROR_MAP} produces (plus 500) appear here; anything else\n * collapses to `INTERNAL_SERVER_ERROR`.\n *\n * @internal\n */\nfunction status_to_trpc_code(status: number): TRPCError[\"code\"] {\n switch (status) {\n case 400:\n return \"BAD_REQUEST\";\n case 409:\n return \"CONFLICT\";\n case 410:\n return \"PRECONDITION_FAILED\";\n case 412:\n return \"CONFLICT\";\n case 422:\n return \"UNPROCESSABLE_CONTENT\";\n default:\n return \"INTERNAL_SERVER_ERROR\";\n }\n}\n\n/**\n * Build a typed tRPC router from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one flat mutation per\n * action under `router.<actionName>`. The framework already enforces\n * no duplicate action names across states, so a flat tier reads\n * cleanly at the client and matches the hand-written router shape\n * adopters already have. Each mutation:\n *\n * 1. Runs the internal {@link authenticated} — `options.actor`\n * resolves the {@link Actor} once and injects it onto\n * `ctx.actor`.\n * 2. Resolves the target stream via `options.stream(name, input, ctx)`.\n * 3. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — throws `CONFLICT` on duplicate.\n * 4. Calls `app.do(name, { stream, actor: ctx.actor }, input)`.\n * 5. Maps any thrown framework error onto a `TRPCError` via\n * {@link toApiError}.\n *\n * The returned router's procedure inputs come straight from each\n * action's Zod schema; outputs are the framework's\n * `Snapshot<TState, TEvents>[]` shape. Strong typing flows\n * end-to-end via tRPC's normal inference path — the client sees\n * `appRouter.Calculator.PressKey.mutate({ key: \"5\" })` as a typed\n * call without any codegen.\n *\n * @template Ctx The tRPC context shape the host's runtime feeds in.\n * Flows through `actor`, `stream`, and `idempotency.keyFrom` so\n * the operator's existing context types just work. The internal\n * middleware augments `Ctx` with `{ actor: Actor }` on the\n * downstream procedure context.\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, and (optional) idempotency seams.\n * @returns A tRPC router covering every registered action, grouped\n * by owning state.\n */\nexport function trpc<Ctx extends object = object>(\n // biome-ignore lint/suspicious/noExplicitAny: erased — the generator walks the registry at runtime, not the typed surface\n app: Act<any, Schemas, Schemas, Record<string, Schema>>,\n options: TrpcOptions<Ctx>\n) {\n const t = initTRPC.context<Ctx>().create();\n const authed = t.procedure.use(authenticated(options.actor));\n\n const handlers: Record<string, ReturnType<typeof authed.mutation>> = {};\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n handlers[action_name] = authed\n .input(state.actions[action_name] as never)\n .mutation(async ({ input, ctx }) => {\n // tRPC's internal `Simplify<Ctx>` transform produces a type\n // that's structurally identical to `Ctx & { actor }` but not\n // assignable to the generic parameter. The cast restores the\n // link; the runtime ctx IS the augmented context.\n const host_ctx = ctx as unknown as Ctx & { actor: Actor };\n try {\n const stream = await options.stream(action_name, input, host_ctx);\n const expected_version = options.expectedVersion\n ? await options.expectedVersion(action_name, input, host_ctx)\n : undefined;\n const target =\n expected_version === undefined\n ? { stream, actor: host_ctx.actor }\n : {\n stream,\n actor: host_ctx.actor,\n expectedVersion: expected_version,\n };\n\n if (options.idempotency) {\n const key = options.idempotency.keyFrom(host_ctx);\n if (!key) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Idempotency-Key required\",\n });\n }\n const outcome = await withIdempotency(\n options.idempotency.store,\n key,\n () =>\n app.do(action_name as never, target as never, input as never)\n );\n if (outcome.deduped) {\n throw new TRPCError({\n code: \"CONFLICT\",\n message:\n \"Idempotency-Key already used; original result not cached\",\n });\n }\n return outcome.result;\n }\n\n return await app.do(\n action_name as never,\n target as never,\n input as never\n );\n } catch (err) {\n if (err instanceof TRPCError) throw err;\n throw to_trpc_error(err);\n }\n });\n }\n\n return t.router(handlers as never);\n}\n"],"mappings":";;;;;;AAsDA,SAAS,UAAU,iBAAiB;AA0F7B,SAAS,cAAc,WAAgC;AAC5D,SAAO,eAAe,iBAAiB;AAAA,IACrC;AAAA,IACA;AAAA,EACF,GAGG;AACD,UAAM,QAAQ,MAAM,UAAU,GAAG;AACjC,WAAO,KAAK,EAAE,KAAK,EAAE,GAAG,KAAK,MAAM,EAAE,CAAC;AAAA,EACxC;AACF;AAaA,SAAS,cAAc,KAAyB;AAC9C,QAAM,EAAE,QAAQ,KAAK,IAAI,WAAW,GAAG;AACvC,QAAM,OAAO,oBAAoB,MAAM;AACvC,SAAO,IAAI,UAAU;AAAA,IACnB;AAAA,IACA,SAAS,KAAK,UAAU,KAAK;AAAA,IAC7B,OAAO,eAAe,QAAQ,MAAM;AAAA,EACtC,CAAC;AACH;AASA,SAAS,oBAAoB,QAAmC;AAC9D,UAAQ,QAAQ;AAAA,IACd,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AAuCO,SAAS,KAEd,KACA,SACA;AACA,QAAM,IAAI,SAAS,QAAa,EAAE,OAAO;AACzC,QAAM,SAAS,EAAE,UAAU,IAAI,cAAc,QAAQ,KAAK,CAAC;AAE3D,QAAM,WAA+D,CAAC;AACtE,aAAW,CAAC,aAAa,KAAK,KAAK,OAAO,QAAQ,IAAI,SAAS,OAAO,GAAG;AACvE,aAAS,WAAW,IAAI,OACrB,MAAM,MAAM,QAAQ,WAAW,CAAU,EACzC,SAAS,OAAO,EAAE,OAAO,IAAI,MAAM;AAKlC,YAAM,WAAW;AACjB,UAAI;AACF,cAAM,SAAS,MAAM,QAAQ,OAAO,aAAa,OAAO,QAAQ;AAChE,cAAM,mBAAmB,QAAQ,kBAC7B,MAAM,QAAQ,gBAAgB,aAAa,OAAO,QAAQ,IAC1D;AACJ,cAAM,SACJ,qBAAqB,SACjB,EAAE,QAAQ,OAAO,SAAS,MAAM,IAChC;AAAA,UACE;AAAA,UACA,OAAO,SAAS;AAAA,UAChB,iBAAiB;AAAA,QACnB;AAEN,YAAI,QAAQ,aAAa;AACvB,gBAAM,MAAM,QAAQ,YAAY,QAAQ,QAAQ;AAChD,cAAI,CAAC,KAAK;AACR,kBAAM,IAAI,UAAU;AAAA,cAClB,MAAM;AAAA,cACN,SAAS;AAAA,YACX,CAAC;AAAA,UACH;AACA,gBAAM,UAAU,MAAM;AAAA,YACpB,QAAQ,YAAY;AAAA,YACpB;AAAA,YACA,MACE,IAAI,GAAG,aAAsB,QAAiB,KAAc;AAAA,UAChE;AACA,cAAI,QAAQ,SAAS;AACnB,kBAAM,IAAI,UAAU;AAAA,cAClB,MAAM;AAAA,cACN,SACE;AAAA,YACJ,CAAC;AAAA,UACH;AACA,iBAAO,QAAQ;AAAA,QACjB;AAEA,eAAO,MAAM,IAAI;AAAA,UACf;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,eAAe,UAAW,OAAM;AACpC,cAAM,cAAc,GAAG;AAAA,MACzB;AAAA,IACF,CAAC;AAAA,EACL;AAEA,SAAO,EAAE,OAAO,QAAiB;AACnC;","names":[]}
1
+ {"version":3,"sources":["../../src/trpc/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/trpc\n *\n * tRPC subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a typed\n * router that exposes every registered action as a flat `mutation`,\n * matching the shape hand-written tRPC routers in the Act ecosystem\n * already use.\n *\n * Usage:\n *\n * ```ts\n * import { trpc } from \"@rotorsoft/act-http/trpc\";\n *\n * const router = trpc(app, {\n * actor: (ctx) => ({ id: ctx.user.id, name: ctx.user.name }),\n * stream: (action, input, ctx) => input.stream ?? ctx.tenant,\n * });\n *\n * // Client sees:\n * // appRouter.PressKey.mutate({ key: \"5\" })\n * // appRouter.OpenTicket.mutate({ title: \"...\" })\n * ```\n *\n * The route shape is flat: one procedure per registered action at\n * the top level. Action names are the unique identifiers across\n * the registry (the framework already enforces no duplicates), so\n * an extra namespace tier would be overhead in the common case.\n * Pure mutations for this slice; query/projection wiring lives in\n * a follow-up. See `libs/act-http/README.md` for the full surface\n * walkthrough.\n *\n * **Actor lives on the context.** The generator wires an internal\n * tRPC middleware that runs the host's\n * {@link TrpcOptions.actor | actor extractor} once per call and\n * injects the resolved {@link Actor} onto the downstream context as\n * `ctx.actor`. Every generated mutation reads from there — auth\n * resolves at the procedure boundary, not inside each action body,\n * and any composed middleware (logging, tracing) sees the same\n * `ctx.actor`. The middleware factory is also exported as\n * {@link authenticated} for hosts that need to compose it into\n * their own procedure chain.\n *\n * Composes the shared utilities at `@rotorsoft/act-http/api`:\n * {@link ActorExtractor} for the auth seam, {@link toApiError} for\n * the uniform error→status/code mapping, {@link withIdempotency}\n * for `Idempotency-Key` dedup. Three transports (this one, Hono,\n * OpenAPI) ride the same utilities so a client talking to two\n * transports never sees two error shapes for the same framework\n * error.\n */\nimport type { Actor, Target } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport {\n type AnyTRPCMiddlewareFunction,\n type AnyTRPCMutationProcedure,\n type AnyTRPCRootTypes,\n initTRPC,\n type TRPCBuiltRouter,\n TRPCError,\n} from \"@trpc/server\";\nimport {\n type ActorExtractor,\n toApiError,\n withIdempotency,\n} from \"../api/index.js\";\n\n/**\n * Per-call options for {@link trpc}. The host supplies the three\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the tRPC context, returns the\n * `Actor` that flows onto `ctx.actor` for every generated\n * procedure (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated action input, and the original tRPC context; returns\n * the target stream. Singleton aggregates return a constant;\n * per-tenant aggregates pull from input or context.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * When set, the procedure threads the resolved value through\n * `target.expectedVersion` so `app.do` refuses to commit if the\n * stream has moved. Hosts typically read it from an `If-Match`\n * header on the underlying HTTP request, or pull it from the\n * client's last-known snapshot. Returning `undefined` skips the\n * check for that call — handy when only some actions are\n * concurrency-sensitive.\n * - `idempotency` (optional) — when set, the procedure honors\n * `Idempotency-Key` via the shared\n * {@link withIdempotency} helper. The host supplies the\n * `IdempotencyStore` and a `keyFrom` extractor that reads the key\n * out of the tRPC context (typically a header). On a duplicate\n * claim, the procedure throws a `CONFLICT` `TRPCError` — the\n * receiver-side convention of \"ack the duplicate\" doesn't carry\n * over because the contract intentionally does not cache the\n * original handler's result.\n *\n * @template Ctx The host's tRPC context shape. Flows end-to-end —\n * procedures see this exact context type (plus `actor`) at\n * runtime.\n */\nexport type TrpcOptions<Ctx> = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom: (ctx: Ctx) => string | undefined;\n };\n};\n\n/**\n * Build a tRPC middleware that runs an {@link ActorExtractor} once\n * per call and forwards the resolved {@link Actor} on the\n * downstream context as `ctx.actor`. The generator at\n * {@link trpc} uses this internally; it's also exported so hosts\n * that want to compose their own procedure chain (logging,\n * tracing, custom auth flavors) can wire it directly:\n *\n * ```ts\n * import { initTRPC } from \"@trpc/server\";\n * import { authenticated } from \"@rotorsoft/act-http/trpc\";\n *\n * type Ctx = { user?: { id: string; name: string } };\n * const t = initTRPC.context<Ctx>().create();\n *\n * const authed = t.procedure.use(\n * authenticated((ctx) => {\n * if (!ctx.user) throw new Error(\"not authenticated\");\n * return { id: ctx.user.id, name: ctx.user.name };\n * })\n * );\n *\n * // Inside any procedure built off `authed`, `ctx.actor: Actor`.\n * ```\n *\n * The middleware is unaware of the host's `t` instance — it returns\n * a structurally-typed function that any tRPC `procedure.use(...)`\n * accepts. Errors thrown by the extractor surface unchanged\n * (typically wrap them as `TRPCError({ code: \"UNAUTHORIZED\" })`\n * in the extractor body).\n */\nexport function authenticated(\n extractor: ActorExtractor\n): AnyTRPCMiddlewareFunction {\n // tRPC's middleware function type lives behind\n // `unstable-core-do-not-import`; we satisfy it structurally with the\n // shape `procedure.use(...)` actually requires (an `{ ctx, next }`\n // bag whose `next` callback is invoked once with the augmented ctx)\n // and cast the structurally-typed result up to the named export. The\n // type cast carries no `any`; both sides are structurally compatible\n // at the call site.\n const middleware = async function actor_middleware({\n ctx,\n next,\n }: {\n ctx: object;\n next: (opts: { ctx: object }) => Promise<unknown>;\n }) {\n const actor = await extractor(ctx);\n return next({ ctx: { ...ctx, actor } });\n };\n return middleware as unknown as AnyTRPCMiddlewareFunction;\n}\n\n/**\n * Map a thrown framework error onto a `TRPCError`. Known errors get\n * their conventional tRPC code (`ConcurrencyError` → `CONFLICT`,\n * `ValidationError` → `UNPROCESSABLE_CONTENT`, etc.); unknown throws\n * surface as `INTERNAL_SERVER_ERROR`. Every transport in the\n * act-http epic passes through the same {@link toApiError} table,\n * so a client talking to both this subpath and the REST sibling\n * sees one envelope per framework error.\n *\n * @internal\n */\nfunction to_trpc_error(err: unknown): TRPCError {\n const { status, body } = toApiError(err);\n const code = status_to_trpc_code(status);\n return new TRPCError({\n code,\n message: body.detail ?? body.error,\n cause: err instanceof Error ? err : undefined,\n });\n}\n\n/**\n * Minimal status → tRPC code map. Only the statuses\n * {@link ERROR_MAP} produces (plus 500) appear here; anything else\n * collapses to `INTERNAL_SERVER_ERROR`.\n *\n * @internal\n */\nfunction status_to_trpc_code(status: number): TRPCError[\"code\"] {\n switch (status) {\n case 400:\n return \"BAD_REQUEST\";\n case 409:\n return \"CONFLICT\";\n case 410:\n return \"PRECONDITION_FAILED\";\n case 412:\n return \"CONFLICT\";\n case 422:\n return \"UNPROCESSABLE_CONTENT\";\n default:\n return \"INTERNAL_SERVER_ERROR\";\n }\n}\n\n/**\n * Build a typed tRPC router from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one flat mutation per\n * action under `router.<actionName>`. The framework already enforces\n * no duplicate action names across states, so a flat tier reads\n * cleanly at the client and matches the hand-written router shape\n * adopters already have. Each mutation:\n *\n * 1. Runs the internal {@link authenticated} — `options.actor`\n * resolves the {@link Actor} once and injects it onto\n * `ctx.actor`.\n * 2. Resolves the target stream via `options.stream(name, input, ctx)`.\n * 3. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — throws `CONFLICT` on duplicate.\n * 4. Calls `app.do(name, { stream, actor: ctx.actor }, input)`.\n * 5. Maps any thrown framework error onto a `TRPCError` via\n * {@link toApiError}.\n *\n * The returned router's procedure inputs come straight from each\n * action's Zod schema; outputs are the framework's\n * `Snapshot<TState, TEvents>[]` shape. Strong typing flows\n * end-to-end via tRPC's normal inference path — the client sees\n * `appRouter.Calculator.PressKey.mutate({ key: \"5\" })` as a typed\n * call without any codegen.\n *\n * @template Ctx The tRPC context shape the host's runtime feeds in.\n * Flows through `actor`, `stream`, and `idempotency.keyFrom` so\n * the operator's existing context types just work. The internal\n * middleware augments `Ctx` with `{ actor: Actor }` on the\n * downstream procedure context.\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, and (optional) idempotency seams.\n * @returns A tRPC router covering every registered action, grouped\n * by owning state.\n */\n/**\n * Structural shape of the Act surface this generator walks at\n * runtime — the registry's action-name → owning-state map plus the\n * `do(...)` dispatch. Letting TApp infer to the caller's concrete\n * `Act<...>` against this structural bound (instead of forcing it\n * to fit a narrow framework-typed upper bound) keeps the variance\n * of nested types like `PatchHandler` from leaking — and avoids\n * `any` in the signature.\n *\n * @internal\n */\ntype ActSurface = {\n readonly registry: {\n actions: Record<\n string,\n {\n readonly name: string;\n readonly actions: Record<string, unknown>;\n }\n >;\n };\n do(action: string, target: Target, payload: unknown): Promise<unknown>;\n};\n\n/**\n * Generated router type — one mutation procedure per action name in\n * `TApp`'s registry. Mapped over the concrete action keys so\n * `createTRPCReact<typeof router>()` sees specific procedure names\n * (`PressKey`, `Clear`, …) instead of a wide `Record<string, ...>`\n * index signature — which is what trips tRPC React's\n * \"useContext / Provider / useUtils collides\" check on widely-typed\n * routers.\n *\n * @internal\n */\ntype GeneratedRouter<TApp> = TRPCBuiltRouter<\n AnyTRPCRootTypes,\n TApp extends { registry: { actions: infer TActions } }\n ? { [K in keyof TActions]: AnyTRPCMutationProcedure }\n : Record<string, AnyTRPCMutationProcedure>\n>;\n\nexport function trpc<\n Ctx extends object = object,\n TApp extends ActSurface = ActSurface,\n>(app: TApp, options: TrpcOptions<Ctx>): GeneratedRouter<TApp> {\n const t = initTRPC.context<Ctx>().create();\n\n // Resolve the actor inline per mutation instead of via\n // `t.procedure.use(authenticated(...))`. The middleware path threads\n // tRPC's internal `Unwrap` type into each procedure's inferred shape,\n // which the d.ts emitter can't name portably (`unstable-core-do-not-\n // import` has a hashed file name that varies per build). Inlining\n // sidesteps the issue entirely — the standalone `authenticated`\n // export still works for hosts who want middleware composition.\n const handlers: Record<\n string,\n ReturnType<ReturnType<typeof t.procedure.input>[\"mutation\"]>\n > = {};\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n handlers[action_name] = t.procedure\n .input(state.actions[action_name] as never)\n .mutation(async ({ input, ctx }) => {\n const host_ctx = ctx as unknown as Ctx;\n try {\n const actor = await options.actor(host_ctx);\n const stream = await options.stream(action_name, input, host_ctx);\n const expected_version = options.expectedVersion\n ? await options.expectedVersion(action_name, input, host_ctx)\n : undefined;\n const target =\n expected_version === undefined\n ? { stream, actor }\n : { stream, actor, expectedVersion: expected_version };\n\n if (options.idempotency) {\n const key = options.idempotency.keyFrom(host_ctx);\n if (!key) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Idempotency-Key required\",\n });\n }\n const outcome = await withIdempotency(\n options.idempotency.store,\n key,\n () =>\n app.do(action_name as never, target as never, input as never)\n );\n if (outcome.deduped) {\n throw new TRPCError({\n code: \"CONFLICT\",\n message:\n \"Idempotency-Key already used; original result not cached\",\n });\n }\n return outcome.result;\n }\n\n return await app.do(\n action_name as never,\n target as never,\n input as never\n );\n } catch (err) {\n if (err instanceof TRPCError) throw err;\n throw to_trpc_error(err);\n }\n });\n }\n\n // The runtime router IS structurally compatible with\n // `GeneratedRouter<TApp>` (one mutation per action key), but tRPC's\n // `BuiltRouter` carries an internal `Unwrap<Ctx>` that the d.ts\n // emitter can't name portably. Bridging through `unknown` keeps the\n // caller-visible type clean (specific procedure names, no\n // index-signature widening that trips React tRPC's collision check).\n return t.router(handlers) as unknown as GeneratedRouter<TApp>;\n}\n"],"mappings":";;;;;;;AAsDA;AAAA,EAIE;AAAA,EAEA;AAAA,OACK;AAyFA,SAAS,cACd,WAC2B;AAQ3B,QAAM,aAAa,eAAe,iBAAiB;AAAA,IACjD;AAAA,IACA;AAAA,EACF,GAGG;AACD,UAAM,QAAQ,MAAM,UAAU,GAAG;AACjC,WAAO,KAAK,EAAE,KAAK,EAAE,GAAG,KAAK,MAAM,EAAE,CAAC;AAAA,EACxC;AACA,SAAO;AACT;AAaA,SAAS,cAAc,KAAyB;AAC9C,QAAM,EAAE,QAAQ,KAAK,IAAI,WAAW,GAAG;AACvC,QAAM,OAAO,oBAAoB,MAAM;AACvC,SAAO,IAAI,UAAU;AAAA,IACnB;AAAA,IACA,SAAS,KAAK,UAAU,KAAK;AAAA,IAC7B,OAAO,eAAe,QAAQ,MAAM;AAAA,EACtC,CAAC;AACH;AASA,SAAS,oBAAoB,QAAmC;AAC9D,UAAQ,QAAQ;AAAA,IACd,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AAiFO,SAAS,KAGd,KAAW,SAAkD;AAC7D,QAAM,IAAI,SAAS,QAAa,EAAE,OAAO;AASzC,QAAM,WAGF,CAAC;AACL,aAAW,CAAC,aAAa,KAAK,KAAK,OAAO,QAAQ,IAAI,SAAS,OAAO,GAAG;AACvE,aAAS,WAAW,IAAI,EAAE,UACvB,MAAM,MAAM,QAAQ,WAAW,CAAU,EACzC,SAAS,OAAO,EAAE,OAAO,IAAI,MAAM;AAClC,YAAM,WAAW;AACjB,UAAI;AACF,cAAM,QAAQ,MAAM,QAAQ,MAAM,QAAQ;AAC1C,cAAM,SAAS,MAAM,QAAQ,OAAO,aAAa,OAAO,QAAQ;AAChE,cAAM,mBAAmB,QAAQ,kBAC7B,MAAM,QAAQ,gBAAgB,aAAa,OAAO,QAAQ,IAC1D;AACJ,cAAM,SACJ,qBAAqB,SACjB,EAAE,QAAQ,MAAM,IAChB,EAAE,QAAQ,OAAO,iBAAiB,iBAAiB;AAEzD,YAAI,QAAQ,aAAa;AACvB,gBAAM,MAAM,QAAQ,YAAY,QAAQ,QAAQ;AAChD,cAAI,CAAC,KAAK;AACR,kBAAM,IAAI,UAAU;AAAA,cAClB,MAAM;AAAA,cACN,SAAS;AAAA,YACX,CAAC;AAAA,UACH;AACA,gBAAM,UAAU,MAAM;AAAA,YACpB,QAAQ,YAAY;AAAA,YACpB;AAAA,YACA,MACE,IAAI,GAAG,aAAsB,QAAiB,KAAc;AAAA,UAChE;AACA,cAAI,QAAQ,SAAS;AACnB,kBAAM,IAAI,UAAU;AAAA,cAClB,MAAM;AAAA,cACN,SACE;AAAA,YACJ,CAAC;AAAA,UACH;AACA,iBAAO,QAAQ;AAAA,QACjB;AAEA,eAAO,MAAM,IAAI;AAAA,UACf;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,eAAe,UAAW,OAAM;AACpC,cAAM,cAAc,GAAG;AAAA,MACzB;AAAA,IACF,CAAC;AAAA,EACL;AAQA,SAAO,EAAE,OAAO,QAAQ;AAC1B;","names":[]}
@@ -1,3 +1,5 @@
1
+ import "../chunk-PZ5AY32C.js";
2
+
1
3
  // src/webhook/types.ts
2
4
  import {
3
5
  NonRetryableError