@rotorsoft/act-http 1.11.2 → 1.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -5
- package/dist/.tsbuildinfo +1 -1
- package/dist/@types/api/actor.d.ts +4 -6
- package/dist/@types/api/actor.d.ts.map +1 -1
- package/dist/@types/api/dispatch.d.ts +46 -1
- package/dist/@types/api/dispatch.d.ts.map +1 -1
- package/dist/@types/api/sse-wiring.d.ts +2 -2
- package/dist/@types/api/sse-wiring.d.ts.map +1 -1
- package/dist/@types/hono/index.d.ts +80 -13
- package/dist/@types/hono/index.d.ts.map +1 -1
- package/dist/@types/openapi/index.d.ts.map +1 -1
- package/dist/@types/trpc/index.d.ts +107 -35
- package/dist/@types/trpc/index.d.ts.map +1 -1
- package/dist/api/index.cjs +1 -3
- package/dist/api/index.cjs.map +1 -1
- package/dist/api/index.js +1 -1
- package/dist/{chunk-QM6YP2XH.js → chunk-QSH2Y3YR.js} +2 -2
- package/dist/chunk-QSH2Y3YR.js.map +1 -0
- package/dist/{chunk-75DA7NJI.js → chunk-YEDAY2PC.js} +2 -4
- package/dist/chunk-YEDAY2PC.js.map +1 -0
- package/dist/hono/index.cjs +1 -3
- package/dist/hono/index.cjs.map +1 -1
- package/dist/hono/index.js +2 -2
- package/dist/hono/index.js.map +1 -1
- package/dist/openapi/index.cjs +102 -34
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.js +103 -35
- package/dist/openapi/index.js.map +1 -1
- package/dist/trpc/index.cjs +2 -8
- package/dist/trpc/index.cjs.map +1 -1
- package/dist/trpc/index.js +3 -7
- package/dist/trpc/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-75DA7NJI.js.map +0 -1
- package/dist/chunk-QM6YP2XH.js.map +0 -1
package/dist/openapi/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
import "../chunk-PZ5AY32C.js";
|
|
5
5
|
|
|
6
6
|
// src/openapi/index.ts
|
|
7
|
-
import { pii_fields } from "@rotorsoft/act";
|
|
7
|
+
import { pii_fields, REDACTED, SHREDDED } from "@rotorsoft/act";
|
|
8
8
|
var DEFAULT_BASE_PATH = "/api";
|
|
9
9
|
var API_ERROR_SCHEMA = {
|
|
10
10
|
type: "object",
|
|
@@ -15,22 +15,53 @@ var API_ERROR_SCHEMA = {
|
|
|
15
15
|
},
|
|
16
16
|
required: ["error"]
|
|
17
17
|
};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
18
|
+
function snapshot_array(state, event) {
|
|
19
|
+
return {
|
|
20
|
+
type: "array",
|
|
21
|
+
items: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
state,
|
|
25
|
+
event,
|
|
26
|
+
patch: patch_of(state),
|
|
27
|
+
id: { type: "integer" },
|
|
28
|
+
version: { type: "integer" },
|
|
29
|
+
patches: { type: "integer" },
|
|
30
|
+
snaps: { type: "integer" },
|
|
31
|
+
cache_hit: { type: "boolean" },
|
|
32
|
+
replayed: { type: "integer" }
|
|
33
|
+
},
|
|
34
|
+
required: [
|
|
35
|
+
"state",
|
|
36
|
+
"id",
|
|
37
|
+
"version",
|
|
38
|
+
"patches",
|
|
39
|
+
"snaps",
|
|
40
|
+
"cache_hit",
|
|
41
|
+
"replayed"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
var SNAPSHOT_ARRAY_SCHEMA = snapshot_array(
|
|
47
|
+
{ type: "object" },
|
|
48
|
+
{ type: "object" }
|
|
49
|
+
);
|
|
50
|
+
function patch_of(schema) {
|
|
51
|
+
const properties = schema.properties;
|
|
52
|
+
if (!properties) return schema;
|
|
53
|
+
const { required: _, ...rest } = schema;
|
|
54
|
+
return {
|
|
55
|
+
...rest,
|
|
56
|
+
properties: Object.fromEntries(
|
|
57
|
+
Object.entries(properties).map(([key, value]) => [
|
|
58
|
+
key,
|
|
59
|
+
{ anyOf: [patch_of(value), { type: "null" }] }
|
|
60
|
+
])
|
|
61
|
+
)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
var PII_MARKERS = { type: "string", enum: [REDACTED, SHREDDED] };
|
|
34
65
|
var API_ERROR_RESPONSE = {
|
|
35
66
|
description: "Error envelope per @rotorsoft/act-http/api's toApiError.",
|
|
36
67
|
content: {
|
|
@@ -88,7 +119,51 @@ function mark_sensitive_fields(body_schema, zod_schema) {
|
|
|
88
119
|
}
|
|
89
120
|
return { ...body_schema, properties: next_properties };
|
|
90
121
|
}
|
|
91
|
-
function
|
|
122
|
+
function json_schema(schema, io) {
|
|
123
|
+
return strip_json_schema_meta(
|
|
124
|
+
external_exports.toJSONSchema(schema, { unrepresentable: "any", io })
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
function allow_markers(schema, fields) {
|
|
128
|
+
const properties = schema.properties;
|
|
129
|
+
const present = fields.filter((f) => properties?.[f]);
|
|
130
|
+
if (!properties || present.length === 0) return schema;
|
|
131
|
+
const next = { ...properties };
|
|
132
|
+
for (const f of present) next[f] = { anyOf: [properties[f], PII_MARKERS] };
|
|
133
|
+
return { ...schema, properties: next };
|
|
134
|
+
}
|
|
135
|
+
function state_snapshots(state, events) {
|
|
136
|
+
const sensitive = /* @__PURE__ */ new Set();
|
|
137
|
+
const variants = Object.entries(events).map(([name, data]) => {
|
|
138
|
+
const fields = pii_fields(data);
|
|
139
|
+
for (const f of fields) sensitive.add(f);
|
|
140
|
+
return {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {
|
|
143
|
+
id: { type: "integer" },
|
|
144
|
+
stream: { type: "string" },
|
|
145
|
+
version: { type: "integer" },
|
|
146
|
+
created: { type: "string", format: "date-time" },
|
|
147
|
+
name: { const: name },
|
|
148
|
+
data: allow_markers(json_schema(data, "output"), fields),
|
|
149
|
+
meta: {
|
|
150
|
+
type: "object",
|
|
151
|
+
properties: {
|
|
152
|
+
correlation: { type: "string" },
|
|
153
|
+
causation: { type: "object" }
|
|
154
|
+
},
|
|
155
|
+
required: ["correlation", "causation"]
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
required: ["id", "stream", "version", "created", "name", "data", "meta"]
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
return snapshot_array(
|
|
162
|
+
allow_markers(json_schema(state, "output"), [...sensitive]),
|
|
163
|
+
{ oneOf: variants }
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
function build_operation(action_name, body_schema, response_ref, options) {
|
|
92
167
|
const parameters = [];
|
|
93
168
|
if (options.idempotency) {
|
|
94
169
|
parameters.push({
|
|
@@ -127,7 +202,7 @@ function build_operation(action_name, body_schema, options) {
|
|
|
127
202
|
description: "Action committed; returns the snapshot array.",
|
|
128
203
|
content: {
|
|
129
204
|
"application/json": {
|
|
130
|
-
schema: { $ref:
|
|
205
|
+
schema: { $ref: response_ref }
|
|
131
206
|
}
|
|
132
207
|
}
|
|
133
208
|
},
|
|
@@ -145,27 +220,19 @@ function openapi(app, options) {
|
|
|
145
220
|
validate_options(options);
|
|
146
221
|
const base_path = options.basePath ?? DEFAULT_BASE_PATH;
|
|
147
222
|
const paths = {};
|
|
223
|
+
const state_schemas = {};
|
|
148
224
|
for (const [action_name, state] of Object.entries(app.registry.actions)) {
|
|
149
225
|
const zod_schema = state.actions[action_name];
|
|
150
226
|
const body_schema = mark_sensitive_fields(
|
|
151
|
-
|
|
152
|
-
// `unrepresentable: "any"` emits an open (`{}`) schema for a Zod type
|
|
153
|
-
// with no JSON-Schema form (z.date/z.bigint/z.map/z.set/...) instead of
|
|
154
|
-
// Zod 4's default `"throw"`, which would abort the WHOLE document over
|
|
155
|
-
// one such field, while hono() serves the same action fine.
|
|
156
|
-
// `io: "input"` describes what a client must SEND: a `.default()`
|
|
157
|
-
// field is optional, extra keys are not rejected (Zod strips them),
|
|
158
|
-
// and a `.pipe()`/`.transform()` documents its input type. Zod's
|
|
159
|
-
// default, `"output"`, describes the parsed result instead.
|
|
160
|
-
external_exports.toJSONSchema(zod_schema, {
|
|
161
|
-
unrepresentable: "any",
|
|
162
|
-
io: "input"
|
|
163
|
-
})
|
|
164
|
-
),
|
|
227
|
+
json_schema(zod_schema, "input"),
|
|
165
228
|
zod_schema
|
|
166
229
|
);
|
|
230
|
+
const snapshots = `${state.name}Snapshots`;
|
|
231
|
+
if (state.state && state.events && !state_schemas[snapshots])
|
|
232
|
+
state_schemas[snapshots] = state_snapshots(state.state, state.events);
|
|
233
|
+
const response_ref = state_schemas[snapshots] ? `#/components/schemas/${snapshots}` : "#/components/schemas/SnapshotArray";
|
|
167
234
|
paths[`${base_path}/actions/${action_name}`] = {
|
|
168
|
-
post: build_operation(action_name, body_schema, options)
|
|
235
|
+
post: build_operation(action_name, body_schema, response_ref, options)
|
|
169
236
|
};
|
|
170
237
|
}
|
|
171
238
|
return {
|
|
@@ -176,7 +243,8 @@ function openapi(app, options) {
|
|
|
176
243
|
components: {
|
|
177
244
|
schemas: {
|
|
178
245
|
ApiError: API_ERROR_SCHEMA,
|
|
179
|
-
SnapshotArray: SNAPSHOT_ARRAY_SCHEMA
|
|
246
|
+
SnapshotArray: SNAPSHOT_ARRAY_SCHEMA,
|
|
247
|
+
...state_schemas
|
|
180
248
|
},
|
|
181
249
|
responses: {
|
|
182
250
|
ApiError: API_ERROR_RESPONSE
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/openapi/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/openapi\n *\n * OpenAPI subpath of the auto-generated API. Walks the\n * registry of a built `Act` once at function call and returns a\n * valid OpenAPI 3.1 document describing the same action surface the\n * Hono sibling at `@rotorsoft/act-http/hono` serves. The doc is\n * suitable for cross-language client codegen (Go, Python, Swift,\n * Kotlin, .NET), for serving over `/openapi.json` alongside a live\n * API, and for the broader OpenAPI tooling ecosystem (Postman,\n * Swagger UI, Redoc, schemathesis, API gateways).\n *\n * Usage:\n *\n * ```ts\n * import { openapi } from \"@rotorsoft/act-http/openapi\";\n *\n * const doc = openapi(app, {\n * info: { title: \"Wolfdesk API\", version: \"1.0.0\" },\n * servers: [{ url: \"https://api.example.com\" }],\n * });\n * // doc: OpenAPIDocument — valid OpenAPI 3.1 object\n * ```\n *\n * **Pure data emit.** No runtime dep on Hono or tRPC. The subpath is\n * consumable standalone for hosts that serve their API some other\n * way (Fastify, Lambda, edge) or that only want the doc for client\n * codegen. The emit walks the registry once at function call; cost\n * is linear in `N actions × M Zod fields each`, single-digit\n * milliseconds for a 100-endpoint registry.\n *\n * **Determinism.** Output is stable across runs given the same\n * registry — entries land in `Object.entries(app.registry.actions)`\n * iteration order, schemas come from the action's Zod definition,\n * and the rest of the doc is fixed content. CI can snapshot the\n * result to catch unintended API surface changes.\n *\n * The same shared utilities at `@rotorsoft/act-http/api` underwrite\n * cross-transport consistency: the `ApiError` envelope is referenced\n * once from `components.schemas`, every error response points at it,\n * and the same shape ships from the tRPC and Hono siblings at\n * runtime. A client speaking the doc and the live REST API sees one\n * envelope per framework error.\n *\n * **The doc describes the Hono surface, not tRPC.** tRPC's URL\n * shape (`POST /trpc/<procedure>`, JSON-RPC-style body framing,\n * batching) doesn't model cleanly as OpenAPI operations — and tRPC\n * consumers already share types directly via `typeof router`, so\n * OpenAPI buys them nothing. The path shape this emitter produces\n * (`POST <basePath>/actions/<name>`) matches the\n * `@rotorsoft/act-http/hono` sibling exactly: same default\n * `basePath`, same request/response shapes, same envelope. If the\n * operator overrides `basePath` on the Hono adapter, they pass the\n * same override here so the doc keeps matching the live routes by\n * construction. tRPC and Hono can run side-by-side at different\n * mount points on the same Act instance; the OpenAPI doc covers\n * the REST half only.\n */\nimport { pii_fields } from \"@rotorsoft/act\";\nimport { z } from \"zod\";\nimport type { ActSurface } from \"../api/dispatch.js\";\n\n/**\n * Minimal OpenAPI 3.1 document type. The emitter targets the 3.1\n * spec; the type is intentionally a structural subset rather than\n * the full surface from `openapi-types` so callers can post-process\n * without fighting the type system. Cast to a fuller type from\n * `openapi-types` when downstream consumers need it.\n */\nexport type OpenAPIDocument = {\n readonly openapi: \"3.1.0\";\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly paths: Record<string, OpenAPIPathItem>;\n readonly components: OpenAPIComponents;\n};\n\n/**\n * Per-call options for {@link openapi}. The host supplies the\n * document-shape fields the registry can't derive (title, version,\n * servers) and toggles for the cross-cutting headers that the live\n * REST API may accept.\n *\n * - `info` — required. `title` and `version` must be non-empty;\n * `info` may carry any other top-level OpenAPI info fields.\n * - `servers` — optional. Each entry's `url` may contain\n * `{variable}` template syntax; bare URLs are validated through\n * `URL`'s parser and reject malformed inputs.\n * - `basePath` — optional, default `/api`. Mirrors the Hono\n * sibling's default so the doc describes the same paths the\n * generated REST surface serves.\n * - `idempotency` — optional, default `false`. When `true`, every\n * mutation operation documents a **required** `Idempotency-Key`\n * request header (the generated route fail-closes with 400 when the\n * header is absent, so the doc marks it required, not optional).\n * - `expectedVersion` — optional, default `false`. When `true`,\n * every mutation operation documents an optional `If-Match`\n * request header carrying the expected stream version.\n */\nexport type OpenAPIOptions = {\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly basePath?: string;\n readonly idempotency?: boolean;\n readonly expectedVersion?: boolean;\n};\n\nexport type OpenAPIInfo = {\n readonly title: string;\n readonly version: string;\n readonly description?: string;\n readonly summary?: string;\n readonly termsOfService?: string;\n readonly contact?: {\n readonly name?: string;\n readonly url?: string;\n readonly email?: string;\n };\n readonly license?: {\n readonly name: string;\n readonly identifier?: string;\n readonly url?: string;\n };\n};\n\nexport type OpenAPIServer = {\n readonly url: string;\n readonly description?: string;\n readonly variables?: Record<\n string,\n {\n readonly default: string;\n readonly enum?: ReadonlyArray<string>;\n readonly description?: string;\n }\n >;\n};\n\nexport type OpenAPIPathItem = {\n readonly post?: OpenAPIOperation;\n};\n\nexport type OpenAPIOperation = {\n readonly operationId: string;\n readonly summary: string;\n readonly tags: ReadonlyArray<string>;\n readonly parameters?: ReadonlyArray<OpenAPIParameter>;\n readonly requestBody?: OpenAPIRequestBody;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nexport type OpenAPIParameter = {\n readonly name: string;\n readonly in: \"header\" | \"query\" | \"path\" | \"cookie\";\n readonly required?: boolean;\n readonly description?: string;\n readonly schema?: Record<string, unknown>;\n};\n\nexport type OpenAPIRequestBody = {\n readonly required?: boolean;\n readonly content: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n};\n\nexport type OpenAPIResponse =\n | {\n readonly description: string;\n readonly content?: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n }\n | { readonly $ref: string };\n\nexport type OpenAPIComponents = {\n readonly schemas: Record<string, Record<string, unknown>>;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nconst DEFAULT_BASE_PATH = \"/api\";\n\n/**\n * The shared `ApiError` envelope schema, ref'd by every error\n * response. Matches the runtime envelope at\n * `@rotorsoft/act-http/api`'s `toApiError` exactly — keep in sync if\n * the envelope shape evolves.\n */\nconst API_ERROR_SCHEMA: Record<string, unknown> = {\n type: \"object\",\n properties: {\n error: { type: \"string\" },\n detail: { type: \"string\" },\n code: { type: \"string\" },\n },\n required: [\"error\"],\n};\n\n/**\n * The generic snapshot-array response schema returned on a\n * successful mutation. Each action commits one or more events; the\n * shape here is `Snapshot[]` with the per-action state and event\n * data left as open `object` (deeper per-action typing would need\n * to walk `state.events[*]` and emit a discriminated union — a\n * future refinement once the doc consumers ask for it).\n */\nconst SNAPSHOT_ARRAY_SCHEMA: Record<string, unknown> = {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n state: { type: \"object\" },\n event: { type: \"object\" },\n version: { type: \"integer\" },\n patches: { type: \"integer\" },\n snaps: { type: \"integer\" },\n cache_hit: { type: \"boolean\" },\n replayed: { type: \"integer\" },\n },\n required: [\"state\", \"version\", \"patches\", \"snaps\", \"cache_hit\", \"replayed\"],\n },\n};\n\n/**\n * The error-response shape every operation references via\n * `$ref: \"#/components/responses/ApiError\"`. Single source of\n * truth — bug fixes to the envelope flow to every operation\n * automatically.\n */\nconst API_ERROR_RESPONSE: OpenAPIResponse = {\n description: \"Error envelope per @rotorsoft/act-http/api's toApiError.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/ApiError\" },\n },\n },\n};\n\n/**\n * Server URL refinement: OpenAPI permits `{variable}` template syntax\n * inside server URLs, so we substitute each capture with `x` before\n * parsing. The character class forbids both `{` and `}` inside the\n * template, matching OpenAPI's variable-name grammar exactly and\n * eliminating the catastrophic-backtracking surface CodeQL flagged\n * on `[^}]+`.\n *\n * @internal\n */\nfunction is_valid_server_url(url: string): boolean {\n try {\n new URL(url.replace(/\\{[^{}]+\\}/g, \"x\"));\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Zod schema for the minimum required shape of {@link OpenAPIOptions}.\n * Same declarative-validation pattern the rest of the framework uses;\n * the OpenAPI surface is mostly free-form (`description`, `contact`,\n * `license` — the spec lets through anything that JSON-stringifies),\n * so only the load-bearing fields land here. Hosts overriding into\n * unusual shapes still get the rest of the doc emitted; this schema\n * only fails the construction call when `info.title` / `info.version`\n * is missing-or-empty or a `servers[].url` won't parse.\n *\n * @internal\n */\nconst OpenAPIOptionsSchema = z.object({\n info: z.object({\n title: z\n .string({ message: \"openapi: info.title is required (non-empty string)\" })\n .trim()\n .min(1, \"openapi: info.title is required (non-empty string)\"),\n version: z\n .string({\n message: \"openapi: info.version is required (non-empty string)\",\n })\n .trim()\n .min(1, \"openapi: info.version is required (non-empty string)\"),\n }),\n servers: z\n .array(\n z.object({\n url: z.string().refine(is_valid_server_url, {\n message: \"openapi: invalid server url\",\n }),\n })\n )\n .optional(),\n});\n\nfunction validate_options(options: OpenAPIOptions): void {\n OpenAPIOptionsSchema.parse({\n info: { title: options.info?.title, version: options.info?.version },\n servers: options.servers,\n });\n}\n\nfunction strip_json_schema_meta(\n schema: Record<string, unknown>\n): Record<string, unknown> {\n // `$schema` at the top of an inline schema is harmless in OpenAPI\n // 3.1 but adds noise to the doc. Drop it.\n const { $schema: _, ...rest } = schema;\n return rest;\n}\n\n/**\n * Annotate the emitted request-body schema's sensitive properties so\n * codegen / Swagger UI treat them as secrets rather than echoing them\n * freely. `z.toJSONSchema` has no knowledge of the sensitive registry\n * (`sensitive()` marks schemas out-of-band in a WeakMap), so we walk the\n * action's declared sensitive fields — via `pii_fields`, the same lookup\n * the orchestrator uses — and mark each matching property `writeOnly:\n * true` + `format: password`. Non-object schemas and actions with no\n * sensitive fields pass through untouched (the zero-cost common path).\n *\n * `pii_fields` only reports top-level keys of a `z.object`, and Zod's\n * JSON Schema emit always renders those as members of `properties` — so\n * once `fields` is non-empty, `body_schema.properties[field]` is\n * guaranteed present. No defensive fallback needed.\n *\n * @internal\n */\nfunction mark_sensitive_fields(\n body_schema: Record<string, unknown>,\n zod_schema: z.ZodType\n): Record<string, unknown> {\n const fields = pii_fields(zod_schema);\n if (fields.length === 0) return body_schema;\n const properties = body_schema.properties as Record<\n string,\n Record<string, unknown>\n >;\n const next_properties: Record<string, Record<string, unknown>> = {\n ...properties,\n };\n for (const field of fields) {\n next_properties[field] = {\n ...properties[field],\n writeOnly: true,\n format: \"password\",\n };\n }\n return { ...body_schema, properties: next_properties };\n}\n\nfunction build_operation(\n action_name: string,\n body_schema: Record<string, unknown>,\n options: OpenAPIOptions\n): OpenAPIOperation {\n const parameters: OpenAPIParameter[] = [];\n if (options.idempotency) {\n parameters.push({\n name: \"Idempotency-Key\",\n in: \"header\",\n // Required: the generated route fail-closes with 400 when idempotency is\n // enabled and the header is absent, so the doc must not advertise it as\n // optional.\n required: true,\n description:\n \"Required idempotency token. A duplicate of a request that already succeeded produces a 409 Conflict (the first result is not cached on the server). A duplicate that arrives while the original is still running produces a retryable 503 with Retry-After, because the original may yet fail.\",\n schema: { type: \"string\" },\n });\n }\n if (options.expectedVersion) {\n parameters.push({\n name: \"If-Match\",\n in: \"header\",\n required: false,\n description:\n \"Expected stream version for optimistic concurrency. Mismatch produces a 412 Precondition Failed via ConcurrencyError.\",\n schema: { type: \"string\" },\n });\n }\n\n return {\n operationId: action_name,\n summary: `Commit the ${action_name} action`,\n tags: [\"Actions\"],\n ...(parameters.length > 0 ? { parameters } : {}),\n requestBody: {\n required: true,\n content: {\n \"application/json\": { schema: body_schema },\n },\n },\n responses: {\n \"200\": {\n description: \"Action committed; returns the snapshot array.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/SnapshotArray\" },\n },\n },\n },\n \"400\": { $ref: \"#/components/responses/ApiError\" },\n \"409\": { $ref: \"#/components/responses/ApiError\" },\n \"410\": { $ref: \"#/components/responses/ApiError\" },\n \"412\": { $ref: \"#/components/responses/ApiError\" },\n \"422\": { $ref: \"#/components/responses/ApiError\" },\n \"500\": { $ref: \"#/components/responses/ApiError\" },\n ...(options.idempotency\n ? { \"503\": { $ref: \"#/components/responses/ApiError\" } }\n : {}),\n },\n };\n}\n\n/**\n * Build an OpenAPI 3.1 document describing a built `Act` instance's\n * action surface.\n *\n * Walks `app.registry.actions` once and emits a `POST\n * <basePath>/actions/<actionName>` operation per action, deriving\n * the request-body schema from each action's Zod definition via\n * `z.toJSONSchema` (Zod 4's native JSON Schema 2020-12 emitter,\n * which is the OpenAPI 3.1 schema dialect — no conversion layer\n * needed). Error responses reference the shared\n * `#/components/responses/ApiError` so a single envelope shape\n * covers every error path.\n *\n * @param app A built `Act` orchestrator. Required.\n * @param options Document-shape fields (`info.title`, `info.version`,\n * servers, base path) and toggles for the cross-cutting headers\n * the live REST API may accept (`Idempotency-Key`, `If-Match`).\n * @returns A valid OpenAPI 3.1 document object — serve as\n * `/openapi.json`, ship to a CDN, write to disk during CI, or\n * pipe to a codegen tool.\n *\n * @throws Error if `info.title` / `info.version` is missing or\n * empty, or if any `servers[].url` fails URL parsing after\n * `{variable}` substitution.\n */\nexport function openapi<TApp extends Pick<ActSurface, \"registry\">>(\n app: TApp,\n options: OpenAPIOptions\n): OpenAPIDocument {\n validate_options(options);\n const base_path = options.basePath ?? DEFAULT_BASE_PATH;\n const paths: Record<string, OpenAPIPathItem> = {};\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const zod_schema = state.actions[action_name] as z.ZodType;\n const body_schema = mark_sensitive_fields(\n strip_json_schema_meta(\n // `unrepresentable: \"any\"` emits an open (`{}`) schema for a Zod type\n // with no JSON-Schema form (z.date/z.bigint/z.map/z.set/...) instead of\n // Zod 4's default `\"throw\"`, which would abort the WHOLE document over\n // one such field, while hono() serves the same action fine.\n // `io: \"input\"` describes what a client must SEND: a `.default()`\n // field is optional, extra keys are not rejected (Zod strips them),\n // and a `.pipe()`/`.transform()` documents its input type. Zod's\n // default, `\"output\"`, describes the parsed result instead.\n z.toJSONSchema(zod_schema, {\n unrepresentable: \"any\",\n io: \"input\",\n }) as Record<string, unknown>\n ),\n zod_schema\n );\n paths[`${base_path}/actions/${action_name}`] = {\n post: build_operation(action_name, body_schema, options),\n };\n }\n\n return {\n openapi: \"3.1.0\",\n info: options.info,\n ...(options.servers ? { servers: options.servers } : {}),\n paths,\n components: {\n schemas: {\n ApiError: API_ERROR_SCHEMA,\n SnapshotArray: SNAPSHOT_ARRAY_SCHEMA,\n },\n responses: {\n ApiError: API_ERROR_RESPONSE,\n },\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/openapi/index.ts"],"sourcesContent":["/**\n * @packageDocumentation\n * @module act-http/openapi\n *\n * OpenAPI subpath of the auto-generated API. Walks the\n * registry of a built `Act` once at function call and returns a\n * valid OpenAPI 3.1 document describing the same action surface the\n * Hono sibling at `@rotorsoft/act-http/hono` serves. The doc is\n * suitable for cross-language client codegen (Go, Python, Swift,\n * Kotlin, .NET), for serving over `/openapi.json` alongside a live\n * API, and for the broader OpenAPI tooling ecosystem (Postman,\n * Swagger UI, Redoc, schemathesis, API gateways).\n *\n * Usage:\n *\n * ```ts\n * import { openapi } from \"@rotorsoft/act-http/openapi\";\n *\n * const doc = openapi(app, {\n * info: { title: \"Wolfdesk API\", version: \"1.0.0\" },\n * servers: [{ url: \"https://api.example.com\" }],\n * });\n * // doc: OpenAPIDocument — valid OpenAPI 3.1 object\n * ```\n *\n * **Pure data emit.** No runtime dep on Hono or tRPC. The subpath is\n * consumable standalone for hosts that serve their API some other\n * way (Fastify, Lambda, edge) or that only want the doc for client\n * codegen. The emit walks the registry once at function call; cost\n * is linear in `N actions × M Zod fields each`, single-digit\n * milliseconds for a 100-endpoint registry.\n *\n * **Determinism.** Output is stable across runs given the same\n * registry — entries land in `Object.entries(app.registry.actions)`\n * iteration order, schemas come from the action's Zod definition,\n * and the rest of the doc is fixed content. CI can snapshot the\n * result to catch unintended API surface changes.\n *\n * The same shared utilities at `@rotorsoft/act-http/api` underwrite\n * cross-transport consistency: the `ApiError` envelope is referenced\n * once from `components.schemas`, every error response points at it,\n * and the same shape ships from the tRPC and Hono siblings at\n * runtime. A client speaking the doc and the live REST API sees one\n * envelope per framework error.\n *\n * **The doc describes the Hono surface, not tRPC.** tRPC's URL\n * shape (`POST /trpc/<procedure>`, JSON-RPC-style body framing,\n * batching) doesn't model cleanly as OpenAPI operations — and tRPC\n * consumers already share types directly via `typeof router`, so\n * OpenAPI buys them nothing. The path shape this emitter produces\n * (`POST <basePath>/actions/<name>`) matches the\n * `@rotorsoft/act-http/hono` sibling exactly: same default\n * `basePath`, same request/response shapes, same envelope. If the\n * operator overrides `basePath` on the Hono adapter, they pass the\n * same override here so the doc keeps matching the live routes by\n * construction. tRPC and Hono can run side-by-side at different\n * mount points on the same Act instance; the OpenAPI doc covers\n * the REST half only.\n */\nimport { pii_fields, REDACTED, SHREDDED } from \"@rotorsoft/act\";\nimport { z } from \"zod\";\nimport type { ActSurface } from \"../api/dispatch.js\";\n\n/**\n * Minimal OpenAPI 3.1 document type. The emitter targets the 3.1\n * spec; the type is intentionally a structural subset rather than\n * the full surface from `openapi-types` so callers can post-process\n * without fighting the type system. Cast to a fuller type from\n * `openapi-types` when downstream consumers need it.\n */\nexport type OpenAPIDocument = {\n readonly openapi: \"3.1.0\";\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly paths: Record<string, OpenAPIPathItem>;\n readonly components: OpenAPIComponents;\n};\n\n/**\n * Per-call options for {@link openapi}. The host supplies the\n * document-shape fields the registry can't derive (title, version,\n * servers) and toggles for the cross-cutting headers that the live\n * REST API may accept.\n *\n * - `info` — required. `title` and `version` must be non-empty;\n * `info` may carry any other top-level OpenAPI info fields.\n * - `servers` — optional. Each entry's `url` may contain\n * `{variable}` template syntax; bare URLs are validated through\n * `URL`'s parser and reject malformed inputs.\n * - `basePath` — optional, default `/api`. Mirrors the Hono\n * sibling's default so the doc describes the same paths the\n * generated REST surface serves.\n * - `idempotency` — optional, default `false`. When `true`, every\n * mutation operation documents a **required** `Idempotency-Key`\n * request header (the generated route fail-closes with 400 when the\n * header is absent, so the doc marks it required, not optional).\n * - `expectedVersion` — optional, default `false`. When `true`,\n * every mutation operation documents an optional `If-Match`\n * request header carrying the expected stream version.\n */\nexport type OpenAPIOptions = {\n readonly info: OpenAPIInfo;\n readonly servers?: ReadonlyArray<OpenAPIServer>;\n readonly basePath?: string;\n readonly idempotency?: boolean;\n readonly expectedVersion?: boolean;\n};\n\nexport type OpenAPIInfo = {\n readonly title: string;\n readonly version: string;\n readonly description?: string;\n readonly summary?: string;\n readonly termsOfService?: string;\n readonly contact?: {\n readonly name?: string;\n readonly url?: string;\n readonly email?: string;\n };\n readonly license?: {\n readonly name: string;\n readonly identifier?: string;\n readonly url?: string;\n };\n};\n\nexport type OpenAPIServer = {\n readonly url: string;\n readonly description?: string;\n readonly variables?: Record<\n string,\n {\n readonly default: string;\n readonly enum?: ReadonlyArray<string>;\n readonly description?: string;\n }\n >;\n};\n\nexport type OpenAPIPathItem = {\n readonly post?: OpenAPIOperation;\n};\n\nexport type OpenAPIOperation = {\n readonly operationId: string;\n readonly summary: string;\n readonly tags: ReadonlyArray<string>;\n readonly parameters?: ReadonlyArray<OpenAPIParameter>;\n readonly requestBody?: OpenAPIRequestBody;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nexport type OpenAPIParameter = {\n readonly name: string;\n readonly in: \"header\" | \"query\" | \"path\" | \"cookie\";\n readonly required?: boolean;\n readonly description?: string;\n readonly schema?: Record<string, unknown>;\n};\n\nexport type OpenAPIRequestBody = {\n readonly required?: boolean;\n readonly content: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n};\n\nexport type OpenAPIResponse =\n | {\n readonly description: string;\n readonly content?: Record<\n string,\n { readonly schema: Record<string, unknown> }\n >;\n }\n | { readonly $ref: string };\n\nexport type OpenAPIComponents = {\n readonly schemas: Record<string, Record<string, unknown>>;\n readonly responses: Record<string, OpenAPIResponse>;\n};\n\nconst DEFAULT_BASE_PATH = \"/api\";\n\n/**\n * The shared `ApiError` envelope schema, ref'd by every error\n * response. Matches the runtime envelope at\n * `@rotorsoft/act-http/api`'s `toApiError` exactly — keep in sync if\n * the envelope shape evolves.\n */\nconst API_ERROR_SCHEMA: Record<string, unknown> = {\n type: \"object\",\n properties: {\n error: { type: \"string\" },\n detail: { type: \"string\" },\n code: { type: \"string\" },\n },\n required: [\"error\"],\n};\n\n/** The `Snapshot[]` an action returns, around a state and event schema. */\nfunction snapshot_array(\n state: Record<string, unknown>,\n event: Record<string, unknown>\n): Record<string, unknown> {\n return {\n type: \"array\",\n items: {\n type: \"object\",\n properties: {\n state,\n event,\n patch: patch_of(state),\n id: { type: \"integer\" },\n version: { type: \"integer\" },\n patches: { type: \"integer\" },\n snaps: { type: \"integer\" },\n cache_hit: { type: \"boolean\" },\n replayed: { type: \"integer\" },\n },\n required: [\n \"state\",\n \"id\",\n \"version\",\n \"patches\",\n \"snaps\",\n \"cache_hit\",\n \"replayed\",\n ],\n },\n };\n}\n\n/**\n * The response of an action whose state the registry doesn't describe\n * (an app surface without schemas): state and event left open.\n */\nconst SNAPSHOT_ARRAY_SCHEMA = snapshot_array(\n { type: \"object\" },\n { type: \"object\" }\n);\n\n/**\n * The patch an event applied to a state: every key optional, `null` to\n * delete it, and a nested object a patch itself.\n */\nfunction patch_of(schema: Record<string, unknown>): Record<string, unknown> {\n const properties = schema.properties as\n | Record<string, Record<string, unknown>>\n | undefined;\n if (!properties) return schema;\n const { required: _, ...rest } = schema;\n return {\n ...rest,\n properties: Object.fromEntries(\n Object.entries(properties).map(([key, value]) => [\n key,\n { anyOf: [patch_of(value), { type: \"null\" }] },\n ])\n ),\n };\n}\n\n/** What a sensitive field reads as when the caller may not see it. */\nconst PII_MARKERS = { type: \"string\", enum: [REDACTED, SHREDDED] };\n\n/**\n * The error-response shape every operation references via\n * `$ref: \"#/components/responses/ApiError\"`. Single source of\n * truth — bug fixes to the envelope flow to every operation\n * automatically.\n */\nconst API_ERROR_RESPONSE: OpenAPIResponse = {\n description: \"Error envelope per @rotorsoft/act-http/api's toApiError.\",\n content: {\n \"application/json\": {\n schema: { $ref: \"#/components/schemas/ApiError\" },\n },\n },\n};\n\n/**\n * Server URL refinement: OpenAPI permits `{variable}` template syntax\n * inside server URLs, so we substitute each capture with `x` before\n * parsing. The character class forbids both `{` and `}` inside the\n * template, matching OpenAPI's variable-name grammar exactly and\n * eliminating the catastrophic-backtracking surface CodeQL flagged\n * on `[^}]+`.\n *\n * @internal\n */\nfunction is_valid_server_url(url: string): boolean {\n try {\n new URL(url.replace(/\\{[^{}]+\\}/g, \"x\"));\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Zod schema for the minimum required shape of {@link OpenAPIOptions}.\n * Same declarative-validation pattern the rest of the framework uses;\n * the OpenAPI surface is mostly free-form (`description`, `contact`,\n * `license` — the spec lets through anything that JSON-stringifies),\n * so only the load-bearing fields land here. Hosts overriding into\n * unusual shapes still get the rest of the doc emitted; this schema\n * only fails the construction call when `info.title` / `info.version`\n * is missing-or-empty or a `servers[].url` won't parse.\n *\n * @internal\n */\nconst OpenAPIOptionsSchema = z.object({\n info: z.object({\n title: z\n .string({ message: \"openapi: info.title is required (non-empty string)\" })\n .trim()\n .min(1, \"openapi: info.title is required (non-empty string)\"),\n version: z\n .string({\n message: \"openapi: info.version is required (non-empty string)\",\n })\n .trim()\n .min(1, \"openapi: info.version is required (non-empty string)\"),\n }),\n servers: z\n .array(\n z.object({\n url: z.string().refine(is_valid_server_url, {\n message: \"openapi: invalid server url\",\n }),\n })\n )\n .optional(),\n});\n\nfunction validate_options(options: OpenAPIOptions): void {\n OpenAPIOptionsSchema.parse({\n info: { title: options.info?.title, version: options.info?.version },\n servers: options.servers,\n });\n}\n\nfunction strip_json_schema_meta(\n schema: Record<string, unknown>\n): Record<string, unknown> {\n // `$schema` at the top of an inline schema is harmless in OpenAPI\n // 3.1 but adds noise to the doc. Drop it.\n const { $schema: _, ...rest } = schema;\n return rest;\n}\n\n/**\n * Annotate the emitted request-body schema's sensitive properties so\n * codegen / Swagger UI treat them as secrets rather than echoing them\n * freely. `z.toJSONSchema` has no knowledge of the sensitive registry\n * (`sensitive()` marks schemas out-of-band in a WeakMap), so we walk the\n * action's declared sensitive fields — via `pii_fields`, the same lookup\n * the orchestrator uses — and mark each matching property `writeOnly:\n * true` + `format: password`. Non-object schemas and actions with no\n * sensitive fields pass through untouched (the zero-cost common path).\n *\n * `pii_fields` only reports top-level keys of a `z.object`, and Zod's\n * JSON Schema emit always renders those as members of `properties` — so\n * once `fields` is non-empty, `body_schema.properties[field]` is\n * guaranteed present. No defensive fallback needed.\n *\n * @internal\n */\nfunction mark_sensitive_fields(\n body_schema: Record<string, unknown>,\n zod_schema: z.ZodType\n): Record<string, unknown> {\n const fields = pii_fields(zod_schema);\n if (fields.length === 0) return body_schema;\n const properties = body_schema.properties as Record<\n string,\n Record<string, unknown>\n >;\n const next_properties: Record<string, Record<string, unknown>> = {\n ...properties,\n };\n for (const field of fields) {\n next_properties[field] = {\n ...properties[field],\n writeOnly: true,\n format: \"password\",\n };\n }\n return { ...body_schema, properties: next_properties };\n}\n\n/** Emit a Zod schema as JSON Schema: what the API sends (`output`) or takes (`input`). */\nfunction json_schema(\n schema: z.ZodType,\n io: \"input\" | \"output\"\n): Record<string, unknown> {\n // `unrepresentable: \"any\"` emits an open (`{}`) schema for a Zod type with\n // no JSON-Schema form (z.date/z.bigint/z.map/z.set/...) instead of Zod 4's\n // default `\"throw\"`, which would abort the whole document over one field.\n return strip_json_schema_meta(\n z.toJSONSchema(schema, { unrepresentable: \"any\", io }) as Record<\n string,\n unknown\n >\n );\n}\n\n/** Let each named property also read as a redaction marker. */\nfunction allow_markers(\n schema: Record<string, unknown>,\n fields: readonly string[]\n): Record<string, unknown> {\n const properties = schema.properties as\n | Record<string, Record<string, unknown>>\n | undefined;\n const present = fields.filter((f) => properties?.[f]);\n if (!properties || present.length === 0) return schema;\n const next = { ...properties };\n for (const f of present) next[f] = { anyOf: [properties[f], PII_MARKERS] };\n return { ...schema, properties: next };\n}\n\n/**\n * The typed `Snapshot[]` of one state: its state schema, and the event as\n * one shape per event the state emits, told apart by `name`. A sensitive\n * field, in the event's data or the state field of the same name, may read\n * as `[REDACTED]` or `[SHREDDED]` for a caller who may not see it.\n */\nfunction state_snapshots(\n state: z.ZodType,\n events: Readonly<Record<string, z.ZodType>>\n): Record<string, unknown> {\n const sensitive = new Set<string>();\n const variants = Object.entries(events).map(([name, data]) => {\n const fields = pii_fields(data);\n for (const f of fields) sensitive.add(f);\n return {\n type: \"object\",\n properties: {\n id: { type: \"integer\" },\n stream: { type: \"string\" },\n version: { type: \"integer\" },\n created: { type: \"string\", format: \"date-time\" },\n name: { const: name },\n data: allow_markers(json_schema(data, \"output\"), fields),\n meta: {\n type: \"object\",\n properties: {\n correlation: { type: \"string\" },\n causation: { type: \"object\" },\n },\n required: [\"correlation\", \"causation\"],\n },\n },\n required: [\"id\", \"stream\", \"version\", \"created\", \"name\", \"data\", \"meta\"],\n };\n });\n return snapshot_array(\n allow_markers(json_schema(state, \"output\"), [...sensitive]),\n { oneOf: variants }\n );\n}\n\nfunction build_operation(\n action_name: string,\n body_schema: Record<string, unknown>,\n response_ref: string,\n options: OpenAPIOptions\n): OpenAPIOperation {\n const parameters: OpenAPIParameter[] = [];\n if (options.idempotency) {\n parameters.push({\n name: \"Idempotency-Key\",\n in: \"header\",\n // Required: the generated route fail-closes with 400 when idempotency is\n // enabled and the header is absent, so the doc must not advertise it as\n // optional.\n required: true,\n description:\n \"Required idempotency token. A duplicate of a request that already succeeded produces a 409 Conflict (the first result is not cached on the server). A duplicate that arrives while the original is still running produces a retryable 503 with Retry-After, because the original may yet fail.\",\n schema: { type: \"string\" },\n });\n }\n if (options.expectedVersion) {\n parameters.push({\n name: \"If-Match\",\n in: \"header\",\n required: false,\n description:\n \"Expected stream version for optimistic concurrency. Mismatch produces a 412 Precondition Failed via ConcurrencyError.\",\n schema: { type: \"string\" },\n });\n }\n\n return {\n operationId: action_name,\n summary: `Commit the ${action_name} action`,\n tags: [\"Actions\"],\n ...(parameters.length > 0 ? { parameters } : {}),\n requestBody: {\n required: true,\n content: {\n \"application/json\": { schema: body_schema },\n },\n },\n responses: {\n \"200\": {\n description: \"Action committed; returns the snapshot array.\",\n content: {\n \"application/json\": {\n schema: { $ref: response_ref },\n },\n },\n },\n \"400\": { $ref: \"#/components/responses/ApiError\" },\n \"409\": { $ref: \"#/components/responses/ApiError\" },\n \"410\": { $ref: \"#/components/responses/ApiError\" },\n \"412\": { $ref: \"#/components/responses/ApiError\" },\n \"422\": { $ref: \"#/components/responses/ApiError\" },\n \"500\": { $ref: \"#/components/responses/ApiError\" },\n ...(options.idempotency\n ? { \"503\": { $ref: \"#/components/responses/ApiError\" } }\n : {}),\n },\n };\n}\n\n/**\n * Build an OpenAPI 3.1 document describing a built `Act` instance's\n * action surface.\n *\n * Walks `app.registry.actions` once and emits a `POST\n * <basePath>/actions/<actionName>` operation per action, deriving\n * the request-body schema from each action's Zod definition via\n * `z.toJSONSchema` (Zod 4's native JSON Schema 2020-12 emitter,\n * which is the OpenAPI 3.1 schema dialect — no conversion layer\n * needed). Error responses reference the shared\n * `#/components/responses/ApiError` so a single envelope shape\n * covers every error path.\n *\n * @param app A built `Act` orchestrator. Required.\n * @param options Document-shape fields (`info.title`, `info.version`,\n * servers, base path) and toggles for the cross-cutting headers\n * the live REST API may accept (`Idempotency-Key`, `If-Match`).\n * @returns A valid OpenAPI 3.1 document object — serve as\n * `/openapi.json`, ship to a CDN, write to disk during CI, or\n * pipe to a codegen tool.\n *\n * @throws Error if `info.title` / `info.version` is missing or\n * empty, or if any `servers[].url` fails URL parsing after\n * `{variable}` substitution.\n */\nexport function openapi<TApp extends Pick<ActSurface, \"registry\">>(\n app: TApp,\n options: OpenAPIOptions\n): OpenAPIDocument {\n validate_options(options);\n const base_path = options.basePath ?? DEFAULT_BASE_PATH;\n const paths: Record<string, OpenAPIPathItem> = {};\n const state_schemas: Record<string, Record<string, unknown>> = {};\n\n for (const [action_name, state] of Object.entries(app.registry.actions)) {\n const zod_schema = state.actions[action_name] as z.ZodType;\n // `io: \"input\"` describes what a client must SEND: a `.default()` field\n // is optional, extra keys are not rejected (Zod strips them), and a\n // `.pipe()`/`.transform()` documents its input type.\n const body_schema = mark_sensitive_fields(\n json_schema(zod_schema, \"input\"),\n zod_schema\n );\n const snapshots = `${state.name}Snapshots`;\n if (state.state && state.events && !state_schemas[snapshots])\n state_schemas[snapshots] = state_snapshots(state.state, state.events);\n const response_ref = state_schemas[snapshots]\n ? `#/components/schemas/${snapshots}`\n : \"#/components/schemas/SnapshotArray\";\n paths[`${base_path}/actions/${action_name}`] = {\n post: build_operation(action_name, body_schema, response_ref, options),\n };\n }\n\n return {\n openapi: \"3.1.0\",\n info: options.info,\n ...(options.servers ? { servers: options.servers } : {}),\n paths,\n components: {\n schemas: {\n ApiError: API_ERROR_SCHEMA,\n SnapshotArray: SNAPSHOT_ARRAY_SCHEMA,\n ...state_schemas,\n },\n responses: {\n ApiError: API_ERROR_RESPONSE,\n },\n },\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/trpc/index.cjs
CHANGED
|
@@ -19958,9 +19958,7 @@ async function* runSseSubscription(channel, stream_id, accounting, signal, optio
|
|
|
19958
19958
|
pending.push(msg);
|
|
19959
19959
|
return;
|
|
19960
19960
|
}
|
|
19961
|
-
const versioned = pending.findIndex(
|
|
19962
|
-
(m) => !is_version_neutral(m)
|
|
19963
|
-
);
|
|
19961
|
+
const versioned = pending.findIndex((m) => !is_version_neutral(m));
|
|
19964
19962
|
if (versioned >= 0) {
|
|
19965
19963
|
pending.splice(versioned, 1);
|
|
19966
19964
|
pending.push(msg);
|
|
@@ -19995,14 +19993,10 @@ async function* runSseSubscription(channel, stream_id, accounting, signal, optio
|
|
|
19995
19993
|
|
|
19996
19994
|
// src/trpc/index.ts
|
|
19997
19995
|
function authenticated(extractor) {
|
|
19998
|
-
|
|
19999
|
-
ctx,
|
|
20000
|
-
next
|
|
20001
|
-
}) {
|
|
19996
|
+
return async ({ ctx, next }) => {
|
|
20002
19997
|
const actor = await extractor(ctx);
|
|
20003
19998
|
return next({ ctx: { ...ctx, actor } });
|
|
20004
19999
|
};
|
|
20005
|
-
return middleware;
|
|
20006
20000
|
}
|
|
20007
20001
|
function to_trpc_error(err) {
|
|
20008
20002
|
const { status, body } = toApiError(err);
|