@beignet/core 0.0.33 → 0.0.35

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.
Files changed (142) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +207 -30
  3. package/dist/agent-capabilities/index.d.ts +167 -0
  4. package/dist/agent-capabilities/index.d.ts.map +1 -0
  5. package/dist/agent-capabilities/index.js +275 -0
  6. package/dist/agent-capabilities/index.js.map +1 -0
  7. package/dist/application/index.d.ts +2 -2
  8. package/dist/application/index.d.ts.map +1 -1
  9. package/dist/application/index.js +13 -3
  10. package/dist/application/index.js.map +1 -1
  11. package/dist/client/client.d.ts +2 -0
  12. package/dist/client/client.d.ts.map +1 -1
  13. package/dist/client/client.js +33 -16
  14. package/dist/client/client.js.map +1 -1
  15. package/dist/contracts/contract-builder.d.ts +5 -7
  16. package/dist/contracts/contract-builder.d.ts.map +1 -1
  17. package/dist/contracts/contract-builder.js +3 -18
  18. package/dist/contracts/contract-builder.js.map +1 -1
  19. package/dist/contracts/contract-group.d.ts +2 -2
  20. package/dist/contracts/contract-group.d.ts.map +1 -1
  21. package/dist/contracts/contract-group.js +2 -1
  22. package/dist/contracts/contract-group.js.map +1 -1
  23. package/dist/contracts/metadata.d.ts +3 -0
  24. package/dist/contracts/metadata.d.ts.map +1 -0
  25. package/dist/contracts/metadata.js +14 -0
  26. package/dist/contracts/metadata.js.map +1 -0
  27. package/dist/contracts/types.d.ts +20 -0
  28. package/dist/contracts/types.d.ts.map +1 -1
  29. package/dist/contracts/types.js.map +1 -1
  30. package/dist/events/index.d.ts +4 -4
  31. package/dist/events/index.d.ts.map +1 -1
  32. package/dist/events/index.js +5 -5
  33. package/dist/events/index.js.map +1 -1
  34. package/dist/flags/index.d.ts +0 -4
  35. package/dist/flags/index.d.ts.map +1 -1
  36. package/dist/flags/index.js +0 -4
  37. package/dist/flags/index.js.map +1 -1
  38. package/dist/idempotency/index.d.ts.map +1 -1
  39. package/dist/idempotency/index.js +14 -9
  40. package/dist/idempotency/index.js.map +1 -1
  41. package/dist/jobs/index.d.ts.map +1 -1
  42. package/dist/jobs/index.js +10 -4
  43. package/dist/jobs/index.js.map +1 -1
  44. package/dist/notifications/index.d.ts +194 -10
  45. package/dist/notifications/index.d.ts.map +1 -1
  46. package/dist/notifications/index.js +397 -59
  47. package/dist/notifications/index.js.map +1 -1
  48. package/dist/openapi/index.js +15 -8
  49. package/dist/openapi/index.js.map +1 -1
  50. package/dist/ports/auth.d.ts +2 -0
  51. package/dist/ports/auth.d.ts.map +1 -1
  52. package/dist/ports/auth.js +2 -0
  53. package/dist/ports/auth.js.map +1 -1
  54. package/dist/ports/events.d.ts +2 -2
  55. package/dist/ports/index.d.ts +1 -1
  56. package/dist/ports/index.d.ts.map +1 -1
  57. package/dist/ports/index.js +1 -1
  58. package/dist/ports/index.js.map +1 -1
  59. package/dist/providers/index.d.ts +1 -1
  60. package/dist/providers/index.d.ts.map +1 -1
  61. package/dist/providers/index.js.map +1 -1
  62. package/dist/providers/provider.d.ts +10 -2
  63. package/dist/providers/provider.d.ts.map +1 -1
  64. package/dist/providers/provider.js.map +1 -1
  65. package/dist/schedules/index.d.ts +6 -13
  66. package/dist/schedules/index.d.ts.map +1 -1
  67. package/dist/schedules/index.js +24 -44
  68. package/dist/schedules/index.js.map +1 -1
  69. package/dist/server/index.d.ts +2 -2
  70. package/dist/server/index.d.ts.map +1 -1
  71. package/dist/server/index.js +1 -1
  72. package/dist/server/index.js.map +1 -1
  73. package/dist/server/request-executor.d.ts +72 -0
  74. package/dist/server/request-executor.d.ts.map +1 -0
  75. package/dist/server/request-executor.js +781 -0
  76. package/dist/server/request-executor.js.map +1 -0
  77. package/dist/server/request-preparation.d.ts +39 -0
  78. package/dist/server/request-preparation.d.ts.map +1 -0
  79. package/dist/server/request-preparation.js +248 -0
  80. package/dist/server/request-preparation.js.map +1 -0
  81. package/dist/server/response-finalization.d.ts +34 -0
  82. package/dist/server/response-finalization.d.ts.map +1 -0
  83. package/dist/server/response-finalization.js +279 -0
  84. package/dist/server/response-finalization.js.map +1 -0
  85. package/dist/server/route-definitions.d.ts +139 -0
  86. package/dist/server/route-definitions.d.ts.map +1 -0
  87. package/dist/server/route-definitions.js +94 -0
  88. package/dist/server/route-definitions.js.map +1 -0
  89. package/dist/server/route-matching.d.ts +15 -0
  90. package/dist/server/route-matching.d.ts.map +1 -0
  91. package/dist/server/route-matching.js +46 -0
  92. package/dist/server/route-matching.js.map +1 -0
  93. package/dist/server/server.d.ts +6 -157
  94. package/dist/server/server.d.ts.map +1 -1
  95. package/dist/server/server.js +29 -1340
  96. package/dist/server/server.js.map +1 -1
  97. package/dist/server/use-case-route.d.ts +1 -1
  98. package/dist/testing/index.d.ts +1 -1
  99. package/dist/testing/index.d.ts.map +1 -1
  100. package/dist/testing/index.js +1 -0
  101. package/dist/testing/index.js.map +1 -1
  102. package/dist/tracing/index.d.ts +1 -1
  103. package/dist/tracing/index.d.ts.map +1 -1
  104. package/dist/tracing/index.js.map +1 -1
  105. package/dist/uploads/client.js +4 -7
  106. package/dist/uploads/client.js.map +1 -1
  107. package/dist/uploads/index.d.ts.map +1 -1
  108. package/dist/uploads/index.js +59 -12
  109. package/dist/uploads/index.js.map +1 -1
  110. package/package.json +5 -5
  111. package/skills/app-architecture/SKILL.md +31 -1
  112. package/src/agent-capabilities/index.ts +578 -0
  113. package/src/application/index.ts +19 -5
  114. package/src/client/client.ts +41 -17
  115. package/src/contracts/contract-builder.ts +8 -23
  116. package/src/contracts/contract-group.ts +4 -6
  117. package/src/contracts/metadata.ts +20 -0
  118. package/src/contracts/types.ts +39 -0
  119. package/src/events/index.ts +16 -20
  120. package/src/flags/index.ts +0 -5
  121. package/src/idempotency/index.ts +17 -9
  122. package/src/jobs/index.ts +8 -3
  123. package/src/notifications/index.ts +678 -70
  124. package/src/openapi/index.ts +24 -12
  125. package/src/ports/auth.ts +2 -0
  126. package/src/ports/events.ts +2 -2
  127. package/src/ports/index.ts +0 -1
  128. package/src/providers/index.ts +1 -0
  129. package/src/providers/provider.ts +18 -3
  130. package/src/schedules/index.ts +57 -63
  131. package/src/server/index.ts +4 -2
  132. package/src/server/request-executor.ts +1170 -0
  133. package/src/server/request-preparation.ts +409 -0
  134. package/src/server/response-finalization.ts +385 -0
  135. package/src/server/route-definitions.ts +338 -0
  136. package/src/server/route-matching.ts +64 -0
  137. package/src/server/server.ts +63 -2171
  138. package/src/server/use-case-route.ts +1 -1
  139. package/src/testing/index.ts +2 -0
  140. package/src/tracing/index.ts +1 -0
  141. package/src/uploads/client.ts +4 -7
  142. package/src/uploads/index.ts +70 -12
@@ -0,0 +1,385 @@
1
+ import {
2
+ BEIGNET_ERROR_OWNER_HEADER,
3
+ type ContractErrorDefinition,
4
+ type HttpContractConfig,
5
+ } from "../contracts/index.js";
6
+ import {
7
+ createErrorResponseBody,
8
+ isErrorResponseBody,
9
+ } from "../errors/index.js";
10
+ import { getRequestIdFromContext } from "./hooks/utils.js";
11
+ import type { HttpResponse, HttpResponseLike } from "./http.js";
12
+ import type { ResponseFinalizerResponseOwner } from "./internal-hooks.js";
13
+ import {
14
+ parseStandardSchema,
15
+ SchemaValidationError,
16
+ } from "./providers/index.js";
17
+
18
+ export type ResponseOwner = ResponseFinalizerResponseOwner;
19
+
20
+ export function errorResponse(
21
+ status: number,
22
+ code: string,
23
+ message: string,
24
+ details?: unknown,
25
+ ): HttpResponseLike {
26
+ return {
27
+ status,
28
+ body: createErrorResponseBody({ code, message, details }),
29
+ };
30
+ }
31
+
32
+ function contractDiagnostics(contract: HttpContractConfig) {
33
+ return {
34
+ contract: contract.name,
35
+ method: contract.method,
36
+ path: contract.path,
37
+ };
38
+ }
39
+
40
+ export function normalizeResponse(res: HttpResponseLike): HttpResponseLike {
41
+ return {
42
+ status: res.status,
43
+ headers: res.headers,
44
+ body: res.body,
45
+ };
46
+ }
47
+
48
+ export function isWebResponse(value: unknown): value is Response {
49
+ return typeof Response !== "undefined" && value instanceof Response;
50
+ }
51
+
52
+ export function normalizeHttpResponse(res: HttpResponse): HttpResponse {
53
+ return isWebResponse(res) ? res : normalizeResponse(res);
54
+ }
55
+
56
+ export function withFrameworkErrorOwnerHeader(
57
+ res: HttpResponseLike,
58
+ owner: ResponseOwner,
59
+ ): HttpResponseLike {
60
+ if (
61
+ owner !== "framework" ||
62
+ res.status < 400 ||
63
+ !isErrorResponseBody(res.body)
64
+ ) {
65
+ return res;
66
+ }
67
+
68
+ return {
69
+ ...res,
70
+ headers: {
71
+ ...(res.headers ?? {}),
72
+ [BEIGNET_ERROR_OWNER_HEADER]: "framework",
73
+ },
74
+ };
75
+ }
76
+
77
+ export function responseOwnerFor(
78
+ res: HttpResponse,
79
+ owner?: ResponseOwner,
80
+ ): ResponseOwner {
81
+ if (isWebResponse(res)) return "transport";
82
+ return owner ?? "route";
83
+ }
84
+
85
+ function headersToRecord(headers: Headers): Record<string, string> {
86
+ const record: Record<string, string> = {};
87
+ headers.forEach((value, key) => {
88
+ record[key] = value;
89
+ });
90
+ return record;
91
+ }
92
+
93
+ export function responseForHooks(res: HttpResponse): HttpResponseLike {
94
+ if (!isWebResponse(res)) {
95
+ return normalizeResponse(res);
96
+ }
97
+
98
+ return {
99
+ status: res.status,
100
+ headers: headersToRecord(res.headers),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Merge hook-applied header changes onto a native web Response.
106
+ *
107
+ * Starts from the native response's `Headers` so `set-cookie` multiplicity is
108
+ * preserved, then applies headers the beforeSend chain added or changed
109
+ * relative to the original headers-only view. The body stream passes through
110
+ * untouched; status and statusText are preserved.
111
+ */
112
+ export function mergeNativeResponseHeaders(
113
+ nativeResponse: Response,
114
+ originalHeaders: Record<string, string>,
115
+ finalHeaders: Record<string, string>,
116
+ ): Response {
117
+ const originalByLowerKey = new Map<string, string>();
118
+ for (const [key, value] of Object.entries(originalHeaders)) {
119
+ originalByLowerKey.set(key.toLowerCase(), value);
120
+ }
121
+
122
+ let changed = false;
123
+ const merged = new Headers(nativeResponse.headers);
124
+ for (const [key, value] of Object.entries(finalHeaders)) {
125
+ const lowerKey = key.toLowerCase();
126
+ if (originalByLowerKey.get(lowerKey) === value) continue;
127
+ changed = true;
128
+ if (lowerKey === "set-cookie") {
129
+ merged.append(lowerKey, value);
130
+ } else {
131
+ merged.set(key, value);
132
+ }
133
+ }
134
+
135
+ if (!changed) {
136
+ return nativeResponse;
137
+ }
138
+
139
+ return new Response(nativeResponse.body, {
140
+ status: nativeResponse.status,
141
+ statusText: nativeResponse.statusText,
142
+ headers: merged,
143
+ });
144
+ }
145
+
146
+ export function isHttpResponseLike(value: unknown): value is HttpResponseLike {
147
+ return (
148
+ !isWebResponse(value) &&
149
+ typeof value === "object" &&
150
+ value !== null &&
151
+ "status" in value &&
152
+ typeof (value as { status?: unknown }).status === "number"
153
+ );
154
+ }
155
+
156
+ export class ResponseContractViolationError extends Error {
157
+ readonly code: "RESPONSE_VALIDATION_ERROR" | "UNDECLARED_RESPONSE_STATUS";
158
+ readonly details?: unknown;
159
+
160
+ constructor(args: {
161
+ code: "RESPONSE_VALIDATION_ERROR" | "UNDECLARED_RESPONSE_STATUS";
162
+ message: string;
163
+ details?: unknown;
164
+ }) {
165
+ super(args.message);
166
+ this.name = "ResponseContractViolationError";
167
+ this.code = args.code;
168
+ this.details = args.details;
169
+ }
170
+ }
171
+
172
+ function responseContractViolationMessage(
173
+ contract: HttpContractConfig,
174
+ status: number,
175
+ ): string {
176
+ return (
177
+ `Response validation failed for ${contract.method} ${contract.path} ` +
178
+ `(status ${status}, contract: ${contract.name})`
179
+ );
180
+ }
181
+
182
+ function declaredResponseStatuses(contract: HttpContractConfig): number[] {
183
+ return Object.keys(contract.responses)
184
+ .map((status) => Number(status))
185
+ .filter((status) => Number.isFinite(status))
186
+ .sort((a, b) => a - b);
187
+ }
188
+
189
+ function responseContractViolationDetails(
190
+ contract: HttpContractConfig,
191
+ status: number,
192
+ details?: Record<string, unknown>,
193
+ ) {
194
+ return {
195
+ ...contractDiagnostics(contract),
196
+ location: "response",
197
+ status,
198
+ declaredStatuses: declaredResponseStatuses(contract),
199
+ ...details,
200
+ };
201
+ }
202
+
203
+ function getDeclaredCatalogErrorsForStatus(
204
+ contract: HttpContractConfig,
205
+ status: number,
206
+ ): ContractErrorDefinition[] {
207
+ const errors = contract.metadata?.errors;
208
+ if (typeof errors !== "object" || errors === null) return [];
209
+
210
+ return Object.values(errors).filter(
211
+ (error): error is ContractErrorDefinition =>
212
+ typeof error === "object" &&
213
+ error !== null &&
214
+ typeof (error as { code?: unknown }).code === "string" &&
215
+ typeof (error as { status?: unknown }).status === "number" &&
216
+ typeof (error as { message?: unknown }).message === "string" &&
217
+ (error as { status: number }).status === status,
218
+ );
219
+ }
220
+
221
+ async function validateCatalogErrorResponse<C extends HttpContractConfig>(
222
+ contract: C,
223
+ res: HttpResponseLike,
224
+ ): Promise<void> {
225
+ const body = res.body;
226
+ if (res.status < 400 || !isErrorResponseBody(body)) return;
227
+
228
+ const declaredErrors = getDeclaredCatalogErrorsForStatus(
229
+ contract,
230
+ res.status,
231
+ );
232
+ if (declaredErrors.length === 0) return;
233
+
234
+ const matchingError = declaredErrors.find(
235
+ (error) => error.code === body.code,
236
+ );
237
+ if (!matchingError) {
238
+ throw new ResponseContractViolationError({
239
+ code: "RESPONSE_VALIDATION_ERROR",
240
+ message: responseContractViolationMessage(contract, res.status),
241
+ details: responseContractViolationDetails(contract, res.status, {
242
+ issues: [
243
+ {
244
+ message:
245
+ `Error response code "${body.code}" is not declared for status ${res.status}. ` +
246
+ `Expected one of: ${declaredErrors.map((error) => error.code).join(", ")}.`,
247
+ },
248
+ ],
249
+ }),
250
+ });
251
+ }
252
+
253
+ if (matchingError.details && body.details !== undefined) {
254
+ try {
255
+ await parseStandardSchema(matchingError.details, body.details);
256
+ } catch (error) {
257
+ if (error instanceof SchemaValidationError) {
258
+ throw new ResponseContractViolationError({
259
+ code: "RESPONSE_VALIDATION_ERROR",
260
+ message: responseContractViolationMessage(contract, res.status),
261
+ details: responseContractViolationDetails(contract, res.status, {
262
+ issues: error.issues,
263
+ }),
264
+ });
265
+ }
266
+ throw error;
267
+ }
268
+ }
269
+ }
270
+
271
+ async function validateResponseAgainstContract<C extends HttpContractConfig>(
272
+ contract: C,
273
+ res: HttpResponseLike,
274
+ responseValidationExemptStatus?: number,
275
+ ): Promise<void> {
276
+ const statusKey = String(res.status);
277
+ const hasDeclaredStatus = Object.hasOwn(contract.responses, statusKey);
278
+
279
+ if (!hasDeclaredStatus) {
280
+ if (Object.keys(contract.responses).length === 0) return;
281
+
282
+ throw new ResponseContractViolationError({
283
+ code: "UNDECLARED_RESPONSE_STATUS",
284
+ message:
285
+ `Handler returned undeclared status ${res.status} for ` +
286
+ `${contract.method} ${contract.path} (contract: ${contract.name})`,
287
+ details: responseContractViolationDetails(contract, res.status, {
288
+ returnedStatus: res.status,
289
+ }),
290
+ });
291
+ }
292
+
293
+ const responseSchema = contract.responses[res.status];
294
+ if (responseSchema === null) {
295
+ if (res.body !== undefined && res.body !== null) {
296
+ throw new ResponseContractViolationError({
297
+ code: "RESPONSE_VALIDATION_ERROR",
298
+ message: responseContractViolationMessage(contract, res.status),
299
+ details: responseContractViolationDetails(contract, res.status, {
300
+ issues: [
301
+ {
302
+ message:
303
+ "Response body must be empty for a null response schema.",
304
+ },
305
+ ],
306
+ }),
307
+ });
308
+ }
309
+ return;
310
+ }
311
+
312
+ if (!responseSchema) return;
313
+
314
+ // Binder routes whose use case output schema is the same object as the
315
+ // declared success response schema skip the redundant success-status parse.
316
+ // Error statuses and undeclared statuses are validated unchanged.
317
+ if (res.status === responseValidationExemptStatus) return;
318
+
319
+ try {
320
+ await parseStandardSchema(responseSchema, res.body);
321
+ await validateCatalogErrorResponse(contract, res);
322
+ } catch (error) {
323
+ if (error instanceof SchemaValidationError) {
324
+ throw new ResponseContractViolationError({
325
+ code: "RESPONSE_VALIDATION_ERROR",
326
+ message: responseContractViolationMessage(contract, res.status),
327
+ details: responseContractViolationDetails(contract, res.status, {
328
+ issues: error.issues,
329
+ }),
330
+ });
331
+ }
332
+ throw error;
333
+ }
334
+ }
335
+
336
+ export async function finalizeResponse<C extends HttpContractConfig>(
337
+ contract: C,
338
+ res: HttpResponseLike,
339
+ responseValidationExemptStatus?: number,
340
+ ): Promise<HttpResponseLike> {
341
+ const normalized = normalizeResponse(res);
342
+ await validateResponseAgainstContract(
343
+ contract,
344
+ normalized,
345
+ responseValidationExemptStatus,
346
+ );
347
+ return normalized;
348
+ }
349
+
350
+ export function toContractViolationResponse(
351
+ error: ResponseContractViolationError,
352
+ ): HttpResponseLike {
353
+ return {
354
+ status: 500,
355
+ body: createErrorResponseBody({
356
+ code: error.code,
357
+ message: error.message,
358
+ details: error.details,
359
+ }),
360
+ };
361
+ }
362
+
363
+ export function defaultErrorResponse(
364
+ err: unknown,
365
+ ctx?: unknown,
366
+ ): HttpResponseLike {
367
+ const requestId = getRequestIdFromContext(ctx);
368
+ return {
369
+ status: 500,
370
+ body: createErrorResponseBody({
371
+ code: "INTERNAL_SERVER_ERROR",
372
+ message: "Internal server error",
373
+ requestId,
374
+ details:
375
+ process.env.NODE_ENV !== "production" && err instanceof Error
376
+ ? {
377
+ error: {
378
+ message: err.message,
379
+ stack: err.stack,
380
+ },
381
+ }
382
+ : undefined,
383
+ }),
384
+ };
385
+ }
@@ -0,0 +1,338 @@
1
+ import type { ContractLike, ResolveContract } from "./contract-like.js";
2
+ import { resolveContract } from "./contract-like.js";
3
+ import type { AddedCtxFromHooks, Handler, RouteHook } from "./http.js";
4
+ import type {
5
+ AnyUseCaseLike,
6
+ AnyUseCaseRouteDef,
7
+ UseCaseRouteDef,
8
+ ValidatedRouteInputs,
9
+ } from "./use-case-route.js";
10
+
11
+ /**
12
+ * Route registration that connects a contract to the handler implementing it.
13
+ *
14
+ * Most apps keep route definitions in `features/<feature>/routes.ts` and
15
+ * compose them with `defineRoutes(...)`.
16
+ */
17
+ export type HandlerRouteDef<
18
+ Ctx,
19
+ CLike extends ContractLike = ContractLike,
20
+ Hooks extends readonly RouteHook<Ctx, object>[] = readonly RouteHook<
21
+ Ctx,
22
+ object
23
+ >[],
24
+ > = {
25
+ /**
26
+ * Contract builder or plain contract config for this route.
27
+ */
28
+ contract: CLike;
29
+ /**
30
+ * Route-scoped hooks that run after group hooks and before the handler.
31
+ */
32
+ hooks?: Hooks;
33
+ /**
34
+ * Handler that implements the contract.
35
+ */
36
+ handle: Handler<Ctx & AddedCtxFromHooks<Hooks>, ResolveContract<CLike>>;
37
+ useCase?: never;
38
+ input?: never;
39
+ status?: never;
40
+ };
41
+
42
+ /**
43
+ * Route registration for one contract.
44
+ *
45
+ * Routes either bind the contract directly to a use case (`{ contract,
46
+ * useCase }`) or implement a full handler (`{ contract, handle }`). The full
47
+ * handler form is the escape hatch for response headers, streaming, native
48
+ * `Response` values, and multi-status handling.
49
+ */
50
+ export type RouteDef<
51
+ Ctx,
52
+ CLike extends ContractLike = ContractLike,
53
+ Hooks extends readonly RouteHook<Ctx, object>[] = readonly RouteHook<
54
+ Ctx,
55
+ object
56
+ >[],
57
+ > = HandlerRouteDef<Ctx, CLike, Hooks> | AnyUseCaseRouteDef<Ctx, CLike, Hooks>;
58
+
59
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at collection boundaries
60
+ type AnyRouteDef = RouteDef<any, any>;
61
+
62
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at collection boundaries
63
+ type PlainRouteDef<Ctx> = RouteDef<Ctx, any, readonly []>;
64
+
65
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at collection boundaries
66
+ type AnyContractRouteDef<Ctx> = RouteDef<Ctx, any>;
67
+
68
+ const ROUTE_GROUP_KIND = "beignet.route-group";
69
+
70
+ /**
71
+ * Named collection of related route registrations.
72
+ *
73
+ * Route groups colocate feature routes and can apply scoped route hooks to
74
+ * every route in the group. `defineRoutes(...)` flattens them before server
75
+ * registration while preserving those hooks.
76
+ */
77
+ export type RouteGroup<
78
+ Ctx,
79
+ Routes extends readonly AnyRouteDef[] = readonly AnyRouteDef[],
80
+ > = {
81
+ /**
82
+ * Internal marker used by `defineRoutes(...)`.
83
+ */
84
+ kind: typeof ROUTE_GROUP_KIND;
85
+ /**
86
+ * Human-readable group name.
87
+ */
88
+ name: string;
89
+ /**
90
+ * Hooks applied to every route in this group.
91
+ */
92
+ hooks?: readonly RouteHook<Ctx, object>[];
93
+ /**
94
+ * Route definitions in this group.
95
+ */
96
+ routes: Routes;
97
+ };
98
+
99
+ type RouteInput<Ctx> =
100
+ | AnyContractRouteDef<Ctx>
101
+ | AnyRouteDef
102
+ | RouteGroup<Ctx, readonly AnyRouteDef[]>;
103
+
104
+ type ContextualRouteInput<Ctx> =
105
+ | PlainRouteDef<Ctx>
106
+ | RouteGroup<Ctx, readonly AnyRouteDef[]>;
107
+
108
+ /**
109
+ * App-bound builder for one route registration.
110
+ */
111
+ export type RouteDefinitionBuilder<Ctx> = {
112
+ <
113
+ CLike extends ContractLike,
114
+ UC extends AnyUseCaseLike,
115
+ const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [],
116
+ >(
117
+ route: UseCaseRouteDef<Ctx, CLike, UC, Hooks>,
118
+ ): UseCaseRouteDef<Ctx, CLike, UC, Hooks>;
119
+ <
120
+ CLike extends ContractLike,
121
+ const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [],
122
+ >(
123
+ route: HandlerRouteDef<Ctx, CLike, Hooks>,
124
+ ): HandlerRouteDef<Ctx, CLike, Hooks>;
125
+ };
126
+
127
+ /**
128
+ * App-bound builder for a named route group.
129
+ */
130
+ export type RouteGroupBuilder<Ctx> = {
131
+ <
132
+ const GroupHooks extends readonly RouteHook<Ctx, object>[] = readonly [],
133
+ const R extends readonly PlainRouteDef<
134
+ Ctx & AddedCtxFromHooks<GroupHooks>
135
+ >[] = readonly PlainRouteDef<Ctx & AddedCtxFromHooks<GroupHooks>>[],
136
+ >(group: {
137
+ name: string;
138
+ hooks?: GroupHooks;
139
+ routes: R & ValidatedRouteInputs<Ctx & AddedCtxFromHooks<GroupHooks>, R>;
140
+ }): RouteGroup<Ctx, R>;
141
+ <
142
+ const GroupHooks extends readonly RouteHook<Ctx, object>[] = readonly [],
143
+ const R extends readonly AnyRouteDef[] = readonly AnyRouteDef[],
144
+ >(group: {
145
+ name: string;
146
+ hooks?: GroupHooks;
147
+ routes: R & ValidatedRouteInputs<Ctx & AddedCtxFromHooks<GroupHooks>, R>;
148
+ }): RouteGroup<Ctx, R>;
149
+ };
150
+
151
+ /**
152
+ * Route declaration builders bound to an application context type.
153
+ */
154
+ export interface Routes<Ctx> {
155
+ /** Define one route registration. */
156
+ defineRoute: RouteDefinitionBuilder<Ctx>;
157
+ /** Define a named group of related route registrations. */
158
+ defineRouteGroup: RouteGroupBuilder<Ctx>;
159
+ }
160
+
161
+ type RoutesFromInput<Input> =
162
+ Input extends RouteGroup<infer _Ctx, infer Routes>
163
+ ? Routes
164
+ : Input extends AnyRouteDef
165
+ ? readonly [Input]
166
+ : readonly [];
167
+
168
+ type FlattenRouteInputs<Inputs extends readonly unknown[]> =
169
+ number extends Inputs["length"]
170
+ ? readonly AnyRouteDef[]
171
+ : Inputs extends readonly [infer First, ...infer Rest]
172
+ ? readonly [...RoutesFromInput<First>, ...FlattenRouteInputs<Rest>]
173
+ : readonly [];
174
+
175
+ type ContractsFromRouteList<
176
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
177
+ Routes extends readonly RouteDef<any, any>[],
178
+ > = {
179
+ readonly [Index in keyof Routes]: Routes[Index] extends RouteDef<
180
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
181
+ any,
182
+ infer CLike
183
+ >
184
+ ? ResolveContract<CLike>
185
+ : never;
186
+ };
187
+
188
+ /**
189
+ * Define one route registration with hook-aware handler typing.
190
+ *
191
+ * Direct route objects are still supported. Use this helper when route-scoped
192
+ * hooks enrich `ctx` for a single handler and you want TypeScript to infer the
193
+ * added fields.
194
+ */
195
+ function createRouteBuilder<Ctx>(): RouteDefinitionBuilder<Ctx> {
196
+ function define<
197
+ CLike extends ContractLike,
198
+ UC extends AnyUseCaseLike,
199
+ const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [],
200
+ >(
201
+ route: UseCaseRouteDef<Ctx, CLike, UC, Hooks>,
202
+ ): UseCaseRouteDef<Ctx, CLike, UC, Hooks>;
203
+ function define<
204
+ CLike extends ContractLike,
205
+ const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [],
206
+ >(
207
+ route: HandlerRouteDef<Ctx, CLike, Hooks>,
208
+ ): HandlerRouteDef<Ctx, CLike, Hooks>;
209
+ function define(route: unknown): unknown {
210
+ return route;
211
+ }
212
+
213
+ return define;
214
+ }
215
+
216
+ /**
217
+ * Define and flatten route registrations with strong type inference.
218
+ *
219
+ * Pass route definitions and route groups here before `createServer(...)`.
220
+ * Group entries are flattened so downstream tooling receives one route list.
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * const routes = defineRoutes<AppContext>([
225
+ * { contract: listPosts, useCase: listPostsUseCase },
226
+ * ]);
227
+ * ```
228
+ */
229
+ export function defineRoutes<
230
+ Ctx,
231
+ const R extends
232
+ readonly ContextualRouteInput<Ctx>[] = readonly ContextualRouteInput<Ctx>[],
233
+ >(routes: R & ValidatedRouteInputs<Ctx, R>): FlattenRouteInputs<R>;
234
+ export function defineRoutes<
235
+ Ctx,
236
+ const R extends readonly RouteInput<Ctx>[] = readonly RouteInput<Ctx>[],
237
+ >(routes: R & ValidatedRouteInputs<Ctx, R>): FlattenRouteInputs<R>;
238
+ export function defineRoutes<
239
+ Ctx,
240
+ const R extends readonly RouteInput<Ctx>[] = readonly RouteInput<Ctx>[],
241
+ >(routes: R): FlattenRouteInputs<R> {
242
+ const flattened: RouteDef<Ctx>[] = [];
243
+
244
+ for (const route of routes) {
245
+ if (isRouteGroup(route)) {
246
+ for (const groupRoute of route.routes) {
247
+ flattened.push({
248
+ ...groupRoute,
249
+ hooks: [...(route.hooks ?? []), ...(groupRoute.hooks ?? [])],
250
+ });
251
+ }
252
+ } else {
253
+ flattened.push(route);
254
+ }
255
+ }
256
+
257
+ return flattened as unknown as FlattenRouteInputs<R>;
258
+ }
259
+
260
+ /**
261
+ * Extract contract configs from a route list.
262
+ *
263
+ * Use this to drive clients, OpenAPI, and docs from the same route list passed
264
+ * to `createServer(...)`.
265
+ */
266
+ export function contractsFromRoutes<
267
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
268
+ const R extends readonly RouteDef<any, any>[],
269
+ >(routes: R): ContractsFromRouteList<R> {
270
+ return routes.map((route) =>
271
+ resolveContract(route.contract),
272
+ ) as unknown as ContractsFromRouteList<R>;
273
+ }
274
+
275
+ /**
276
+ * Define a named group of related route registrations.
277
+ *
278
+ * Route groups are flattened by defineRoutes, so createServer still receives
279
+ * a regular route list while app code can keep feature route wiring and scoped
280
+ * hooks colocated.
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * const { defineRouteGroup } = createRoutes<AppContext>();
285
+ * const todoRoutes = defineRouteGroup({
286
+ * name: "todos",
287
+ * hooks: [auth.optional()],
288
+ * routes: [
289
+ * { contract: listTodos, useCase: listTodosUseCase },
290
+ * ]
291
+ * });
292
+ * ```
293
+ */
294
+ function createRouteGroupBuilder<Ctx>(): RouteGroupBuilder<Ctx> {
295
+ const createGroup = <
296
+ const GroupHooks extends readonly RouteHook<Ctx, object>[] = readonly [],
297
+ const GroupRoutes extends readonly AnyRouteDef[] = readonly AnyRouteDef[],
298
+ >(input: {
299
+ name: string;
300
+ hooks?: GroupHooks;
301
+ routes: GroupRoutes;
302
+ }): RouteGroup<Ctx, GroupRoutes> => ({
303
+ kind: ROUTE_GROUP_KIND,
304
+ name: input.name,
305
+ hooks: input.hooks,
306
+ routes: input.routes,
307
+ });
308
+
309
+ return createGroup as RouteGroupBuilder<Ctx>;
310
+ }
311
+
312
+ /**
313
+ * Create route declaration builders bound to an application context type.
314
+ *
315
+ * Call this once in `lib/routes.ts`, then import the app-bound builders from
316
+ * feature route files.
317
+ *
318
+ * @example
319
+ * ```ts
320
+ * export const { defineRoute, defineRouteGroup } =
321
+ * createRoutes<AppContext>();
322
+ * ```
323
+ */
324
+ export function createRoutes<Ctx>(): Routes<Ctx> {
325
+ return {
326
+ defineRoute: createRouteBuilder<Ctx>(),
327
+ defineRouteGroup: createRouteGroupBuilder<Ctx>(),
328
+ };
329
+ }
330
+
331
+ function isRouteGroup<Ctx>(route: RouteInput<Ctx>): route is RouteGroup<Ctx> {
332
+ return (
333
+ typeof route === "object" &&
334
+ route !== null &&
335
+ "kind" in route &&
336
+ route.kind === ROUTE_GROUP_KIND
337
+ );
338
+ }