@beignet/core 0.0.2 → 0.0.4

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 (360) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +821 -30
  3. package/dist/application/index.d.ts +28 -2
  4. package/dist/application/index.d.ts.map +1 -1
  5. package/dist/application/index.js +140 -12
  6. package/dist/application/index.js.map +1 -1
  7. package/dist/client/client.d.ts +2 -2
  8. package/dist/client/client.d.ts.map +1 -1
  9. package/dist/client/client.js +136 -48
  10. package/dist/client/client.js.map +1 -1
  11. package/dist/client/error-messages.d.ts +14 -0
  12. package/dist/client/error-messages.d.ts.map +1 -0
  13. package/dist/client/error-messages.js +23 -0
  14. package/dist/client/error-messages.js.map +1 -0
  15. package/dist/client/index.d.ts +8 -4
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +6 -2
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/client/types.d.ts +35 -5
  20. package/dist/client/types.d.ts.map +1 -1
  21. package/dist/client-only.d.ts +8 -0
  22. package/dist/client-only.d.ts.map +1 -0
  23. package/dist/client-only.js +8 -0
  24. package/dist/client-only.js.map +1 -0
  25. package/dist/config/index.d.ts +5 -5
  26. package/dist/config/index.d.ts.map +1 -1
  27. package/dist/config/index.js +2 -2
  28. package/dist/config/index.js.map +1 -1
  29. package/dist/contracts/catalog-errors.d.ts +27 -0
  30. package/dist/contracts/catalog-errors.d.ts.map +1 -0
  31. package/dist/contracts/catalog-errors.js +69 -0
  32. package/dist/contracts/catalog-errors.js.map +1 -0
  33. package/dist/contracts/contract-builder.d.ts +15 -12
  34. package/dist/contracts/contract-builder.d.ts.map +1 -1
  35. package/dist/contracts/contract-builder.js +15 -41
  36. package/dist/contracts/contract-builder.js.map +1 -1
  37. package/dist/contracts/contract-group.d.ts +11 -8
  38. package/dist/contracts/contract-group.d.ts.map +1 -1
  39. package/dist/contracts/contract-group.js +13 -40
  40. package/dist/contracts/contract-group.js.map +1 -1
  41. package/dist/contracts/contract-like.d.ts +1 -1
  42. package/dist/contracts/contract-like.d.ts.map +1 -1
  43. package/dist/contracts/index.d.ts +13 -9
  44. package/dist/contracts/index.d.ts.map +1 -1
  45. package/dist/contracts/index.js +9 -5
  46. package/dist/contracts/index.js.map +1 -1
  47. package/dist/contracts/openapi-meta.d.ts +48 -0
  48. package/dist/contracts/openapi-meta.d.ts.map +1 -1
  49. package/dist/contracts/openapi-meta.js +3 -0
  50. package/dist/contracts/openapi-meta.js.map +1 -1
  51. package/dist/contracts/path-template.d.ts +1 -1
  52. package/dist/contracts/path-template.js +2 -2
  53. package/dist/contracts/path-template.js.map +1 -1
  54. package/dist/contracts/schema-shape.d.ts +37 -0
  55. package/dist/contracts/schema-shape.d.ts.map +1 -0
  56. package/dist/contracts/schema-shape.js +61 -0
  57. package/dist/contracts/schema-shape.js.map +1 -0
  58. package/dist/contracts/success-status.d.ts +32 -0
  59. package/dist/contracts/success-status.d.ts.map +1 -0
  60. package/dist/contracts/success-status.js +18 -0
  61. package/dist/contracts/success-status.js.map +1 -0
  62. package/dist/contracts/types.d.ts +25 -5
  63. package/dist/contracts/types.d.ts.map +1 -1
  64. package/dist/contracts/types.js.map +1 -1
  65. package/dist/contracts/utils.d.ts +1 -1
  66. package/dist/contracts/utils.d.ts.map +1 -1
  67. package/dist/contracts/utils.js +1 -1
  68. package/dist/contracts/utils.js.map +1 -1
  69. package/dist/domain/events.d.ts +1 -1
  70. package/dist/domain/events.d.ts.map +1 -1
  71. package/dist/domain/events.js +1 -1
  72. package/dist/domain/events.js.map +1 -1
  73. package/dist/domain/index.d.ts +3 -3
  74. package/dist/domain/index.d.ts.map +1 -1
  75. package/dist/domain/index.js +3 -3
  76. package/dist/domain/index.js.map +1 -1
  77. package/dist/errors/catalog.d.ts +9 -1
  78. package/dist/errors/catalog.d.ts.map +1 -1
  79. package/dist/errors/catalog.js +7 -1
  80. package/dist/errors/catalog.js.map +1 -1
  81. package/dist/errors/http.d.ts +10 -0
  82. package/dist/errors/http.d.ts.map +1 -1
  83. package/dist/errors/http.js +11 -1
  84. package/dist/errors/http.js.map +1 -1
  85. package/dist/errors/index.d.ts +4 -4
  86. package/dist/errors/index.d.ts.map +1 -1
  87. package/dist/errors/index.js +4 -4
  88. package/dist/errors/index.js.map +1 -1
  89. package/dist/errors/response.d.ts +4 -1
  90. package/dist/errors/response.d.ts.map +1 -1
  91. package/dist/errors/response.js.map +1 -1
  92. package/dist/events/index.d.ts +10 -12
  93. package/dist/events/index.d.ts.map +1 -1
  94. package/dist/events/index.js +10 -10
  95. package/dist/events/index.js.map +1 -1
  96. package/dist/idempotency/index.d.ts +5 -3
  97. package/dist/idempotency/index.d.ts.map +1 -1
  98. package/dist/idempotency/index.js.map +1 -1
  99. package/dist/jobs/index.d.ts +148 -16
  100. package/dist/jobs/index.d.ts.map +1 -1
  101. package/dist/jobs/index.js +174 -14
  102. package/dist/jobs/index.js.map +1 -1
  103. package/dist/notifications/index.d.ts +14 -16
  104. package/dist/notifications/index.d.ts.map +1 -1
  105. package/dist/notifications/index.js +14 -14
  106. package/dist/notifications/index.js.map +1 -1
  107. package/dist/openapi/index.d.ts +8 -3
  108. package/dist/openapi/index.d.ts.map +1 -1
  109. package/dist/openapi/index.js +41 -29
  110. package/dist/openapi/index.js.map +1 -1
  111. package/dist/openapi/schema-introspector.d.ts +37 -0
  112. package/dist/openapi/schema-introspector.d.ts.map +1 -1
  113. package/dist/openapi/schema-introspector.js +23 -17
  114. package/dist/openapi/schema-introspector.js.map +1 -1
  115. package/dist/outbox/index.d.ts +18 -4
  116. package/dist/outbox/index.d.ts.map +1 -1
  117. package/dist/outbox/index.js +104 -4
  118. package/dist/outbox/index.js.map +1 -1
  119. package/dist/ports/audit.d.ts +56 -10
  120. package/dist/ports/audit.d.ts.map +1 -1
  121. package/dist/ports/audit.js +71 -3
  122. package/dist/ports/audit.js.map +1 -1
  123. package/dist/ports/auth.d.ts +92 -0
  124. package/dist/ports/auth.d.ts.map +1 -1
  125. package/dist/ports/auth.js +92 -0
  126. package/dist/ports/auth.js.map +1 -1
  127. package/dist/ports/events.d.ts +2 -2
  128. package/dist/ports/events.d.ts.map +1 -1
  129. package/dist/ports/index.d.ts +62 -33
  130. package/dist/ports/index.d.ts.map +1 -1
  131. package/dist/ports/index.js +28 -34
  132. package/dist/ports/index.js.map +1 -1
  133. package/dist/ports/policy.d.ts +32 -3
  134. package/dist/ports/policy.d.ts.map +1 -1
  135. package/dist/ports/policy.js +13 -2
  136. package/dist/ports/policy.js.map +1 -1
  137. package/dist/ports/testing.d.ts +1030 -2
  138. package/dist/ports/testing.d.ts.map +1 -1
  139. package/dist/ports/testing.js +1031 -1
  140. package/dist/ports/testing.js.map +1 -1
  141. package/dist/ports/unbound.d.ts +21 -0
  142. package/dist/ports/unbound.d.ts.map +1 -0
  143. package/dist/ports/unbound.js +57 -0
  144. package/dist/ports/unbound.js.map +1 -0
  145. package/dist/ports/unit-of-work.d.ts +1 -1
  146. package/dist/ports/unit-of-work.d.ts.map +1 -1
  147. package/dist/ports/unit-of-work.js +1 -1
  148. package/dist/ports/unit-of-work.js.map +1 -1
  149. package/dist/providers/index.d.ts +3 -2
  150. package/dist/providers/index.d.ts.map +1 -1
  151. package/dist/providers/index.js +3 -2
  152. package/dist/providers/index.js.map +1 -1
  153. package/dist/providers/instrumentation.d.ts +46 -5
  154. package/dist/providers/instrumentation.d.ts.map +1 -1
  155. package/dist/providers/instrumentation.js +25 -6
  156. package/dist/providers/instrumentation.js.map +1 -1
  157. package/dist/providers/metadata.d.ts +39 -0
  158. package/dist/providers/metadata.d.ts.map +1 -0
  159. package/dist/providers/metadata.js +169 -0
  160. package/dist/providers/metadata.js.map +1 -0
  161. package/dist/providers/provider.d.ts +114 -9
  162. package/dist/providers/provider.d.ts.map +1 -1
  163. package/dist/providers/provider.js +3 -20
  164. package/dist/providers/provider.js.map +1 -1
  165. package/dist/schedules/index.d.ts +94 -13
  166. package/dist/schedules/index.d.ts.map +1 -1
  167. package/dist/schedules/index.js +66 -12
  168. package/dist/schedules/index.js.map +1 -1
  169. package/dist/server/audit-context.d.ts +29 -0
  170. package/dist/server/audit-context.d.ts.map +1 -0
  171. package/dist/server/audit-context.js +44 -0
  172. package/dist/server/audit-context.js.map +1 -0
  173. package/dist/server/context.d.ts +141 -0
  174. package/dist/server/context.d.ts.map +1 -0
  175. package/dist/server/context.js +39 -0
  176. package/dist/server/context.js.map +1 -0
  177. package/dist/server/contract-like.d.ts +1 -1
  178. package/dist/server/contract-like.d.ts.map +1 -1
  179. package/dist/server/contract-like.js +1 -1
  180. package/dist/server/contract-like.js.map +1 -1
  181. package/dist/server/health.d.ts +2 -2
  182. package/dist/server/health.d.ts.map +1 -1
  183. package/dist/server/hooks/auth.d.ts +89 -65
  184. package/dist/server/hooks/auth.d.ts.map +1 -1
  185. package/dist/server/hooks/auth.js +84 -55
  186. package/dist/server/hooks/auth.js.map +1 -1
  187. package/dist/server/hooks/cors.d.ts +1 -1
  188. package/dist/server/hooks/cors.d.ts.map +1 -1
  189. package/dist/server/hooks/errors.d.ts +2 -2
  190. package/dist/server/hooks/errors.d.ts.map +1 -1
  191. package/dist/server/hooks/errors.js +2 -2
  192. package/dist/server/hooks/errors.js.map +1 -1
  193. package/dist/server/hooks/idempotency.d.ts +78 -0
  194. package/dist/server/hooks/idempotency.d.ts.map +1 -0
  195. package/dist/server/hooks/idempotency.js +154 -0
  196. package/dist/server/hooks/idempotency.js.map +1 -0
  197. package/dist/server/hooks/index.d.ts +8 -7
  198. package/dist/server/hooks/index.d.ts.map +1 -1
  199. package/dist/server/hooks/index.js +6 -5
  200. package/dist/server/hooks/index.js.map +1 -1
  201. package/dist/server/hooks/logging.d.ts +2 -2
  202. package/dist/server/hooks/logging.d.ts.map +1 -1
  203. package/dist/server/hooks/logging.js +1 -1
  204. package/dist/server/hooks/logging.js.map +1 -1
  205. package/dist/server/hooks/rate-limit.d.ts +25 -7
  206. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  207. package/dist/server/hooks/rate-limit.js +47 -12
  208. package/dist/server/hooks/rate-limit.js.map +1 -1
  209. package/dist/server/hooks.d.ts +1 -1
  210. package/dist/server/hooks.d.ts.map +1 -1
  211. package/dist/server/hooks.js +1 -1
  212. package/dist/server/hooks.js.map +1 -1
  213. package/dist/server/http.d.ts +84 -6
  214. package/dist/server/http.d.ts.map +1 -1
  215. package/dist/server/index.d.ts +36 -12
  216. package/dist/server/index.d.ts.map +1 -1
  217. package/dist/server/index.js +24 -8
  218. package/dist/server/index.js.map +1 -1
  219. package/dist/server/instrumentation.d.ts +108 -0
  220. package/dist/server/instrumentation.d.ts.map +1 -0
  221. package/dist/server/instrumentation.js +297 -0
  222. package/dist/server/instrumentation.js.map +1 -0
  223. package/dist/server/openapi.d.ts +3 -3
  224. package/dist/server/openapi.d.ts.map +1 -1
  225. package/dist/server/openapi.js +1 -1
  226. package/dist/server/openapi.js.map +1 -1
  227. package/dist/server/providers/index.d.ts +3 -3
  228. package/dist/server/providers/index.d.ts.map +1 -1
  229. package/dist/server/providers/index.js +3 -3
  230. package/dist/server/providers/index.js.map +1 -1
  231. package/dist/server/providers/loadProviderConfig.d.ts +2 -2
  232. package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
  233. package/dist/server/providers/loadProviderConfig.js +2 -2
  234. package/dist/server/providers/loadProviderConfig.js.map +1 -1
  235. package/dist/server/request-context.d.ts +67 -0
  236. package/dist/server/request-context.d.ts.map +1 -0
  237. package/dist/server/request-context.js +79 -0
  238. package/dist/server/request-context.js.map +1 -0
  239. package/dist/server/server-context.d.ts +38 -0
  240. package/dist/server/server-context.d.ts.map +1 -0
  241. package/dist/server/server-context.js +38 -0
  242. package/dist/server/server-context.js.map +1 -0
  243. package/dist/server/server.d.ts +148 -35
  244. package/dist/server/server.d.ts.map +1 -1
  245. package/dist/server/server.js +482 -145
  246. package/dist/server/server.js.map +1 -1
  247. package/dist/server/types.d.ts +2 -2
  248. package/dist/server/types.d.ts.map +1 -1
  249. package/dist/server/types.js +2 -2
  250. package/dist/server/types.js.map +1 -1
  251. package/dist/server/use-case-route.d.ts +263 -0
  252. package/dist/server/use-case-route.d.ts.map +1 -0
  253. package/dist/server/use-case-route.js +77 -0
  254. package/dist/server/use-case-route.js.map +1 -0
  255. package/dist/server-only.d.ts +8 -0
  256. package/dist/server-only.d.ts.map +1 -0
  257. package/dist/server-only.js +8 -0
  258. package/dist/server-only.js.map +1 -0
  259. package/dist/tasks/index.d.ts +139 -0
  260. package/dist/tasks/index.d.ts.map +1 -0
  261. package/dist/tasks/index.js +98 -0
  262. package/dist/tasks/index.js.map +1 -0
  263. package/dist/testing/index.d.ts +611 -5
  264. package/dist/testing/index.d.ts.map +1 -1
  265. package/dist/testing/index.js +434 -4
  266. package/dist/testing/index.js.map +1 -1
  267. package/dist/tracing/index.d.ts +89 -0
  268. package/dist/tracing/index.d.ts.map +1 -0
  269. package/dist/tracing/index.js +101 -0
  270. package/dist/tracing/index.js.map +1 -0
  271. package/dist/uploads/client.d.ts +278 -0
  272. package/dist/uploads/client.d.ts.map +1 -0
  273. package/dist/uploads/client.js +428 -0
  274. package/dist/uploads/client.js.map +1 -0
  275. package/dist/uploads/index.d.ts +361 -0
  276. package/dist/uploads/index.d.ts.map +1 -0
  277. package/dist/uploads/index.js +543 -0
  278. package/dist/uploads/index.js.map +1 -0
  279. package/package.json +34 -3
  280. package/src/application/index.ts +193 -10
  281. package/src/client/client.ts +148 -150
  282. package/src/client/error-messages.ts +35 -0
  283. package/src/client/index.ts +12 -4
  284. package/src/client/types.ts +44 -5
  285. package/src/client-only.ts +7 -0
  286. package/src/config/index.ts +6 -6
  287. package/src/contracts/catalog-errors.ts +115 -0
  288. package/src/contracts/contract-builder.ts +39 -76
  289. package/src/contracts/contract-group.ts +33 -68
  290. package/src/contracts/contract-like.ts +1 -1
  291. package/src/contracts/index.ts +24 -11
  292. package/src/contracts/openapi-meta.ts +55 -0
  293. package/src/contracts/path-template.ts +2 -2
  294. package/src/contracts/schema-shape.ts +75 -0
  295. package/src/contracts/success-status.ts +68 -0
  296. package/src/contracts/types.ts +32 -5
  297. package/src/contracts/utils.ts +5 -2
  298. package/src/domain/events.ts +6 -2
  299. package/src/domain/index.ts +3 -3
  300. package/src/errors/catalog.ts +9 -1
  301. package/src/errors/http.ts +11 -1
  302. package/src/errors/index.ts +4 -4
  303. package/src/errors/response.ts +4 -1
  304. package/src/events/index.ts +12 -26
  305. package/src/idempotency/index.ts +5 -3
  306. package/src/jobs/index.ts +340 -29
  307. package/src/notifications/index.ts +17 -27
  308. package/src/openapi/index.ts +73 -38
  309. package/src/openapi/schema-introspector.ts +68 -17
  310. package/src/outbox/index.ts +151 -6
  311. package/src/ports/audit.ts +120 -11
  312. package/src/ports/auth.ts +132 -0
  313. package/src/ports/events.ts +2 -2
  314. package/src/ports/index.ts +104 -35
  315. package/src/ports/policy.ts +50 -3
  316. package/src/ports/testing.ts +2220 -33
  317. package/src/ports/unbound.ts +64 -0
  318. package/src/ports/unit-of-work.ts +6 -2
  319. package/src/providers/index.ts +16 -3
  320. package/src/providers/instrumentation.ts +93 -8
  321. package/src/providers/metadata.ts +234 -0
  322. package/src/providers/provider.ts +168 -9
  323. package/src/schedules/index.ts +173 -23
  324. package/src/server/audit-context.ts +45 -0
  325. package/src/server/context.ts +224 -0
  326. package/src/server/contract-like.ts +1 -1
  327. package/src/server/health.ts +2 -2
  328. package/src/server/hooks/auth.ts +175 -158
  329. package/src/server/hooks/cors.ts +1 -1
  330. package/src/server/hooks/errors.ts +7 -4
  331. package/src/server/hooks/idempotency.ts +263 -0
  332. package/src/server/hooks/index.ts +15 -12
  333. package/src/server/hooks/logging.ts +3 -3
  334. package/src/server/hooks/rate-limit.ts +85 -17
  335. package/src/server/hooks.ts +1 -1
  336. package/src/server/http.ts +112 -6
  337. package/src/server/index.ts +63 -12
  338. package/src/server/instrumentation.ts +470 -0
  339. package/src/server/openapi.ts +4 -4
  340. package/src/server/providers/index.ts +6 -3
  341. package/src/server/providers/loadProviderConfig.ts +4 -4
  342. package/src/server/request-context.ts +116 -0
  343. package/src/server/server-context.ts +44 -0
  344. package/src/server/server.ts +1045 -229
  345. package/src/server/types.ts +2 -2
  346. package/src/server/use-case-route.ts +430 -0
  347. package/src/server-only.ts +7 -0
  348. package/src/tasks/index.ts +275 -0
  349. package/src/testing/index.ts +1153 -6
  350. package/src/tracing/index.ts +176 -0
  351. package/src/uploads/client.ts +861 -0
  352. package/src/uploads/index.ts +1071 -0
  353. package/dist/ports/mailer.d.ts +0 -6
  354. package/dist/ports/mailer.d.ts.map +0 -1
  355. package/dist/ports/mailer.js +0 -2
  356. package/dist/ports/mailer.js.map +0 -1
  357. package/dist/ports/schedules.d.ts +0 -9
  358. package/dist/ports/schedules.d.ts.map +0 -1
  359. package/dist/ports/schedules.js +0 -2
  360. package/dist/ports/schedules.js.map +0 -1
@@ -7,70 +7,151 @@ import {
7
7
  methodSupportsRequestBody,
8
8
  parsePathTemplate,
9
9
  type StandardSchema,
10
- } from "../contracts";
10
+ } from "../contracts/index.js";
11
+ import {
12
+ comparePathParamsToTemplate,
13
+ formatPathParamsMismatch,
14
+ getObjectSchemaShape,
15
+ } from "../contracts/schema-shape.js";
11
16
  import {
12
17
  createErrorResponseBody,
18
+ httpErrors,
13
19
  isAppError,
14
20
  isErrorResponseBody,
15
21
  toErrorResponseBody,
16
- } from "../errors";
17
- import type { AnyPorts } from "../ports";
18
- import { AuthUnauthorizedError, GateAuthorizationError } from "../ports";
22
+ } from "../errors/index.js";
23
+ import {
24
+ IdempotencyConflictError,
25
+ IdempotencyInProgressError,
26
+ } from "../idempotency/index.js";
27
+ import type { AnyPorts } from "../ports/index.js";
28
+ import {
29
+ AuthUnauthorizedError,
30
+ GateAuthorizationError,
31
+ isUnboundPort,
32
+ TenantRequiredError,
33
+ } from "../ports/index.js";
19
34
  import type {
20
- ProvidedPortsOfList,
35
+ InferProviderPorts,
21
36
  ProviderSetupResult,
22
37
  ServiceProvider,
23
- } from "../providers";
24
- import type { ContractLike, ResolveContract } from "./contract-like";
25
- import { resolveContract } from "./contract-like";
26
- import { getRequestIdFromContext } from "./hooks/utils";
38
+ } from "../providers/index.js";
27
39
  import type {
40
+ ContextSeed,
41
+ ServerContextConfig,
42
+ ServiceContextInputArgs,
43
+ } from "./context.js";
44
+ import { createContextFinalizer, resolveServerContext } from "./context.js";
45
+ import type { ContractLike, ResolveContract } from "./contract-like.js";
46
+ import { resolveContract } from "./contract-like.js";
47
+ import { getRequestIdFromContext } from "./hooks/utils.js";
48
+ import type {
49
+ AddedCtxFromHooks,
28
50
  Handler,
29
51
  HandlerArgs,
30
52
  HttpRequestLike,
31
53
  HttpResponse,
32
54
  HttpResponseLike,
33
55
  ResolvedRoute,
56
+ RouteHook,
34
57
  ServerCaughtErrorHook,
35
58
  ServerHook,
36
59
  ServerUnhandledErrorMapper,
37
- } from "./http";
60
+ } from "./http.js";
61
+ import type { ServerInstrumentationOptions } from "./instrumentation.js";
62
+ import { createServerInstrumentation } from "./instrumentation.js";
38
63
  import {
39
64
  loadProviderConfig,
40
65
  parseStandardSchema,
41
66
  SchemaValidationError,
42
- } from "./providers";
67
+ } from "./providers/index.js";
68
+ import type { ActiveRequestContext } from "./request-context.js";
69
+ import {
70
+ enterActiveRequestContext,
71
+ readContextActor,
72
+ readContextTenant,
73
+ setActiveRequestIdentity,
74
+ } from "./request-context.js";
75
+ import type {
76
+ AnyUseCaseLike,
77
+ AnyUseCaseRouteDef,
78
+ UseCaseRouteDef,
79
+ ValidatedRouteInputs,
80
+ } from "./use-case-route.js";
81
+ import {
82
+ createUseCaseRouteHandler,
83
+ isUseCaseRouteDef,
84
+ } from "./use-case-route.js";
43
85
 
44
86
  /**
45
- * Route registration for one contract.
87
+ * Route registration that connects a contract to the handler implementing it.
46
88
  *
47
- * Route definitions connect a contract to the handler that implements it. Most
48
- * apps keep these in `features/<feature>/routes.ts` and compose them with
49
- * `defineRoutes(...)`.
89
+ * Most apps keep route definitions in `features/<feature>/routes.ts` and
90
+ * compose them with `defineRoutes(...)`.
50
91
  */
51
- export type RouteDef<Ctx, CLike extends ContractLike = ContractLike> = {
92
+ export type HandlerRouteDef<
93
+ Ctx,
94
+ CLike extends ContractLike = ContractLike,
95
+ Hooks extends readonly RouteHook<Ctx, object>[] = readonly RouteHook<
96
+ Ctx,
97
+ object
98
+ >[],
99
+ > = {
52
100
  /**
53
101
  * Contract builder or plain contract config for this route.
54
102
  */
55
103
  contract: CLike;
104
+ /**
105
+ * Route-scoped hooks that run after group hooks and before the handler.
106
+ */
107
+ hooks?: Hooks;
56
108
  /**
57
109
  * Handler that implements the contract.
58
110
  */
59
- handle: Handler<Ctx, ResolveContract<CLike>>;
111
+ handle: Handler<Ctx & AddedCtxFromHooks<Hooks>, ResolveContract<CLike>>;
112
+ useCase?: never;
113
+ input?: never;
114
+ status?: never;
60
115
  };
61
116
 
117
+ /**
118
+ * Route registration for one contract.
119
+ *
120
+ * Routes either bind the contract directly to a use case (`{ contract,
121
+ * useCase }`) or implement a full handler (`{ contract, handle }`). The full
122
+ * handler form is the escape hatch for response headers, streaming, native
123
+ * `Response` values, and multi-status handling.
124
+ */
125
+ export type RouteDef<
126
+ Ctx,
127
+ CLike extends ContractLike = ContractLike,
128
+ Hooks extends readonly RouteHook<Ctx, object>[] = readonly RouteHook<
129
+ Ctx,
130
+ object
131
+ >[],
132
+ > = HandlerRouteDef<Ctx, CLike, Hooks> | AnyUseCaseRouteDef<Ctx, CLike, Hooks>;
133
+
134
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at collection boundaries
135
+ type AnyRouteDef = RouteDef<any, any>;
136
+
137
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at collection boundaries
138
+ type PlainRouteDef<Ctx> = RouteDef<Ctx, any, readonly []>;
139
+
140
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at collection boundaries
141
+ type AnyContractRouteDef<Ctx> = RouteDef<Ctx, any>;
142
+
62
143
  const ROUTE_GROUP_KIND = "beignet.route-group";
63
144
 
64
145
  /**
65
146
  * Named collection of related route registrations.
66
147
  *
67
- * Route groups are an organization helper only. `defineRoutes(...)` flattens
68
- * them before server registration.
148
+ * Route groups colocate feature routes and can apply scoped route hooks to
149
+ * every route in the group. `defineRoutes(...)` flattens them before server
150
+ * registration while preserving those hooks.
69
151
  */
70
152
  export type RouteGroup<
71
153
  Ctx,
72
- // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
73
- Routes extends readonly RouteDef<Ctx, any>[] = readonly RouteDef<Ctx, any>[],
154
+ Routes extends readonly AnyRouteDef[] = readonly AnyRouteDef[],
74
155
  > = {
75
156
  /**
76
157
  * Internal marker used by `defineRoutes(...)`.
@@ -80,6 +161,10 @@ export type RouteGroup<
80
161
  * Human-readable group name.
81
162
  */
82
163
  name: string;
164
+ /**
165
+ * Hooks applied to every route in this group.
166
+ */
167
+ hooks?: readonly RouteHook<Ctx, object>[];
83
168
  /**
84
169
  * Route definitions in this group.
85
170
  */
@@ -87,24 +172,45 @@ export type RouteGroup<
87
172
  };
88
173
 
89
174
  type RouteInput<Ctx> =
90
- // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
91
- | RouteDef<Ctx, any>
92
- // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
93
- | RouteGroup<Ctx, readonly RouteDef<Ctx, any>[]>;
175
+ | AnyContractRouteDef<Ctx>
176
+ | AnyRouteDef
177
+ | RouteGroup<Ctx, readonly AnyRouteDef[]>;
178
+
179
+ type ContextualRouteInput<Ctx> =
180
+ | PlainRouteDef<Ctx>
181
+ | RouteGroup<Ctx, readonly AnyRouteDef[]>;
182
+
183
+ type RouteGroupBuilder<Ctx> = {
184
+ <
185
+ const GroupHooks extends readonly RouteHook<Ctx, object>[] = readonly [],
186
+ const R extends readonly PlainRouteDef<
187
+ Ctx & AddedCtxFromHooks<GroupHooks>
188
+ >[] = readonly PlainRouteDef<Ctx & AddedCtxFromHooks<GroupHooks>>[],
189
+ >(group: {
190
+ name: string;
191
+ hooks?: GroupHooks;
192
+ routes: R & ValidatedRouteInputs<Ctx & AddedCtxFromHooks<GroupHooks>, R>;
193
+ }): RouteGroup<Ctx, R>;
194
+ <
195
+ const GroupHooks extends readonly RouteHook<Ctx, object>[] = readonly [],
196
+ const R extends readonly AnyRouteDef[] = readonly AnyRouteDef[],
197
+ >(group: {
198
+ name: string;
199
+ hooks?: GroupHooks;
200
+ routes: R & ValidatedRouteInputs<Ctx & AddedCtxFromHooks<GroupHooks>, R>;
201
+ }): RouteGroup<Ctx, R>;
202
+ };
94
203
 
95
204
  type RoutesFromInput<Input> =
96
- // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
97
- Input extends RouteGroup<any, infer Routes>
205
+ Input extends RouteGroup<infer _Ctx, infer Routes>
98
206
  ? Routes
99
- : // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
100
- Input extends RouteDef<any, any>
207
+ : Input extends AnyRouteDef
101
208
  ? readonly [Input]
102
209
  : readonly [];
103
210
 
104
211
  type FlattenRouteInputs<Inputs extends readonly unknown[]> =
105
212
  number extends Inputs["length"]
106
- ? // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
107
- readonly RouteDef<any, any>[]
213
+ ? readonly AnyRouteDef[]
108
214
  : Inputs extends readonly [infer First, ...infer Rest]
109
215
  ? readonly [...RoutesFromInput<First>, ...FlattenRouteInputs<Rest>]
110
216
  : readonly [];
@@ -122,20 +228,62 @@ type ContractsFromRouteList<
122
228
  : never;
123
229
  };
124
230
 
231
+ /**
232
+ * Define one route registration with hook-aware handler typing.
233
+ *
234
+ * Direct route objects are still supported. Use this helper when route-scoped
235
+ * hooks enrich `ctx` for a single handler and you want TypeScript to infer the
236
+ * added fields.
237
+ */
238
+ export function defineRoute<Ctx>() {
239
+ function define<
240
+ CLike extends ContractLike,
241
+ UC extends AnyUseCaseLike,
242
+ const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [],
243
+ >(
244
+ route: UseCaseRouteDef<Ctx, CLike, UC, Hooks>,
245
+ ): UseCaseRouteDef<Ctx, CLike, UC, Hooks>;
246
+ function define<
247
+ CLike extends ContractLike,
248
+ const Hooks extends readonly RouteHook<Ctx, object>[] = readonly [],
249
+ >(
250
+ route: HandlerRouteDef<Ctx, CLike, Hooks>,
251
+ ): HandlerRouteDef<Ctx, CLike, Hooks>;
252
+ function define(route: unknown): unknown {
253
+ return route;
254
+ }
255
+
256
+ return define;
257
+ }
258
+
259
+ /**
260
+ * Loosely typed provider list element.
261
+ *
262
+ * Required-port, app-context, and service-input generics are erased here so
263
+ * providers created with the typed `createProvider<Requires, Context,
264
+ * ServiceInput>()` form stay assignable to server provider lists.
265
+ */
266
+ type AnyServiceProvider = ServiceProvider<
267
+ unknown,
268
+ // biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
269
+ StandardSchemaV1<any, any>,
270
+ AnyPorts,
271
+ // biome-ignore lint/suspicious/noExplicitAny: provider context types are erased at this level
272
+ any,
273
+ // biome-ignore lint/suspicious/noExplicitAny: provider service-input types are erased at this level
274
+ any
275
+ >;
276
+
125
277
  /**
126
278
  * Options for creating a Beignet server instance.
127
279
  */
128
280
  export type CreateServerOptions<
129
281
  Ctx,
130
282
  Ports extends AnyPorts,
283
+ ServiceInput = void,
131
284
  // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
132
- Routes extends readonly RouteDef<Ctx, any>[] = readonly RouteDef<Ctx, any>[],
133
- Providers extends readonly ServiceProvider<
134
- unknown,
135
- // biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
136
- StandardSchemaV1<any, any>,
137
- AnyPorts
138
- >[] = readonly [],
285
+ Routes extends readonly RouteDef<any, any>[] = readonly RouteDef<any, any>[],
286
+ Providers extends readonly AnyServiceProvider[] = readonly [],
139
287
  > = {
140
288
  /**
141
289
  * App-owned ports available to context creation, hooks, and handlers.
@@ -159,24 +307,64 @@ export type CreateServerOptions<
159
307
  providerConfig?: Record<string, unknown>;
160
308
 
161
309
  /**
162
- * Create request context after a route is matched.
310
+ * Context blueprint for request and service contexts.
311
+ *
312
+ * Gate-less contexts may pass a plain request factory. Contexts with a
313
+ * `gate` property must use the blueprint form
314
+ * `{ gate: (ports) => ports.gate, request, service }` so the server owns
315
+ * gate attachment and identity changes can never go stale.
163
316
  *
164
317
  * The `ports` argument includes app ports plus ports provided during server
165
318
  * startup.
166
319
  */
167
- createContext: (args: {
168
- req: HttpRequestLike;
169
- ports: Ports & ProvidedPortsOfList<Providers>;
170
- contract?: HttpContractConfig;
171
- }) => Ctx | Promise<Ctx>;
320
+ context: ServerContextConfig<
321
+ Ctx,
322
+ Ports & InferProviderPorts<Providers>,
323
+ ServiceInput
324
+ >;
172
325
  /**
173
326
  * Server hooks that wrap every registered route.
174
327
  */
175
- hooks?: ServerHook<Ctx, Ports & ProvidedPortsOfList<Providers>>[];
328
+ hooks?: ServerHook<Ctx, Ports & InferProviderPorts<Providers>>[];
329
+ /**
330
+ * Server-owned request instrumentation.
331
+ *
332
+ * The server resolves a request ID and W3C trace context for every request
333
+ * before user hooks and context creation, writes `x-request-id` and
334
+ * `traceparent` response headers, and records request and error events into
335
+ * the resolved provider instrumentation port (`ports.instrumentation`, then
336
+ * `ports.devtools`) when one is installed.
337
+ *
338
+ * Pass `false` to disable headers and event recording. Context factories
339
+ * still receive `requestId` and `trace` arguments.
340
+ */
341
+ instrumentation?: ServerInstrumentationOptions<Ctx> | false;
342
+ /**
343
+ * Whether route-owned responses are validated against the contract's
344
+ * declared statuses and response schemas before they are sent.
345
+ *
346
+ * Disable this to trade response guarantees for throughput, mirroring the
347
+ * client-side `validateResponses` option.
348
+ *
349
+ * @default true
350
+ */
351
+ validateResponses?: boolean;
176
352
  /**
177
353
  * Route list to register up front.
178
354
  */
179
355
  routes?: Routes;
356
+ /**
357
+ * How to handle ports that are still unbound after all providers have
358
+ * started.
359
+ *
360
+ * Ports declared as `deferred` in `definePorts(...)` boot as throwing
361
+ * placeholders until a provider contributes them. The default `"error"`
362
+ * fails startup and lists the unbound port keys. Apps that bind every port
363
+ * directly are unaffected.
364
+ *
365
+ * @default "error"
366
+ */
367
+ onUnboundPorts?: "error" | "warn" | "ignore";
180
368
  /**
181
369
  * Global caught-error observer.
182
370
  */
@@ -196,7 +384,11 @@ interface RouteBuilder<Ctx, C extends HttpContractConfig> {
196
384
  /**
197
385
  * Runtime server object returned by `createServer(...)`.
198
386
  */
199
- export interface ServerInstance<Ctx, Ports extends AnyPorts = AnyPorts> {
387
+ export interface ServerInstance<
388
+ Ctx,
389
+ Ports extends AnyPorts = AnyPorts,
390
+ ServiceInput = void,
391
+ > {
200
392
  /**
201
393
  * Catch-all request handler for platform adapters.
202
394
  */
@@ -207,6 +399,22 @@ export interface ServerInstance<Ctx, Ports extends AnyPorts = AnyPorts> {
207
399
  route: <CLike extends ContractLike>(
208
400
  contractLike: CLike,
209
401
  ) => RouteBuilder<Ctx, ResolveContract<CLike>>;
402
+ /**
403
+ * Build a fully assembled request context from a framework-neutral request.
404
+ *
405
+ * Use this for adapter entry points outside the route pipeline, such as
406
+ * server components or upload routes.
407
+ */
408
+ createRequestContext: (req: HttpRequestLike) => Promise<Ctx>;
409
+ /**
410
+ * Build a fully assembled service context for schedules, outbox drains,
411
+ * tasks, and background work.
412
+ *
413
+ * Requires `context.service` to be declared in `createServer(...)`.
414
+ */
415
+ createServiceContext: (
416
+ ...args: ServiceContextInputArgs<ServiceInput>
417
+ ) => Promise<Ctx>;
210
418
  /**
211
419
  * Contract configs registered through the `routes` option.
212
420
  */
@@ -302,6 +510,59 @@ function errorResponse(
302
510
  };
303
511
  }
304
512
 
513
+ type RequestValidationLocation = "query" | "path" | "headers" | "body";
514
+
515
+ function contractDiagnostics(contract: HttpContractConfig) {
516
+ return {
517
+ contract: contract.name,
518
+ method: contract.method,
519
+ path: contract.path,
520
+ };
521
+ }
522
+
523
+ function requestValidationDetails(
524
+ contract: HttpContractConfig,
525
+ location: RequestValidationLocation,
526
+ error?: unknown,
527
+ ) {
528
+ const details = {
529
+ ...contractDiagnostics(contract),
530
+ location,
531
+ };
532
+
533
+ if (error instanceof SchemaValidationError) {
534
+ return {
535
+ ...details,
536
+ issues: error.issues,
537
+ };
538
+ }
539
+
540
+ if (error instanceof Error) {
541
+ return {
542
+ ...details,
543
+ message: error.message,
544
+ };
545
+ }
546
+
547
+ return details;
548
+ }
549
+
550
+ function requestValidationError(
551
+ contract: HttpContractConfig,
552
+ status: number,
553
+ code: string,
554
+ message: string,
555
+ location: RequestValidationLocation,
556
+ error?: unknown,
557
+ ): HttpResponseLike {
558
+ return errorResponse(
559
+ status,
560
+ code,
561
+ message,
562
+ requestValidationDetails(contract, location, error),
563
+ );
564
+ }
565
+
305
566
  function normalizeResponse(res: HttpResponseLike): HttpResponseLike {
306
567
  return {
307
568
  status: res.status,
@@ -395,6 +656,48 @@ function responseForHooks(res: HttpResponse): HttpResponseLike {
395
656
  };
396
657
  }
397
658
 
659
+ /**
660
+ * Merge hook-applied header changes onto a native web Response.
661
+ *
662
+ * Starts from the native response's `Headers` so `set-cookie` multiplicity is
663
+ * preserved, then applies headers the beforeSend chain added or changed
664
+ * relative to the original headers-only view. The body stream passes through
665
+ * untouched; status and statusText are preserved.
666
+ */
667
+ function mergeNativeResponseHeaders(
668
+ nativeResponse: Response,
669
+ originalHeaders: Record<string, string>,
670
+ finalHeaders: Record<string, string>,
671
+ ): Response {
672
+ const originalByLowerKey = new Map<string, string>();
673
+ for (const [key, value] of Object.entries(originalHeaders)) {
674
+ originalByLowerKey.set(key.toLowerCase(), value);
675
+ }
676
+
677
+ let changed = false;
678
+ const merged = new Headers(nativeResponse.headers);
679
+ for (const [key, value] of Object.entries(finalHeaders)) {
680
+ const lowerKey = key.toLowerCase();
681
+ if (originalByLowerKey.get(lowerKey) === value) continue;
682
+ changed = true;
683
+ if (lowerKey === "set-cookie") {
684
+ merged.append(lowerKey, value);
685
+ } else {
686
+ merged.set(key, value);
687
+ }
688
+ }
689
+
690
+ if (!changed) {
691
+ return nativeResponse;
692
+ }
693
+
694
+ return new Response(nativeResponse.body, {
695
+ status: nativeResponse.status,
696
+ statusText: nativeResponse.statusText,
697
+ headers: merged,
698
+ });
699
+ }
700
+
398
701
  function isHttpResponseLike(value: unknown): value is HttpResponseLike {
399
702
  return (
400
703
  !isWebResponse(value) &&
@@ -421,6 +724,37 @@ class ResponseContractViolationError extends Error {
421
724
  }
422
725
  }
423
726
 
727
+ function responseContractViolationMessage(
728
+ contract: HttpContractConfig,
729
+ status: number,
730
+ ): string {
731
+ return (
732
+ `Response validation failed for ${contract.method} ${contract.path} ` +
733
+ `(status ${status}, contract: ${contract.name})`
734
+ );
735
+ }
736
+
737
+ function declaredResponseStatuses(contract: HttpContractConfig): number[] {
738
+ return Object.keys(contract.responses)
739
+ .map((status) => Number(status))
740
+ .filter((status) => Number.isFinite(status))
741
+ .sort((a, b) => a - b);
742
+ }
743
+
744
+ function responseContractViolationDetails(
745
+ contract: HttpContractConfig,
746
+ status: number,
747
+ details?: Record<string, unknown>,
748
+ ) {
749
+ return {
750
+ ...contractDiagnostics(contract),
751
+ location: "response",
752
+ status,
753
+ declaredStatuses: declaredResponseStatuses(contract),
754
+ ...details,
755
+ };
756
+ }
757
+
424
758
  function getDeclaredCatalogErrorsForStatus(
425
759
  contract: HttpContractConfig,
426
760
  status: number,
@@ -458,10 +792,8 @@ async function validateCatalogErrorResponse<C extends HttpContractConfig>(
458
792
  if (!matchingError) {
459
793
  throw new ResponseContractViolationError({
460
794
  code: "RESPONSE_VALIDATION_ERROR",
461
- message:
462
- `Response validation failed for ${contract.method} ${contract.path} ` +
463
- `(status ${res.status}, contract: ${contract.name})`,
464
- details: {
795
+ message: responseContractViolationMessage(contract, res.status),
796
+ details: responseContractViolationDetails(contract, res.status, {
465
797
  issues: [
466
798
  {
467
799
  message:
@@ -469,7 +801,7 @@ async function validateCatalogErrorResponse<C extends HttpContractConfig>(
469
801
  `Expected one of: ${declaredErrors.map((error) => error.code).join(", ")}.`,
470
802
  },
471
803
  ],
472
- },
804
+ }),
473
805
  });
474
806
  }
475
807
 
@@ -480,10 +812,10 @@ async function validateCatalogErrorResponse<C extends HttpContractConfig>(
480
812
  if (error instanceof SchemaValidationError) {
481
813
  throw new ResponseContractViolationError({
482
814
  code: "RESPONSE_VALIDATION_ERROR",
483
- message:
484
- `Response validation failed for ${contract.method} ${contract.path} ` +
485
- `(status ${res.status}, contract: ${contract.name})`,
486
- details: { issues: error.issues },
815
+ message: responseContractViolationMessage(contract, res.status),
816
+ details: responseContractViolationDetails(contract, res.status, {
817
+ issues: error.issues,
818
+ }),
487
819
  });
488
820
  }
489
821
  throw error;
@@ -494,6 +826,7 @@ async function validateCatalogErrorResponse<C extends HttpContractConfig>(
494
826
  async function validateResponseAgainstContract<C extends HttpContractConfig>(
495
827
  contract: C,
496
828
  res: HttpResponseLike,
829
+ responseValidationExemptStatus?: number,
497
830
  ): Promise<void> {
498
831
  const statusKey = String(res.status);
499
832
  const hasDeclaredStatus = Object.hasOwn(contract.responses, statusKey);
@@ -506,10 +839,9 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
506
839
  message:
507
840
  `Handler returned undeclared status ${res.status} for ` +
508
841
  `${contract.method} ${contract.path} (contract: ${contract.name})`,
509
- details: {
510
- status: res.status,
511
- body: res.body,
512
- },
842
+ details: responseContractViolationDetails(contract, res.status, {
843
+ returnedStatus: res.status,
844
+ }),
513
845
  });
514
846
  }
515
847
 
@@ -518,17 +850,15 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
518
850
  if (res.body !== undefined && res.body !== null) {
519
851
  throw new ResponseContractViolationError({
520
852
  code: "RESPONSE_VALIDATION_ERROR",
521
- message:
522
- `Response validation failed for ${contract.method} ${contract.path} ` +
523
- `(status ${res.status}, contract: ${contract.name})`,
524
- details: {
853
+ message: responseContractViolationMessage(contract, res.status),
854
+ details: responseContractViolationDetails(contract, res.status, {
525
855
  issues: [
526
856
  {
527
857
  message:
528
858
  "Response body must be empty for a null response schema.",
529
859
  },
530
860
  ],
531
- },
861
+ }),
532
862
  });
533
863
  }
534
864
  return;
@@ -536,6 +866,11 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
536
866
 
537
867
  if (!responseSchema) return;
538
868
 
869
+ // Binder routes whose use case output schema is the same object as the
870
+ // declared success response schema skip the redundant success-status parse.
871
+ // Error statuses and undeclared statuses are validated unchanged.
872
+ if (res.status === responseValidationExemptStatus) return;
873
+
539
874
  try {
540
875
  await parseStandardSchema(responseSchema, res.body);
541
876
  await validateCatalogErrorResponse(contract, res);
@@ -543,10 +878,10 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
543
878
  if (error instanceof SchemaValidationError) {
544
879
  throw new ResponseContractViolationError({
545
880
  code: "RESPONSE_VALIDATION_ERROR",
546
- message:
547
- `Response validation failed for ${contract.method} ${contract.path} ` +
548
- `(status ${res.status}, contract: ${contract.name})`,
549
- details: { issues: error.issues },
881
+ message: responseContractViolationMessage(contract, res.status),
882
+ details: responseContractViolationDetails(contract, res.status, {
883
+ issues: error.issues,
884
+ }),
550
885
  });
551
886
  }
552
887
  throw error;
@@ -556,9 +891,14 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
556
891
  async function finalizeResponse<C extends HttpContractConfig>(
557
892
  contract: C,
558
893
  res: HttpResponseLike,
894
+ responseValidationExemptStatus?: number,
559
895
  ): Promise<HttpResponseLike> {
560
896
  const normalized = normalizeResponse(res);
561
- await validateResponseAgainstContract(contract, normalized);
897
+ await validateResponseAgainstContract(
898
+ contract,
899
+ normalized,
900
+ responseValidationExemptStatus,
901
+ );
562
902
  return normalized;
563
903
  }
564
904
 
@@ -618,38 +958,65 @@ async function parseBody(req: HttpRequestLike): Promise<unknown> {
618
958
  }
619
959
  }
620
960
 
621
- function buildHandler<
961
+ type ExecutionTarget<Ctx, C extends HttpContractConfig> = {
962
+ contract: C;
963
+ /**
964
+ * Compiled route pattern. Only required when the executor is invoked
965
+ * without pre-matched params.
966
+ */
967
+ compiled?: CompiledPath;
968
+ handler: Handler<Ctx, C>;
969
+ /**
970
+ * Success status whose response schema validation is skipped because the
971
+ * use case bound to this route already validated its output against the
972
+ * same schema object.
973
+ */
974
+ responseValidationExemptStatus?: number;
975
+ };
976
+
977
+ /**
978
+ * Build the per-request execution pipeline once.
979
+ *
980
+ * The returned executor takes the contract, compiled pattern, and user handler
981
+ * per invocation so fallback responses (404/405) can reuse a single pipeline
982
+ * across requests instead of rebuilding it per unmatched request.
983
+ */
984
+ function createRequestExecutor<
622
985
  Ctx,
623
986
  Ports extends AnyPorts,
624
987
  C extends HttpContractConfig,
625
- Providers extends readonly ServiceProvider<
626
- unknown,
627
- // biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
628
- StandardSchemaV1<any, any>,
629
- AnyPorts
630
- >[] = readonly [],
631
- FinalPorts extends Ports & ProvidedPortsOfList<Providers> = Ports &
632
- ProvidedPortsOfList<Providers>,
988
+ Providers extends readonly AnyServiceProvider[] = readonly [],
989
+ FinalPorts extends Ports & InferProviderPorts<Providers> = Ports &
990
+ InferProviderPorts<Providers>,
633
991
  >(
634
992
  // biome-ignore lint/suspicious/noExplicitAny: Options are generic and need to work with any routes
635
- options: CreateServerOptions<Ctx, Ports, any, Providers>,
993
+ options: CreateServerOptions<Ctx, Ports, any, any, Providers>,
636
994
  finalPorts: FinalPorts,
637
- contract: C,
638
- userHandler: Handler<Ctx, C>,
995
+ contextRuntime: {
996
+ createRequestContext: (
997
+ req: HttpRequestLike,
998
+ contract: HttpContractConfig,
999
+ ) => Promise<Ctx>;
1000
+ finalizeContext: (seed: ContextSeed<Ctx> | Ctx) => Ctx;
1001
+ },
639
1002
  hooks: ServerHook<Ctx, FinalPorts>[],
1003
+ routeHooks: readonly RouteHook<unknown, object>[] = [],
640
1004
  optionsOverrides?: {
641
1005
  skipRoutePreparation?: boolean;
642
1006
  },
643
1007
  ): (
1008
+ target: ExecutionTarget<Ctx, C>,
644
1009
  req: HttpRequestLike,
645
1010
  preMatchedParams?: Record<string, string>,
646
1011
  ) => Promise<HttpResponse> {
647
- const compiled = compilePath(contract.path);
1012
+ const warnedNativeReplacementHooks = new WeakSet<object>();
648
1013
 
649
1014
  return async (
1015
+ target: ExecutionTarget<Ctx, C>,
650
1016
  req: HttpRequestLike,
651
1017
  preMatchedParams?: Record<string, string>,
652
1018
  ) => {
1019
+ const { contract, handler: userHandler } = target;
653
1020
  let baseCtx: Ctx | undefined;
654
1021
  let pathValue: HandlerArgs<Ctx, C>["path"] | undefined;
655
1022
  let queryValue: HandlerArgs<Ctx, C>["query"] | undefined;
@@ -730,6 +1097,53 @@ function buildHandler<
730
1097
  };
731
1098
  }
732
1099
 
1100
+ if (currentError instanceof TenantRequiredError) {
1101
+ return {
1102
+ ctx,
1103
+ response: errorResponse(
1104
+ currentError.status,
1105
+ currentError.code,
1106
+ currentError.message,
1107
+ ),
1108
+ error: currentError,
1109
+ owner: "framework",
1110
+ };
1111
+ }
1112
+
1113
+ if (currentError instanceof IdempotencyConflictError) {
1114
+ return {
1115
+ ctx,
1116
+ response: errorResponse(
1117
+ httpErrors.IdempotencyConflict.status,
1118
+ httpErrors.IdempotencyConflict.code,
1119
+ currentError.message,
1120
+ {
1121
+ namespace: currentError.namespace,
1122
+ key: currentError.key,
1123
+ },
1124
+ ),
1125
+ error: currentError,
1126
+ owner: "framework",
1127
+ };
1128
+ }
1129
+
1130
+ if (currentError instanceof IdempotencyInProgressError) {
1131
+ return {
1132
+ ctx,
1133
+ response: errorResponse(
1134
+ httpErrors.IdempotencyInProgress.status,
1135
+ httpErrors.IdempotencyInProgress.code,
1136
+ currentError.message,
1137
+ {
1138
+ namespace: currentError.namespace,
1139
+ key: currentError.key,
1140
+ },
1141
+ ),
1142
+ error: currentError,
1143
+ owner: "framework",
1144
+ };
1145
+ }
1146
+
733
1147
  if (currentError instanceof GateAuthorizationError) {
734
1148
  return {
735
1149
  ctx,
@@ -814,8 +1228,10 @@ function buildHandler<
814
1228
  if (preMatchedParams) {
815
1229
  matchedParams = preMatchedParams;
816
1230
  } else {
817
- const match = compiled.pattern.exec(url.pathname);
1231
+ const compiled = target.compiled;
1232
+ const match = compiled ? compiled.pattern.exec(url.pathname) : null;
818
1233
  if (
1234
+ !compiled ||
819
1235
  !match ||
820
1236
  contract.method.toUpperCase() !== req.method.toUpperCase()
821
1237
  ) {
@@ -832,15 +1248,67 @@ function buildHandler<
832
1248
  }
833
1249
  const rawHeaders = requestHeadersToRecord(req.headers);
834
1250
 
1251
+ const runNativeBeforeSend = async (
1252
+ initialResult: ExecutionResult<Ctx>,
1253
+ nativeResponse: Response,
1254
+ ): Promise<Response> => {
1255
+ const originalView = responseForHooks(nativeResponse);
1256
+ const originalHeaders = originalView.headers ?? {};
1257
+ let transformed = originalView;
1258
+ for (const hook of hooks) {
1259
+ if (!hook.beforeSend) continue;
1260
+ const nextResponse = await hook.beforeSend({
1261
+ req,
1262
+ ctx: initialResult.ctx,
1263
+ contract,
1264
+ path: pathValue,
1265
+ query: queryValue,
1266
+ headers: headersValue,
1267
+ body: bodyValue,
1268
+ response: transformed,
1269
+ error: initialResult.error,
1270
+ native: true,
1271
+ });
1272
+ if (nextResponse) {
1273
+ if (
1274
+ (nextResponse.status !== nativeResponse.status ||
1275
+ nextResponse.body !== undefined) &&
1276
+ !warnedNativeReplacementHooks.has(hook) &&
1277
+ process.env.NODE_ENV !== "production"
1278
+ ) {
1279
+ warnedNativeReplacementHooks.add(hook);
1280
+ console.warn(
1281
+ `[beignet] beforeSend hook "${hook.name ?? "(anonymous)"}" returned a replacement status or body for a native Response on ${contract.method} ${contract.path}. Native responses are headers-only in beforeSend; status and body changes are ignored.`,
1282
+ );
1283
+ }
1284
+ transformed = {
1285
+ status: nativeResponse.status,
1286
+ headers: nextResponse.headers,
1287
+ };
1288
+ }
1289
+ }
1290
+ return mergeNativeResponseHeaders(
1291
+ nativeResponse,
1292
+ originalHeaders,
1293
+ transformed.headers ?? {},
1294
+ );
1295
+ };
1296
+
835
1297
  const applyTransformHooks = async (
836
1298
  initialResult: ExecutionResult<Ctx>,
837
1299
  allowRetry: boolean,
838
1300
  ): Promise<ExecutionResult<Ctx>> => {
839
- if (isWebResponse(initialResult.response)) {
840
- return initialResult;
841
- }
842
-
843
1301
  try {
1302
+ if (isWebResponse(initialResult.response)) {
1303
+ return {
1304
+ ...initialResult,
1305
+ response: await runNativeBeforeSend(
1306
+ initialResult,
1307
+ initialResult.response,
1308
+ ),
1309
+ };
1310
+ }
1311
+
844
1312
  let transformed = normalizeResponse(initialResult.response);
845
1313
  for (const hook of hooks) {
846
1314
  if (!hook.beforeSend) continue;
@@ -917,11 +1385,10 @@ function buildHandler<
917
1385
  if (optionsOverrides?.skipRoutePreparation) {
918
1386
  let createdCtx!: Ctx;
919
1387
  try {
920
- createdCtx = await options.createContext({
1388
+ createdCtx = await contextRuntime.createRequestContext(
921
1389
  req,
922
- ports: finalPorts,
923
1390
  contract,
924
- });
1391
+ );
925
1392
  baseCtx = createdCtx;
926
1393
  } catch (error) {
927
1394
  result = await resolveErrorResult(
@@ -977,26 +1444,17 @@ function buildHandler<
977
1444
  try {
978
1445
  query = await parseStandardSchema(contract.query, query);
979
1446
  } catch (error) {
980
- result =
981
- error instanceof SchemaValidationError
982
- ? {
983
- response: errorResponse(
984
- 422,
985
- "VALIDATION_ERROR",
986
- "Invalid query parameters",
987
- { issues: error.issues },
988
- ),
989
- owner: "framework",
990
- }
991
- : {
992
- response: errorResponse(
993
- 422,
994
- "VALIDATION_ERROR",
995
- "Invalid query parameters",
996
- (error as Error).message,
997
- ),
998
- owner: "framework",
999
- };
1447
+ result = {
1448
+ response: requestValidationError(
1449
+ contract,
1450
+ 422,
1451
+ "VALIDATION_ERROR",
1452
+ "Invalid query parameters",
1453
+ "query",
1454
+ error,
1455
+ ),
1456
+ owner: "framework",
1457
+ };
1000
1458
  }
1001
1459
  }
1002
1460
 
@@ -1009,26 +1467,17 @@ function buildHandler<
1009
1467
  matchedParams,
1010
1468
  );
1011
1469
  } catch (error) {
1012
- result =
1013
- error instanceof SchemaValidationError
1014
- ? {
1015
- response: errorResponse(
1016
- 422,
1017
- "VALIDATION_ERROR",
1018
- "Invalid path parameters",
1019
- { issues: error.issues },
1020
- ),
1021
- owner: "framework",
1022
- }
1023
- : {
1024
- response: errorResponse(
1025
- 422,
1026
- "VALIDATION_ERROR",
1027
- "Invalid path parameters",
1028
- (error as Error).message,
1029
- ),
1030
- owner: "framework",
1031
- };
1470
+ result = {
1471
+ response: requestValidationError(
1472
+ contract,
1473
+ 422,
1474
+ "VALIDATION_ERROR",
1475
+ "Invalid path parameters",
1476
+ "path",
1477
+ error,
1478
+ ),
1479
+ owner: "framework",
1480
+ };
1032
1481
  }
1033
1482
  }
1034
1483
 
@@ -1039,26 +1488,17 @@ function buildHandler<
1039
1488
  try {
1040
1489
  headers = await parseHeaderSchemas(headerSchemas, rawHeaders);
1041
1490
  } catch (error) {
1042
- result =
1043
- error instanceof SchemaValidationError
1044
- ? {
1045
- response: errorResponse(
1046
- 422,
1047
- "VALIDATION_ERROR",
1048
- "Invalid request headers",
1049
- { issues: error.issues },
1050
- ),
1051
- owner: "framework",
1052
- }
1053
- : {
1054
- response: errorResponse(
1055
- 422,
1056
- "VALIDATION_ERROR",
1057
- "Invalid request headers",
1058
- (error as Error).message,
1059
- ),
1060
- owner: "framework",
1061
- };
1491
+ result = {
1492
+ response: requestValidationError(
1493
+ contract,
1494
+ 422,
1495
+ "VALIDATION_ERROR",
1496
+ "Invalid request headers",
1497
+ "headers",
1498
+ error,
1499
+ ),
1500
+ owner: "framework",
1501
+ };
1062
1502
  }
1063
1503
  }
1064
1504
 
@@ -1067,9 +1507,16 @@ function buildHandler<
1067
1507
  if (!result) {
1068
1508
  try {
1069
1509
  body = await parseBody(req);
1070
- } catch {
1510
+ } catch (error) {
1071
1511
  result = {
1072
- response: errorResponse(400, "INVALID_BODY", "Malformed JSON"),
1512
+ response: requestValidationError(
1513
+ contract,
1514
+ 400,
1515
+ "INVALID_BODY",
1516
+ "Malformed JSON",
1517
+ "body",
1518
+ error,
1519
+ ),
1073
1520
  owner: "framework",
1074
1521
  };
1075
1522
  }
@@ -1084,34 +1531,28 @@ function buildHandler<
1084
1531
  error instanceof SchemaValidationError
1085
1532
  ) {
1086
1533
  result = {
1087
- response: errorResponse(
1534
+ response: requestValidationError(
1535
+ contract,
1088
1536
  400,
1089
1537
  "MISSING_BODY",
1090
1538
  "Request body is required",
1539
+ "body",
1540
+ error,
1091
1541
  ),
1092
1542
  owner: "framework",
1093
1543
  };
1094
1544
  } else {
1095
- result =
1096
- error instanceof SchemaValidationError
1097
- ? {
1098
- response: errorResponse(
1099
- 422,
1100
- "VALIDATION_ERROR",
1101
- "Invalid request body",
1102
- { issues: error.issues },
1103
- ),
1104
- owner: "framework",
1105
- }
1106
- : {
1107
- response: errorResponse(
1108
- 422,
1109
- "VALIDATION_ERROR",
1110
- "Invalid request body",
1111
- (error as Error).message,
1112
- ),
1113
- owner: "framework",
1114
- };
1545
+ result = {
1546
+ response: requestValidationError(
1547
+ contract,
1548
+ 422,
1549
+ "VALIDATION_ERROR",
1550
+ "Invalid request body",
1551
+ "body",
1552
+ error,
1553
+ ),
1554
+ owner: "framework",
1555
+ };
1115
1556
  }
1116
1557
  }
1117
1558
  }
@@ -1124,11 +1565,10 @@ function buildHandler<
1124
1565
 
1125
1566
  let createdCtx!: Ctx;
1126
1567
  try {
1127
- createdCtx = await options.createContext({
1568
+ createdCtx = await contextRuntime.createRequestContext(
1128
1569
  req,
1129
- ports: finalPorts,
1130
1570
  contract,
1131
- });
1571
+ );
1132
1572
  baseCtx = createdCtx;
1133
1573
  } catch (error) {
1134
1574
  result = await resolveErrorResult(
@@ -1183,7 +1623,7 @@ function buildHandler<
1183
1623
  break;
1184
1624
  }
1185
1625
  if (hookResult?.ctx !== undefined) {
1186
- currentCtx = hookResult.ctx;
1626
+ currentCtx = contextRuntime.finalizeContext(hookResult.ctx);
1187
1627
  }
1188
1628
  if (hookResult?.response) {
1189
1629
  const response = normalizeHttpResponse(hookResult.response);
@@ -1209,6 +1649,47 @@ function buildHandler<
1209
1649
  }
1210
1650
 
1211
1651
  if (!result) {
1652
+ for (const hook of routeHooks) {
1653
+ try {
1654
+ const additions = await hook.resolve({
1655
+ req,
1656
+ ctx: currentCtx,
1657
+ contract,
1658
+ path,
1659
+ query,
1660
+ headers,
1661
+ body,
1662
+ });
1663
+ if (additions && typeof additions === "object") {
1664
+ currentCtx = contextRuntime.finalizeContext({
1665
+ ...(currentCtx as object),
1666
+ ...additions,
1667
+ } as Ctx);
1668
+ }
1669
+ } catch (error) {
1670
+ result = await resolveErrorResult(
1671
+ error,
1672
+ currentCtx,
1673
+ pathValue,
1674
+ queryValue,
1675
+ headersValue,
1676
+ bodyValue,
1677
+ { owner: "framework" },
1678
+ );
1679
+ break;
1680
+ }
1681
+ }
1682
+ }
1683
+
1684
+ if (!result) {
1685
+ // Hooks may have elevated the actor or resolved a tenant.
1686
+ // Refresh the ambient request context so record-time
1687
+ // consumers such as createAmbientAuditLog see the finalized
1688
+ // identity.
1689
+ setActiveRequestIdentity({
1690
+ actor: readContextActor(currentCtx),
1691
+ tenant: readContextTenant(currentCtx),
1692
+ });
1212
1693
  try {
1213
1694
  result = {
1214
1695
  ctx: currentCtx,
@@ -1237,9 +1718,17 @@ function buildHandler<
1237
1718
  let finalResponse = normalizeHttpResponse(result.response);
1238
1719
  let finalError = result.error;
1239
1720
  let finalOwner = responseOwnerFor(finalResponse, result.owner);
1240
- if (finalOwner === "route" && !isWebResponse(finalResponse)) {
1721
+ if (
1722
+ finalOwner === "route" &&
1723
+ !isWebResponse(finalResponse) &&
1724
+ (options.validateResponses ?? true)
1725
+ ) {
1241
1726
  try {
1242
- finalResponse = await finalizeResponse(contract, finalResponse);
1727
+ finalResponse = await finalizeResponse(
1728
+ contract,
1729
+ finalResponse,
1730
+ target.responseValidationExemptStatus,
1731
+ );
1243
1732
  } catch (error) {
1244
1733
  if (error instanceof ResponseContractViolationError) {
1245
1734
  result = await applyTransformHooks(
@@ -1312,6 +1801,55 @@ function buildHandler<
1312
1801
  };
1313
1802
  }
1314
1803
 
1804
+ function buildHandler<
1805
+ Ctx,
1806
+ Ports extends AnyPorts,
1807
+ C extends HttpContractConfig,
1808
+ Providers extends readonly AnyServiceProvider[] = readonly [],
1809
+ FinalPorts extends Ports & InferProviderPorts<Providers> = Ports &
1810
+ InferProviderPorts<Providers>,
1811
+ >(
1812
+ // biome-ignore lint/suspicious/noExplicitAny: Options are generic and need to work with any routes
1813
+ options: CreateServerOptions<Ctx, Ports, any, any, Providers>,
1814
+ finalPorts: FinalPorts,
1815
+ contextRuntime: {
1816
+ createRequestContext: (
1817
+ req: HttpRequestLike,
1818
+ contract: HttpContractConfig,
1819
+ ) => Promise<Ctx>;
1820
+ finalizeContext: (seed: ContextSeed<Ctx> | Ctx) => Ctx;
1821
+ },
1822
+ contract: C,
1823
+ userHandler: Handler<Ctx, C>,
1824
+ hooks: ServerHook<Ctx, FinalPorts>[],
1825
+ routeHooks: readonly RouteHook<unknown, object>[] = [],
1826
+ optionsOverrides?: {
1827
+ skipRoutePreparation?: boolean;
1828
+ },
1829
+ responseValidationExemptStatus?: number,
1830
+ ): (
1831
+ req: HttpRequestLike,
1832
+ preMatchedParams?: Record<string, string>,
1833
+ ) => Promise<HttpResponse> {
1834
+ const execute = createRequestExecutor<Ctx, Ports, C, Providers, FinalPorts>(
1835
+ options,
1836
+ finalPorts,
1837
+ contextRuntime,
1838
+ hooks,
1839
+ routeHooks,
1840
+ optionsOverrides,
1841
+ );
1842
+ const executionTarget: ExecutionTarget<Ctx, C> = {
1843
+ contract,
1844
+ compiled: compilePath(contract.path),
1845
+ handler: userHandler,
1846
+ responseValidationExemptStatus,
1847
+ };
1848
+
1849
+ return (req, preMatchedParams) =>
1850
+ execute(executionTarget, req, preMatchedParams);
1851
+ }
1852
+
1315
1853
  /**
1316
1854
  * Create a Beignet server instance.
1317
1855
  *
@@ -1320,42 +1858,128 @@ function buildHandler<
1320
1858
  * Use adapter packages such as `@beignet/next` to expose `server.api` to a
1321
1859
  * specific runtime.
1322
1860
  *
1323
- * @param options - Ports, providers, routes, hooks, context factory, and error
1324
- * mapping hooks for the server.
1861
+ * @param options - Ports, providers, routes, hooks, context blueprint, and
1862
+ * error mapping hooks for the server.
1325
1863
  * @returns A started server instance with final ports and a catch-all handler.
1326
1864
  */
1327
1865
  export async function createServer<
1328
1866
  Ctx,
1329
1867
  Ports extends AnyPorts,
1868
+ ServiceInput = void,
1330
1869
  // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
1331
- Routes extends readonly RouteDef<Ctx, any>[] = readonly RouteDef<Ctx, any>[],
1332
- Providers extends readonly ServiceProvider<
1333
- unknown,
1334
- // biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
1335
- StandardSchemaV1<any, any>,
1336
- AnyPorts
1337
- >[] = readonly [],
1870
+ Routes extends readonly RouteDef<any, any>[] = readonly RouteDef<any, any>[],
1871
+ Providers extends readonly AnyServiceProvider[] = readonly [],
1338
1872
  >(
1339
- options: CreateServerOptions<Ctx, Ports, Routes, Providers>,
1340
- ): Promise<ServerInstance<Ctx, Ports & ProvidedPortsOfList<Providers>>> {
1873
+ options: CreateServerOptions<Ctx, Ports, ServiceInput, Routes, Providers>,
1874
+ ): Promise<
1875
+ ServerInstance<Ctx, Ports & InferProviderPorts<Providers>, ServiceInput>
1876
+ > {
1341
1877
  type RegisteredRoute = ResolvedRoute<Ctx, HttpContractConfig> & {
1342
1878
  compiled: CompiledPath;
1879
+ /**
1880
+ * Uppercased contract method, cached for dispatch.
1881
+ */
1882
+ method: string;
1343
1883
  };
1344
1884
  const registry: RegisteredRoute[] = [];
1345
- type FinalPorts = Ports & ProvidedPortsOfList<Providers>;
1346
- const providers = (options.providers ?? []) as readonly ServiceProvider<
1347
- unknown,
1348
- // biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
1349
- StandardSchemaV1<any, any>,
1350
- AnyPorts
1351
- >[];
1885
+ // Routes can register after startup via server.route(...), so the registry
1886
+ // is re-sorted lazily before the next dispatch instead of on every
1887
+ // registration.
1888
+ let registryNeedsSort = false;
1889
+ type FinalPorts = Ports & InferProviderPorts<Providers>;
1890
+ const providers = (options.providers ?? []) as readonly AnyServiceProvider[];
1352
1891
  const env = options.providerEnv ?? process.env;
1353
1892
  const overrides = options.providerConfig ?? {};
1354
1893
  const providerResults: ProviderSetupResult<AnyPorts>[] = [];
1355
1894
  const finalPorts = { ...options.ports } as FinalPorts;
1356
- const hooks = [...((options.hooks ?? []) as ServerHook<Ctx, FinalPorts>[])];
1895
+ const instrumentation = createServerInstrumentation<Ctx>(
1896
+ options.instrumentation,
1897
+ );
1898
+ const hooks = [
1899
+ ...(instrumentation.hook
1900
+ ? [instrumentation.hook as ServerHook<Ctx, FinalPorts>]
1901
+ : []),
1902
+ ...((options.hooks ?? []) as ServerHook<Ctx, FinalPorts>[]),
1903
+ ];
1357
1904
  const contracts = options.routes ? contractsFromRoutes(options.routes) : [];
1358
1905
 
1906
+ const resolvedContext = resolveServerContext<Ctx, FinalPorts, ServiceInput>(
1907
+ options.context,
1908
+ );
1909
+ const finalizeContext = createContextFinalizer(
1910
+ resolvedContext,
1911
+ () => finalPorts,
1912
+ );
1913
+ const createRequestContext = async (
1914
+ req: HttpRequestLike,
1915
+ contract?: HttpContractConfig,
1916
+ ): Promise<Ctx> => {
1917
+ const { requestId, trace } = instrumentation.prepareRequest(req);
1918
+ return finalizeContext(
1919
+ await resolvedContext.request({
1920
+ req,
1921
+ ports: finalPorts,
1922
+ contract,
1923
+ requestId,
1924
+ trace,
1925
+ }),
1926
+ );
1927
+ };
1928
+ const createServiceContext = async (
1929
+ ...args: ServiceContextInputArgs<ServiceInput>
1930
+ ): Promise<Ctx> => {
1931
+ const serviceFactory = resolvedContext.service;
1932
+ if (!serviceFactory) {
1933
+ throw new Error(
1934
+ "Define context.service in createServer(...) to create service contexts.",
1935
+ );
1936
+ }
1937
+
1938
+ const { requestId, trace } = instrumentation.createServiceCorrelation();
1939
+ // Enter the ambient context synchronously (before the factory awaits) so
1940
+ // it propagates to the caller's continuation. Identity fields are filled
1941
+ // onto the same object once the context is finalized, so jobs, listeners,
1942
+ // schedules, and tasks observe the service actor/tenant at record time.
1943
+ const ambient: ActiveRequestContext = {
1944
+ requestId,
1945
+ traceId: trace.traceId,
1946
+ spanId: trace.spanId,
1947
+ parentSpanId: trace.parentSpanId,
1948
+ traceparent: trace.traceparent,
1949
+ };
1950
+ enterActiveRequestContext(ambient);
1951
+ const ctx = finalizeContext(
1952
+ await serviceFactory({
1953
+ ports: finalPorts,
1954
+ input: args[0] as ServiceInput,
1955
+ requestId,
1956
+ trace,
1957
+ }),
1958
+ );
1959
+ ambient.actor = readContextActor(ctx);
1960
+ ambient.tenant = readContextTenant(ctx);
1961
+ return ctx;
1962
+ };
1963
+ const contextRuntime = {
1964
+ createRequestContext,
1965
+ finalizeContext,
1966
+ };
1967
+
1968
+ let serviceContextsAvailable = false;
1969
+ const lifecycleCreateServiceContext = async (
1970
+ input?: unknown,
1971
+ ): Promise<unknown> => {
1972
+ if (!serviceContextsAvailable) {
1973
+ throw new Error(
1974
+ "Service contexts are unavailable until providers have started.",
1975
+ );
1976
+ }
1977
+
1978
+ return createServiceContext(
1979
+ ...([input] as ServiceContextInputArgs<ServiceInput>),
1980
+ );
1981
+ };
1982
+
1359
1983
  let stopped = false;
1360
1984
  const stop = async () => {
1361
1985
  if (stopped) return;
@@ -1366,6 +1990,7 @@ export async function createServer<
1366
1990
  try {
1367
1991
  await result?.stop?.({
1368
1992
  ports: finalPorts,
1993
+ createServiceContext: lifecycleCreateServiceContext,
1369
1994
  });
1370
1995
  } catch (err) {
1371
1996
  errors.push(err);
@@ -1378,10 +2003,13 @@ export async function createServer<
1378
2003
 
1379
2004
  const registeredPaths = new Set<string>();
1380
2005
  const registeredShapes = new Map<string, string>();
2006
+ const registeredNames = new Map<string, string>();
1381
2007
 
1382
2008
  const registerRoute = <C extends HttpContractConfig>(
1383
2009
  contract: C,
1384
2010
  handler: Handler<Ctx, C>,
2011
+ routeHooks: readonly RouteHook<unknown, object>[] = [],
2012
+ responseValidationExemptStatus?: number,
1385
2013
  ): void => {
1386
2014
  if (contract.body && !methodSupportsRequestBody(contract.method)) {
1387
2015
  throw new Error(
@@ -1403,19 +2031,46 @@ export async function createServer<
1403
2031
  `Ambiguous route: ${routeKey} conflicts with ${conflictingRoute}. Dynamic parameter names are ignored during routing, so each method + path shape must be unique.`,
1404
2032
  );
1405
2033
  }
2034
+ const conflictingName = registeredNames.get(contract.name);
2035
+ if (conflictingName) {
2036
+ throw new Error(
2037
+ `Duplicate contract name: "${contract.name}" is registered for both ${conflictingName} and ${routeKey}. Contract names must be unique because typed clients, OpenAPI operations, and devtools key on them.`,
2038
+ );
2039
+ }
2040
+ if (contract.pathParams) {
2041
+ const shape = getObjectSchemaShape(contract.pathParams);
2042
+ if (shape) {
2043
+ const { missingKeys, extraKeys } = comparePathParamsToTemplate({
2044
+ pathKeys: compiled.keys,
2045
+ shapeKeys: Object.keys(shape),
2046
+ });
2047
+ if (missingKeys.length > 0 || extraKeys.length > 0) {
2048
+ const details = formatPathParamsMismatch({ missingKeys, extraKeys });
2049
+ throw new Error(
2050
+ `Path parameters for contract "${contract.name}" must match "${contract.path}" (${details}). Path templates and pathParams schemas drive routing, clients, and OpenAPI together.`,
2051
+ );
2052
+ }
2053
+ }
2054
+ }
1406
2055
  registeredPaths.add(routeKey);
1407
2056
  registeredShapes.set(shapeRouteKey, routeKey);
2057
+ registeredNames.set(contract.name, routeKey);
1408
2058
 
1409
2059
  const builtHandler = buildHandler(
1410
2060
  options,
1411
2061
  finalPorts,
2062
+ contextRuntime,
1412
2063
  contract,
1413
2064
  handler,
1414
2065
  hooks,
2066
+ routeHooks,
2067
+ undefined,
2068
+ responseValidationExemptStatus,
1415
2069
  );
1416
2070
  registry.push({
1417
2071
  contract,
1418
2072
  compiled,
2073
+ method: contract.method.toUpperCase(),
1419
2074
  handler: builtHandler,
1420
2075
  match: (method, pathname) => {
1421
2076
  if (contract.method.toUpperCase() !== method.toUpperCase()) {
@@ -1426,7 +2081,7 @@ export async function createServer<
1426
2081
  return { matched: true as const };
1427
2082
  },
1428
2083
  });
1429
- registry.sort((a, b) => compareRouteSpecificity(a.compiled, b.compiled));
2084
+ registryNeedsSort = true;
1430
2085
  };
1431
2086
 
1432
2087
  const createBuilder = <C extends HttpContractConfig>(
@@ -1434,7 +2089,14 @@ export async function createServer<
1434
2089
  shouldRegister: boolean,
1435
2090
  ): RouteBuilder<Ctx, C> => ({
1436
2091
  handle: (fn) => {
1437
- const wrapped = buildHandler(options, finalPorts, contract, fn, hooks);
2092
+ const wrapped = buildHandler(
2093
+ options,
2094
+ finalPorts,
2095
+ contextRuntime,
2096
+ contract,
2097
+ fn,
2098
+ hooks,
2099
+ );
1438
2100
  if (shouldRegister) registerRoute(contract, fn);
1439
2101
  return wrapped;
1440
2102
  },
@@ -1444,7 +2106,36 @@ export async function createServer<
1444
2106
  try {
1445
2107
  for (const route of options.routes) {
1446
2108
  const contract = resolveContract(route.contract);
1447
- registerRoute(contract, route.handle);
2109
+ const hasHandle = typeof route.handle === "function";
2110
+ const hasUseCase = isUseCaseRouteDef(route);
2111
+
2112
+ if (hasHandle && hasUseCase) {
2113
+ throw new Error(
2114
+ `Route for contract "${contract.name}" declares both "handle" and "useCase". Bind the contract to exactly one of them.`,
2115
+ );
2116
+ }
2117
+ if (!hasHandle && !hasUseCase) {
2118
+ throw new Error(
2119
+ `Route for contract "${contract.name}" declares neither "handle" nor "useCase". Bind the contract to a use case or implement a handler.`,
2120
+ );
2121
+ }
2122
+
2123
+ if (isUseCaseRouteDef(route)) {
2124
+ const { handler, responseValidationExemptStatus } =
2125
+ createUseCaseRouteHandler<Ctx, typeof contract>(contract, route);
2126
+ registerRoute(
2127
+ contract,
2128
+ handler,
2129
+ route.hooks as readonly RouteHook<unknown, object>[] | undefined,
2130
+ responseValidationExemptStatus,
2131
+ );
2132
+ } else {
2133
+ registerRoute(
2134
+ contract,
2135
+ route.handle as Handler<Ctx, typeof contract>,
2136
+ route.hooks as readonly RouteHook<unknown, object>[] | undefined,
2137
+ );
2138
+ }
1448
2139
  }
1449
2140
  } catch (error) {
1450
2141
  try {
@@ -1465,6 +2156,7 @@ export async function createServer<
1465
2156
  const result = await provider.setup({
1466
2157
  ports: finalPorts,
1467
2158
  config: cfg,
2159
+ createServiceContext: lifecycleCreateServiceContext,
1468
2160
  });
1469
2161
  if (result.ports) {
1470
2162
  Object.assign(finalPorts, result.ports);
@@ -1476,8 +2168,31 @@ export async function createServer<
1476
2168
  if (!result.start) continue;
1477
2169
  await result.start({
1478
2170
  ports: finalPorts,
2171
+ createServiceContext: lifecycleCreateServiceContext,
1479
2172
  });
1480
2173
  }
2174
+
2175
+ instrumentation.attachPorts(finalPorts);
2176
+
2177
+ const onUnboundPorts = options.onUnboundPorts ?? "error";
2178
+ if (onUnboundPorts !== "ignore") {
2179
+ const unboundKeys = Object.keys(finalPorts).filter((key) =>
2180
+ isUnboundPort((finalPorts as AnyPorts)[key]),
2181
+ );
2182
+ if (unboundKeys.length > 0) {
2183
+ const message =
2184
+ `Unbound ports after provider startup: ${unboundKeys.join(", ")}. ` +
2185
+ "Each port declared as deferred in definePorts(...) must be contributed " +
2186
+ "by a provider (server/providers.ts) or bound in infra/app-ports.ts. " +
2187
+ 'Pass onUnboundPorts: "warn" or "ignore" to change this behavior.';
2188
+ if (onUnboundPorts === "error") {
2189
+ throw new Error(message);
2190
+ }
2191
+ console.warn(`[beignet] ${message}`);
2192
+ }
2193
+ }
2194
+
2195
+ serviceContextsAvailable = true;
1481
2196
  } catch (error) {
1482
2197
  try {
1483
2198
  await stop();
@@ -1490,38 +2205,88 @@ export async function createServer<
1490
2205
  throw error;
1491
2206
  }
1492
2207
 
2208
+ // The fallback 404/405 pipeline is built once at server creation. Only the
2209
+ // contract surface that hooks observe (method, path, and the Allow set for
2210
+ // 405s) is assembled per unmatched request.
2211
+ const executeFallback = createRequestExecutor<
2212
+ Ctx,
2213
+ Ports,
2214
+ HttpContractConfig,
2215
+ Providers
2216
+ >(options, finalPorts, contextRuntime, hooks, [], {
2217
+ skipRoutePreparation: true,
2218
+ });
2219
+
2220
+ const fallbackContract = (
2221
+ name: string,
2222
+ method: string,
2223
+ path: string,
2224
+ ): HttpContractConfig => ({
2225
+ kind: "http",
2226
+ name,
2227
+ method: method as HttpContractConfig["method"],
2228
+ path,
2229
+ pathParams: null,
2230
+ query: null,
2231
+ body: null,
2232
+ responses: {},
2233
+ metadata: {},
2234
+ });
2235
+
2236
+ const notFoundHandler: Handler<Ctx, HttpContractConfig> = async () =>
2237
+ errorResponse(404, "NOT_FOUND", "Not found");
2238
+
1493
2239
  const api = async (req: HttpRequestLike) => {
2240
+ if (registryNeedsSort) {
2241
+ registry.sort((a, b) => compareRouteSpecificity(a.compiled, b.compiled));
2242
+ registryNeedsSort = false;
2243
+ }
2244
+
1494
2245
  const url = new URL(req.url);
2246
+ const method = req.method.toUpperCase();
1495
2247
 
2248
+ let pathMatchedMethods: Set<string> | undefined;
1496
2249
  for (const entry of registry) {
1497
- const result = entry.match(req.method, url.pathname);
1498
- if (result.matched) {
2250
+ if (!entry.compiled.pattern.test(url.pathname)) continue;
2251
+ if (entry.method === method) {
1499
2252
  return await entry.handler(req);
1500
2253
  }
2254
+ if (!pathMatchedMethods) {
2255
+ pathMatchedMethods = new Set();
2256
+ }
2257
+ pathMatchedMethods.add(entry.method);
1501
2258
  }
1502
2259
 
1503
- const notFoundContract: HttpContractConfig = {
1504
- kind: "http",
1505
- name: "notFound",
1506
- method: req.method.toUpperCase() as HttpContractConfig["method"],
1507
- path: url.pathname || "/",
1508
- pathParams: null,
1509
- query: null,
1510
- body: null,
1511
- responses: {},
1512
- metadata: {},
1513
- };
2260
+ const pathname = url.pathname || "/";
1514
2261
 
1515
- const notFoundHandler = buildHandler(
1516
- options,
1517
- finalPorts,
1518
- notFoundContract,
1519
- async () => errorResponse(404, "NOT_FOUND", "Not found"),
1520
- hooks,
1521
- { skipRoutePreparation: true },
1522
- );
2262
+ if (pathMatchedMethods) {
2263
+ const allow = [...pathMatchedMethods].sort().join(", ");
2264
+
2265
+ return await executeFallback(
2266
+ {
2267
+ contract: fallbackContract("methodNotAllowed", method, pathname),
2268
+ handler: async () => ({
2269
+ status: 405,
2270
+ headers: { allow },
2271
+ body: createErrorResponseBody({
2272
+ code: "METHOD_NOT_ALLOWED",
2273
+ message: `Method ${method} is not allowed for ${pathname}`,
2274
+ }),
2275
+ }),
2276
+ },
2277
+ req,
2278
+ {},
2279
+ );
2280
+ }
1523
2281
 
1524
- return await notFoundHandler(req);
2282
+ return await executeFallback(
2283
+ {
2284
+ contract: fallbackContract("notFound", method, pathname),
2285
+ handler: notFoundHandler,
2286
+ },
2287
+ req,
2288
+ {},
2289
+ );
1525
2290
  };
1526
2291
 
1527
2292
  return {
@@ -1530,6 +2295,8 @@ export async function createServer<
1530
2295
  const contract = resolveContract(contractLike);
1531
2296
  return createBuilder(contract, true);
1532
2297
  },
2298
+ createRequestContext: (req) => createRequestContext(req),
2299
+ createServiceContext,
1533
2300
  contracts,
1534
2301
  stop,
1535
2302
  ports: finalPorts,
@@ -1545,10 +2312,19 @@ export async function createServer<
1545
2312
  * @example
1546
2313
  * ```ts
1547
2314
  * const routes = defineRoutes<AppContext>([
1548
- * { contract: listPosts, handle: async ({ ctx }) => ctx.posts.list() },
2315
+ * { contract: listPosts, useCase: listPostsUseCase },
1549
2316
  * ]);
1550
2317
  * ```
1551
2318
  */
2319
+ export function defineRoutes<
2320
+ Ctx,
2321
+ const R extends
2322
+ readonly ContextualRouteInput<Ctx>[] = readonly ContextualRouteInput<Ctx>[],
2323
+ >(routes: R & ValidatedRouteInputs<Ctx, R>): FlattenRouteInputs<R>;
2324
+ export function defineRoutes<
2325
+ Ctx,
2326
+ const R extends readonly RouteInput<Ctx>[] = readonly RouteInput<Ctx>[],
2327
+ >(routes: R & ValidatedRouteInputs<Ctx, R>): FlattenRouteInputs<R>;
1552
2328
  export function defineRoutes<
1553
2329
  Ctx,
1554
2330
  const R extends readonly RouteInput<Ctx>[] = readonly RouteInput<Ctx>[],
@@ -1557,7 +2333,12 @@ export function defineRoutes<
1557
2333
 
1558
2334
  for (const route of routes) {
1559
2335
  if (isRouteGroup(route)) {
1560
- flattened.push(...route.routes);
2336
+ for (const groupRoute of route.routes) {
2337
+ flattened.push({
2338
+ ...groupRoute,
2339
+ hooks: [...(route.hooks ?? []), ...(groupRoute.hooks ?? [])],
2340
+ });
2341
+ }
1561
2342
  } else {
1562
2343
  flattened.push(route);
1563
2344
  }
@@ -1585,26 +2366,61 @@ export function contractsFromRoutes<
1585
2366
  * Define a named group of related route registrations.
1586
2367
  *
1587
2368
  * Route groups are flattened by defineRoutes, so createServer still receives
1588
- * a regular route list while app code can keep feature route wiring colocated.
2369
+ * a regular route list while app code can keep feature route wiring and scoped
2370
+ * hooks colocated.
1589
2371
  *
1590
2372
  * @example
1591
2373
  * ```ts
1592
- * const todoRoutes = defineRouteGroup<AppContext>({
2374
+ * const todoRoutes = defineRouteGroup<AppContext>()({
1593
2375
  * name: "todos",
2376
+ * hooks: [auth.optional()],
1594
2377
  * routes: [
1595
- * { contract: listTodos, handle: async ({ ctx }) => ctx.todos.list() },
2378
+ * { contract: listTodos, useCase: listTodosUseCase },
1596
2379
  * ]
1597
2380
  * });
1598
2381
  * ```
1599
2382
  */
2383
+ export function defineRouteGroup<Ctx>(): RouteGroupBuilder<Ctx>;
1600
2384
  export function defineRouteGroup<
1601
2385
  Ctx,
1602
2386
  // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
1603
2387
  const R extends readonly RouteDef<Ctx, any>[] = readonly RouteDef<Ctx, any>[],
1604
- >(group: { name: string; routes: R }): RouteGroup<Ctx, R> {
2388
+ >(group: {
2389
+ name: string;
2390
+ hooks?: readonly RouteHook<Ctx, object>[];
2391
+ routes: R & ValidatedRouteInputs<Ctx, R>;
2392
+ }): RouteGroup<Ctx, R>;
2393
+ export function defineRouteGroup<
2394
+ Ctx,
2395
+ // biome-ignore lint/suspicious/noExplicitAny: route contract types are erased at this level
2396
+ const R extends readonly RouteDef<any, any>[] = readonly RouteDef<any, any>[],
2397
+ >(group?: {
2398
+ name: string;
2399
+ hooks?: readonly RouteHook<Ctx, object>[];
2400
+ routes: R;
2401
+ }): RouteGroup<Ctx, R> | RouteGroupBuilder<Ctx> {
2402
+ const createGroup = <
2403
+ const GroupHooks extends readonly RouteHook<Ctx, object>[] = readonly [],
2404
+ const GroupRoutes extends readonly AnyRouteDef[] = readonly AnyRouteDef[],
2405
+ >(input: {
2406
+ name: string;
2407
+ hooks?: GroupHooks;
2408
+ routes: GroupRoutes;
2409
+ }): RouteGroup<Ctx, GroupRoutes> => ({
2410
+ kind: ROUTE_GROUP_KIND,
2411
+ name: input.name,
2412
+ hooks: input.hooks,
2413
+ routes: input.routes,
2414
+ });
2415
+
2416
+ if (!group) {
2417
+ return createGroup;
2418
+ }
2419
+
1605
2420
  return {
1606
2421
  kind: ROUTE_GROUP_KIND,
1607
2422
  name: group.name,
2423
+ hooks: group.hooks,
1608
2424
  routes: group.routes,
1609
2425
  };
1610
2426
  }