@rotorsoft/act-http 1.3.0 → 1.5.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.
- package/README.md +81 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/hono/index.d.ts +109 -0
- package/dist/@types/hono/index.d.ts.map +1 -0
- package/dist/@types/openapi/index.d.ts +191 -0
- package/dist/@types/openapi/index.d.ts.map +1 -0
- package/dist/api/index.js +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/hono/index.cjs +192 -0
- package/dist/hono/index.cjs.map +1 -0
- package/dist/hono/index.js +117 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/openapi/index.cjs +14682 -0
- package/dist/openapi/index.cjs.map +1 -0
- package/dist/openapi/index.js +14659 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/receiver/express/index.js +1 -0
- package/dist/receiver/express/index.js.map +1 -1
- package/dist/receiver/fastify/index.js +1 -0
- package/dist/receiver/fastify/index.js.map +1 -1
- package/dist/receiver/hono/index.js +1 -0
- package/dist/receiver/index.js +1 -0
- package/dist/receiver/index.js.map +1 -1
- package/dist/receiver/trpc/index.js +1 -0
- package/dist/receiver/trpc/index.js.map +1 -1
- package/dist/sse/index.js +2 -0
- package/dist/sse/index.js.map +1 -1
- package/dist/trpc/index.js +1 -0
- package/dist/trpc/index.js.map +1 -1
- package/dist/webhook/index.js +2 -0
- package/dist/webhook/index.js.map +1 -1
- package/package.json +14 -3
|
@@ -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":"
|
|
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 +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":"
|
|
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":[]}
|
package/dist/receiver/index.js
CHANGED
|
@@ -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":"
|
|
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 +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":"
|
|
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
package/dist/sse/index.js.map
CHANGED
|
@@ -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":[]}
|
package/dist/trpc/index.js
CHANGED
package/dist/trpc/index.js.map
CHANGED
|
@@ -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 { 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":[]}
|
package/dist/webhook/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts","../../src/webhook/index.ts"],"sourcesContent":["import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classify_http_response(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link try_ok}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"my_sdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async (event) => {\n * const response = await my_sdk.deliver(event);\n * await try_ok(response, { url: my_sdk.url, label: \"my_sdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function try_ok(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classify_http_response(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function sign_request(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n","/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options shipped in ACT-601 (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classify_http_response } from \"./classify.js\";\nimport { sign_request } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport type { HttpDisposition } from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction has_header(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetch_impl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhook_deliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const custom_headers = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...custom_headers };\n\n if (!has_header(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!has_header(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !has_header(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = sign_request(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!has_header(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetch_impl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n } finally {\n clearTimeout(timer);\n }\n\n const disposition = classify_http_response(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/webhook/types.ts","../../src/webhook/classify.ts","../../src/webhook/sign.ts","../../src/webhook/index.ts"],"sourcesContent":["import {\n type Committed,\n NonRetryableError,\n type Schemas,\n} from \"@rotorsoft/act\";\n\n/**\n * Function or static value resolver. Used so callers can pass either a\n * constant or a per-event function for headers / body / url.\n *\n * The static side `T` is constrained to non-function types so that a\n * passed `(event) => ...` is unambiguously typed as the function variant.\n */\nexport type WebhookResolver<TEvents extends Schemas, T> =\n | T\n | ((event: Committed<TEvents, keyof TEvents>) => T);\n\n/**\n * Plain-data body shape the helper accepts as a static value. Functions\n * are deliberately excluded so the union with the resolver function is\n * unambiguous at the call site (TypeScript can discriminate by shape).\n */\nexport type WebhookBody =\n | string\n | { readonly [k: string]: unknown }\n | readonly unknown[];\n\n/**\n * Configuration for {@link webhook}.\n *\n * @template TEvents - Event schemas; resolvers receive the typed committed event.\n */\nexport type WebhookConfig<TEvents extends Schemas = Schemas> = {\n /** Target URL — static string or per-event function. */\n readonly url: WebhookResolver<TEvents, string>;\n /** HTTP method. Defaults to `\"POST\"`. */\n readonly method?: \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n /**\n * Headers to send. Resolver may return a record per event. The\n * `Content-Type: application/json` and `Idempotency-Key` headers are\n * applied automatically; both can be overridden by returning a header\n * with the same name (case-insensitive).\n */\n readonly headers?: WebhookResolver<TEvents, Record<string, string>>;\n /**\n * Request body. Static plain data (object, array, string) or a\n * per-event function returning the same. Strings are sent as-is;\n * anything else is JSON-serialized. Defaults to the committed event\n * itself.\n */\n readonly body?:\n | WebhookBody\n | ((event: Committed<TEvents, keyof TEvents>) => WebhookBody);\n /**\n * Per-request timeout in milliseconds. Defaults to 5000.\n * The handler throws after the timeout via `AbortController`.\n */\n readonly timeoutMs?: number;\n /**\n * Override for the auto-generated `Idempotency-Key`. By default, the\n * helper sends `event.id` (the immutable, monotonic event identifier).\n * Return a string to override; return `null` to skip the header entirely.\n */\n readonly idempotencyKey?: (\n event: Committed<TEvents, keyof TEvents>\n ) => string | null;\n /**\n * Injection point for tests. Defaults to global `fetch`.\n */\n readonly fetch?: typeof fetch;\n /**\n * HMAC-SHA256 signing key. When set, the webhook helper attaches\n * two headers to every request:\n *\n * - `X-Webhook-Signature: sha256=<hex>` — HMAC of\n * `${timestamp}.${body}` (`body` is the final serialized payload)\n * - `X-Webhook-Timestamp: <unix-seconds>`\n *\n * Pair with `verifyWebhook` from `@rotorsoft/act-http/receiver` on\n * the receiving side. When undefined, no signature headers are\n * added — back-compat with consumers that don't need signing.\n *\n * Callers can override either header by returning it from the\n * `headers` resolver (case-insensitive), the same way the\n * `Idempotency-Key` and `Content-Type` defaults yield to caller\n * intent.\n */\n readonly secret?: string;\n};\n\n/**\n * Common fields carried on every HTTP delivery error in this package.\n */\nexport type HttpDeliveryErrorInit = {\n status: number;\n url: string;\n responseBody?: string;\n};\n\n/**\n * Thrown when an HTTP delivery fails in a way the drain pipeline\n * should retry: network failure, timeout, or 5xx response. `status` is\n * `0` for network / timeout errors, the HTTP status code otherwise.\n *\n * The class itself is the retry signal — if a reaction throws this,\n * drain treats it like any other error (counts against `maxRetries`,\n * paces with `backoff`). For permanent failures, throw\n * {@link NonRetryableHttpError} instead.\n *\n * Generic enough to cover any custom HTTP-like integration (gRPC\n * bridges, SDK-based reactions). {@link WebhookError} is a\n * webhook-specific subclass kept for backward compatibility.\n */\nexport class RetryableHttpError extends Error {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"RetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Thrown when an HTTP delivery returns a 3xx or 4xx response —\n * permanent client errors that won't recover on retry. Extends\n * {@link NonRetryableError} so the drain finalizer blocks the stream\n * on the first failed attempt (when `blockOnError` is true) — no\n * wasted retries on a malformed payload or wrong URL.\n *\n * Generic enough to cover any custom HTTP-like integration.\n * {@link NonRetryableWebhookError} is a webhook-specific subclass kept\n * for backward compatibility.\n */\nexport class NonRetryableHttpError extends NonRetryableError {\n readonly status: number;\n readonly url: string;\n readonly responseBody?: string;\n\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message);\n this.name = \"NonRetryableHttpError\";\n this.status = init.status;\n this.url = init.url;\n this.responseBody = init.responseBody;\n }\n}\n\n/**\n * Webhook-specific subclass of {@link RetryableHttpError}. Thrown by\n * the {@link webhook} helper on 5xx responses, network failures, and\n * timeouts. Existing `instanceof WebhookError` checks continue to\n * work; new code targeting the generic HTTP integration shape can\n * catch {@link RetryableHttpError} instead and handle webhook +\n * custom integrations uniformly.\n */\nexport class WebhookError extends RetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"WebhookError\";\n }\n}\n\n/**\n * Webhook-specific subclass of {@link NonRetryableHttpError}. Thrown\n * by the {@link webhook} helper on 3xx/4xx responses. Existing\n * `instanceof NonRetryableWebhookError` checks continue to work; new\n * code can catch {@link NonRetryableHttpError} or\n * {@link NonRetryableError} for broader coverage.\n */\nexport class NonRetryableWebhookError extends NonRetryableHttpError {\n constructor(message: string, init: HttpDeliveryErrorInit) {\n super(message, init);\n this.name = \"NonRetryableWebhookError\";\n }\n}\n","import { NonRetryableHttpError, RetryableHttpError } from \"./types.js\";\n\n/**\n * Three buckets for an HTTP response from an outbound delivery:\n *\n * - `ok` — the receiver accepted the delivery (2xx). Stop and return.\n * - `retry` — the receiver had a transient problem (5xx). Throw a\n * retryable error; drain will pace the next attempt per `backoff`.\n * - `block` — the receiver rejected the delivery permanently (3xx\n * or 4xx). Throw a non-retryable error; drain blocks the stream\n * on the first failed attempt (when `blockOnError` is true) and\n * surfaces it via the `\"blocked\"` lifecycle event.\n *\n * The 3xx → `block` mapping is intentional: a redirect at the\n * delivery layer means the configured URL is wrong, and retrying\n * the same URL won't fix that. Manual operator review is the right\n * next step, which is what the block path produces.\n */\nexport type HttpDisposition = \"ok\" | \"retry\" | \"block\";\n\n/**\n * Classify an HTTP response as `ok` (2xx), `retry` (5xx), or\n * `block` (3xx, 4xx). The classification {@link webhook} uses\n * internally, lifted here so custom integrations (gRPC bridges,\n * SDK-based reactions, etc.) can apply the same retry semantics\n * without inventing a parallel rule.\n */\nexport function classify_http_response(response: Response): HttpDisposition {\n if (response.ok) return \"ok\";\n if (response.status >= 500) return \"retry\";\n return \"block\";\n}\n\n/** Options for {@link try_ok}. */\nexport type TryOkOptions = {\n /** The endpoint that received the request. Surfaced on the thrown error and in its message. */\n url: string;\n /**\n * Label prefixed onto the error message — typically the\n * integration's identity (`\"webhook\"`, `\"my_sdk\"`, `\"grpc\"`).\n * Default: `\"request\"`.\n */\n label?: string;\n};\n\n/**\n * If `response` is 2xx, return. Otherwise, capture the response body\n * (best-effort) and throw a {@link RetryableHttpError} (for 5xx) or\n * {@link NonRetryableHttpError} (for 3xx/4xx). Collapses the\n * classify-and-throw boilerplate every custom HTTP-like reaction\n * would otherwise write into one line:\n *\n * ```ts\n * .on(\"OrderConfirmed\").do(async (event) => {\n * const response = await my_sdk.deliver(event);\n * await try_ok(response, { url: my_sdk.url, label: \"my_sdk\" });\n * // ...response was 2xx; continue with downstream work...\n * });\n * ```\n *\n * The {@link webhook} helper throws webhook-specific subclasses\n * ({@link WebhookError} / {@link NonRetryableWebhookError}) for\n * backward compatibility — both extend the generic classes thrown\n * here, so `instanceof RetryableHttpError` matches both webhook and\n * custom-integration errors uniformly.\n */\nexport async function try_ok(\n response: Response,\n options: TryOkOptions\n): Promise<void> {\n const disposition = classify_http_response(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const label = options.label ?? \"request\";\n const ErrorClass =\n disposition === \"retry\" ? RetryableHttpError : NonRetryableHttpError;\n throw new ErrorClass(`${label} ${options.url} responded ${response.status}`, {\n status: response.status,\n url: options.url,\n responseBody,\n });\n}\n","import { createHmac } from \"node:crypto\";\n\n/**\n * Compute the HMAC-SHA256 signature for an outbound webhook request.\n *\n * The signed payload is `${timestamp}.${body}` — Stripe-style. The\n * timestamp is included so the receiver can reject replays via a\n * window check, and the dot separator prevents `timestamp + body`\n * ambiguity (12 + 345 vs 123 + 45).\n *\n * Returns `{ signature, timestamp }` so the webhook helper can attach\n * both as headers — `X-Webhook-Signature: sha256=<hex>` and\n * `X-Webhook-Timestamp: <unix-seconds>` — for the receiver to verify\n * via `verifyWebhook` from `@rotorsoft/act-http/receiver`.\n *\n * `now` is exposed for tests; production callers should leave it\n * undefined so wall-clock is used.\n *\n * @internal Reachable from tests via the source path. Not re-exported\n * from the package's `./webhook` entry — the webhook helper calls\n * it internally, and operators don't need it directly.\n */\nexport function sign_request(\n body: string,\n secret: string,\n now: number = Math.floor(Date.now() / 1000)\n): { signature: string; timestamp: string } {\n const timestamp = String(now);\n const payload = `${timestamp}.${body}`;\n const hex = createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n return { signature: `sha256=${hex}`, timestamp };\n}\n","/**\n * @packageDocumentation\n * @module act-http/webhook\n *\n * Reaction-handler sugar for POSTing committed events to external URLs.\n *\n * Wraps `fetch` with timeouts, automatic `Idempotency-Key` derivation, and\n * status-classified errors. Designed to be composed with the reaction\n * options shipped in ACT-601 (`maxRetries`, `blockOnError`, `backoff`):\n *\n * ```ts\n * import { webhook } from \"@rotorsoft/act-http/webhook\";\n *\n * .on(\"OrderConfirmed\")\n * .do(\n * webhook({\n * url: \"https://api.example.com/webhooks/orders\",\n * headers: (e) => ({ Authorization: \"Bearer ...\" }),\n * body: (e) => ({ orderId: e.stream, total: e.data.total }),\n * timeoutMs: 5_000,\n * }),\n * { maxRetries: 5, backoff: { strategy: \"exponential\", baseMs: 200, maxMs: 30_000 } }\n * )\n * .to(resolver);\n * ```\n */\n\nimport type { Committed, ReactionHandler, Schemas } from \"@rotorsoft/act\";\nimport { classify_http_response } from \"./classify.js\";\nimport { sign_request } from \"./sign.js\";\nimport {\n NonRetryableWebhookError,\n type WebhookConfig,\n WebhookError,\n} from \"./types.js\";\n\nexport type { HttpDisposition } from \"./classify.js\";\nexport type {\n HttpDeliveryErrorInit,\n WebhookBody,\n WebhookConfig,\n WebhookResolver,\n} from \"./types.js\";\nexport {\n NonRetryableHttpError,\n NonRetryableWebhookError,\n RetryableHttpError,\n WebhookError,\n} from \"./types.js\";\n\nfunction resolve<TEvents extends Schemas, T>(\n resolver: T | ((e: Committed<TEvents, keyof TEvents>) => T) | undefined,\n event: Committed<TEvents, keyof TEvents>,\n fallback: T\n): T {\n if (resolver === undefined) return fallback;\n return typeof resolver === \"function\"\n ? (resolver as (e: Committed<TEvents, keyof TEvents>) => T)(event)\n : resolver;\n}\n\n/** Case-insensitive lookup; returns true if a header is already set. */\nfunction has_header(headers: Record<string, string>, name: string): boolean {\n const lower = name.toLowerCase();\n for (const k of Object.keys(headers)) {\n if (k.toLowerCase() === lower) return true;\n }\n return false;\n}\n\n/**\n * Build a reaction handler that POSTs each event to an external URL.\n *\n * Behavior:\n *\n * - 2xx and 3xx return successfully.\n * - 5xx responses, network errors, and timeouts throw\n * {@link WebhookError} (`status: 0` for network/timeout). Drain\n * retries per the reaction's `maxRetries` / `backoff`.\n * - 4xx responses throw {@link NonRetryableWebhookError}, which\n * extends `NonRetryableError`. The drain finalizer blocks the\n * stream immediately (when `blockOnError` is true) without\n * consuming the retry budget.\n */\nexport function webhook<TEvents extends Schemas = Schemas>(\n config: WebhookConfig<TEvents>\n): ReactionHandler<TEvents, keyof TEvents> {\n const timeoutMs = config.timeoutMs ?? 5_000;\n const method = config.method ?? \"POST\";\n const fetch_impl = config.fetch ?? globalThis.fetch;\n\n // Named function: slice/act builders require non-anonymous reaction\n // handlers so lifecycle telemetry can attribute work.\n return async function webhook_deliver(event) {\n const url = resolve(config.url, event, \"\");\n\n const custom_headers = resolve(\n config.headers,\n event,\n {} as Record<string, string>\n );\n const headers: Record<string, string> = { ...custom_headers };\n\n if (!has_header(headers, \"content-type\")) {\n headers[\"Content-Type\"] = \"application/json\";\n }\n if (!has_header(headers, \"idempotency-key\")) {\n const key = config.idempotencyKey\n ? config.idempotencyKey(event)\n : String(event.id);\n if (key !== null) headers[\"Idempotency-Key\"] = key;\n }\n\n const rawBody = resolve(config.body, event, event as unknown);\n const body =\n typeof rawBody === \"string\" ? rawBody : JSON.stringify(rawBody);\n\n if (config.secret && !has_header(headers, \"x-webhook-signature\")) {\n const { signature, timestamp } = sign_request(body, config.secret);\n headers[\"X-Webhook-Signature\"] = signature;\n if (!has_header(headers, \"x-webhook-timestamp\")) {\n headers[\"X-Webhook-Timestamp\"] = timestamp;\n }\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n let response: Response;\n try {\n response = await fetch_impl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n const aborted = controller.signal.aborted;\n throw new WebhookError(\n aborted\n ? `webhook ${method} ${url} timed out after ${timeoutMs}ms`\n : `webhook ${method} ${url} failed: ${(err as Error).message}`,\n { status: 0, url }\n );\n } finally {\n clearTimeout(timer);\n }\n\n const disposition = classify_http_response(response);\n if (disposition === \"ok\") return;\n\n let responseBody: string | undefined;\n try {\n responseBody = await response.text();\n } catch {\n // best-effort body capture; ignore read errors\n }\n\n const ErrorClass =\n disposition === \"retry\" ? WebhookError : NonRetryableWebhookError;\n throw new ErrorClass(\n `webhook ${method} ${url} responded ${response.status}`,\n { status: response.status, url, responseBody }\n );\n };\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rotorsoft/act-http",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"description": "HTTP integrations for act apps — webhooks and SSE",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"typescript",
|
|
@@ -31,6 +31,16 @@
|
|
|
31
31
|
"import": "./dist/api/index.js",
|
|
32
32
|
"require": "./dist/api/index.cjs"
|
|
33
33
|
},
|
|
34
|
+
"./hono": {
|
|
35
|
+
"types": "./dist/@types/hono/index.d.ts",
|
|
36
|
+
"import": "./dist/hono/index.js",
|
|
37
|
+
"require": "./dist/hono/index.cjs"
|
|
38
|
+
},
|
|
39
|
+
"./openapi": {
|
|
40
|
+
"types": "./dist/@types/openapi/index.d.ts",
|
|
41
|
+
"import": "./dist/openapi/index.js",
|
|
42
|
+
"require": "./dist/openapi/index.cjs"
|
|
43
|
+
},
|
|
34
44
|
"./receiver": {
|
|
35
45
|
"types": "./dist/@types/receiver/index.d.ts",
|
|
36
46
|
"import": "./dist/receiver/index.js",
|
|
@@ -80,11 +90,11 @@
|
|
|
80
90
|
"access": "public"
|
|
81
91
|
},
|
|
82
92
|
"peerDependencies": {
|
|
83
|
-
"@trpc/server": ">=11.17.0",
|
|
84
93
|
"@hono/node-server": "^2.0.4",
|
|
94
|
+
"@trpc/server": ">=11.17.0",
|
|
85
95
|
"express": "^5.2.1",
|
|
86
96
|
"fastify": "^5.8.5",
|
|
87
|
-
"hono": "^4.12.
|
|
97
|
+
"hono": "^4.12.24",
|
|
88
98
|
"@rotorsoft/act": "^1.10.2",
|
|
89
99
|
"@rotorsoft/act-ops": "^0.2.1"
|
|
90
100
|
},
|
|
@@ -113,6 +123,7 @@
|
|
|
113
123
|
},
|
|
114
124
|
"devDependencies": {
|
|
115
125
|
"@hono/node-server": "^2.0.4",
|
|
126
|
+
"@hono/zod-validator": "^0.8.0",
|
|
116
127
|
"@trpc/server": "^11.17.0",
|
|
117
128
|
"@types/express": "^5.0.6",
|
|
118
129
|
"express": "^5.2.1",
|