@nifrajs/core 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +21 -0
  2. package/dist/durable-adapters.d.ts.map +1 -1
  3. package/dist/durable-adapters.js +26 -3
  4. package/dist/durable-adapters.js.map +1 -1
  5. package/dist/durable-approval.d.ts +56 -0
  6. package/dist/durable-approval.d.ts.map +1 -0
  7. package/dist/durable-approval.js +233 -0
  8. package/dist/durable-approval.js.map +1 -0
  9. package/dist/durable-effect.d.ts +42 -0
  10. package/dist/durable-effect.d.ts.map +1 -0
  11. package/dist/durable-effect.js +234 -0
  12. package/dist/durable-effect.js.map +1 -0
  13. package/dist/durable-execution.d.ts +8 -346
  14. package/dist/durable-execution.d.ts.map +1 -1
  15. package/dist/durable-execution.js +7 -1150
  16. package/dist/durable-execution.js.map +1 -1
  17. package/dist/durable-saga.d.ts +94 -0
  18. package/dist/durable-saga.d.ts.map +1 -0
  19. package/dist/durable-saga.js +550 -0
  20. package/dist/durable-saga.js.map +1 -0
  21. package/dist/edge-kit.d.ts +2 -2
  22. package/dist/edge-kit.d.ts.map +1 -1
  23. package/dist/edge-kit.js +2 -2
  24. package/dist/edge-kit.js.map +1 -1
  25. package/dist/index.d.ts +23 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +18 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  30. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  31. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  32. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  33. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  34. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  35. package/dist/internal/durable-shared.d.ts +26 -0
  36. package/dist/internal/durable-shared.d.ts.map +1 -0
  37. package/dist/internal/durable-shared.js +131 -0
  38. package/dist/internal/durable-shared.js.map +1 -0
  39. package/dist/internal/durable-types.d.ts +159 -0
  40. package/dist/internal/durable-types.d.ts.map +1 -0
  41. package/dist/internal/durable-types.js +2 -0
  42. package/dist/internal/durable-types.js.map +1 -0
  43. package/dist/internal/route-compiler.d.ts +61 -0
  44. package/dist/internal/route-compiler.d.ts.map +1 -0
  45. package/dist/internal/route-compiler.js +121 -0
  46. package/dist/internal/route-compiler.js.map +1 -0
  47. package/dist/internal/route-execution.d.ts +6 -4
  48. package/dist/internal/route-execution.d.ts.map +1 -1
  49. package/dist/internal/route-execution.js +2 -17
  50. package/dist/internal/route-execution.js.map +1 -1
  51. package/dist/internal/route-lanes.d.ts +35 -0
  52. package/dist/internal/route-lanes.d.ts.map +1 -0
  53. package/dist/internal/route-lanes.js +150 -0
  54. package/dist/internal/route-lanes.js.map +1 -0
  55. package/dist/internal/route-program.d.ts +67 -0
  56. package/dist/internal/route-program.d.ts.map +1 -0
  57. package/dist/internal/route-program.js +199 -0
  58. package/dist/internal/route-program.js.map +1 -0
  59. package/dist/server/bare-error-lane.d.ts +1 -1
  60. package/dist/server/bare-error-lane.d.ts.map +1 -1
  61. package/dist/server/bare-error-lane.js +3 -3
  62. package/dist/server/bare-error-lane.js.map +1 -1
  63. package/dist/server/body-lane.d.ts +13 -0
  64. package/dist/server/body-lane.d.ts.map +1 -0
  65. package/dist/server/body-lane.js +134 -0
  66. package/dist/server/body-lane.js.map +1 -0
  67. package/dist/server/contract.d.ts +40 -14
  68. package/dist/server/contract.d.ts.map +1 -1
  69. package/dist/server/contract.js +7 -0
  70. package/dist/server/contract.js.map +1 -1
  71. package/dist/server/cookies.d.ts.map +1 -1
  72. package/dist/server/cookies.js +16 -2
  73. package/dist/server/cookies.js.map +1 -1
  74. package/dist/server/plugin.d.ts +2 -2
  75. package/dist/server/plugin.d.ts.map +1 -1
  76. package/dist/server/plugin.js +6 -1
  77. package/dist/server/plugin.js.map +1 -1
  78. package/dist/server/registry.d.ts +40 -12
  79. package/dist/server/registry.d.ts.map +1 -1
  80. package/dist/server/request-context.d.ts +4 -28
  81. package/dist/server/request-context.d.ts.map +1 -1
  82. package/dist/server/request-context.js +10 -185
  83. package/dist/server/request-context.js.map +1 -1
  84. package/dist/server/respond.d.ts.map +1 -1
  85. package/dist/server/respond.js +39 -2
  86. package/dist/server/respond.js.map +1 -1
  87. package/dist/server/runtime-core.d.ts +18 -2
  88. package/dist/server/runtime-core.d.ts.map +1 -1
  89. package/dist/server/runtime-core.js +0 -23
  90. package/dist/server/runtime-core.js.map +1 -1
  91. package/dist/server/server-types.d.ts +3 -0
  92. package/dist/server/server-types.d.ts.map +1 -1
  93. package/dist/server/server.d.ts +113 -84
  94. package/dist/server/server.d.ts.map +1 -1
  95. package/dist/server/server.js +506 -639
  96. package/dist/server/server.js.map +1 -1
  97. package/dist/server/validation.d.ts +5 -0
  98. package/dist/server/validation.d.ts.map +1 -0
  99. package/dist/server/validation.js +13 -0
  100. package/dist/server/validation.js.map +1 -0
  101. package/dist/server/ws-bun.d.ts +11 -3
  102. package/dist/server/ws-bun.d.ts.map +1 -1
  103. package/dist/server/ws-bun.js +42 -12
  104. package/dist/server/ws-bun.js.map +1 -1
  105. package/dist/server/ws-hook.d.ts +3 -2
  106. package/dist/server/ws-hook.d.ts.map +1 -1
  107. package/dist/server.d.ts +2 -2
  108. package/dist/server.d.ts.map +1 -1
  109. package/dist/server.js +1 -1
  110. package/dist/server.js.map +1 -1
  111. package/dist/single-copy.d.ts.map +1 -1
  112. package/dist/single-copy.js +3 -1
  113. package/dist/single-copy.js.map +1 -1
  114. package/package.json +6 -3
  115. package/src/durable-adapters.ts +20 -0
  116. package/src/durable-approval.ts +281 -0
  117. package/src/durable-effect.ts +300 -0
  118. package/src/durable-execution.ts +8 -1627
  119. package/src/durable-saga.ts +744 -0
  120. package/src/edge-kit.ts +2 -2
  121. package/src/index.ts +129 -2
  122. package/src/internal/durable-approval-protocol.ts +1 -1
  123. package/src/internal/durable-effect-protocol.ts +1 -1
  124. package/src/internal/durable-saga-protocol.ts +1 -1
  125. package/src/internal/durable-shared.ts +157 -0
  126. package/src/internal/durable-types.ts +194 -0
  127. package/src/internal/route-compiler.ts +234 -0
  128. package/src/internal/route-execution.ts +34 -84
  129. package/src/internal/route-lanes.ts +221 -0
  130. package/src/internal/route-program.ts +404 -0
  131. package/src/server/bare-error-lane.ts +3 -2
  132. package/src/server/body-lane.ts +203 -0
  133. package/src/server/contract.ts +70 -20
  134. package/src/server/cookies.ts +16 -2
  135. package/src/server/plugin.ts +11 -5
  136. package/src/server/registry.ts +71 -16
  137. package/src/server/request-context.ts +7 -262
  138. package/src/server/respond.ts +45 -2
  139. package/src/server/runtime-core.ts +28 -2
  140. package/src/server/server-types.ts +3 -0
  141. package/src/server/server.ts +1152 -1260
  142. package/src/server/validation.ts +19 -0
  143. package/src/server/ws-bun.ts +44 -11
  144. package/src/server/ws-hook.ts +3 -2
  145. package/src/server.ts +7 -2
  146. package/src/single-copy.ts +3 -1
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Registration-time route compilation.
3
+ *
4
+ * This module owns the policy decisions that turn a route declaration into the immutable facts the
5
+ * execution-plan builder and the route catalog consume. It deliberately runs only from `Server.register`
6
+ * and `Server.registerBatch`; no function here is reachable from the request path.
7
+ *
8
+ * The public Server remains the fluent registration surface. This module is the deep implementation seam
9
+ * behind that surface: body limits, capability and assurance evidence, optional runtimes, decorations, and
10
+ * lane selection are resolved once, together, so adding a new route policy does not require editing the
11
+ * kernel's dispatch code.
12
+ */
13
+ import type { EffectLifecycleObserver } from "../effect-lifecycle.ts"
14
+ import { RouteConfigError } from "../errors.ts"
15
+ import { type CompiledRoutePattern, compileRoutePattern } from "../router/pattern.ts"
16
+ import type { Method } from "../router/router.ts"
17
+ import type { StandardSchemaV1 } from "../schema/standard.ts"
18
+ import { assertByteLimit } from "../server/body.ts"
19
+ import type { RouteSchema } from "../server/context.ts"
20
+ import type { IdempotencyRuntime, ResolvedIdempotency } from "../server/idempotency-lane.ts"
21
+ import type { EffectLedgerRuntime, ResolvedEffectLedger } from "../server/ledger-lane.ts"
22
+ import type { ResponseContractRuntime } from "../server/response-contract-lane.ts"
23
+ import {
24
+ CAPABILITY_GUARD,
25
+ type CapabilityUseEvent,
26
+ createCapabilityGuard,
27
+ normalizeRouteCapabilities,
28
+ type RegisteredCapabilityInterceptor,
29
+ } from "./capability-runtime.ts"
30
+ import {
31
+ type AssuranceDeclaration,
32
+ assuranceDeclarationsOf,
33
+ assuranceEvidenceFor,
34
+ NIFRA_ASSURANCE_IDS,
35
+ validEvidenceId,
36
+ } from "./route-assurance.ts"
37
+ import type {
38
+ RawAfterHandle,
39
+ RawAround,
40
+ RawBeforeHandle,
41
+ RawDerive,
42
+ RawErrorHandler,
43
+ } from "./route-execution.ts"
44
+ import { type RouteLaneSelection, selectRouteLanes } from "./route-lanes.ts"
45
+
46
+ export interface RouteCompilerContext {
47
+ readonly maxBodyBytes: number
48
+ readonly activeAssurance: readonly AssuranceDeclaration[]
49
+ readonly globalAssurance: readonly AssuranceDeclaration[]
50
+ readonly decorations: Record<string, unknown>
51
+ readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
52
+ readonly capabilityInterceptors: readonly RegisteredCapabilityInterceptor[]
53
+ readonly capabilityObservers: readonly EffectLifecycleObserver[]
54
+ readonly effectLedgerRuntime: EffectLedgerRuntime | undefined
55
+ readonly idempotencyRuntime: IdempotencyRuntime | undefined
56
+ readonly responseContractRuntime: ResponseContractRuntime | undefined
57
+ readonly derives: readonly RawDerive[]
58
+ readonly beforeHandleHooks: readonly RawBeforeHandle[]
59
+ readonly afterHandleHooks: readonly RawAfterHandle[]
60
+ readonly onErrorHooks: readonly RawErrorHandler[]
61
+ readonly aroundHooks: readonly RawAround[]
62
+ readonly defaultOnValidationError: RouteSchema["onValidationError"] | undefined
63
+ }
64
+
65
+ export interface CompiledRouteOptions {
66
+ readonly pattern: CompiledRoutePattern
67
+ readonly bodyLimit: number | undefined
68
+ readonly capabilities: readonly string[]
69
+ readonly handlerAssurance: readonly AssuranceDeclaration[]
70
+ readonly routeDecorations: Record<PropertyKey, unknown>
71
+ readonly hasDecorations: boolean
72
+ readonly idempotent: ResolvedIdempotency | undefined
73
+ readonly ledgered: ResolvedEffectLedger | undefined
74
+ readonly responseContract:
75
+ | { readonly runtime: ResponseContractRuntime; readonly schema: StandardSchemaV1 }
76
+ | undefined
77
+ readonly lanes: RouteLaneSelection
78
+ readonly routeAssurance: readonly AssuranceDeclaration[]
79
+ }
80
+
81
+ /** Resolve all registration-time policy and lane facts for one route. */
82
+ export function compileRouteOptions(
83
+ context: RouteCompilerContext,
84
+ method: Method,
85
+ path: string,
86
+ schema: RouteSchema | undefined,
87
+ handler: (context: never) => unknown,
88
+ ): CompiledRouteOptions {
89
+ const pattern = compileRoutePattern(path)
90
+ let bodyLimit: number | undefined = context.maxBodyBytes
91
+ const invalidBodyLimit = (message: string): never => {
92
+ throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`)
93
+ }
94
+ if (schema?.bodyLimitReason !== undefined && schema.bodyLimit !== "unlimited") {
95
+ invalidBodyLimit('bodyLimitReason is only valid with bodyLimit: "unlimited"')
96
+ }
97
+ if (schema?.bodyLimit === "unlimited") {
98
+ if (typeof schema.bodyLimitReason !== "string" || schema.bodyLimitReason.trim().length === 0) {
99
+ invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason')
100
+ }
101
+ if (schema.body !== undefined) {
102
+ invalidBodyLimit('bodyLimit: "unlimited" cannot be used with a body schema; use a finite cap')
103
+ }
104
+ if (schema.idempotency !== undefined) {
105
+ invalidBodyLimit('bodyLimit: "unlimited" cannot be used with idempotency')
106
+ }
107
+ bodyLimit = undefined
108
+ } else if (schema?.bodyLimit !== undefined) {
109
+ try {
110
+ assertByteLimit(schema.bodyLimit, "route bodyLimit")
111
+ } catch {
112
+ invalidBodyLimit('bodyLimit must be a non-negative safe integer or "unlimited"')
113
+ }
114
+ bodyLimit = schema.bodyLimit
115
+ }
116
+
117
+ const capabilities = normalizeRouteCapabilities(schema?.capabilities)
118
+ const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
119
+ const invalidHandlerScope = handlerAssurance.find((declaration) => declaration.scope !== "plugin")
120
+ if (invalidHandlerScope !== undefined) {
121
+ throw new RouteConfigError(
122
+ "INVALID_ASSURANCE",
123
+ `route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`,
124
+ )
125
+ }
126
+
127
+ const authenticated = assuranceEvidenceFor(
128
+ [...context.activeAssurance, ...handlerAssurance, ...context.globalAssurance],
129
+ method,
130
+ path,
131
+ ).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
132
+ const routeDecorations: Record<PropertyKey, unknown> = { ...context.decorations }
133
+ if (capabilities.length > 0) {
134
+ routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
135
+ capabilities,
136
+ method,
137
+ path,
138
+ context.onCapabilityUse,
139
+ context.idempotencyRuntime?.trackEffect,
140
+ Object.freeze([...context.capabilityInterceptors]),
141
+ Object.freeze([...context.capabilityObservers]),
142
+ )
143
+ }
144
+ const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
145
+
146
+ if (schema?.idempotency !== undefined && context.idempotencyRuntime === undefined) {
147
+ throw new RouteConfigError(
148
+ "INVALID_IDEMPOTENCY",
149
+ "route declares idempotency but the idempotency plugin is not installed; add .use(idempotency())",
150
+ )
151
+ }
152
+ const idempotent = context.idempotencyRuntime?.resolve(
153
+ schema,
154
+ authenticated,
155
+ context.maxBodyBytes,
156
+ )
157
+ const ledgered = context.effectLedgerRuntime?.resolve(capabilities, method, path)
158
+ const contracted =
159
+ context.responseContractRuntime !== undefined && schema?.response !== undefined
160
+ ? { runtime: context.responseContractRuntime, schema: schema.response }
161
+ : undefined
162
+ const lanes = selectRouteLanes({
163
+ schema,
164
+ hasIdempotency: idempotent !== undefined,
165
+ hasLedger: ledgered !== undefined,
166
+ hasResponseContract: contracted !== undefined,
167
+ hasDecorations,
168
+ derives: context.derives.length,
169
+ beforeHandle: context.beforeHandleHooks.length,
170
+ afterHandle: context.afterHandleHooks.length,
171
+ onError: context.onErrorHooks.length,
172
+ around: context.aroundHooks.length,
173
+ defaultOnValidationError: context.defaultOnValidationError !== undefined,
174
+ })
175
+
176
+ const routeAssurance: AssuranceDeclaration[] = [...context.activeAssurance, ...handlerAssurance]
177
+ if (contracted?.runtime.mode === "enforce" && schema?.response !== undefined) {
178
+ routeAssurance.push(
179
+ Object.freeze({
180
+ id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
181
+ source: "response-contract",
182
+ scope: "plugin",
183
+ }),
184
+ )
185
+ }
186
+ for (const id of schema?.assurance ?? []) {
187
+ if (!validEvidenceId(id)) {
188
+ throw new Error(
189
+ `route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`,
190
+ )
191
+ }
192
+ const declared = { id, source: "declared", scope: "plugin" } as AssuranceDeclaration
193
+ Object.defineProperty(declared, "provenance", {
194
+ value: "declared",
195
+ enumerable: false,
196
+ configurable: false,
197
+ writable: false,
198
+ })
199
+ routeAssurance.push(Object.freeze(declared))
200
+ }
201
+ const unlimitedBody = schema?.bodyLimit === "unlimited"
202
+ if (!unlimitedBody && (schema?.body !== undefined || typeof schema?.bodyLimit === "number")) {
203
+ routeAssurance.push(
204
+ Object.freeze({
205
+ id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
206
+ source: "route-schema",
207
+ scope: "plugin",
208
+ }),
209
+ )
210
+ }
211
+ if (schema?.idempotency !== undefined) {
212
+ routeAssurance.push(
213
+ Object.freeze({
214
+ id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
215
+ source: "route-schema",
216
+ scope: "plugin",
217
+ }),
218
+ )
219
+ }
220
+
221
+ return {
222
+ pattern,
223
+ bodyLimit,
224
+ capabilities,
225
+ handlerAssurance,
226
+ routeDecorations,
227
+ hasDecorations,
228
+ idempotent,
229
+ ledgered,
230
+ responseContract: contracted,
231
+ lanes,
232
+ routeAssurance: Object.freeze(routeAssurance),
233
+ }
234
+ }
@@ -29,6 +29,7 @@ import { RequestContext } from "../server/request-context.ts"
29
29
  import type { ResponseContractRuntime } from "../server/response-contract-lane.ts"
30
30
  import type { HandlerResult, ResponseResult } from "../server/runtime-core.ts"
31
31
  import type { CtxSet, MaybePromise, RawContext, RequestSource, Server } from "../server/server.ts"
32
+ import type { RouteProgram } from "./route-program.ts"
32
33
 
33
34
  export type InternalHandler = (ctx: RawContext) => MaybePromise<HandlerResult>
34
35
 
@@ -98,33 +99,7 @@ interface RouteExecutionRuntime {
98
99
  finalize: (result: unknown, set: CtxSet) => T,
99
100
  wrapResponse: (response: Response | ResponseResult) => T,
100
101
  ): MaybePromise<T>
101
- runLifecycleHooks<T>(
102
- entry: RouteEntry,
103
- ctx: RawContext,
104
- finalize: (result: unknown, set: CtxSet) => T,
105
- wrapResponse: (response: Response | ResponseResult) => T,
106
- ): MaybePromise<T>
107
- runLifecycleQuery<T>(
108
- entry: RouteEntry,
109
- ctx: RawContext,
110
- finalize: (result: unknown, set: CtxSet) => T,
111
- wrapResponse: (response: Response | ResponseResult) => T,
112
- ): MaybePromise<T>
113
- runLifecycleBody<T>(
114
- entry: RouteEntry,
115
- source: RequestSource,
116
- ctx: RawContext,
117
- finalize: (result: unknown, set: CtxSet) => T,
118
- wrapResponse: (response: Response | ResponseResult) => T,
119
- ): MaybePromise<T>
120
- runLifecycleBodyQuery<T>(
121
- entry: RouteEntry,
122
- source: RequestSource,
123
- ctx: RawContext,
124
- finalize: (result: unknown, set: CtxSet) => T,
125
- wrapResponse: (response: Response | ResponseResult) => T,
126
- ): MaybePromise<T>
127
- runLifecycle<T>(
102
+ runProgram<T>(
128
103
  entry: RouteEntry,
129
104
  source: RequestSource,
130
105
  ctx: RawContext,
@@ -158,13 +133,19 @@ export function compileRouteExecutionPlan(options: {
158
133
  readonly contextless: boolean
159
134
  readonly hasAround: boolean
160
135
  readonly hasLedger: boolean
161
- readonly lifecycleLane: LifecycleExecutionLane
162
136
  readonly fusedWeb: FusedWebRunner | undefined
163
137
  readonly fusedBody: FusedBodyRunner | undefined
164
- readonly fusedLane: "bare" | "body" | "query" | undefined
138
+ readonly fusedLane:
139
+ | "bare"
140
+ | "body"
141
+ | "query"
142
+ | "derive-before"
143
+ | "derive-before-after"
144
+ | "body-derive-before"
145
+ | "body-derive-before-after"
146
+ | undefined
165
147
  }): RouteExecutionPlan {
166
- const { lane, contextless, hasAround, hasLedger, lifecycleLane, fusedWeb, fusedBody, fusedLane } =
167
- options
148
+ const { lane, contextless, hasAround, hasLedger, fusedWeb, fusedBody, fusedLane } = options
168
149
 
169
150
  if (contextless) {
170
151
  const run: RouteExecutionRunner = (
@@ -191,7 +172,7 @@ export function compileRouteExecutionPlan(options: {
191
172
  finalize,
192
173
  wrapResponse,
193
174
  )
194
- return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
175
+ return Object.freeze({ run, fusedWeb, fusedBody, fusedLane }) as RouteExecutionPlan
195
176
  }
196
177
 
197
178
  let inner: ContextRouteRunner
@@ -220,55 +201,14 @@ export function compileRouteExecutionPlan(options: {
220
201
  )
221
202
  break
222
203
  default:
223
- switch (lifecycleLane) {
224
- case "hooks":
225
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
226
- (runtime as unknown as RouteExecutionRuntime).runLifecycleHooks(
227
- entry,
228
- ctx,
229
- finalize,
230
- wrapResponse,
231
- )
232
- break
233
- case "query":
234
- inner = (runtime, entry, _source, ctx, finalize, wrapResponse) =>
235
- (runtime as unknown as RouteExecutionRuntime).runLifecycleQuery(
236
- entry,
237
- ctx,
238
- finalize,
239
- wrapResponse,
240
- )
241
- break
242
- case "body":
243
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
244
- (runtime as unknown as RouteExecutionRuntime).runLifecycleBody(
245
- entry,
246
- source,
247
- ctx,
248
- finalize,
249
- wrapResponse,
250
- )
251
- break
252
- case "body-query":
253
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
254
- (runtime as unknown as RouteExecutionRuntime).runLifecycleBodyQuery(
255
- entry,
256
- source,
257
- ctx,
258
- finalize,
259
- wrapResponse,
260
- )
261
- break
262
- default:
263
- inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
264
- (runtime as unknown as RouteExecutionRuntime).runLifecycle(
265
- entry,
266
- source,
267
- ctx,
268
- finalize,
269
- wrapResponse,
270
- )
271
- }
204
+ inner = (runtime, entry, source, ctx, finalize, wrapResponse) =>
205
+ (runtime as unknown as RouteExecutionRuntime).runProgram(
206
+ entry,
207
+ source,
208
+ ctx,
209
+ finalize,
210
+ wrapResponse,
211
+ )
272
212
  }
273
213
 
274
214
  const execute: ContextRouteRunner = hasAround
@@ -329,7 +269,7 @@ export function compileRouteExecutionPlan(options: {
329
269
  }
330
270
  return outcome
331
271
  }
332
- return Object.freeze({ run, fusedWeb, fusedBody, fusedLane })
272
+ return Object.freeze({ run, fusedWeb, fusedBody, fusedLane }) as RouteExecutionPlan
333
273
  }
334
274
 
335
275
  /** Registration-compiled route behavior. Every adapter invokes the same runner; the optional fused
@@ -341,7 +281,15 @@ export interface RouteExecutionPlan {
341
281
  /** Which builder produced {@link fusedWeb} - a merge rebinds the closure to the executing server
342
282
  * and must rebuild it with the SAME semantics (a query-fused route rebuilt as bare would skip its
343
283
  * validation). `undefined` iff `fusedWeb` is. */
344
- readonly fusedLane: "bare" | "body" | "query" | undefined
284
+ readonly fusedLane:
285
+ | "bare"
286
+ | "body"
287
+ | "query"
288
+ | "derive-before"
289
+ | "derive-before-after"
290
+ | "body-derive-before"
291
+ | "body-derive-before-after"
292
+ | undefined
345
293
  }
346
294
 
347
295
  export interface RouteEntry {
@@ -377,11 +325,13 @@ export interface RouteEntry {
377
325
  readonly afterHandle: ReadonlyArray<RawAfterHandle>
378
326
  readonly onError: ReadonlyArray<RawErrorHandler>
379
327
  /** Registration-specialized hook shape for the common derive + before middleware route. */
380
- readonly lifecycleHookLane: "derive-before" | undefined
328
+ readonly lifecycleHookLane: "derive-before" | "derive-before-after" | undefined
381
329
  /** Wraps the matched route lifecycle. Empty for the common no-around path. */
382
330
  readonly around: ReadonlyArray<RawAround>
383
331
  /** The single immutable execution decision consumed by portable, Node-direct, and Bun-native paths. */
384
332
  readonly execution: RouteExecutionPlan
333
+ /** One immutable general lifecycle program compiled at registration. */
334
+ readonly program: RouteProgram
385
335
  }
386
336
 
387
337
  /** The fused Web lane: same inputs `routeAndRun` would hand the generic path, a `Response` out. */
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Registration-time route-lane selection.
3
+ *
4
+ * A route's lane is a property of its registered shape, not of an individual request. Keeping this
5
+ * decision in a small internal module gives the server kernel one vocabulary for bare/body/query/
6
+ * lifecycle execution while preserving the important invariant: this module is never called from the
7
+ * request path. The returned booleans are consumed by the existing compiled execution plan and fused
8
+ * builders; no adapter or extra dispatch hop is introduced at runtime.
9
+ */
10
+ import type { RouteSchema } from "../server/context.ts"
11
+ import type { LifecycleExecutionLane, RouteExecutionLane } from "./route-execution.ts"
12
+
13
+ export interface RouteLaneSelection {
14
+ readonly bare: boolean
15
+ readonly fusedQuery: boolean
16
+ readonly bodyOnly: boolean
17
+ readonly fusedBody: boolean
18
+ readonly lane: RouteExecutionLane
19
+ readonly lifecycleLane: LifecycleExecutionLane
20
+ readonly lifecycleHookLane: "derive-before" | "derive-before-after" | undefined
21
+ readonly fusedLane:
22
+ | "bare"
23
+ | "body"
24
+ | "query"
25
+ | "derive-before"
26
+ | "derive-before-after"
27
+ | "body-derive-before"
28
+ | "body-derive-before-after"
29
+ | undefined
30
+ }
31
+
32
+ export function selectRouteLanes(options: {
33
+ readonly schema: RouteSchema | undefined
34
+ readonly hasIdempotency: boolean
35
+ readonly hasLedger: boolean
36
+ readonly hasResponseContract: boolean
37
+ readonly hasDecorations: boolean
38
+ readonly derives: number
39
+ readonly beforeHandle: number
40
+ readonly afterHandle: number
41
+ readonly onError: number
42
+ readonly around: number
43
+ readonly defaultOnValidationError: boolean
44
+ }): RouteLaneSelection {
45
+ const {
46
+ schema,
47
+ hasIdempotency,
48
+ hasLedger,
49
+ hasResponseContract,
50
+ hasDecorations,
51
+ derives,
52
+ beforeHandle,
53
+ afterHandle,
54
+ onError,
55
+ around,
56
+ defaultOnValidationError,
57
+ } = options
58
+
59
+ const bare =
60
+ schema?.params === undefined &&
61
+ schema?.headers === undefined &&
62
+ schema?.body === undefined &&
63
+ schema?.query === undefined &&
64
+ !hasIdempotency &&
65
+ !hasLedger &&
66
+ !hasResponseContract &&
67
+ derives === 0 &&
68
+ beforeHandle === 0 &&
69
+ afterHandle === 0 &&
70
+ onError === 0
71
+
72
+ // A route whose ONLY lifecycle step is a query schema can fuse too: the parse + validate + handler +
73
+ // respond collapse into one closure with no lifecycle promise on the sync path. The guards mirror the
74
+ // query lane below PLUS everything the fused dispatch skips: around hooks, the idempotency/ledger
75
+ // wrappers, and validation-error recovery (schema or server default) - the fused invalid path is
76
+ // exactly `validationError(issues)`, so any recovery semantics keep the generic lane.
77
+ const fusedQuery =
78
+ !bare &&
79
+ !hasResponseContract &&
80
+ schema?.query !== undefined &&
81
+ schema.body === undefined &&
82
+ schema.params === undefined &&
83
+ schema.headers === undefined &&
84
+ schema.onValidationError === undefined &&
85
+ !defaultOnValidationError &&
86
+ !hasIdempotency &&
87
+ !hasLedger &&
88
+ derives === 0 &&
89
+ beforeHandle === 0 &&
90
+ afterHandle === 0 &&
91
+ onError === 0 &&
92
+ around === 0
93
+
94
+ const bodyOnly =
95
+ !hasResponseContract &&
96
+ schema?.body !== undefined &&
97
+ schema.query === undefined &&
98
+ schema.params === undefined &&
99
+ schema.headers === undefined &&
100
+ derives === 0 &&
101
+ beforeHandle === 0 &&
102
+ afterHandle === 0 &&
103
+ onError === 0
104
+
105
+ const fusedBody =
106
+ !bare &&
107
+ bodyOnly &&
108
+ schema.onValidationError === undefined &&
109
+ !defaultOnValidationError &&
110
+ !hasIdempotency &&
111
+ !hasLedger &&
112
+ around === 0
113
+
114
+ const lane: RouteExecutionLane = bare
115
+ ? "bare"
116
+ : bodyOnly
117
+ ? "body"
118
+ : !hasResponseContract &&
119
+ schema?.body === undefined &&
120
+ schema?.query !== undefined &&
121
+ schema.params === undefined &&
122
+ schema.headers === undefined &&
123
+ derives === 0 &&
124
+ beforeHandle === 0 &&
125
+ afterHandle === 0 &&
126
+ onError === 0
127
+ ? "query"
128
+ : "lifecycle"
129
+
130
+ // Lifecycle routes with no params schema are the common middleware shape: derive/before plus an
131
+ // optional query or body schema. Select their complete validation stage at registration so the
132
+ // request path never re-checks params/body presence. Parameter-schema routes retain the generic
133
+ // lifecycle runner until their more involved recovery matrix is selected explicitly.
134
+ const lifecycleLane: LifecycleExecutionLane =
135
+ lane !== "lifecycle" || schema?.params !== undefined || schema?.headers !== undefined
136
+ ? undefined
137
+ : schema?.body !== undefined
138
+ ? schema.query !== undefined
139
+ ? "body-query"
140
+ : "body"
141
+ : schema?.query !== undefined
142
+ ? "query"
143
+ : "hooks"
144
+
145
+ // The realistic middleware shape is commonly exactly one synchronous-or-async derive followed by
146
+ // one before hook. Keep the generic runner for every route that can observe decorations, after hooks,
147
+ // error hooks, or response contracts; this lane only removes the two per-request hook-loop dispatches
148
+ // and preserves the same async continuations and error handling. A params or headers schema routes
149
+ // back to the generic lane because the fused builder has no `params` / `headers` validation step
150
+ // baked in - adding them would be more code than it saves. A `body` schema is the same story
151
+ // because the fused derive-before / derive-before-after builders don't parse the body: routes with a
152
+ // body schema are routed to the `body-derive-before` / `body-derive-before-after` lane below.
153
+ const lifecycleHookLane =
154
+ lane === "lifecycle" &&
155
+ !hasResponseContract &&
156
+ !hasDecorations &&
157
+ !hasIdempotency &&
158
+ !hasLedger &&
159
+ schema?.onValidationError === undefined &&
160
+ !defaultOnValidationError &&
161
+ schema?.params === undefined &&
162
+ schema?.headers === undefined &&
163
+ schema?.body === undefined &&
164
+ derives === 1 &&
165
+ beforeHandle === 1 &&
166
+ afterHandle <= 1 &&
167
+ onError === 0 &&
168
+ around === 0
169
+ ? afterHandle === 0
170
+ ? "derive-before"
171
+ : "derive-before-after"
172
+ : undefined
173
+
174
+ // Body + lifecycle hooks in a closed shape: a body schema plus the same 1-derive + 1-before +
175
+ // 0-or-1-after + 0-onError shape, with no other body/query/path conflict. The fused closure runs
176
+ // parse → validate → derive → before → handler → after → respond in one function frame, the same
177
+ // win `fusedBody` already gives for the no-hooks body shape.
178
+ const fusedBodyLifecycle =
179
+ lane === "lifecycle" &&
180
+ schema?.body !== undefined &&
181
+ schema?.query === undefined &&
182
+ schema?.params === undefined &&
183
+ schema?.headers === undefined &&
184
+ schema?.onValidationError === undefined &&
185
+ !defaultOnValidationError &&
186
+ !hasResponseContract &&
187
+ !hasDecorations &&
188
+ !hasIdempotency &&
189
+ !hasLedger &&
190
+ derives === 1 &&
191
+ beforeHandle === 1 &&
192
+ afterHandle <= 1 &&
193
+ onError === 0 &&
194
+ around === 0
195
+
196
+ return {
197
+ bare,
198
+ fusedQuery,
199
+ bodyOnly,
200
+ fusedBody,
201
+ lane,
202
+ lifecycleLane,
203
+ lifecycleHookLane,
204
+ fusedLane:
205
+ around === 0
206
+ ? fusedQuery
207
+ ? "query"
208
+ : fusedBody
209
+ ? "body"
210
+ : bare
211
+ ? "bare"
212
+ : lifecycleHookLane !== undefined
213
+ ? (lifecycleHookLane as "derive-before" | "derive-before-after")
214
+ : fusedBodyLifecycle
215
+ ? afterHandle === 0
216
+ ? "body-derive-before"
217
+ : "body-derive-before-after"
218
+ : undefined
219
+ : undefined,
220
+ }
221
+ }