opinionated-machine 7.0.0 → 9.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # opinionated-machine
2
+
3
+ ## 9.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - d6da03e: Adopt mandatory contract visibility (`@lokalise/api-contracts` v8):
8
+
9
+ - Raise peer dependency floors to `@lokalise/api-contracts` >= 8.0.0 (visibility is now a required
10
+ field of every contract builder config) and `@lokalise/fastify-api-contracts` >= 7.0.0.
11
+ - Derive the fastify-swagger `hide` flag from contract `visibility` in the SSE and dual-mode route
12
+ builders, failing closed: only `visibility: 'public'` contracts appear in generated OpenAPI docs.
13
+ Anything else — `'internal'`, or a contract that lacks the field at runtime because it was compiled
14
+ against a pre-visibility `@lokalise/api-contracts` — sets `schema.hide: true` and is excluded,
15
+ matching `@lokalise/fastify-api-contracts`. The same builders now also map the contract's
16
+ `description`, `summary` and `tags` to the route schema; previously these fields were dropped and
17
+ the routes appeared undocumented.
18
+ - Remove the unused `visibility` field from the gateway metadata schema. No generator consumed it
19
+ and it collided with the (unrelated) contract `visibility` — a docs-internal BFF route can still
20
+ be gateway-public, so the two concepts cannot be derived from each other. If gateway-level
21
+ exposure classification is needed later, it will be reintroduced under a non-colliding name
22
+ (e.g. `exposure`). The metadata schema is strict, so passing `visibility` now fails loudly.
23
+
24
+ ## 8.0.0
25
+
26
+ ### Major Changes
27
+
28
+ - de12db0: Replace the local ApiContract route builder with `buildFastifyApiRoute` from `@lokalise/fastify-api-contracts` (>=6.0.0, now the source of the route-building, handler inference, response validation, and SSE streaming logic). `buildApiRoute` remains as a thin wrapper that only adds the contract-narrowed `gatewayMetadata` option.
29
+
30
+ Breaking changes:
31
+
32
+ - Handlers now use the package's unified `(request, reply, context) => { status, body }` shape for every response mode. SSE streaming is driven via `context.sse.start(...)` (or by returning an `AsyncIterable` body for an SSE status); dual-mode `{ nonSse, sse }` handler objects and `sse.respond()` / `sse.sendHeaders()` are gone — branch on `context.expectedContentType` and return `{ status, body }` for early HTTP responses.
33
+ - `ApiNonSseHandler`, `ApiSseHandler`, `InferApiRequest`, and `InferApiStatusResponse` are removed; `InferApiHandler`, `InferApiHandlerRequest`, `InferApiHandlerResult`, `ApiHandlerContext`, and `ApiHandlerReply` are re-exported from `@lokalise/fastify-api-contracts` instead.
34
+ - `buildApiRoute` options: `defaultMode` is removed (use `context.expectedContentType`). `heartbeatInterval` is removed — per-route heartbeat intervals are no longer possible: the package's `heartbeat` option is a boolean that only enables/disables the heartbeat for a route, and the interval itself is configured once for all routes at `@fastify/sse` plugin registration (`heartbeatInterval`, default 30000 ms).
35
+ - Response body validation is delegated to the `fastify-type-provider-zod` serializer compiler — apps must register `validatorCompiler` / `serializerCompiler`. This applies to every ApiContract route, not just JSON ones: the package emits a Zod response schema for every declared status (including `sseBody()` and `noBodyResponse()`, which previously produced no response schema), so an app missing `setSerializerCompiler` now fails at boot during route registration — even for SSE-only contracts — with `FST_ERR_SCH_SERIALIZATION_BUILD: Failed building the serialization schema … schema is invalid: data/required must be array`. If you hit that error, register the zod compilers. Peer ranges bumped: `@lokalise/fastify-api-contracts` >=6.0.0, `fastify-type-provider-zod` >=7.0.0.
36
+ - Error handling is delegated to the app's global `fastify.setErrorHandler` (per the `@lokalise/fastify-api-contracts` README), including for SSE routes: the route builder no longer maps the node-core `httpStatusCode` error convention (`PublicNonRecoverableError`, `InternalError`, …) onto responses — such errors now reach the error handler unmapped and default to 500 — and no longer emits a terminal SSE `error` event when a handler throws after `sse.start()`. Note the resulting cross-system split: legacy `buildFastifyRoute` / SSE / dual-mode routes still honor `httpStatusCode` internally, so an app mixing both route families maps the same error to different statuses. Install a global error handler that maps `httpStatusCode` (and, if your clients rely on it, emits the terminal SSE `error` event) before upgrading.
37
+ - The `SSESession` / `SSEContext` / `SSESessionMode` / `FastifySSERouteOptions` types exported from the package root still resolve to the legacy `lib/routes` types and no longer match what `buildApiRoute` handlers and lifecycle hooks (`onConnect` / `onClose`) actually receive — the package's session has no `rooms` / `eventSchemas`, an optional `context`, adds `close()`, and its context has no `respond()` / `sendHeaders()`. When typing `buildApiRoute` sessions, contexts, or hooks explicitly, import these types from `@lokalise/fastify-api-contracts` directly; the root exports keep typing the legacy `AbstractSSEController` / `AbstractDualModeController` routes.
38
+
39
+ ## 7.0.0
40
+
41
+ ### Major Changes
42
+
43
+ - e3a05b6: Require `@lokalise/api-contracts` >= 7.0.0. The route builder no longer handles the legacy response entries removed in api-contracts 7 (`anyOfResponses`, `sseResponse`/`blobResponse`/`textResponse` tagged objects, `ContractNoBody` as a response) — declare responses with bare Zod schemas, `noBodyResponse()`, or content maps (`{ content: { 'text/event-stream': sseBody(...) } }`). Handler body types are now also inferred from content-map entries (JSON media types resolve to their Zod output type, blob to `Blob`, `allowNoBody` to `undefined`).
44
+
45
+ ## 6.20.3
46
+
47
+ ### Patch Changes
48
+
49
+ - 22a290b: Migrate release automation to Changesets.
package/README.md CHANGED
@@ -475,6 +475,7 @@ const PATH_PARAMS_SCHEMA = z.object({
475
475
  })
476
476
 
477
477
  const contract = buildRestContract({
478
+ visibility: 'public',
478
479
  method: 'delete',
479
480
  successResponseBodySchema: BODY_SCHEMA,
480
481
  requestPathParamsSchema: PATH_PARAMS_SCHEMA,
@@ -741,6 +742,7 @@ import { buildSseContract } from '@lokalise/api-contracts'
741
742
 
742
743
  // GET-based SSE stream with path params
743
744
  export const channelStreamContract = buildSseContract({
745
+ visibility: 'public',
744
746
  method: 'get',
745
747
  pathResolver: (params) => `/api/channels/${params.channelId}/stream`,
746
748
  requestPathParamsSchema: z.object({ channelId: z.string() }),
@@ -753,6 +755,7 @@ export const channelStreamContract = buildSseContract({
753
755
 
754
756
  // GET-based SSE stream without path params
755
757
  export const notificationsContract = buildSseContract({
758
+ visibility: 'public',
756
759
  method: 'get',
757
760
  pathResolver: () => '/api/notifications/stream',
758
761
  requestPathParamsSchema: z.object({}),
@@ -768,6 +771,7 @@ export const notificationsContract = buildSseContract({
768
771
 
769
772
  // POST-based SSE stream (e.g., AI chat completions)
770
773
  export const chatCompletionContract = buildSseContract({
774
+ visibility: 'public',
771
775
  method: 'post',
772
776
  pathResolver: () => '/api/chat/completions',
773
777
  requestPathParamsSchema: z.object({}),
@@ -1128,6 +1132,7 @@ The mapper can return any of: `config`, `bodyLimit`, `onRequest`, `preParsing`,
1128
1132
  ```ts
1129
1133
  // In the contract definition
1130
1134
  const adminStreamContract = buildSseContract({
1135
+ visibility: 'public',
1131
1136
  method: 'get',
1132
1137
  pathResolver: () => '/api/admin/stream',
1133
1138
  // ...schemas...
@@ -1521,6 +1526,7 @@ import { z } from 'zod'
1521
1526
  import { injectSSE } from 'opinionated-machine'
1522
1527
 
1523
1528
  const streamContract = buildSseContract({
1529
+ visibility: 'public',
1524
1530
  method: 'get',
1525
1531
  pathResolver: () => '/api/stream',
1526
1532
  requestQuerySchema: z.object({}),
@@ -2262,6 +2268,7 @@ import { buildSseContract } from '@lokalise/api-contracts'
2262
2268
 
2263
2269
  // GET dual-mode route (polling or streaming job status)
2264
2270
  export const jobStatusContract = buildSseContract({
2271
+ visibility: 'public',
2265
2272
  method: 'get',
2266
2273
  pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
2267
2274
  requestPathParamsSchema: z.object({ jobId: z.string().uuid() }),
@@ -2280,6 +2287,7 @@ export const jobStatusContract = buildSseContract({
2280
2287
 
2281
2288
  // POST dual-mode route (OpenAI-style chat completion)
2282
2289
  export const chatCompletionContract = buildSseContract({
2290
+ visibility: 'public',
2283
2291
  method: 'post',
2284
2292
  pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
2285
2293
  requestPathParamsSchema: z.object({ chatId: z.string().uuid() }),
@@ -2305,6 +2313,7 @@ Dual-mode contracts support an optional `responseHeaderSchema` to define and val
2305
2313
 
2306
2314
  ```ts
2307
2315
  export const rateLimitedContract = buildSseContract({
2316
+ visibility: 'public',
2308
2317
  method: 'post',
2309
2318
  pathResolver: () => '/api/rate-limited',
2310
2319
  requestPathParamsSchema: z.object({}),
@@ -2350,6 +2359,7 @@ Dual-mode and SSE contracts support `responseBodySchemasByStatusCode` to define
2350
2359
 
2351
2360
  ```ts
2352
2361
  export const resourceContract = buildSseContract({
2362
+ visibility: 'public',
2353
2363
  method: 'post',
2354
2364
  pathResolver: (params) => `/api/resources/${params.id}`,
2355
2365
  requestPathParamsSchema: z.object({ id: z.string() }),
@@ -2876,12 +2886,14 @@ import {
2876
2886
  import { z } from 'zod/v4'
2877
2887
 
2878
2888
  const getUser = buildRestContract({
2889
+ visibility: 'public',
2879
2890
  method: 'get',
2880
2891
  successResponseBodySchema: z.object({ id: z.string() }),
2881
2892
  requestPathParamsSchema: z.object({ userId: z.string() }),
2882
2893
  pathResolver: (p) => `/users/${p.userId}`,
2883
2894
  })
2884
2895
  const createUser = buildRestContract({
2896
+ visibility: 'public',
2885
2897
  method: 'post',
2886
2898
  requestBodySchema: z.object({ name: z.string() }),
2887
2899
  successResponseBodySchema: z.object({ id: z.string() }),
@@ -3031,6 +3043,7 @@ become compile errors before you ever ship a config:
3031
3043
 
3032
3044
  ```ts
3033
3045
  const getUser = buildRestContract({
3046
+ visibility: 'public',
3034
3047
  method: 'get',
3035
3048
  successResponseBodySchema: ResponseBody,
3036
3049
  requestHeaderSchema: z.object({ 'x-trace-id': z.string() }),
@@ -3088,7 +3101,7 @@ time.
3088
3101
  | `rewrite` | `{ stripPrefix: '/v2' }` or `{ replacePrefix: { from: '/v1', to: '/v2' } }` | |
3089
3102
  | `traffic` | `{ weights: [{ upstream: 'a', weight: 80 }, { upstream: 'b', weight: 20 }] }` | Also `shadow: { upstream, percent }` |
3090
3103
  | `headers` | `{ request: { add: { 'x-internal': 'true' }, remove: ['cookie'] }, response: … }` | Free-form keys; typically infra headers not in the contract |
3091
- | `tags`, `visibility` | `tags: ['users']`, `visibility: 'internal'` | Documentation / partitioning |
3104
+ | `tags` | `tags: ['users']` | Documentation / partitioning |
3092
3105
  | `extensions` | `{ envoy: { … }, krakend: { … }, kong: { … } }` | Vendor escape hatch; merged onto the generated route last |
3093
3106
 
3094
3107
  ### Generating Gateway Configs
@@ -20,9 +20,12 @@ import type { GatewayMetadataValue } from '../gateway/gatewayMetadata.ts';
20
20
  * status: 200,
21
21
  * body: { id: req.params.id },
22
22
  * })),
23
- * streamUpdates: buildApiRoute(UserController.contracts.streamUpdates, async (_req, sse) => {
24
- * sse.start('keepAlive')
25
- * }),
23
+ * streamUpdates: buildApiRoute(
24
+ * UserController.contracts.streamUpdates,
25
+ * async (_req, _reply, { sse }) => {
26
+ * sse.start('keepAlive')
27
+ * },
28
+ * ),
26
29
  * }
27
30
  * }
28
31
  * ```
@@ -17,9 +17,12 @@
17
17
  * status: 200,
18
18
  * body: { id: req.params.id },
19
19
  * })),
20
- * streamUpdates: buildApiRoute(UserController.contracts.streamUpdates, async (_req, sse) => {
21
- * sse.start('keepAlive')
22
- * }),
20
+ * streamUpdates: buildApiRoute(
21
+ * UserController.contracts.streamUpdates,
22
+ * async (_req, _reply, { sse }) => {
23
+ * sse.start('keepAlive')
24
+ * },
25
+ * ),
23
26
  * }
24
27
  * }
25
28
  * ```
@@ -1 +1 @@
1
- {"version":3,"file":"AbstractApiController.js","sourceRoot":"","sources":["../../../lib/api-contracts/AbstractApiController.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,OAAgB,qBAAqB;IAGzC;;;;;;;OAOG;IACa,eAAe,CAAuB;CACvD"}
1
+ {"version":3,"file":"AbstractApiController.js","sourceRoot":"","sources":["../../../lib/api-contracts/AbstractApiController.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,OAAgB,qBAAqB;IAGzC;;;;;;;OAOG;IACa,eAAe,CAAuB;CACvD"}
@@ -1,23 +1,65 @@
1
- import { type ApiContract } from '@lokalise/api-contracts';
1
+ import type { ApiContract } from '@lokalise/api-contracts';
2
+ import { type ApiRouteOptions as FastifyApiRouteOptions, type InferApiHandler } from '@lokalise/fastify-api-contracts';
2
3
  import type { RouteOptions } from 'fastify';
3
- import type { ApiRouteOptions, InferApiHandler } from './apiHandlerTypes.ts';
4
+ import type { GatewayMetadata } from '../gateway/gatewayTypes.ts';
5
+ /**
6
+ * Options for configuring an ApiContract route.
7
+ *
8
+ * All options from `@lokalise/fastify-api-contracts` (any Fastify route field
9
+ * minus the ones the contract provides, SSE lifecycle hooks, and
10
+ * `contractMetadataToRouteMapper`) pass through to `buildFastifyApiRoute`
11
+ * unchanged.
12
+ *
13
+ * Generic in `Contract` so `gatewayMetadata.match.headers` / `match.query`
14
+ * keys are narrowed to the contract's request schemas. The generic is always
15
+ * inferred from the contract argument at the `buildApiRoute` call site, so
16
+ * direct references should write `ApiRouteOptions<typeof myContract>` when
17
+ * gateway metadata typing is needed.
18
+ */
19
+ export type ApiRouteOptions<Contract extends ApiContract> = FastifyApiRouteOptions & {
20
+ /**
21
+ * Per-route gateway metadata. `match.headers` / `match.query` keys are
22
+ * narrowed to the contract's request schemas; `customHeaders` /
23
+ * `customQuery` remain the escape hatch for headers and params not
24
+ * declared on the contract. Validated at runtime against the same Zod
25
+ * schema used by `withGatewayMetadata` and stamped on the route via the
26
+ * shared `GATEWAY_METADATA_SYMBOL`.
27
+ *
28
+ * Equivalent to wrapping the result with `withGatewayMetadata` — keep
29
+ * to one form per route. If both are used on the same route, the later
30
+ * call (typically `withGatewayMetadata`) overwrites the inline value;
31
+ * there is no merge.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * buildApiRoute(MyController.contracts.getItem, this.getItem, {
36
+ * gatewayMetadata: {
37
+ * cache: { ttl: '60s' },
38
+ * match: {
39
+ * // narrowed to keys of the contract's requestHeaderSchema:
40
+ * headers: { 'x-trace-id': { regex: '^[a-f0-9]+$' } },
41
+ * // escape hatch for headers not declared on the contract:
42
+ * customHeaders: { 'x-tenant-id': { regex: '^t_' } },
43
+ * },
44
+ * },
45
+ * })
46
+ * ```
47
+ */
48
+ gatewayMetadata?: GatewayMetadata<Contract>;
49
+ };
4
50
  /**
5
51
  * Build a Fastify `RouteOptions` object from an `ApiContract` + handler.
6
52
  *
7
- * The handler shape is inferred from the contract's response mode:
8
- * - `'non-sse'` — bare async function returning `{ status, body }`
9
- * - `'sse'` — bare async function calling `sse.start(...)` / `sse.respond(...)`
10
- * - `'dual'` — `{ nonSse, sse }` object branched by the `Accept` header
53
+ * Thin wrapper around `buildFastifyApiRoute` from
54
+ * `@lokalise/fastify-api-contracts` — the handler shape, response mode
55
+ * inference, SSE streaming, and validation semantics are all the package's.
56
+ * See its docs for the `(request, reply, context) => { status, body }`
57
+ * handler model and `context.sse` streaming.
11
58
  *
12
- * The optional `options` argument carries:
13
- * - any Fastify route field (`preHandler`, `onRequest`, `config`, `bodyLimit`, …)
14
- * minus the ones the contract provides (`method`, `url`, `schema`, `handler`, `sse`),
15
- * - SSE lifecycle hooks (`onConnect`, `onClose`, `onReconnect`, `serializer`,
16
- * `heartbeatInterval`) — applied for `'sse'` and `'dual'` contracts only,
17
- * - `defaultMode` for `'dual'` contracts when the `Accept` header is ambiguous,
18
- * - `gatewayMetadata` — per-route gateway policy with header / query keys
19
- * narrowed to the contract; equivalent to wrapping the result with
20
- * `withGatewayMetadata`. See `ApiRouteOptions` for full details.
59
+ * On top of the package builder this adds one option: `gatewayMetadata` —
60
+ * per-route gateway policy with header / query keys narrowed to the
61
+ * contract; equivalent to wrapping the result with `withGatewayMetadata`.
62
+ * See `ApiRouteOptions` for full details.
21
63
  *
22
64
  * @returns Fastify `RouteOptions` ready to pass to `app.route()`
23
65
  */
@@ -1,358 +1,25 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { ContractNoBody, getSseSchemaByEventName, hasAnySuccessSseResponse, isContentResponseEntry, isSseBody, mapApiContractToPath, resolveContractResponse, SUCCESSFUL_HTTP_STATUS_CODES, } from '@lokalise/api-contracts';
3
- import { InternalError } from '@lokalise/node-core';
4
- import { isErrorLike } from "../errorUtils.js";
1
+ import { buildFastifyApiRoute, } from '@lokalise/fastify-api-contracts';
5
2
  import { attachGatewayMetadata } from "../gateway/withGatewayMetadata.js";
6
- import { determineMode, hasHttpStatusCode } from "../routes/fastifyRouteUtils.js";
7
- function isSuccessResponseDual(value) {
8
- if (isContentResponseEntry(value)) {
9
- // A content-map entry offers a non-SSE representation when it allows an empty
10
- // body or declares any non-SSE media type descriptor.
11
- if (value.allowNoBody || !value.content)
12
- return true;
13
- return Object.values(value.content).some((descriptor) => !isSseBody(descriptor));
14
- }
15
- // A bare Zod schema is a JSON response, which always has a non-SSE representation.
16
- return true;
17
- }
18
- function getContractResponseMode(contract) {
19
- if (!hasAnySuccessSseResponse(contract))
20
- return 'non-sse';
21
- for (const code of SUCCESSFUL_HTTP_STATUS_CODES) {
22
- const value = contract.responsesByStatusCode[code];
23
- if (value && isSuccessResponseDual(value))
24
- return 'dual';
25
- }
26
- return 'sse';
27
- }
28
- function buildSSERouteConfig(options) {
29
- if (!options?.serializer && options?.heartbeatInterval === undefined)
30
- return true;
31
- const sseConfig = {};
32
- if (options.serializer)
33
- sseConfig.serializer = options.serializer;
34
- if (options.heartbeatInterval !== undefined)
35
- sseConfig.heartbeatInterval = options.heartbeatInterval;
36
- return sseConfig;
37
- }
38
- // ============================================================================
39
- // Internal Helpers — Sync Route
40
- // ============================================================================
41
- function getSchemaForStatusCode(contract, status) {
42
- const entry = contract.responsesByStatusCode[status];
43
- if (!entry)
44
- return null;
45
- // Resolve the JSON representation for this status code, covering both bare Zod
46
- // schemas and content-map entries. Non-JSON responses (blob, SSE, no-body) are
47
- // not validated here.
48
- const resolved = resolveContractResponse(entry, 'application/json', false);
49
- return resolved?.kind === 'json' ? resolved.schema : null;
50
- }
51
- function validateApiResponseHeaders(contract, reply) {
52
- const schema = contract.responseHeaderSchema;
53
- if (!schema) {
54
- return;
55
- }
56
- const result = schema.safeParse(reply.getHeaders());
57
- if (!result.success) {
58
- throw new InternalError({
59
- message: 'Internal Server Error',
60
- errorCode: 'RESPONSE_HEADERS_VALIDATION_FAILED',
61
- details: { validationError: result.error.message },
62
- });
63
- }
64
- }
65
- async function handleApiSyncRoute(contract,
66
- // biome-ignore lint/suspicious/noExplicitAny: Handler types are validated by InferApiHandler at the call site
67
- handler,
68
- // biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema
69
- request, reply) {
70
- const { status, body } = await handler(request, reply);
71
- if (reply.sent) {
72
- request.log.warn({
73
- msg: 'Sync handler sent response directly, bypassing response validation',
74
- tag: 'response_sent_directly',
75
- method: request.method,
76
- url: request.url,
77
- });
78
- return;
79
- }
80
- try {
81
- const schema = getSchemaForStatusCode(contract, status);
82
- if (schema) {
83
- const result = schema.safeParse(body);
84
- if (!result.success) {
85
- throw new InternalError({
86
- message: 'Internal Server Error',
87
- errorCode: 'RESPONSE_VALIDATION_FAILED',
88
- details: { validationError: result.error.message },
89
- });
90
- }
91
- }
92
- }
93
- catch (err) {
94
- reply.code(500);
95
- throw err;
96
- }
97
- validateApiResponseHeaders(contract, reply);
98
- if (!reply.hasHeader('content-type')) {
99
- reply.type('application/json');
100
- }
101
- return reply.code(status).send(body);
102
- }
103
- // ============================================================================
104
- // Internal Helpers — SSE Route (no controller, uses reply.sse directly)
105
- // ============================================================================
106
- function buildApiSSEContext(
107
- // biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema
108
- request, reply, eventSchemas, options) {
109
- let started = false;
110
- let responseData;
111
- const sseReply = reply;
112
- const sseContext = {
113
- start: (mode, startOptions) => {
114
- started = true;
115
- if (mode === 'keepAlive') {
116
- sseReply.sse.keepAlive();
117
- }
118
- // sendHeaders() calls writeHead(200) but only queues headers in the buffer.
119
- // flushHeaders() forces them onto the wire so the client's fetch() returns.
120
- sseReply.sse.sendHeaders();
121
- reply.raw.flushHeaders();
122
- const connectionId = randomUUID();
123
- const send = async (eventName, data, sendOptions) => {
124
- const schema = eventSchemas[eventName];
125
- if (schema) {
126
- const result = schema.safeParse(data);
127
- if (!result.success) {
128
- throw new InternalError({
129
- message: `SSE event validation failed for event "${eventName}": ${result.error.message}`,
130
- errorCode: 'RESPONSE_VALIDATION_FAILED',
131
- });
132
- }
133
- }
134
- try {
135
- await sseReply.sse.send({
136
- event: eventName,
137
- data,
138
- id: sendOptions?.id,
139
- retry: sendOptions?.retry,
140
- });
141
- return true;
142
- }
143
- catch {
144
- return false;
145
- }
146
- };
147
- const session = {
148
- id: connectionId,
149
- request,
150
- reply,
151
- context: (startOptions?.context ?? {}),
152
- connectedAt: new Date(),
153
- // biome-ignore lint/suspicious/noExplicitAny: SSEEventSender generic is satisfied at handler call site
154
- send: send,
155
- isConnected: () => sseReply.sse.isConnected,
156
- getStream: () => sseReply.sse.stream(),
157
- sendStream: async (messages) => {
158
- for await (const message of messages) {
159
- await send(message.event, message.data, { id: message.id, retry: message.retry });
160
- }
161
- },
162
- rooms: { join: () => { }, leave: () => { } },
163
- eventSchemas,
164
- };
165
- if (options?.onConnect) {
166
- void Promise.resolve(options.onConnect(session)).catch(() => { });
167
- }
168
- if (options?.onClose) {
169
- const onClose = options.onClose;
170
- sseReply.sse.onClose(() => {
171
- void Promise.resolve(onClose(session, 'client')).catch(() => { });
172
- });
173
- }
174
- if (options?.onReconnect && sseReply.sse.lastEventId) {
175
- const onReconnect = options.onReconnect;
176
- const lastEventId = sseReply.sse.lastEventId;
177
- void sseReply.sse.replay(async () => {
178
- const replay = await onReconnect(session, lastEventId);
179
- if (replay) {
180
- for await (const msg of replay) {
181
- await sseReply.sse.send(msg);
182
- }
183
- }
184
- });
185
- }
186
- return session;
187
- },
188
- respond: ((code, body) => {
189
- if (started) {
190
- throw new Error('Cannot call sse.respond() after sse.start() — the SSE stream is already open.');
191
- }
192
- responseData = { code, body };
193
- return { _type: 'respond', code, body };
194
- // biome-ignore lint/suspicious/noExplicitAny: respond typing is enforced by contract at call site
195
- }),
196
- sendHeaders: () => {
197
- sseReply.sse.sendHeaders();
198
- },
199
- reply,
200
- };
201
- return {
202
- sseContext,
203
- isStarted: () => started,
204
- hasResponse: () => responseData !== undefined,
205
- getResponseData: () => responseData,
206
- };
207
- }
208
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Core SSE handler coordinates context, error handling, and lifecycle
209
- async function handleApiSseRoute(
210
- // biome-ignore lint/suspicious/noExplicitAny: SSE handler types are validated by InferApiHandler at call site
211
- sseHandler, eventSchemas, options,
212
- // biome-ignore lint/suspicious/noExplicitAny: Request types are validated by Fastify schema
213
- request, reply) {
214
- const { sseContext, isStarted, hasResponse, getResponseData } = buildApiSSEContext(request, reply, eventSchemas, options);
215
- try {
216
- await sseHandler(request, sseContext);
217
- if (!isStarted() && !hasResponse()) {
218
- throw new Error('SSE handler must either send a response (sse.respond()) ' +
219
- 'or start streaming (sse.start()). Handler returned without doing either.');
220
- }
221
- const responseData = getResponseData();
222
- if (responseData) {
223
- // Early HTTP response (sse.respond() was called before streaming)
224
- reply.removeHeader('cache-control');
225
- reply.removeHeader('x-accel-buffering');
226
- reply.type('application/json').code(responseData.code).send(responseData.body);
227
- }
228
- // If started, @fastify/sse manages the rest of the connection lifecycle
229
- }
230
- catch (err) {
231
- if (isStarted()) {
232
- // Headers already sent — can't change status code; try to send error event
233
- const sseReply = reply;
234
- if (sseReply.sse.isConnected) {
235
- try {
236
- await sseReply.sse.send({
237
- event: 'error',
238
- data: { message: isErrorLike(err) ? err.message : 'Internal Server Error' },
239
- });
240
- }
241
- catch {
242
- // Ignore send failures during error handling
243
- }
244
- }
245
- throw err;
246
- }
247
- // Streaming not started — send HTTP error response
248
- const message = isErrorLike(err) ? err.message : 'Internal Server Error';
249
- const statusCode = hasHttpStatusCode(err) ? err.httpStatusCode : 500;
250
- const statusText = statusCode >= 500 ? 'Internal Server Error' : 'Error';
251
- reply.code(statusCode).type('application/json').send({ statusCode, error: statusText, message });
252
- }
253
- }
254
- // ============================================================================
255
- // Internal Helpers — Schema
256
- // ============================================================================
257
- function buildResponseSchemas(contract) {
258
- return Object.keys(contract.responsesByStatusCode).reduce((acc, statusCode) => {
259
- const schema = getSchemaForStatusCode(contract, Number(statusCode));
260
- if (schema) {
261
- acc[Number(statusCode)] = schema;
262
- }
263
- return acc;
264
- }, {});
265
- }
266
- function buildBaseSchema(contract) {
267
- const schema = {};
268
- if (contract.requestPathParamsSchema)
269
- schema.params = contract.requestPathParamsSchema;
270
- if (contract.requestQuerySchema)
271
- schema.querystring = contract.requestQuerySchema;
272
- if (contract.requestHeaderSchema)
273
- schema.headers = contract.requestHeaderSchema;
274
- if (contract.requestBodySchema !== undefined && contract.requestBodySchema !== ContractNoBody) {
275
- schema.body = contract.requestBodySchema;
276
- }
277
- schema.response = buildResponseSchemas(contract);
278
- return schema;
279
- }
280
- // ============================================================================
281
- // Public API
282
- // ============================================================================
283
3
  /**
284
4
  * Build a Fastify `RouteOptions` object from an `ApiContract` + handler.
285
5
  *
286
- * The handler shape is inferred from the contract's response mode:
287
- * - `'non-sse'` — bare async function returning `{ status, body }`
288
- * - `'sse'` — bare async function calling `sse.start(...)` / `sse.respond(...)`
289
- * - `'dual'` — `{ nonSse, sse }` object branched by the `Accept` header
6
+ * Thin wrapper around `buildFastifyApiRoute` from
7
+ * `@lokalise/fastify-api-contracts` — the handler shape, response mode
8
+ * inference, SSE streaming, and validation semantics are all the package's.
9
+ * See its docs for the `(request, reply, context) => { status, body }`
10
+ * handler model and `context.sse` streaming.
290
11
  *
291
- * The optional `options` argument carries:
292
- * - any Fastify route field (`preHandler`, `onRequest`, `config`, `bodyLimit`, …)
293
- * minus the ones the contract provides (`method`, `url`, `schema`, `handler`, `sse`),
294
- * - SSE lifecycle hooks (`onConnect`, `onClose`, `onReconnect`, `serializer`,
295
- * `heartbeatInterval`) — applied for `'sse'` and `'dual'` contracts only,
296
- * - `defaultMode` for `'dual'` contracts when the `Accept` header is ambiguous,
297
- * - `gatewayMetadata` — per-route gateway policy with header / query keys
298
- * narrowed to the contract; equivalent to wrapping the result with
299
- * `withGatewayMetadata`. See `ApiRouteOptions` for full details.
12
+ * On top of the package builder this adds one option: `gatewayMetadata` —
13
+ * per-route gateway policy with header / query keys narrowed to the
14
+ * contract; equivalent to wrapping the result with `withGatewayMetadata`.
15
+ * See `ApiRouteOptions` for full details.
300
16
  *
301
17
  * @returns Fastify `RouteOptions` ready to pass to `app.route()`
302
18
  */
303
19
  export function buildApiRoute(contract, handler, options) {
304
- // Separate SSE-specific options (not in Fastify RouteOptions) and gateway
305
- // metadata (stamped via Symbol, not spread) from passthrough options.
306
- const { defaultMode, contractMetadataToRouteMapper, gatewayMetadata, serializer: _serializer, heartbeatInterval: _heartbeatInterval, onConnect: _onConnect, onClose: _onClose, onReconnect: _onReconnect, logger: _logger, ...fastifyOptions } = options ?? {};
307
- const url = mapApiContractToPath(contract);
308
- const mode = getContractResponseMode(contract);
309
- const eventSchemas = getSseSchemaByEventName(contract) ?? {};
310
- const baseSchema = buildBaseSchema(contract);
311
- const contractMetadata = contractMetadataToRouteMapper?.(contract.metadata) ?? {};
312
- const finalize = (route) => gatewayMetadata !== undefined ? attachGatewayMetadata(route, gatewayMetadata) : route;
313
- if (mode === 'non-sse') {
314
- // biome-ignore lint/suspicious/noExplicitAny: handler shape validated by InferApiHandler at call site
315
- const syncHandler = handler;
316
- return finalize({
317
- ...fastifyOptions,
318
- ...contractMetadata,
319
- method: contract.method,
320
- url,
321
- schema: baseSchema,
322
- handler: async (request, reply) => handleApiSyncRoute(contract, syncHandler, request, reply),
323
- });
324
- }
325
- if (mode === 'dual') {
326
- const resolvedDefaultMode = defaultMode ?? 'json';
327
- // biome-ignore lint/suspicious/noExplicitAny: handler shape validated by InferApiHandler at call site
328
- const dualHandlers = handler;
329
- return finalize({
330
- ...fastifyOptions,
331
- ...contractMetadata,
332
- method: contract.method,
333
- url,
334
- sse: buildSSERouteConfig(options),
335
- schema: baseSchema,
336
- handler: (request, reply) => {
337
- const responseMode = determineMode(request.headers.accept, resolvedDefaultMode);
338
- if (responseMode === 'json') {
339
- return handleApiSyncRoute(contract, dualHandlers.nonSse, request, reply);
340
- }
341
- return handleApiSseRoute(dualHandlers.sse, eventSchemas, options, request, reply);
342
- },
343
- });
344
- }
345
- // SSE-only
346
- // biome-ignore lint/suspicious/noExplicitAny: handler shape validated by InferApiHandler at call site
347
- const sseHandler = handler;
348
- return finalize({
349
- ...fastifyOptions,
350
- ...contractMetadata,
351
- method: contract.method,
352
- url,
353
- sse: buildSSERouteConfig(options),
354
- schema: baseSchema,
355
- handler: async (request, reply) => handleApiSseRoute(sseHandler, eventSchemas, options, request, reply),
356
- });
20
+ // Gateway metadata is stamped via Symbol, not spread into Fastify options.
21
+ const { gatewayMetadata, ...fastifyOptions } = options ?? {};
22
+ const route = buildFastifyApiRoute(contract, handler, fastifyOptions);
23
+ return gatewayMetadata !== undefined ? attachGatewayMetadata(route, gatewayMetadata) : route;
357
24
  }
358
25
  //# sourceMappingURL=apiRouteBuilder.js.map