@rotorsoft/act-http 1.2.1 → 1.4.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 +92 -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/trpc/index.d.ts +168 -0
- package/dist/@types/trpc/index.d.ts.map +1 -0
- package/dist/api/index.js +4 -55
- package/dist/api/index.js.map +1 -1
- package/dist/chunk-Z6FVXDDU.js +63 -0
- package/dist/chunk-Z6FVXDDU.js.map +1 -0
- package/dist/hono/index.cjs +192 -0
- package/dist/hono/index.cjs.map +1 -0
- package/dist/hono/index.js +116 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/trpc/index.cjs +169 -0
- package/dist/trpc/index.cjs.map +1 -0
- package/dist/trpc/index.js +95 -0
- package/dist/trpc/index.js.map +1 -0
- package/package.json +34 -23
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/hono/index.ts","../../../../node_modules/.pnpm/@hono+zod-validator@0.8.0_hono@4.12.23_zod@4.4.3/node_modules/@hono/zod-validator/src/index.ts","../../src/api/errors.ts","../../src/api/idempotency.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/hono\n *\n * Hono subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a Hono\n * app with one `POST /actions/<name>` route per registered action,\n * Zod-validated bodies, and the shared error envelope.\n *\n * Usage:\n *\n * ```ts\n * import { hono } from \"@rotorsoft/act-http/hono\";\n *\n * const api = hono(app, {\n * actor: (c) => ({ id: c.req.header(\"x-user-id\")!, name: \"...\" }),\n * stream: (action, input, c) => `tenant-${c.req.header(\"x-tenant\")}`,\n * });\n *\n * // Mounts at /api by default — pass `basePath` to override.\n * // POST /api/actions/OpenTicket body: { title } → 200 Snapshot[] | 4xx ApiError\n * ```\n *\n * The route shape matches the flat tRPC sibling at\n * `@rotorsoft/act-http/trpc`: one endpoint per action, no\n * state-name tier. Action names are unique across the registry —\n * the framework enforces no duplicates — so an extra namespace\n * level would be overhead. Queries live in a follow-up; this\n * slice is mutations only.\n *\n * **Actor lives on the context.** The generator wires an internal\n * middleware that runs the host's\n * {@link HonoOptions.actor | actor extractor} once per call and\n * stashes the resolved {@link Actor} under `c.get(\"actor\")`. Every\n * generated route reads from there — auth resolves at one seam,\n * downstream middleware (logging, tracing) sees the same actor.\n * The middleware factory is also exported as {@link authenticated}\n * for hosts that want to compose it into their own Hono chain\n * alongside hand-written routes.\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 (tRPC, this one,\n * OpenAPI) ride the same utilities so a client speaking two of\n * them never sees two shapes for the same framework error.\n */\nimport { zValidator } from \"@hono/zod-validator\";\nimport type { Act, Actor, Schema, Schemas } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport { Hono } from \"hono\";\nimport {\n type ActorExtractor,\n type ApiError,\n toApiError,\n withIdempotency,\n} from \"../api/index.js\";\n\n/**\n * Hono context variables this subpath contributes. The generic on\n * the returned middleware threads it through so routes downstream\n * of {@link authenticated} see `c.get(\"actor\")` typed without a\n * manual cast.\n */\nexport type ActMiddlewareVariables = {\n actor: Actor;\n};\n\n/**\n * Per-call options for {@link hono}. The host supplies the four\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the Hono context, returns the\n * `Actor` that flows onto `c.get(\"actor\")` for every generated\n * route (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated body, and the Hono context; returns the target\n * stream. Singleton aggregates return a constant; per-tenant\n * aggregates pull from headers, path, or body.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * Hosts typically read it from an `If-Match` header on the\n * request, or from the client's last-known snapshot. Returning\n * `undefined` skips the check for that call — handy when only\n * some actions are concurrency-sensitive.\n * - `idempotency` (optional) — when set, the route honors\n * `Idempotency-Key` via {@link withIdempotency}. The host\n * supplies the `IdempotencyStore`; `keyFrom` defaults to reading\n * the `Idempotency-Key` header. On a duplicate claim, the route\n * responds `409 Conflict` — the contract intentionally doesn't\n * cache the original handler's result, matching the\n * receiver-side \"ack the duplicate\" semantics.\n * - `basePath` (optional, default `\"/api\"`) — Hono basePath the\n * routes mount under.\n */\nexport type HonoOptions = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n c: Context\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n c: Context\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom?: (c: Context) => string | undefined;\n };\n readonly basePath?: string;\n};\n\n/**\n * Build a Hono middleware that runs an {@link ActorExtractor} once\n * per call and stashes the resolved {@link Actor} under\n * `c.set(\"actor\", ...)` so downstream routes can read it via\n * `c.get(\"actor\")`. The generator at {@link hono} uses this\n * internally; it's also exported so hosts that want to compose\n * their own route chain (logging, tracing, custom auth flavors)\n * can wire it directly:\n *\n * ```ts\n * import { Hono } from \"hono\";\n * import { authenticated, type ActMiddlewareVariables } from \"@rotorsoft/act-http/hono\";\n *\n * const api = new Hono<{ Variables: ActMiddlewareVariables }>();\n * api.use(\"*\", authenticated((c) => resolveUserFromJwt(c)));\n * api.get(\"/me\", (c) => c.json(c.get(\"actor\"))); // typed\n * ```\n *\n * Errors thrown by the extractor surface as `401 Unauthorized` with\n * the shared {@link ApiError} envelope.\n */\nexport function authenticated(\n extractor: ActorExtractor\n): MiddlewareHandler<{ Variables: ActMiddlewareVariables }> {\n return async (c, next) => {\n try {\n const actor = await extractor(c);\n c.set(\"actor\", actor);\n await next();\n } catch (err) {\n const body: ApiError = {\n error: \"Unauthorized\",\n detail: err instanceof Error ? err.message : undefined,\n code: \"UNAUTHORIZED\",\n };\n return c.json(body, 401);\n }\n };\n}\n\nconst default_key_from = (c: Context): string | undefined =>\n c.req.header(\"idempotency-key\");\n\n/**\n * Build a typed Hono REST surface from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one\n * `POST /actions/<actionName>` per action under the configured\n * `basePath` (default `/api`). Each route:\n *\n * 1. Runs the internal {@link authenticated} middleware —\n * `options.actor` resolves the {@link Actor} once and stashes\n * it under `c.get(\"actor\")`.\n * 2. Validates the JSON body against the action's Zod schema via\n * `@hono/zod-validator`. Failures short-circuit with `422`.\n * 3. Resolves the target stream via\n * `options.stream(name, input, c)`.\n * 4. (Optionally) resolves `expectedVersion` for optimistic\n * concurrency.\n * 5. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — responds `409` on duplicate.\n * 6. Calls\n * `app.do(name, { stream, actor, expectedVersion? }, input)`.\n * 7. Maps any thrown framework error onto the shared\n * {@link ApiError} envelope via {@link toApiError}, returning\n * the conventional HTTP status (`409` / `412` for concurrency,\n * `422` for validation, `400` for non-retryable, etc.).\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, expected-version, idempotency, and\n * base-path seams.\n * @returns A Hono app covering every registered action under\n * `<basePath>/actions/<name>`.\n */\nexport function hono(\n app: Act<Schemas, Schemas, Schemas, Record<string, Schema>>,\n options: HonoOptions\n): Hono<{ Variables: ActMiddlewareVariables }> {\n const base_path = options.basePath ?? \"/api\";\n const api = new Hono<{ Variables: ActMiddlewareVariables }>().basePath(\n base_path\n );\n api.use(\"*\", authenticated(options.actor));\n\n const key_from = options.idempotency?.keyFrom ?? default_key_from;\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const schema = state.actions[action_name] as Schema;\n api.post(\n `/actions/${action_name}`,\n zValidator(\"json\", schema as never),\n async (c) => {\n try {\n const input = c.req.valid(\"json\" as never) as unknown;\n const actor = c.get(\"actor\");\n const stream = await options.stream(action_name, input, c);\n const expected_version = options.expectedVersion\n ? await options.expectedVersion(action_name, input, c)\n : undefined;\n const target =\n expected_version === undefined\n ? { stream, actor }\n : { stream, actor, expectedVersion: expected_version };\n\n if (options.idempotency) {\n const key = key_from(c);\n if (!key) {\n const body: ApiError = {\n error: \"BadRequest\",\n detail: \"Idempotency-Key required\",\n code: \"BAD_REQUEST\",\n };\n return c.json(body, 400);\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 const body: ApiError = {\n error: \"Conflict\",\n detail:\n \"Idempotency-Key already used; original result not cached\",\n code: \"CONFLICT\",\n };\n return c.json(body, 409);\n }\n return c.json(outcome.result);\n }\n\n const snapshots = await app.do(\n action_name as never,\n target as never,\n input as never\n );\n return c.json(snapshots);\n } catch (err) {\n const { status, body } = toApiError(err);\n // Hono types narrow `status` to its known status-number union;\n // `toApiError` returns a numeric status from a closed set that\n // matches but TypeScript can't prove it through the union.\n return c.json(body, status as 400 | 409 | 410 | 412 | 422 | 500);\n }\n }\n );\n }\n\n return api;\n}\n","/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono'\nimport { validator } from 'hono/validator'\nimport type * as v3 from 'zod/v3'\nimport type { ZodSafeParseResult as v4ZodSafeParseResult } from 'zod/v4'\nimport type * as v4 from 'zod/v4/core'\nimport type { InferInput } from './utils'\n\ntype ZodSchema = v3.ZodType | v4.$ZodType\ntype ZodError<T extends ZodSchema> = T extends v4.$ZodType\n ? v4.$ZodError<v4.output<T>>\n : v3.ZodError\ntype ZodSafeParseResult<T, T2, T3 extends ZodSchema> = T3 extends v4.$ZodType\n ? v4ZodSafeParseResult<T>\n : v3.SafeParseReturnType<T, T2>\ntype zInput<T> = T extends v3.ZodType ? v3.input<T> : T extends v4.$ZodType ? v4.input<T> : never\ntype zOutput<T> = T extends v3.ZodType ? v3.output<T> : T extends v4.$ZodType ? v4.output<T> : never\ntype zInfer<T> = T extends v3.ZodType ? v3.infer<T> : T extends v4.$ZodType ? v4.infer<T> : never\n\n// Failure variant of `safeParseAsync`'s return value.\n// Mirrors the runtime body returned by `c.json(result, 400)` on validation failure.\ntype ZodValidatorFailureBody<T extends ZodSchema> = T extends v4.$ZodType\n ? Extract<v4ZodSafeParseResult<v4.output<T>>, { success: false }>\n : T extends v3.ZodType\n ? Extract<v3.SafeParseReturnType<v3.input<T>, v3.output<T>>, { success: false }>\n : never\n\ntype ZodValidatorFailureResponse<T extends ZodSchema> = Response &\n TypedResponse<ZodValidatorFailureBody<T>, 400, 'json'>\n\nexport type Hook<\n T,\n E extends Env,\n P extends string,\n Target extends keyof ValidationTargets = keyof ValidationTargets,\n O = {},\n Schema extends ZodSchema = any,\n> = (\n result: ({ success: true; data: T } | { success: false; error: ZodError<Schema>; data: T }) & {\n target: Target\n },\n c: Context<E, P>\n) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>\n\ntype HasUndefined<T> = undefined extends T ? true : false\n\ntype ExtractValidationResponse<VF> = VF extends (value: any, c: any) => infer R\n ? R extends Promise<infer PR>\n ? PR extends TypedResponse<infer T, infer S, infer F>\n ? Response & TypedResponse<T, S, F>\n : PR extends Response\n ? PR\n : PR extends undefined\n ? never\n : never\n : R extends TypedResponse<infer T, infer S, infer F>\n ? Response & TypedResponse<T, S, F>\n : R extends Response\n ? R\n : R extends undefined\n ? never\n : never\n : never\n\ntype ValidationFunctionOption<T extends ZodSchema, Target extends keyof ValidationTargets> = {\n validationFunction: (\n schema: T,\n value: ValidationTargets[Target]\n ) => ZodSafeParseResult<any, any, T> | Promise<ZodSafeParseResult<any, any, T>>\n}\n\ntype DefaultInput<Target extends keyof ValidationTargets, In, Out> = {\n in: HasUndefined<In> extends true\n ? {\n [K in Target]?: [In] extends [ValidationTargets[K]] ? In : InferInput<In, K>\n }\n : {\n [K in Target]: [In] extends [ValidationTargets[K]] ? In : InferInput<In, K>\n }\n out: { [K in Target]: Out }\n}\n\n// without hook (with or without options)\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n>(\n target: Target,\n schema: T,\n hook?: undefined,\n options?: ValidationFunctionOption<T, Target>\n): MiddlewareHandler<E, P, V, ZodValidatorFailureResponse<T>>\n\n// with hook and options\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n HookFn extends Hook<InferredValue, E, P, Target, {}, T>,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n InferredValue = zInfer<T>,\n>(\n target: Target,\n schema: T,\n hook?: HookFn,\n options?: ValidationFunctionOption<T, Target>\n): MiddlewareHandler<E, P, V, ExtractValidationResponse<HookFn>>\n\n// implementation\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n HookFn extends Hook<InferredValue, E, P, Target, {}, T>,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n InferredValue = zInfer<T>,\n>(\n target: Target,\n schema: T,\n hook?: HookFn,\n options?: ValidationFunctionOption<T, Target>\n):\n | MiddlewareHandler<E, P, V, ZodValidatorFailureResponse<T>>\n | MiddlewareHandler<E, P, V, ExtractValidationResponse<HookFn>> {\n // @ts-expect-error not typed well\n return validator(target, async (value: ValidationTargets[Target], c) => {\n let validatorValue = value\n\n // in case where our `target` === `header`, Hono parses all of the headers into lowercase.\n // this might not match the Zod schema, so we want to make sure that we account for that when parsing the schema.\n if ((target === 'header' && '_def' in schema) || (target === 'header' && '_zod' in schema)) {\n // create an object that maps lowercase schema keys to lowercase\n // @ts-expect-error the schema is a Zod Schema\n const schemaKeys = Object.keys('in' in schema ? schema.in.shape : schema.shape)\n const caseInsensitiveKeymap = Object.fromEntries(\n schemaKeys.map((key) => [key.toLowerCase(), key])\n )\n\n validatorValue = Object.fromEntries(\n Object.entries(value).map(([key, value]) => [caseInsensitiveKeymap[key] || key, value])\n )\n }\n\n const result =\n options && options.validationFunction\n ? await options.validationFunction(schema, validatorValue)\n : // @ts-expect-error z4.$ZodType has safeParseAsync\n await schema.safeParseAsync(validatorValue)\n\n if (hook) {\n const hookResult = await hook({ data: validatorValue, ...result, target }, c)\n if (hookResult) {\n if (hookResult instanceof Response) {\n return hookResult\n }\n\n if ('response' in hookResult) {\n return hookResult.response\n }\n }\n }\n\n if (!result.success) {\n return c.json(result, 400)\n }\n\n return result.data as zInfer<T>\n })\n}\n\nexport const zValidator: typeof zValidatorFunction = zValidatorFunction\n","import {\n ConcurrencyError,\n InvariantError,\n NonRetryableError,\n StreamClosedError,\n ValidationError,\n} from \"@rotorsoft/act\";\n\n/**\n * Uniform error envelope shipped over the wire by every act-http\n * transport. Hosts get the same shape from REST, tRPC, and OpenAPI —\n * a client that talks to two transports doesn't have to invent two\n * error parsers.\n *\n * - `error` — the framework error name (`\"ValidationError\"`,\n * `\"InvariantError\"`, …). Stable identifier, safe to switch on.\n * - `detail` — the framework's message text. Human-readable; not\n * parsed by clients.\n * - `code` — a machine-readable status code from {@link ERROR_MAP}\n * for clients that prefer enum-style branching over name strings.\n */\nexport type ApiError = {\n error: string;\n detail?: string;\n code?: string;\n};\n\n/**\n * Status + code pair for one known framework error.\n */\nexport type ErrorMapEntry = {\n status: number;\n code: string;\n};\n\n/**\n * The single table that maps framework error types to HTTP status\n * codes and machine-readable codes. One table, three consumers\n * (Hono, tRPC, OpenAPI) — cross-transport consistency by\n * construction.\n *\n * Operators wanting different mappings wrap the generated transport\n * rather than mutating this — the consistency is the load-bearing\n * property, not the specific status codes.\n */\nexport const ERROR_MAP = {\n ValidationError: { status: 422, code: \"VALIDATION\" },\n InvariantError: { status: 409, code: \"INVARIANT\" },\n ConcurrencyError: { status: 412, code: \"CONCURRENCY\" },\n StreamClosedError: { status: 410, code: \"STREAM_CLOSED\" },\n NonRetryableError: { status: 400, code: \"NON_RETRYABLE\" },\n} as const satisfies Record<string, ErrorMapEntry>;\n\nconst lookup_known = (\n err: unknown\n): { name: keyof typeof ERROR_MAP } | null => {\n if (err instanceof ValidationError) return { name: \"ValidationError\" };\n if (err instanceof InvariantError) return { name: \"InvariantError\" };\n if (err instanceof ConcurrencyError) return { name: \"ConcurrencyError\" };\n if (err instanceof StreamClosedError) return { name: \"StreamClosedError\" };\n if (err instanceof NonRetryableError) return { name: \"NonRetryableError\" };\n return null;\n};\n\n/**\n * Translate an unknown thrown value into the canonical\n * {@link ApiError} envelope plus HTTP status. Each transport's error\n * boundary calls this once and forwards the result to the wire.\n *\n * Known framework errors map per {@link ERROR_MAP}. Everything else\n * surfaces as a 500 with `code: \"INTERNAL\"`; the `detail` field is\n * populated when the throw was an `Error` instance, omitted\n * otherwise (a thrown string or object doesn't get to leak its\n * payload to the client).\n */\nexport function toApiError(err: unknown): { status: number; body: ApiError } {\n const known = lookup_known(err);\n if (known) {\n const entry = ERROR_MAP[known.name];\n return {\n status: entry.status,\n body: {\n error: known.name,\n detail: (err as Error).message,\n code: entry.code,\n },\n };\n }\n if (err instanceof Error) {\n return {\n status: 500,\n body: { error: \"InternalError\", detail: err.message, code: \"INTERNAL\" },\n };\n }\n return {\n status: 500,\n body: { error: \"InternalError\", code: \"INTERNAL\" },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * Result of a {@link withIdempotency} call.\n *\n * - `{ deduped: false, result }` — the claim was fresh; the handler\n * ran and produced `result`.\n * - `{ deduped: true }` — the claim was already taken; the handler\n * was *not* invoked. The caller decides how to respond (typically\n * a 2xx with no body, matching the receiver-side convention).\n *\n * Note: the contract does not cache the previous response. A\n * duplicate call returns the deduped marker only — replaying the\n * original handler's output would require a response-caching\n * adapter, which is out of scope here. The receiver-side convention\n * (and the convention the generated transports follow) is \"ack the\n * duplicate; do nothing else.\"\n */\nexport type IdempotencyResult<T> =\n | { deduped: false; result: T }\n | { deduped: true };\n\n/**\n * Wrap an action handler so the framework honors `Idempotency-Key`\n * dedup. Acquires the key via {@link IdempotencyStore.claim}, runs\n * the handler exactly when the claim was fresh, and skips the\n * handler entirely on a duplicate.\n *\n * Reuses the contract `@rotorsoft/act-ops/idempotency` already\n * defines for the receiver-side `Idempotency-Key` story. A single\n * `IdempotencyStore` implementation covers both halves of the \"Act\n * over the wire\" surface — receiver and generated API.\n */\nexport async function withIdempotency<T>(\n store: IdempotencyStore,\n key: string,\n handler: () => Promise<T>\n): Promise<IdempotencyResult<T>> {\n const fresh = await store.claim(key);\n if (!fresh) {\n return { deduped: true };\n }\n return { deduped: false, result: await handler() };\n}\n"],"mappings":"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","names":["value","zValidator: typeof zValidatorFunction"]}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
toApiError,
|
|
3
|
+
withIdempotency
|
|
4
|
+
} from "../chunk-Z6FVXDDU.js";
|
|
5
|
+
|
|
6
|
+
// ../../node_modules/.pnpm/@hono+zod-validator@0.8.0_hono@4.12.23_zod@4.4.3/node_modules/@hono/zod-validator/dist/index.js
|
|
7
|
+
import { validator } from "hono/validator";
|
|
8
|
+
function zValidatorFunction(target, schema, hook, options) {
|
|
9
|
+
return validator(target, async (value, c) => {
|
|
10
|
+
let validatorValue = value;
|
|
11
|
+
if (target === "header" && "_def" in schema || target === "header" && "_zod" in schema) {
|
|
12
|
+
const schemaKeys = Object.keys("in" in schema ? schema.in.shape : schema.shape);
|
|
13
|
+
const caseInsensitiveKeymap = Object.fromEntries(schemaKeys.map((key) => [key.toLowerCase(), key]));
|
|
14
|
+
validatorValue = Object.fromEntries(Object.entries(value).map(([key, value$1]) => [caseInsensitiveKeymap[key] || key, value$1]));
|
|
15
|
+
}
|
|
16
|
+
const result = options && options.validationFunction ? await options.validationFunction(schema, validatorValue) : await schema.safeParseAsync(validatorValue);
|
|
17
|
+
if (hook) {
|
|
18
|
+
const hookResult = await hook({
|
|
19
|
+
data: validatorValue,
|
|
20
|
+
...result,
|
|
21
|
+
target
|
|
22
|
+
}, c);
|
|
23
|
+
if (hookResult) {
|
|
24
|
+
if (hookResult instanceof Response) return hookResult;
|
|
25
|
+
if ("response" in hookResult) return hookResult.response;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!result.success) return c.json(result, 400);
|
|
29
|
+
return result.data;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
var zValidator = zValidatorFunction;
|
|
33
|
+
|
|
34
|
+
// src/hono/index.ts
|
|
35
|
+
import { Hono } from "hono";
|
|
36
|
+
function authenticated(extractor) {
|
|
37
|
+
return async (c, next) => {
|
|
38
|
+
try {
|
|
39
|
+
const actor = await extractor(c);
|
|
40
|
+
c.set("actor", actor);
|
|
41
|
+
await next();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const body = {
|
|
44
|
+
error: "Unauthorized",
|
|
45
|
+
detail: err instanceof Error ? err.message : void 0,
|
|
46
|
+
code: "UNAUTHORIZED"
|
|
47
|
+
};
|
|
48
|
+
return c.json(body, 401);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
var default_key_from = (c) => c.req.header("idempotency-key");
|
|
53
|
+
function hono(app, options) {
|
|
54
|
+
const base_path = options.basePath ?? "/api";
|
|
55
|
+
const api = new Hono().basePath(
|
|
56
|
+
base_path
|
|
57
|
+
);
|
|
58
|
+
api.use("*", authenticated(options.actor));
|
|
59
|
+
const key_from = options.idempotency?.keyFrom ?? default_key_from;
|
|
60
|
+
for (const [action_name, state] of Object.entries(app.registry.actions)) {
|
|
61
|
+
const schema = state.actions[action_name];
|
|
62
|
+
api.post(
|
|
63
|
+
`/actions/${action_name}`,
|
|
64
|
+
zValidator("json", schema),
|
|
65
|
+
async (c) => {
|
|
66
|
+
try {
|
|
67
|
+
const input = c.req.valid("json");
|
|
68
|
+
const actor = c.get("actor");
|
|
69
|
+
const stream = await options.stream(action_name, input, c);
|
|
70
|
+
const expected_version = options.expectedVersion ? await options.expectedVersion(action_name, input, c) : void 0;
|
|
71
|
+
const target = expected_version === void 0 ? { stream, actor } : { stream, actor, expectedVersion: expected_version };
|
|
72
|
+
if (options.idempotency) {
|
|
73
|
+
const key = key_from(c);
|
|
74
|
+
if (!key) {
|
|
75
|
+
const body = {
|
|
76
|
+
error: "BadRequest",
|
|
77
|
+
detail: "Idempotency-Key required",
|
|
78
|
+
code: "BAD_REQUEST"
|
|
79
|
+
};
|
|
80
|
+
return c.json(body, 400);
|
|
81
|
+
}
|
|
82
|
+
const outcome = await withIdempotency(
|
|
83
|
+
options.idempotency.store,
|
|
84
|
+
key,
|
|
85
|
+
() => app.do(action_name, target, input)
|
|
86
|
+
);
|
|
87
|
+
if (outcome.deduped) {
|
|
88
|
+
const body = {
|
|
89
|
+
error: "Conflict",
|
|
90
|
+
detail: "Idempotency-Key already used; original result not cached",
|
|
91
|
+
code: "CONFLICT"
|
|
92
|
+
};
|
|
93
|
+
return c.json(body, 409);
|
|
94
|
+
}
|
|
95
|
+
return c.json(outcome.result);
|
|
96
|
+
}
|
|
97
|
+
const snapshots = await app.do(
|
|
98
|
+
action_name,
|
|
99
|
+
target,
|
|
100
|
+
input
|
|
101
|
+
);
|
|
102
|
+
return c.json(snapshots);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const { status, body } = toApiError(err);
|
|
105
|
+
return c.json(body, status);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return api;
|
|
111
|
+
}
|
|
112
|
+
export {
|
|
113
|
+
authenticated,
|
|
114
|
+
hono
|
|
115
|
+
};
|
|
116
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../node_modules/.pnpm/@hono+zod-validator@0.8.0_hono@4.12.23_zod@4.4.3/node_modules/@hono/zod-validator/src/index.ts","../../src/hono/index.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-explicit-any */\nimport type { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono'\nimport { validator } from 'hono/validator'\nimport type * as v3 from 'zod/v3'\nimport type { ZodSafeParseResult as v4ZodSafeParseResult } from 'zod/v4'\nimport type * as v4 from 'zod/v4/core'\nimport type { InferInput } from './utils'\n\ntype ZodSchema = v3.ZodType | v4.$ZodType\ntype ZodError<T extends ZodSchema> = T extends v4.$ZodType\n ? v4.$ZodError<v4.output<T>>\n : v3.ZodError\ntype ZodSafeParseResult<T, T2, T3 extends ZodSchema> = T3 extends v4.$ZodType\n ? v4ZodSafeParseResult<T>\n : v3.SafeParseReturnType<T, T2>\ntype zInput<T> = T extends v3.ZodType ? v3.input<T> : T extends v4.$ZodType ? v4.input<T> : never\ntype zOutput<T> = T extends v3.ZodType ? v3.output<T> : T extends v4.$ZodType ? v4.output<T> : never\ntype zInfer<T> = T extends v3.ZodType ? v3.infer<T> : T extends v4.$ZodType ? v4.infer<T> : never\n\n// Failure variant of `safeParseAsync`'s return value.\n// Mirrors the runtime body returned by `c.json(result, 400)` on validation failure.\ntype ZodValidatorFailureBody<T extends ZodSchema> = T extends v4.$ZodType\n ? Extract<v4ZodSafeParseResult<v4.output<T>>, { success: false }>\n : T extends v3.ZodType\n ? Extract<v3.SafeParseReturnType<v3.input<T>, v3.output<T>>, { success: false }>\n : never\n\ntype ZodValidatorFailureResponse<T extends ZodSchema> = Response &\n TypedResponse<ZodValidatorFailureBody<T>, 400, 'json'>\n\nexport type Hook<\n T,\n E extends Env,\n P extends string,\n Target extends keyof ValidationTargets = keyof ValidationTargets,\n O = {},\n Schema extends ZodSchema = any,\n> = (\n result: ({ success: true; data: T } | { success: false; error: ZodError<Schema>; data: T }) & {\n target: Target\n },\n c: Context<E, P>\n) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>\n\ntype HasUndefined<T> = undefined extends T ? true : false\n\ntype ExtractValidationResponse<VF> = VF extends (value: any, c: any) => infer R\n ? R extends Promise<infer PR>\n ? PR extends TypedResponse<infer T, infer S, infer F>\n ? Response & TypedResponse<T, S, F>\n : PR extends Response\n ? PR\n : PR extends undefined\n ? never\n : never\n : R extends TypedResponse<infer T, infer S, infer F>\n ? Response & TypedResponse<T, S, F>\n : R extends Response\n ? R\n : R extends undefined\n ? never\n : never\n : never\n\ntype ValidationFunctionOption<T extends ZodSchema, Target extends keyof ValidationTargets> = {\n validationFunction: (\n schema: T,\n value: ValidationTargets[Target]\n ) => ZodSafeParseResult<any, any, T> | Promise<ZodSafeParseResult<any, any, T>>\n}\n\ntype DefaultInput<Target extends keyof ValidationTargets, In, Out> = {\n in: HasUndefined<In> extends true\n ? {\n [K in Target]?: [In] extends [ValidationTargets[K]] ? In : InferInput<In, K>\n }\n : {\n [K in Target]: [In] extends [ValidationTargets[K]] ? In : InferInput<In, K>\n }\n out: { [K in Target]: Out }\n}\n\n// without hook (with or without options)\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n>(\n target: Target,\n schema: T,\n hook?: undefined,\n options?: ValidationFunctionOption<T, Target>\n): MiddlewareHandler<E, P, V, ZodValidatorFailureResponse<T>>\n\n// with hook and options\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n HookFn extends Hook<InferredValue, E, P, Target, {}, T>,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n InferredValue = zInfer<T>,\n>(\n target: Target,\n schema: T,\n hook?: HookFn,\n options?: ValidationFunctionOption<T, Target>\n): MiddlewareHandler<E, P, V, ExtractValidationResponse<HookFn>>\n\n// implementation\nfunction zValidatorFunction<\n T extends ZodSchema,\n Target extends keyof ValidationTargets,\n E extends Env,\n P extends string,\n HookFn extends Hook<InferredValue, E, P, Target, {}, T>,\n In = zInput<T>,\n Out = zOutput<T>,\n I extends Input = DefaultInput<Target, In, Out>,\n V extends I = I,\n InferredValue = zInfer<T>,\n>(\n target: Target,\n schema: T,\n hook?: HookFn,\n options?: ValidationFunctionOption<T, Target>\n):\n | MiddlewareHandler<E, P, V, ZodValidatorFailureResponse<T>>\n | MiddlewareHandler<E, P, V, ExtractValidationResponse<HookFn>> {\n // @ts-expect-error not typed well\n return validator(target, async (value: ValidationTargets[Target], c) => {\n let validatorValue = value\n\n // in case where our `target` === `header`, Hono parses all of the headers into lowercase.\n // this might not match the Zod schema, so we want to make sure that we account for that when parsing the schema.\n if ((target === 'header' && '_def' in schema) || (target === 'header' && '_zod' in schema)) {\n // create an object that maps lowercase schema keys to lowercase\n // @ts-expect-error the schema is a Zod Schema\n const schemaKeys = Object.keys('in' in schema ? schema.in.shape : schema.shape)\n const caseInsensitiveKeymap = Object.fromEntries(\n schemaKeys.map((key) => [key.toLowerCase(), key])\n )\n\n validatorValue = Object.fromEntries(\n Object.entries(value).map(([key, value]) => [caseInsensitiveKeymap[key] || key, value])\n )\n }\n\n const result =\n options && options.validationFunction\n ? await options.validationFunction(schema, validatorValue)\n : // @ts-expect-error z4.$ZodType has safeParseAsync\n await schema.safeParseAsync(validatorValue)\n\n if (hook) {\n const hookResult = await hook({ data: validatorValue, ...result, target }, c)\n if (hookResult) {\n if (hookResult instanceof Response) {\n return hookResult\n }\n\n if ('response' in hookResult) {\n return hookResult.response\n }\n }\n }\n\n if (!result.success) {\n return c.json(result, 400)\n }\n\n return result.data as zInfer<T>\n })\n}\n\nexport const zValidator: typeof zValidatorFunction = zValidatorFunction\n","/**\n * @packageDocumentation\n * @module act-http/hono\n *\n * Hono subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a Hono\n * app with one `POST /actions/<name>` route per registered action,\n * Zod-validated bodies, and the shared error envelope.\n *\n * Usage:\n *\n * ```ts\n * import { hono } from \"@rotorsoft/act-http/hono\";\n *\n * const api = hono(app, {\n * actor: (c) => ({ id: c.req.header(\"x-user-id\")!, name: \"...\" }),\n * stream: (action, input, c) => `tenant-${c.req.header(\"x-tenant\")}`,\n * });\n *\n * // Mounts at /api by default — pass `basePath` to override.\n * // POST /api/actions/OpenTicket body: { title } → 200 Snapshot[] | 4xx ApiError\n * ```\n *\n * The route shape matches the flat tRPC sibling at\n * `@rotorsoft/act-http/trpc`: one endpoint per action, no\n * state-name tier. Action names are unique across the registry —\n * the framework enforces no duplicates — so an extra namespace\n * level would be overhead. Queries live in a follow-up; this\n * slice is mutations only.\n *\n * **Actor lives on the context.** The generator wires an internal\n * middleware that runs the host's\n * {@link HonoOptions.actor | actor extractor} once per call and\n * stashes the resolved {@link Actor} under `c.get(\"actor\")`. Every\n * generated route reads from there — auth resolves at one seam,\n * downstream middleware (logging, tracing) sees the same actor.\n * The middleware factory is also exported as {@link authenticated}\n * for hosts that want to compose it into their own Hono chain\n * alongside hand-written routes.\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 (tRPC, this one,\n * OpenAPI) ride the same utilities so a client speaking two of\n * them never sees two shapes for the same framework error.\n */\nimport { zValidator } from \"@hono/zod-validator\";\nimport type { Act, Actor, Schema, Schemas } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport { Hono } from \"hono\";\nimport {\n type ActorExtractor,\n type ApiError,\n toApiError,\n withIdempotency,\n} from \"../api/index.js\";\n\n/**\n * Hono context variables this subpath contributes. The generic on\n * the returned middleware threads it through so routes downstream\n * of {@link authenticated} see `c.get(\"actor\")` typed without a\n * manual cast.\n */\nexport type ActMiddlewareVariables = {\n actor: Actor;\n};\n\n/**\n * Per-call options for {@link hono}. The host supplies the four\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the Hono context, returns the\n * `Actor` that flows onto `c.get(\"actor\")` for every generated\n * route (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated body, and the Hono context; returns the target\n * stream. Singleton aggregates return a constant; per-tenant\n * aggregates pull from headers, path, or body.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * Hosts typically read it from an `If-Match` header on the\n * request, or from the client's last-known snapshot. Returning\n * `undefined` skips the check for that call — handy when only\n * some actions are concurrency-sensitive.\n * - `idempotency` (optional) — when set, the route honors\n * `Idempotency-Key` via {@link withIdempotency}. The host\n * supplies the `IdempotencyStore`; `keyFrom` defaults to reading\n * the `Idempotency-Key` header. On a duplicate claim, the route\n * responds `409 Conflict` — the contract intentionally doesn't\n * cache the original handler's result, matching the\n * receiver-side \"ack the duplicate\" semantics.\n * - `basePath` (optional, default `\"/api\"`) — Hono basePath the\n * routes mount under.\n */\nexport type HonoOptions = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n c: Context\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n c: Context\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom?: (c: Context) => string | undefined;\n };\n readonly basePath?: string;\n};\n\n/**\n * Build a Hono middleware that runs an {@link ActorExtractor} once\n * per call and stashes the resolved {@link Actor} under\n * `c.set(\"actor\", ...)` so downstream routes can read it via\n * `c.get(\"actor\")`. The generator at {@link hono} uses this\n * internally; it's also exported so hosts that want to compose\n * their own route chain (logging, tracing, custom auth flavors)\n * can wire it directly:\n *\n * ```ts\n * import { Hono } from \"hono\";\n * import { authenticated, type ActMiddlewareVariables } from \"@rotorsoft/act-http/hono\";\n *\n * const api = new Hono<{ Variables: ActMiddlewareVariables }>();\n * api.use(\"*\", authenticated((c) => resolveUserFromJwt(c)));\n * api.get(\"/me\", (c) => c.json(c.get(\"actor\"))); // typed\n * ```\n *\n * Errors thrown by the extractor surface as `401 Unauthorized` with\n * the shared {@link ApiError} envelope.\n */\nexport function authenticated(\n extractor: ActorExtractor\n): MiddlewareHandler<{ Variables: ActMiddlewareVariables }> {\n return async (c, next) => {\n try {\n const actor = await extractor(c);\n c.set(\"actor\", actor);\n await next();\n } catch (err) {\n const body: ApiError = {\n error: \"Unauthorized\",\n detail: err instanceof Error ? err.message : undefined,\n code: \"UNAUTHORIZED\",\n };\n return c.json(body, 401);\n }\n };\n}\n\nconst default_key_from = (c: Context): string | undefined =>\n c.req.header(\"idempotency-key\");\n\n/**\n * Build a typed Hono REST surface from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one\n * `POST /actions/<actionName>` per action under the configured\n * `basePath` (default `/api`). Each route:\n *\n * 1. Runs the internal {@link authenticated} middleware —\n * `options.actor` resolves the {@link Actor} once and stashes\n * it under `c.get(\"actor\")`.\n * 2. Validates the JSON body against the action's Zod schema via\n * `@hono/zod-validator`. Failures short-circuit with `422`.\n * 3. Resolves the target stream via\n * `options.stream(name, input, c)`.\n * 4. (Optionally) resolves `expectedVersion` for optimistic\n * concurrency.\n * 5. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — responds `409` on duplicate.\n * 6. Calls\n * `app.do(name, { stream, actor, expectedVersion? }, input)`.\n * 7. Maps any thrown framework error onto the shared\n * {@link ApiError} envelope via {@link toApiError}, returning\n * the conventional HTTP status (`409` / `412` for concurrency,\n * `422` for validation, `400` for non-retryable, etc.).\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, expected-version, idempotency, and\n * base-path seams.\n * @returns A Hono app covering every registered action under\n * `<basePath>/actions/<name>`.\n */\nexport function hono(\n app: Act<Schemas, Schemas, Schemas, Record<string, Schema>>,\n options: HonoOptions\n): Hono<{ Variables: ActMiddlewareVariables }> {\n const base_path = options.basePath ?? \"/api\";\n const api = new Hono<{ Variables: ActMiddlewareVariables }>().basePath(\n base_path\n );\n api.use(\"*\", authenticated(options.actor));\n\n const key_from = options.idempotency?.keyFrom ?? default_key_from;\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const schema = state.actions[action_name] as Schema;\n api.post(\n `/actions/${action_name}`,\n zValidator(\"json\", schema as never),\n async (c) => {\n try {\n const input = c.req.valid(\"json\" as never) as unknown;\n const actor = c.get(\"actor\");\n const stream = await options.stream(action_name, input, c);\n const expected_version = options.expectedVersion\n ? await options.expectedVersion(action_name, input, c)\n : undefined;\n const target =\n expected_version === undefined\n ? { stream, actor }\n : { stream, actor, expectedVersion: expected_version };\n\n if (options.idempotency) {\n const key = key_from(c);\n if (!key) {\n const body: ApiError = {\n error: \"BadRequest\",\n detail: \"Idempotency-Key required\",\n code: \"BAD_REQUEST\",\n };\n return c.json(body, 400);\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 const body: ApiError = {\n error: \"Conflict\",\n detail:\n \"Idempotency-Key already used; original result not cached\",\n code: \"CONFLICT\",\n };\n return c.json(body, 409);\n }\n return c.json(outcome.result);\n }\n\n const snapshots = await app.do(\n action_name as never,\n target as never,\n input as never\n );\n return c.json(snapshots);\n } catch (err) {\n const { status, body } = toApiError(err);\n // Hono types narrow `status` to its known status-number union;\n // `toApiError` returns a numeric status from a closed set that\n // matches but TypeScript can't prove it through the union.\n return c.json(body, status as 400 | 409 | 410 | 412 | 422 | 500);\n }\n }\n );\n }\n\n return api;\n}\n"],"mappings":"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","names":["value","zValidator: typeof zValidatorFunction"]}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/trpc/index.ts
|
|
21
|
+
var trpc_exports = {};
|
|
22
|
+
__export(trpc_exports, {
|
|
23
|
+
authenticated: () => authenticated,
|
|
24
|
+
trpc: () => trpc
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(trpc_exports);
|
|
27
|
+
var import_server = require("@trpc/server");
|
|
28
|
+
|
|
29
|
+
// src/api/errors.ts
|
|
30
|
+
var import_act = require("@rotorsoft/act");
|
|
31
|
+
var ERROR_MAP = {
|
|
32
|
+
ValidationError: { status: 422, code: "VALIDATION" },
|
|
33
|
+
InvariantError: { status: 409, code: "INVARIANT" },
|
|
34
|
+
ConcurrencyError: { status: 412, code: "CONCURRENCY" },
|
|
35
|
+
StreamClosedError: { status: 410, code: "STREAM_CLOSED" },
|
|
36
|
+
NonRetryableError: { status: 400, code: "NON_RETRYABLE" }
|
|
37
|
+
};
|
|
38
|
+
var lookup_known = (err) => {
|
|
39
|
+
if (err instanceof import_act.ValidationError) return { name: "ValidationError" };
|
|
40
|
+
if (err instanceof import_act.InvariantError) return { name: "InvariantError" };
|
|
41
|
+
if (err instanceof import_act.ConcurrencyError) return { name: "ConcurrencyError" };
|
|
42
|
+
if (err instanceof import_act.StreamClosedError) return { name: "StreamClosedError" };
|
|
43
|
+
if (err instanceof import_act.NonRetryableError) return { name: "NonRetryableError" };
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
function toApiError(err) {
|
|
47
|
+
const known = lookup_known(err);
|
|
48
|
+
if (known) {
|
|
49
|
+
const entry = ERROR_MAP[known.name];
|
|
50
|
+
return {
|
|
51
|
+
status: entry.status,
|
|
52
|
+
body: {
|
|
53
|
+
error: known.name,
|
|
54
|
+
detail: err.message,
|
|
55
|
+
code: entry.code
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (err instanceof Error) {
|
|
60
|
+
return {
|
|
61
|
+
status: 500,
|
|
62
|
+
body: { error: "InternalError", detail: err.message, code: "INTERNAL" }
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
status: 500,
|
|
67
|
+
body: { error: "InternalError", code: "INTERNAL" }
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/api/idempotency.ts
|
|
72
|
+
async function withIdempotency(store, key, handler) {
|
|
73
|
+
const fresh = await store.claim(key);
|
|
74
|
+
if (!fresh) {
|
|
75
|
+
return { deduped: true };
|
|
76
|
+
}
|
|
77
|
+
return { deduped: false, result: await handler() };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/trpc/index.ts
|
|
81
|
+
function authenticated(extractor) {
|
|
82
|
+
return async function actor_middleware({
|
|
83
|
+
ctx,
|
|
84
|
+
next
|
|
85
|
+
}) {
|
|
86
|
+
const actor = await extractor(ctx);
|
|
87
|
+
return next({ ctx: { ...ctx, actor } });
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function to_trpc_error(err) {
|
|
91
|
+
const { status, body } = toApiError(err);
|
|
92
|
+
const code = status_to_trpc_code(status);
|
|
93
|
+
return new import_server.TRPCError({
|
|
94
|
+
code,
|
|
95
|
+
message: body.detail ?? body.error,
|
|
96
|
+
cause: err instanceof Error ? err : void 0
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function status_to_trpc_code(status) {
|
|
100
|
+
switch (status) {
|
|
101
|
+
case 400:
|
|
102
|
+
return "BAD_REQUEST";
|
|
103
|
+
case 409:
|
|
104
|
+
return "CONFLICT";
|
|
105
|
+
case 410:
|
|
106
|
+
return "PRECONDITION_FAILED";
|
|
107
|
+
case 412:
|
|
108
|
+
return "CONFLICT";
|
|
109
|
+
case 422:
|
|
110
|
+
return "UNPROCESSABLE_CONTENT";
|
|
111
|
+
default:
|
|
112
|
+
return "INTERNAL_SERVER_ERROR";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function trpc(app, options) {
|
|
116
|
+
const t = import_server.initTRPC.context().create();
|
|
117
|
+
const authed = t.procedure.use(authenticated(options.actor));
|
|
118
|
+
const handlers = {};
|
|
119
|
+
for (const [action_name, state] of Object.entries(app.registry.actions)) {
|
|
120
|
+
handlers[action_name] = authed.input(state.actions[action_name]).mutation(async ({ input, ctx }) => {
|
|
121
|
+
const host_ctx = ctx;
|
|
122
|
+
try {
|
|
123
|
+
const stream = await options.stream(action_name, input, host_ctx);
|
|
124
|
+
const expected_version = options.expectedVersion ? await options.expectedVersion(action_name, input, host_ctx) : void 0;
|
|
125
|
+
const target = expected_version === void 0 ? { stream, actor: host_ctx.actor } : {
|
|
126
|
+
stream,
|
|
127
|
+
actor: host_ctx.actor,
|
|
128
|
+
expectedVersion: expected_version
|
|
129
|
+
};
|
|
130
|
+
if (options.idempotency) {
|
|
131
|
+
const key = options.idempotency.keyFrom(host_ctx);
|
|
132
|
+
if (!key) {
|
|
133
|
+
throw new import_server.TRPCError({
|
|
134
|
+
code: "BAD_REQUEST",
|
|
135
|
+
message: "Idempotency-Key required"
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const outcome = await withIdempotency(
|
|
139
|
+
options.idempotency.store,
|
|
140
|
+
key,
|
|
141
|
+
() => app.do(action_name, target, input)
|
|
142
|
+
);
|
|
143
|
+
if (outcome.deduped) {
|
|
144
|
+
throw new import_server.TRPCError({
|
|
145
|
+
code: "CONFLICT",
|
|
146
|
+
message: "Idempotency-Key already used; original result not cached"
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return outcome.result;
|
|
150
|
+
}
|
|
151
|
+
return await app.do(
|
|
152
|
+
action_name,
|
|
153
|
+
target,
|
|
154
|
+
input
|
|
155
|
+
);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err instanceof import_server.TRPCError) throw err;
|
|
158
|
+
throw to_trpc_error(err);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return t.router(handlers);
|
|
163
|
+
}
|
|
164
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
165
|
+
0 && (module.exports = {
|
|
166
|
+
authenticated,
|
|
167
|
+
trpc
|
|
168
|
+
});
|
|
169
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/trpc/index.ts","../../src/api/errors.ts","../../src/api/idempotency.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/trpc\n *\n * tRPC subpath for the auto-generated API epic (#835). Walks the\n * registry of a built `Act` once at function call and emits a typed\n * router that exposes every registered action as a flat `mutation`,\n * matching the shape hand-written tRPC routers in the Act ecosystem\n * already use.\n *\n * Usage:\n *\n * ```ts\n * import { trpc } from \"@rotorsoft/act-http/trpc\";\n *\n * const router = trpc(app, {\n * actor: (ctx) => ({ id: ctx.user.id, name: ctx.user.name }),\n * stream: (action, input, ctx) => input.stream ?? ctx.tenant,\n * });\n *\n * // Client sees:\n * // appRouter.PressKey.mutate({ key: \"5\" })\n * // appRouter.OpenTicket.mutate({ title: \"...\" })\n * ```\n *\n * The route shape is flat: one procedure per registered action at\n * the top level. Action names are the unique identifiers across\n * the registry (the framework already enforces no duplicates), so\n * an extra namespace tier would be overhead in the common case.\n * Pure mutations for this slice; query/projection wiring lives in\n * a follow-up. See `libs/act-http/README.md` for the full surface\n * walkthrough.\n *\n * **Actor lives on the context.** The generator wires an internal\n * tRPC middleware that runs the host's\n * {@link TrpcOptions.actor | actor extractor} once per call and\n * injects the resolved {@link Actor} onto the downstream context as\n * `ctx.actor`. Every generated mutation reads from there — auth\n * resolves at the procedure boundary, not inside each action body,\n * and any composed middleware (logging, tracing) sees the same\n * `ctx.actor`. The middleware factory is also exported as\n * {@link authenticated} for hosts that need to compose it into\n * their own procedure chain.\n *\n * Composes the shared utilities at `@rotorsoft/act-http/api`:\n * {@link ActorExtractor} for the auth seam, {@link toApiError} for\n * the uniform error→status/code mapping, {@link withIdempotency}\n * for `Idempotency-Key` dedup. Three transports (this one, Hono,\n * OpenAPI) ride the same utilities so a client talking to two\n * transports never sees two error shapes for the same framework\n * error.\n */\nimport type { Act, Actor, Schema, Schemas } from \"@rotorsoft/act\";\nimport type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n type ActorExtractor,\n toApiError,\n withIdempotency,\n} from \"../api/index.js\";\n\n/**\n * Per-call options for {@link trpc}. The host supplies the three\n * seams the package can't make decisions on:\n *\n * - `actor` — auth resolver. Receives the tRPC context, returns the\n * `Actor` that flows onto `ctx.actor` for every generated\n * procedure (via an internal {@link authenticated}).\n * - `stream` — stream-id resolver. Receives the action name, the\n * validated action input, and the original tRPC context; returns\n * the target stream. Singleton aggregates return a constant;\n * per-tenant aggregates pull from input or context.\n * - `expectedVersion` (optional) — optimistic-concurrency resolver.\n * When set, the procedure threads the resolved value through\n * `target.expectedVersion` so `app.do` refuses to commit if the\n * stream has moved. Hosts typically read it from an `If-Match`\n * header on the underlying HTTP request, or pull it from the\n * client's last-known snapshot. Returning `undefined` skips the\n * check for that call — handy when only some actions are\n * concurrency-sensitive.\n * - `idempotency` (optional) — when set, the procedure honors\n * `Idempotency-Key` via the shared\n * {@link withIdempotency} helper. The host supplies the\n * `IdempotencyStore` and a `keyFrom` extractor that reads the key\n * out of the tRPC context (typically a header). On a duplicate\n * claim, the procedure throws a `CONFLICT` `TRPCError` — the\n * receiver-side convention of \"ack the duplicate\" doesn't carry\n * over because the contract intentionally does not cache the\n * original handler's result.\n *\n * @template Ctx The host's tRPC context shape. Flows end-to-end —\n * procedures see this exact context type (plus `actor`) at\n * runtime.\n */\nexport type TrpcOptions<Ctx> = {\n readonly actor: ActorExtractor;\n readonly stream: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => string | Promise<string>;\n readonly expectedVersion?: (\n action_name: string,\n input: unknown,\n ctx: Ctx\n ) => number | undefined | Promise<number | undefined>;\n readonly idempotency?: {\n readonly store: IdempotencyStore;\n readonly keyFrom: (ctx: Ctx) => string | undefined;\n };\n};\n\n/**\n * Build a tRPC middleware that runs an {@link ActorExtractor} once\n * per call and forwards the resolved {@link Actor} on the\n * downstream context as `ctx.actor`. The generator at\n * {@link trpc} uses this internally; it's also exported so hosts\n * that want to compose their own procedure chain (logging,\n * tracing, custom auth flavors) can wire it directly:\n *\n * ```ts\n * import { initTRPC } from \"@trpc/server\";\n * import { authenticated } from \"@rotorsoft/act-http/trpc\";\n *\n * type Ctx = { user?: { id: string; name: string } };\n * const t = initTRPC.context<Ctx>().create();\n *\n * const authed = t.procedure.use(\n * authenticated((ctx) => {\n * if (!ctx.user) throw new Error(\"not authenticated\");\n * return { id: ctx.user.id, name: ctx.user.name };\n * })\n * );\n *\n * // Inside any procedure built off `authed`, `ctx.actor: Actor`.\n * ```\n *\n * The middleware is unaware of the host's `t` instance — it returns\n * a structurally-typed function that any tRPC `procedure.use(...)`\n * accepts. Errors thrown by the extractor surface unchanged\n * (typically wrap them as `TRPCError({ code: \"UNAUTHORIZED\" })`\n * in the extractor body).\n */\n// biome-ignore lint/suspicious/noExplicitAny: tRPC's internal middleware shape lives behind `unstable-core-do-not-import`; structural typing carries the host's `t` validation.\nexport function authenticated(extractor: ActorExtractor): any {\n return async function actor_middleware({\n ctx,\n next,\n }: {\n ctx: object;\n next: (opts: { ctx: object }) => Promise<unknown>;\n }) {\n const actor = await extractor(ctx);\n return next({ ctx: { ...ctx, actor } });\n };\n}\n\n/**\n * Map a thrown framework error onto a `TRPCError`. Known errors get\n * their conventional tRPC code (`ConcurrencyError` → `CONFLICT`,\n * `ValidationError` → `UNPROCESSABLE_CONTENT`, etc.); unknown throws\n * surface as `INTERNAL_SERVER_ERROR`. Every transport in the\n * act-http epic passes through the same {@link toApiError} table,\n * so a client talking to both this subpath and the REST sibling\n * sees one envelope per framework error.\n *\n * @internal\n */\nfunction to_trpc_error(err: unknown): TRPCError {\n const { status, body } = toApiError(err);\n const code = status_to_trpc_code(status);\n return new TRPCError({\n code,\n message: body.detail ?? body.error,\n cause: err instanceof Error ? err : undefined,\n });\n}\n\n/**\n * Minimal status → tRPC code map. Only the statuses\n * {@link ERROR_MAP} produces (plus 500) appear here; anything else\n * collapses to `INTERNAL_SERVER_ERROR`.\n *\n * @internal\n */\nfunction status_to_trpc_code(status: number): TRPCError[\"code\"] {\n switch (status) {\n case 400:\n return \"BAD_REQUEST\";\n case 409:\n return \"CONFLICT\";\n case 410:\n return \"PRECONDITION_FAILED\";\n case 412:\n return \"CONFLICT\";\n case 422:\n return \"UNPROCESSABLE_CONTENT\";\n default:\n return \"INTERNAL_SERVER_ERROR\";\n }\n}\n\n/**\n * Build a typed tRPC router from a built `Act` instance.\n *\n * Walks `app.registry.actions` once and emits one flat mutation per\n * action under `router.<actionName>`. The framework already enforces\n * no duplicate action names across states, so a flat tier reads\n * cleanly at the client and matches the hand-written router shape\n * adopters already have. Each mutation:\n *\n * 1. Runs the internal {@link authenticated} — `options.actor`\n * resolves the {@link Actor} once and injects it onto\n * `ctx.actor`.\n * 2. Resolves the target stream via `options.stream(name, input, ctx)`.\n * 3. (Optionally) claims the `Idempotency-Key` via\n * {@link withIdempotency} — throws `CONFLICT` on duplicate.\n * 4. Calls `app.do(name, { stream, actor: ctx.actor }, input)`.\n * 5. Maps any thrown framework error onto a `TRPCError` via\n * {@link toApiError}.\n *\n * The returned router's procedure inputs come straight from each\n * action's Zod schema; outputs are the framework's\n * `Snapshot<TState, TEvents>[]` shape. Strong typing flows\n * end-to-end via tRPC's normal inference path — the client sees\n * `appRouter.Calculator.PressKey.mutate({ key: \"5\" })` as a typed\n * call without any codegen.\n *\n * @template Ctx The tRPC context shape the host's runtime feeds in.\n * Flows through `actor`, `stream`, and `idempotency.keyFrom` so\n * the operator's existing context types just work. The internal\n * middleware augments `Ctx` with `{ actor: Actor }` on the\n * downstream procedure context.\n *\n * @param app A built `Act` orchestrator.\n * @param options Auth, stream, and (optional) idempotency seams.\n * @returns A tRPC router covering every registered action, grouped\n * by owning state.\n */\nexport function trpc<Ctx extends object = object>(\n // biome-ignore lint/suspicious/noExplicitAny: erased — the generator walks the registry at runtime, not the typed surface\n app: Act<any, Schemas, Schemas, Record<string, Schema>>,\n options: TrpcOptions<Ctx>\n) {\n const t = initTRPC.context<Ctx>().create();\n const authed = t.procedure.use(authenticated(options.actor));\n\n const handlers: Record<string, ReturnType<typeof authed.mutation>> = {};\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n handlers[action_name] = authed\n .input(state.actions[action_name] as never)\n .mutation(async ({ input, ctx }) => {\n // tRPC's internal `Simplify<Ctx>` transform produces a type\n // that's structurally identical to `Ctx & { actor }` but not\n // assignable to the generic parameter. The cast restores the\n // link; the runtime ctx IS the augmented context.\n const host_ctx = ctx as unknown as Ctx & { actor: Actor };\n try {\n const stream = await options.stream(action_name, input, host_ctx);\n const expected_version = options.expectedVersion\n ? await options.expectedVersion(action_name, input, host_ctx)\n : undefined;\n const target =\n expected_version === undefined\n ? { stream, actor: host_ctx.actor }\n : {\n stream,\n actor: host_ctx.actor,\n expectedVersion: expected_version,\n };\n\n if (options.idempotency) {\n const key = options.idempotency.keyFrom(host_ctx);\n if (!key) {\n throw new TRPCError({\n code: \"BAD_REQUEST\",\n message: \"Idempotency-Key required\",\n });\n }\n const outcome = await withIdempotency(\n options.idempotency.store,\n key,\n () =>\n app.do(action_name as never, target as never, input as never)\n );\n if (outcome.deduped) {\n throw new TRPCError({\n code: \"CONFLICT\",\n message:\n \"Idempotency-Key already used; original result not cached\",\n });\n }\n return outcome.result;\n }\n\n return await app.do(\n action_name as never,\n target as never,\n input as never\n );\n } catch (err) {\n if (err instanceof TRPCError) throw err;\n throw to_trpc_error(err);\n }\n });\n }\n\n return t.router(handlers as never);\n}\n","import {\n ConcurrencyError,\n InvariantError,\n NonRetryableError,\n StreamClosedError,\n ValidationError,\n} from \"@rotorsoft/act\";\n\n/**\n * Uniform error envelope shipped over the wire by every act-http\n * transport. Hosts get the same shape from REST, tRPC, and OpenAPI —\n * a client that talks to two transports doesn't have to invent two\n * error parsers.\n *\n * - `error` — the framework error name (`\"ValidationError\"`,\n * `\"InvariantError\"`, …). Stable identifier, safe to switch on.\n * - `detail` — the framework's message text. Human-readable; not\n * parsed by clients.\n * - `code` — a machine-readable status code from {@link ERROR_MAP}\n * for clients that prefer enum-style branching over name strings.\n */\nexport type ApiError = {\n error: string;\n detail?: string;\n code?: string;\n};\n\n/**\n * Status + code pair for one known framework error.\n */\nexport type ErrorMapEntry = {\n status: number;\n code: string;\n};\n\n/**\n * The single table that maps framework error types to HTTP status\n * codes and machine-readable codes. One table, three consumers\n * (Hono, tRPC, OpenAPI) — cross-transport consistency by\n * construction.\n *\n * Operators wanting different mappings wrap the generated transport\n * rather than mutating this — the consistency is the load-bearing\n * property, not the specific status codes.\n */\nexport const ERROR_MAP = {\n ValidationError: { status: 422, code: \"VALIDATION\" },\n InvariantError: { status: 409, code: \"INVARIANT\" },\n ConcurrencyError: { status: 412, code: \"CONCURRENCY\" },\n StreamClosedError: { status: 410, code: \"STREAM_CLOSED\" },\n NonRetryableError: { status: 400, code: \"NON_RETRYABLE\" },\n} as const satisfies Record<string, ErrorMapEntry>;\n\nconst lookup_known = (\n err: unknown\n): { name: keyof typeof ERROR_MAP } | null => {\n if (err instanceof ValidationError) return { name: \"ValidationError\" };\n if (err instanceof InvariantError) return { name: \"InvariantError\" };\n if (err instanceof ConcurrencyError) return { name: \"ConcurrencyError\" };\n if (err instanceof StreamClosedError) return { name: \"StreamClosedError\" };\n if (err instanceof NonRetryableError) return { name: \"NonRetryableError\" };\n return null;\n};\n\n/**\n * Translate an unknown thrown value into the canonical\n * {@link ApiError} envelope plus HTTP status. Each transport's error\n * boundary calls this once and forwards the result to the wire.\n *\n * Known framework errors map per {@link ERROR_MAP}. Everything else\n * surfaces as a 500 with `code: \"INTERNAL\"`; the `detail` field is\n * populated when the throw was an `Error` instance, omitted\n * otherwise (a thrown string or object doesn't get to leak its\n * payload to the client).\n */\nexport function toApiError(err: unknown): { status: number; body: ApiError } {\n const known = lookup_known(err);\n if (known) {\n const entry = ERROR_MAP[known.name];\n return {\n status: entry.status,\n body: {\n error: known.name,\n detail: (err as Error).message,\n code: entry.code,\n },\n };\n }\n if (err instanceof Error) {\n return {\n status: 500,\n body: { error: \"InternalError\", detail: err.message, code: \"INTERNAL\" },\n };\n }\n return {\n status: 500,\n body: { error: \"InternalError\", code: \"INTERNAL\" },\n };\n}\n","import type { IdempotencyStore } from \"@rotorsoft/act-ops/idempotency\";\n\n/**\n * Result of a {@link withIdempotency} call.\n *\n * - `{ deduped: false, result }` — the claim was fresh; the handler\n * ran and produced `result`.\n * - `{ deduped: true }` — the claim was already taken; the handler\n * was *not* invoked. The caller decides how to respond (typically\n * a 2xx with no body, matching the receiver-side convention).\n *\n * Note: the contract does not cache the previous response. A\n * duplicate call returns the deduped marker only — replaying the\n * original handler's output would require a response-caching\n * adapter, which is out of scope here. The receiver-side convention\n * (and the convention the generated transports follow) is \"ack the\n * duplicate; do nothing else.\"\n */\nexport type IdempotencyResult<T> =\n | { deduped: false; result: T }\n | { deduped: true };\n\n/**\n * Wrap an action handler so the framework honors `Idempotency-Key`\n * dedup. Acquires the key via {@link IdempotencyStore.claim}, runs\n * the handler exactly when the claim was fresh, and skips the\n * handler entirely on a duplicate.\n *\n * Reuses the contract `@rotorsoft/act-ops/idempotency` already\n * defines for the receiver-side `Idempotency-Key` story. A single\n * `IdempotencyStore` implementation covers both halves of the \"Act\n * over the wire\" surface — receiver and generated API.\n */\nexport async function withIdempotency<T>(\n store: IdempotencyStore,\n key: string,\n handler: () => Promise<T>\n): Promise<IdempotencyResult<T>> {\n const fresh = await store.claim(key);\n if (!fresh) {\n return { deduped: true };\n }\n return { deduped: false, result: await handler() };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
toApiError,
|
|
3
|
+
withIdempotency
|
|
4
|
+
} from "../chunk-Z6FVXDDU.js";
|
|
5
|
+
|
|
6
|
+
// src/trpc/index.ts
|
|
7
|
+
import { initTRPC, TRPCError } from "@trpc/server";
|
|
8
|
+
function authenticated(extractor) {
|
|
9
|
+
return async function actor_middleware({
|
|
10
|
+
ctx,
|
|
11
|
+
next
|
|
12
|
+
}) {
|
|
13
|
+
const actor = await extractor(ctx);
|
|
14
|
+
return next({ ctx: { ...ctx, actor } });
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function to_trpc_error(err) {
|
|
18
|
+
const { status, body } = toApiError(err);
|
|
19
|
+
const code = status_to_trpc_code(status);
|
|
20
|
+
return new TRPCError({
|
|
21
|
+
code,
|
|
22
|
+
message: body.detail ?? body.error,
|
|
23
|
+
cause: err instanceof Error ? err : void 0
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function status_to_trpc_code(status) {
|
|
27
|
+
switch (status) {
|
|
28
|
+
case 400:
|
|
29
|
+
return "BAD_REQUEST";
|
|
30
|
+
case 409:
|
|
31
|
+
return "CONFLICT";
|
|
32
|
+
case 410:
|
|
33
|
+
return "PRECONDITION_FAILED";
|
|
34
|
+
case 412:
|
|
35
|
+
return "CONFLICT";
|
|
36
|
+
case 422:
|
|
37
|
+
return "UNPROCESSABLE_CONTENT";
|
|
38
|
+
default:
|
|
39
|
+
return "INTERNAL_SERVER_ERROR";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function trpc(app, options) {
|
|
43
|
+
const t = initTRPC.context().create();
|
|
44
|
+
const authed = t.procedure.use(authenticated(options.actor));
|
|
45
|
+
const handlers = {};
|
|
46
|
+
for (const [action_name, state] of Object.entries(app.registry.actions)) {
|
|
47
|
+
handlers[action_name] = authed.input(state.actions[action_name]).mutation(async ({ input, ctx }) => {
|
|
48
|
+
const host_ctx = ctx;
|
|
49
|
+
try {
|
|
50
|
+
const stream = await options.stream(action_name, input, host_ctx);
|
|
51
|
+
const expected_version = options.expectedVersion ? await options.expectedVersion(action_name, input, host_ctx) : void 0;
|
|
52
|
+
const target = expected_version === void 0 ? { stream, actor: host_ctx.actor } : {
|
|
53
|
+
stream,
|
|
54
|
+
actor: host_ctx.actor,
|
|
55
|
+
expectedVersion: expected_version
|
|
56
|
+
};
|
|
57
|
+
if (options.idempotency) {
|
|
58
|
+
const key = options.idempotency.keyFrom(host_ctx);
|
|
59
|
+
if (!key) {
|
|
60
|
+
throw new TRPCError({
|
|
61
|
+
code: "BAD_REQUEST",
|
|
62
|
+
message: "Idempotency-Key required"
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const outcome = await withIdempotency(
|
|
66
|
+
options.idempotency.store,
|
|
67
|
+
key,
|
|
68
|
+
() => app.do(action_name, target, input)
|
|
69
|
+
);
|
|
70
|
+
if (outcome.deduped) {
|
|
71
|
+
throw new TRPCError({
|
|
72
|
+
code: "CONFLICT",
|
|
73
|
+
message: "Idempotency-Key already used; original result not cached"
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return outcome.result;
|
|
77
|
+
}
|
|
78
|
+
return await app.do(
|
|
79
|
+
action_name,
|
|
80
|
+
target,
|
|
81
|
+
input
|
|
82
|
+
);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err instanceof TRPCError) throw err;
|
|
85
|
+
throw to_trpc_error(err);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return t.router(handlers);
|
|
90
|
+
}
|
|
91
|
+
export {
|
|
92
|
+
authenticated,
|
|
93
|
+
trpc
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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":[]}
|