@nifrajs/core 2.12.1 → 2.14.1

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 (70) hide show
  1. package/dist/binary.d.ts +32 -0
  2. package/dist/binary.d.ts.map +1 -1
  3. package/dist/binary.js +17 -0
  4. package/dist/binary.js.map +1 -1
  5. package/dist/durable-adapters.d.ts.map +1 -1
  6. package/dist/durable-adapters.js +21 -50
  7. package/dist/durable-adapters.js.map +1 -1
  8. package/dist/durable-execution.d.ts.map +1 -1
  9. package/dist/durable-execution.js +49 -69
  10. package/dist/durable-execution.js.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/internal/durable-approval-protocol.d.ts +13 -0
  14. package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
  15. package/dist/internal/durable-approval-protocol.js +66 -0
  16. package/dist/internal/durable-approval-protocol.js.map +1 -0
  17. package/dist/internal/durable-effect-protocol.d.ts +14 -0
  18. package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
  19. package/dist/internal/durable-effect-protocol.js +16 -0
  20. package/dist/internal/durable-effect-protocol.js.map +1 -0
  21. package/dist/internal/durable-saga-protocol.d.ts +8 -0
  22. package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
  23. package/dist/internal/durable-saga-protocol.js +21 -0
  24. package/dist/internal/durable-saga-protocol.js.map +1 -0
  25. package/dist/internal/route-catalog.d.ts +3 -0
  26. package/dist/internal/route-catalog.d.ts.map +1 -1
  27. package/dist/internal/route-catalog.js +5 -0
  28. package/dist/internal/route-catalog.js.map +1 -1
  29. package/dist/range.d.ts.map +1 -1
  30. package/dist/range.js +4 -2
  31. package/dist/range.js.map +1 -1
  32. package/dist/server/body.d.ts +5 -0
  33. package/dist/server/body.d.ts.map +1 -1
  34. package/dist/server/body.js +26 -9
  35. package/dist/server/body.js.map +1 -1
  36. package/dist/server/plugin.d.ts +1 -1
  37. package/dist/server/plugin.d.ts.map +1 -1
  38. package/dist/server/registry.d.ts +11 -2
  39. package/dist/server/registry.d.ts.map +1 -1
  40. package/dist/server/request-context.d.ts +12 -0
  41. package/dist/server/request-context.d.ts.map +1 -1
  42. package/dist/server/request-context.js +132 -20
  43. package/dist/server/request-context.js.map +1 -1
  44. package/dist/server/respond.d.ts +1 -0
  45. package/dist/server/respond.d.ts.map +1 -1
  46. package/dist/server/respond.js +1 -1
  47. package/dist/server/respond.js.map +1 -1
  48. package/dist/server/server-types.d.ts +15 -0
  49. package/dist/server/server-types.d.ts.map +1 -1
  50. package/dist/server/server.d.ts +43 -9
  51. package/dist/server/server.d.ts.map +1 -1
  52. package/dist/server/server.js +131 -6
  53. package/dist/server/server.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/binary.ts +35 -0
  56. package/src/durable-adapters.ts +26 -51
  57. package/src/durable-execution.ts +46 -88
  58. package/src/index.ts +1 -1
  59. package/src/internal/durable-approval-protocol.ts +92 -0
  60. package/src/internal/durable-effect-protocol.ts +37 -0
  61. package/src/internal/durable-saga-protocol.ts +55 -0
  62. package/src/internal/route-catalog.ts +6 -0
  63. package/src/range.ts +3 -1
  64. package/src/server/body.ts +25 -8
  65. package/src/server/plugin.ts +1 -1
  66. package/src/server/registry.ts +19 -4
  67. package/src/server/request-context.ts +138 -22
  68. package/src/server/respond.ts +1 -1
  69. package/src/server/server-types.ts +15 -0
  70. package/src/server/server.ts +182 -10
package/src/binary.ts CHANGED
@@ -34,6 +34,23 @@ export const NIFRA_BINARY_HEADER = "x-nifra-binary"
34
34
  /** A `Response` a route declared as binary. `Jsonify` maps this to `Blob`. */
35
35
  export type BinaryResponse = Response & { readonly [NIFRA_BYTES]: true }
36
36
 
37
+ /**
38
+ * Marker on a raw response's TYPE carrying the payload it serializes to. Like {@link NIFRA_BYTES} it is
39
+ * a unique symbol declared *required*, so an ordinary `Response` cannot satisfy it by accident - an
40
+ * optional brand would be met by every `Response`, and every raw-response route would wrongly claim a
41
+ * typed body.
42
+ */
43
+ declare const NIFRA_RAW: unique symbol
44
+
45
+ /**
46
+ * A raw `Response` whose JSON body a route declared as `T`. Returning a bare `Response` drops a route
47
+ * out of the typed contract - the client reads its `data` as `never`, since a `Response`'s own
48
+ * properties say nothing about the payload. {@link raw} brands the `Response` with the shape it
49
+ * actually serializes, so `Jsonify` answers `Jsonify<T>` and the route stays inside the typed client.
50
+ * The brand is a phantom: nothing is added at runtime.
51
+ */
52
+ export type RawResponse<T> = Response & { readonly [NIFRA_RAW]: T }
53
+
37
54
  /** What can be sent as bytes without being re-encoded on the way out. */
38
55
  export type BinaryBody = ArrayBuffer | ArrayBufferView | Blob | ReadableStream<Uint8Array>
39
56
 
@@ -126,3 +143,21 @@ export function bytes(body: BinaryBody, options: BytesOptions = {}): BinaryRespo
126
143
  headers,
127
144
  }) as BinaryResponse
128
145
  }
146
+
147
+ /**
148
+ * Brand a hand-built `Response` with the payload type `T` its body serializes to, so a route that must
149
+ * return a `Response` directly still types `res.data` as `Jsonify<T>` on the client instead of `never`.
150
+ * Use it for the endpoints that build their own `Response` - a token minted by a third-party SDK, a
151
+ * redirect that also carries a JSON body, a hand-tuned cache header - but should still be part of the
152
+ * contract. Purely a type assertion: the `Response` is returned unchanged, nothing is added at runtime.
153
+ *
154
+ * app.get("/livekit-token", async (c) =>
155
+ * raw<{ token: string }>(Response.json({ token: await mint(c) })))
156
+ * // client: res.data is typed { token: string }
157
+ *
158
+ * The payload `T` is the type the body serializes to - it is not validated, so it is a promise the route
159
+ * must keep. For bytes prefer {@link bytes}, which also sets the media-type headers.
160
+ */
161
+ export function raw<T>(response: Response): RawResponse<T> {
162
+ return response as RawResponse<T>
163
+ }
@@ -13,6 +13,9 @@ import type {
13
13
  SagaRecord,
14
14
  SagaStore,
15
15
  } from "./durable-execution.ts"
16
+ import { durableApprovalProtocol } from "./internal/durable-approval-protocol.ts"
17
+ import { durableEffectProtocol } from "./internal/durable-effect-protocol.ts"
18
+ import { durableSagaProtocol } from "./internal/durable-saga-protocol.ts"
16
19
  import { safeEqual } from "./internal/safe-equal.ts"
17
20
  import type { ReconciliationLease, ReconciliationLeaseStore } from "./reconciliation-worker.ts"
18
21
  import { parse as parseWire, stringify as stringifyWire } from "./wire.ts"
@@ -93,20 +96,8 @@ export function createDurableExecutionAdapter(
93
96
  get: (effectId) => backend.get<DurableEffectRecord>("effect", effectId),
94
97
  async transition(input) {
95
98
  const current = await backend.get<DurableEffectRecord>("effect", input.effectId)
96
- if (
97
- current === undefined ||
98
- current.version !== input.version ||
99
- current.state !== input.from
100
- ) {
101
- return false
102
- }
103
- const next: DurableEffectRecord = {
104
- ...current,
105
- state: input.to,
106
- updatedAt: input.updatedAt,
107
- version: current.version + 1,
108
- ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
109
- }
99
+ const next = durableEffectProtocol.transition(current, input)
100
+ if (next === undefined) return false
110
101
  return await backend.compareAndSet("effect", input.effectId, input.version, next)
111
102
  },
112
103
  scan: (input) =>
@@ -119,46 +110,26 @@ export function createDurableExecutionAdapter(
119
110
  get: (approvalId) => backend.get<ApprovalRecord>("approval", approvalId),
120
111
  async decide(input) {
121
112
  const current = await backend.get<ApprovalRecord>("approval", input.approvalId)
122
- if (
123
- current === undefined ||
124
- current.tenantId !== input.tenantId ||
125
- current.state !== "pending" ||
126
- current.expiresAt <= input.now
127
- ) {
128
- return false
129
- }
130
- const decided: ApprovalRecord = {
131
- ...current,
132
- state: input.decision,
133
- decidedBy: input.decidedBy,
134
- updatedAt: input.now,
135
- version: current.version + 1,
136
- }
137
- return await backend.compareAndSet("approval", input.approvalId, current.version, decided)
113
+ const next = durableApprovalProtocol.decide(current, input)
114
+ if (current === undefined || next === undefined) return false
115
+ return await backend.compareAndSet("approval", input.approvalId, current.version, next)
138
116
  },
139
117
  async consume(input): Promise<ApprovalConsumeResult> {
140
118
  const current = await backend.get<ApprovalRecord>("approval", input.approvalId)
141
- if (current === undefined) return { state: "missing" }
142
- if (current.tenantId !== input.tenantId || current.principalId !== input.principalId)
143
- return { state: "binding" }
119
+ const transition = durableApprovalProtocol.consume(current, input)
144
120
  if (
145
- current.capability !== input.capability ||
146
- current.target !== input.target ||
147
- !safeEqual(current.digest, input.digest)
121
+ current === undefined ||
122
+ transition.result.state !== "consumed" ||
123
+ transition.next === undefined
148
124
  )
149
- return { state: "binding" }
150
- if (!safeEqual(current.tokenHash, input.tokenHash)) return { state: "token" }
151
- if (current.state === "consumed") return { state: "replay" }
152
- if (current.state === "denied") return { state: "denied" }
153
- if (current.state === "pending") return { state: "pending" }
154
- if (current.state === "expired" || current.expiresAt <= input.now) return { state: "expired" }
155
- const consumed = await backend.compareAndSet("approval", input.approvalId, current.version, {
156
- ...current,
157
- state: "consumed",
158
- updatedAt: input.now,
159
- version: current.version + 1,
160
- })
161
- return consumed ? { state: "consumed" } : { state: "replay" }
125
+ return transition.result
126
+ const consumed = await backend.compareAndSet(
127
+ "approval",
128
+ input.approvalId,
129
+ current.version,
130
+ transition.next,
131
+ )
132
+ return consumed ? transition.result : { state: "replay" }
162
133
  },
163
134
  }
164
135
 
@@ -166,8 +137,12 @@ export function createDurableExecutionAdapter(
166
137
  durability: "durable",
167
138
  create: (record) => backend.create("saga", record.sagaId, clone(record)),
168
139
  get: (sagaId) => backend.get<SagaRecord>("saga", sagaId),
169
- compareAndSet: (input) =>
170
- backend.compareAndSet("saga", input.sagaId, input.version, clone(input.record)),
140
+ compareAndSet: (input) => {
141
+ const next = durableSagaProtocol.transition(undefined, input)
142
+ return next === undefined
143
+ ? false
144
+ : backend.compareAndSet("saga", input.sagaId, input.version, clone(next))
145
+ },
171
146
  scan: (input) => backend.scan<SagaRecord>("saga", input as ReconciliationScanOptions<string>),
172
147
  }
173
148
 
@@ -13,7 +13,9 @@ import type {
13
13
  CapabilityExecutionJournal,
14
14
  } from "./internal/capability-runtime.ts"
15
15
  import { validCapabilityId } from "./internal/capability-runtime.ts"
16
- import { safeEqual } from "./internal/safe-equal.ts"
16
+ import { durableApprovalProtocol } from "./internal/durable-approval-protocol.ts"
17
+ import { durableEffectProtocol } from "./internal/durable-effect-protocol.ts"
18
+ import { durableSagaProtocol } from "./internal/durable-saga-protocol.ts"
17
19
 
18
20
  export type { CapabilityApprovalGate, CapabilityExecutionIdentity, CapabilityExecutionJournal }
19
21
 
@@ -208,8 +210,7 @@ export function createDurableEffectJournal(
208
210
  errorCode?: string,
209
211
  ): Promise<void> => {
210
212
  const record = await current(effectId)
211
- if (record.state !== from) throw new DurableEffectTransitionError(effectId, `${from}->${to}`)
212
- const accepted = await options.store.transition({
213
+ const next = durableEffectProtocol.transition(record, {
213
214
  effectId,
214
215
  version: record.version,
215
216
  from,
@@ -217,6 +218,15 @@ export function createDurableEffectJournal(
217
218
  updatedAt: now(),
218
219
  ...(errorCode === undefined ? {} : { errorCode }),
219
220
  })
221
+ if (next === undefined) throw new DurableEffectTransitionError(effectId, `${from}->${to}`)
222
+ const accepted = await options.store.transition({
223
+ effectId,
224
+ version: record.version,
225
+ from,
226
+ to,
227
+ updatedAt: next.updatedAt,
228
+ ...(errorCode === undefined ? {} : { errorCode }),
229
+ })
220
230
  if (!accepted) throw new DurableEffectTransitionError(effectId, `${from}->${to}`)
221
231
  }
222
232
 
@@ -261,7 +271,7 @@ export function createDurableEffectJournal(
261
271
  throw new TypeError("durable effect errorCode is invalid")
262
272
  const record = await current(effectId)
263
273
  const to = input.began ? "unknown" : "failed"
264
- const accepted = await options.store.transition({
274
+ const next = durableEffectProtocol.transition(record, {
265
275
  effectId,
266
276
  version: record.version,
267
277
  from: record.state,
@@ -269,6 +279,16 @@ export function createDurableEffectJournal(
269
279
  updatedAt: now(),
270
280
  errorCode: input.errorCode,
271
281
  })
282
+ if (next === undefined)
283
+ throw new DurableEffectTransitionError(effectId, `${record.state}->${to}`)
284
+ const accepted = await options.store.transition({
285
+ effectId,
286
+ version: record.version,
287
+ from: record.state,
288
+ to,
289
+ updatedAt: next.updatedAt,
290
+ errorCode: input.errorCode,
291
+ })
272
292
  if (!accepted) throw new DurableEffectTransitionError(effectId, `${record.state}->${to}`)
273
293
  },
274
294
  }
@@ -296,18 +316,10 @@ export class MemoryDurableEffectStore implements DurableEffectStore {
296
316
 
297
317
  transition(input: Parameters<DurableEffectStore["transition"]>[0]): boolean {
298
318
  const current = this.records.get(input.effectId)
299
- if (current === undefined || current.version !== input.version || current.state !== input.from)
300
- return false
301
- this.records.set(
302
- input.effectId,
303
- Object.freeze({
304
- ...current,
305
- state: input.to,
306
- updatedAt: input.updatedAt,
307
- version: current.version + 1,
308
- ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
309
- }),
310
- )
319
+ const next = durableEffectProtocol.transition(current, input)
320
+ if (next === undefined) return false
321
+ if (current === undefined) return false
322
+ this.records.set(input.effectId, Object.freeze(next))
311
323
  bucketMove(this.byState, current.state, input.to, input.effectId)
312
324
  return true
313
325
  }
@@ -801,64 +813,17 @@ export class MemoryApprovalStore implements ApprovalStore {
801
813
  }
802
814
  decide(input: Parameters<ApprovalStore["decide"]>[0]): boolean {
803
815
  const record = this.records.get(input.approvalId)
804
- if (
805
- record === undefined ||
806
- record.tenantId !== input.tenantId ||
807
- record.state !== "pending" ||
808
- record.expiresAt <= input.now
809
- )
810
- return false
811
- this.records.set(
812
- input.approvalId,
813
- Object.freeze({
814
- ...record,
815
- state: input.decision,
816
- decidedBy: input.decidedBy,
817
- updatedAt: input.now,
818
- version: record.version + 1,
819
- }),
820
- )
816
+ const next = durableApprovalProtocol.decide(record, input)
817
+ if (next === undefined) return false
818
+ this.records.set(input.approvalId, Object.freeze(next))
821
819
  return true
822
820
  }
823
821
  consume(input: Parameters<ApprovalStore["consume"]>[0]): ApprovalConsumeResult {
824
822
  const record = this.records.get(input.approvalId)
825
- if (record === undefined) return { state: "missing" }
826
- if (
827
- record.tenantId !== input.tenantId ||
828
- record.principalId !== input.principalId ||
829
- record.capability !== input.capability ||
830
- record.target !== input.target ||
831
- !safeEqual(record.digest, input.digest)
832
- )
833
- return { state: "binding" }
834
- if (!safeEqual(record.tokenHash, input.tokenHash)) return { state: "token" }
835
- if (record.state === "consumed") return { state: "replay" }
836
- if (record.expiresAt <= input.now || record.state === "expired") {
837
- if (record.state !== "expired")
838
- this.records.set(
839
- input.approvalId,
840
- Object.freeze({
841
- ...record,
842
- state: "expired",
843
- updatedAt: input.now,
844
- version: record.version + 1,
845
- }),
846
- )
847
- return { state: "expired" }
848
- }
849
- if (record.state === "pending") return { state: "pending" }
850
- if (record.state === "denied") return { state: "denied" }
851
- if (record.state !== "approved") return { state: "token" }
852
- this.records.set(
853
- input.approvalId,
854
- Object.freeze({
855
- ...record,
856
- state: "consumed",
857
- updatedAt: input.now,
858
- version: record.version + 1,
859
- }),
860
- )
861
- return { state: "consumed" }
823
+ const transition = durableApprovalProtocol.consume(record, input)
824
+ if (transition.next !== undefined)
825
+ this.records.set(input.approvalId, Object.freeze(transition.next))
826
+ return transition.result
862
827
  }
863
828
  list(): readonly ApprovalRecord[] {
864
829
  return Object.freeze(
@@ -1174,7 +1139,12 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
1174
1139
  current: SagaRecord,
1175
1140
  update: Omit<SagaRecord, "version">,
1176
1141
  ): Promise<SagaRecord> => {
1177
- const next = Object.freeze({ ...update, version: current.version + 1 })
1142
+ const next = durableSagaProtocol.transition(current, {
1143
+ sagaId: current.sagaId,
1144
+ version: current.version,
1145
+ record: Object.freeze({ ...update, version: current.version + 1 }),
1146
+ })
1147
+ if (next === undefined) throw new SagaConcurrencyError(current.sagaId)
1178
1148
  if (
1179
1149
  !(await options.store.compareAndSet({
1180
1150
  sagaId: current.sagaId,
@@ -1190,16 +1160,7 @@ export function createSagaEngine(options: SagaEngineOptions): SagaEngine {
1190
1160
  index: number,
1191
1161
  step: SagaStepRecord,
1192
1162
  state = record.state,
1193
- ): Omit<SagaRecord, "version"> => ({
1194
- ...record,
1195
- state,
1196
- steps: Object.freeze(
1197
- record.steps.map((candidate, candidateIndex) =>
1198
- candidateIndex === index ? Object.freeze(step) : candidate,
1199
- ),
1200
- ),
1201
- updatedAt: now(),
1202
- })
1163
+ ): Omit<SagaRecord, "version"> => durableSagaProtocol.withStep(record, index, step, state, now())
1203
1164
  const effectScope = createEffectScope({
1204
1165
  ...(observer === undefined ? {} : { observers: [observer] }),
1205
1166
  })
@@ -1569,13 +1530,10 @@ export class MemorySagaStore implements SagaStore {
1569
1530
  }
1570
1531
  compareAndSet(input: Parameters<SagaStore["compareAndSet"]>[0]): boolean {
1571
1532
  const current = this.records.get(input.sagaId)
1572
- if (
1573
- current === undefined ||
1574
- current.version !== input.version ||
1575
- input.record.version !== input.version + 1
1576
- )
1577
- return false
1578
- this.records.set(input.sagaId, Object.freeze(cloneValue(input.record)))
1533
+ const next = durableSagaProtocol.transition(current, input)
1534
+ if (next === undefined) return false
1535
+ if (current === undefined) return false
1536
+ this.records.set(input.sagaId, Object.freeze(next))
1579
1537
  bucketMove(this.byState, current.state, input.record.state, input.sagaId)
1580
1538
  return true
1581
1539
  }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
11
11
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
12
12
  */
13
- export const VERSION = "2.12.1" as const
13
+ export const VERSION = "2.14.1" as const
14
14
 
15
15
  export type Version = typeof VERSION
16
16
 
@@ -0,0 +1,92 @@
1
+ import type { ApprovalConsumeResult, ApprovalRecord, ApprovalStore } from "../durable-execution.ts"
2
+ import { safeEqual } from "./safe-equal.ts"
3
+
4
+ export type DurableApprovalDecisionInput = Parameters<ApprovalStore["decide"]>[0]
5
+ export type DurableApprovalConsumeInput = Parameters<ApprovalStore["consume"]>[0]
6
+
7
+ export interface DurableApprovalConsumeTransition {
8
+ readonly result: ApprovalConsumeResult
9
+ readonly next?: ApprovalRecord
10
+ }
11
+
12
+ export interface DurableApprovalProtocol {
13
+ decide(
14
+ current: ApprovalRecord | undefined,
15
+ input: DurableApprovalDecisionInput,
16
+ ): ApprovalRecord | undefined
17
+ consume(
18
+ current: ApprovalRecord | undefined,
19
+ input: DurableApprovalConsumeInput,
20
+ ): DurableApprovalConsumeTransition
21
+ }
22
+
23
+ function decideApproval(
24
+ current: ApprovalRecord | undefined,
25
+ input: DurableApprovalDecisionInput,
26
+ ): ApprovalRecord | undefined {
27
+ if (
28
+ current === undefined ||
29
+ current.tenantId !== input.tenantId ||
30
+ current.state !== "pending" ||
31
+ current.expiresAt <= input.now
32
+ ) {
33
+ return undefined
34
+ }
35
+ return Object.freeze({
36
+ ...current,
37
+ state: input.decision,
38
+ decidedBy: input.decidedBy,
39
+ updatedAt: input.now,
40
+ version: current.version + 1,
41
+ })
42
+ }
43
+
44
+ function consumeApproval(
45
+ current: ApprovalRecord | undefined,
46
+ input: DurableApprovalConsumeInput,
47
+ ): DurableApprovalConsumeTransition {
48
+ if (current === undefined) return { result: { state: "missing" } }
49
+ if (
50
+ current.tenantId !== input.tenantId ||
51
+ current.principalId !== input.principalId ||
52
+ current.capability !== input.capability ||
53
+ current.target !== input.target ||
54
+ !safeEqual(current.digest, input.digest)
55
+ ) {
56
+ return { result: { state: "binding" } }
57
+ }
58
+ if (!safeEqual(current.tokenHash, input.tokenHash)) return { result: { state: "token" } }
59
+ if (current.state === "consumed") return { result: { state: "replay" } }
60
+ if (current.state === "denied") return { result: { state: "denied" } }
61
+ if (current.state === "pending") return { result: { state: "pending" } }
62
+ if (current.state === "expired" || current.expiresAt <= input.now) {
63
+ return {
64
+ result: { state: "expired" },
65
+ ...(current.state === "expired"
66
+ ? {}
67
+ : {
68
+ next: Object.freeze({
69
+ ...current,
70
+ state: "expired" as const,
71
+ updatedAt: input.now,
72
+ version: current.version + 1,
73
+ }),
74
+ }),
75
+ }
76
+ }
77
+ if (current.state !== "approved") return { result: { state: "token" } }
78
+ return {
79
+ result: { state: "consumed" },
80
+ next: Object.freeze({
81
+ ...current,
82
+ state: "consumed" as const,
83
+ updatedAt: input.now,
84
+ version: current.version + 1,
85
+ }),
86
+ }
87
+ }
88
+
89
+ export const durableApprovalProtocol: DurableApprovalProtocol = Object.freeze({
90
+ decide: decideApproval,
91
+ consume: consumeApproval,
92
+ })
@@ -0,0 +1,37 @@
1
+ import type { DurableEffectRecord, DurableEffectState } from "../durable-execution.ts"
2
+
3
+ export interface DurableEffectTransitionInput {
4
+ readonly effectId: string
5
+ readonly version: number
6
+ readonly from: DurableEffectState
7
+ readonly to: DurableEffectState
8
+ readonly updatedAt: number
9
+ readonly errorCode?: string
10
+ }
11
+
12
+ export interface DurableEffectProtocol {
13
+ transition(
14
+ current: DurableEffectRecord | undefined,
15
+ input: DurableEffectTransitionInput,
16
+ ): DurableEffectRecord | undefined
17
+ }
18
+
19
+ function transitionEffect(
20
+ current: DurableEffectRecord | undefined,
21
+ input: DurableEffectTransitionInput,
22
+ ): DurableEffectRecord | undefined {
23
+ if (current === undefined || current.version !== input.version || current.state !== input.from) {
24
+ return undefined
25
+ }
26
+ return Object.freeze({
27
+ ...current,
28
+ state: input.to,
29
+ updatedAt: input.updatedAt,
30
+ version: current.version + 1,
31
+ ...(input.errorCode === undefined ? {} : { errorCode: input.errorCode }),
32
+ })
33
+ }
34
+
35
+ export const durableEffectProtocol: DurableEffectProtocol = Object.freeze({
36
+ transition: transitionEffect,
37
+ })
@@ -0,0 +1,55 @@
1
+ import type { SagaRecord, SagaState, SagaStepRecord, SagaStore } from "../durable-execution.ts"
2
+
3
+ export type DurableSagaTransitionInput = Parameters<SagaStore["compareAndSet"]>[0]
4
+
5
+ export interface DurableSagaProtocol {
6
+ transition(
7
+ current: SagaRecord | undefined,
8
+ input: DurableSagaTransitionInput,
9
+ ): SagaRecord | undefined
10
+ withStep(
11
+ record: SagaRecord,
12
+ index: number,
13
+ step: SagaStepRecord,
14
+ state: SagaState,
15
+ updatedAt: number,
16
+ ): Omit<SagaRecord, "version">
17
+ }
18
+
19
+ function transitionSaga(
20
+ current: SagaRecord | undefined,
21
+ input: DurableSagaTransitionInput,
22
+ ): SagaRecord | undefined {
23
+ if (
24
+ input.record.version !== input.version + 1 ||
25
+ (current !== undefined &&
26
+ (current.sagaId !== input.sagaId || current.version !== input.version))
27
+ ) {
28
+ return undefined
29
+ }
30
+ return Object.freeze({ ...input.record })
31
+ }
32
+
33
+ function withSagaStep(
34
+ record: SagaRecord,
35
+ index: number,
36
+ step: SagaStepRecord,
37
+ state: SagaState,
38
+ updatedAt: number,
39
+ ): Omit<SagaRecord, "version"> {
40
+ return {
41
+ ...record,
42
+ state,
43
+ steps: Object.freeze(
44
+ record.steps.map((candidate, candidateIndex) =>
45
+ candidateIndex === index ? Object.freeze(step) : candidate,
46
+ ),
47
+ ),
48
+ updatedAt,
49
+ }
50
+ }
51
+
52
+ export const durableSagaProtocol: DurableSagaProtocol = Object.freeze({
53
+ transition: transitionSaga,
54
+ withStep: withSagaStep,
55
+ })
@@ -80,6 +80,12 @@ export class RouteCatalog {
80
80
  return this.records
81
81
  }
82
82
 
83
+ /** Number of registered routes. Read at hook push time and at seal to detect an order-scoped hook
84
+ * that captured no route (see `Server.sealConfiguration`). */
85
+ get size(): number {
86
+ return this.records.length
87
+ }
88
+
83
89
  routeDescriptors(): ReadonlyArray<RouteDescriptor> {
84
90
  return this.descriptors
85
91
  }
package/src/range.ts CHANGED
@@ -42,10 +42,12 @@ function validSize(size: number): void {
42
42
  export function parseByteRange(value: string | null, size: number): ByteRangeResult {
43
43
  validSize(size)
44
44
  if (value === null) return { kind: "none" }
45
+ // Bound the input before the regex runs, not after: an oversized header must never reach the
46
+ // matcher in the first place.
47
+ if (value.length > MAX_RANGE_HEADER_LENGTH) return { kind: "none" }
45
48
  const match = /^bytes\s*=\s*(.+)$/i.exec(value)
46
49
  if (match === null) return { kind: "none" }
47
50
  const source = match[1]!
48
- if (source.length > MAX_RANGE_HEADER_LENGTH) return { kind: "none" }
49
51
  let separators = 0
50
52
  for (const character of source) {
51
53
  if (character === "," && ++separators >= MAX_RANGE_SPECIFIERS) return { kind: "none" }
@@ -256,10 +256,8 @@ function cappedStreamOf(
256
256
  * An over-cap read rejects with `reject(413)` (a lying `Content-Length` with `reject(400)`), which
257
257
  * the lifecycle already treats as control flow - the same contract as `c.boundedBody`.
258
258
  */
259
- function capRequestBodyReads(request: Request, maxBytes: number): void {
260
- if (TRANSPORT_CAPPED.has(request)) return
261
- TRANSPORT_CAPPED.add(request)
262
- const reject = transportCapError
259
+ /** The request's own pre-shadow readers - the fallback surface when the source offers none. */
260
+ function requestBodyReaders(request: Request): RawBodyReaders {
263
261
  const bodyDescriptor = descriptorOf(request, "body")
264
262
  const raw: RawBodyReaders = {
265
263
  get headers() {
@@ -276,6 +274,14 @@ function capRequestBodyReads(request: Request, maxBytes: number): void {
276
274
  json: request.json.bind(request),
277
275
  }
278
276
  if (typeof request.bytes === "function") raw.bytes = request.bytes.bind(request)
277
+ return raw
278
+ }
279
+
280
+ function capRequestBodyReads(request: Request, maxBytes: number, preset?: RawBodyReaders): void {
281
+ if (TRANSPORT_CAPPED.has(request)) return
282
+ TRANSPORT_CAPPED.add(request)
283
+ const reject = transportCapError
284
+ const raw = preset ?? requestBodyReaders(request)
279
285
  // One bounded buffer backs every shadowed reader; a second read replays it instead of failing
280
286
  // with "body already used" (a strict superset of the native one-shot contract).
281
287
  let buffered: Promise<Uint8Array> | undefined
@@ -350,13 +356,24 @@ export function markTransportCap(source: object, maxBytes: number): void {
350
356
  * lane's pre-decoded body), and hook-observed references all survive. Framework readers bypass the
351
357
  * shadow through {@link rawBodySourceOf} and enforce their own caps - `c.boundedBody(explicit)`
352
358
  * still overrides in either direction. An unmarked source (`bodyLimit: "unlimited"`) is untouched.
359
+ *
360
+ * A source that owns its transport bytes directly (Node's lazy source reads the socket itself)
361
+ * supplies them through `RequestSource.rawBodyReaders`: the capped readers then buffer off the
362
+ * socket instead of routing every read back through a runtime `Request` the source was still
363
+ * deferring. The cap is identical either way - the same {@link readBoundedBytes} enforces it.
353
364
  */
354
365
  export function applyTransportCap(source: object, request: Request): void {
355
366
  const maxBytes = (source as { [TRANSPORT_CAP]?: number })[TRANSPORT_CAP]
356
- // The capped check comes BEFORE the body-null check: post-cap, `request.body` is the shadowed
357
- // getter, and re-reading it on every `c.req` access would rebuild capped state for nothing.
358
- if (maxBytes === undefined || TRANSPORT_CAPPED.has(request) || request.body === null) return
359
- capRequestBodyReads(request, maxBytes)
367
+ // The capped check comes BEFORE the body probe: post-cap, `request.body` is the shadowed getter,
368
+ // and re-reading it on every `c.req` access would rebuild capped state for nothing.
369
+ if (maxBytes === undefined || TRANSPORT_CAPPED.has(request)) return
370
+ const provide = (source as { rawBodyReaders?: () => RawBodyReaders }).rawBodyReaders
371
+ const preset = provide === undefined ? undefined : provide.call(source)
372
+ // The null-body shortcut is only worth its probe when there is no cheaper surface: on a source
373
+ // that defers its `Request`, reading `request.body` would materialize the very object the preset
374
+ // readers exist to avoid. Capping a bodiless request costs nothing but the shadowing itself.
375
+ if (preset === undefined && request.body === null) return
376
+ capRequestBodyReads(request, maxBytes, preset)
360
377
  // A wrapper source (Node's lazy source) delegates its own body readers to the request once it
361
378
  // materializes - which are now the shadowed ones. Mirror the raw-reader stash onto the source so
362
379
  // framework readers (`rawBodySourceOf(source)`) keep reading pre-shadow there too.
@@ -86,7 +86,7 @@ export interface PluginTypeCollapsed {
86
86
  * every route type flowing through it is about to be widened away.
87
87
  */
88
88
  // biome-ignore lint/suspicious/noExplicitAny: matches any Server shape to inspect its Registry alone
89
- type ServerTypeUnpinned<S> = [S] extends [Server<infer R, any>]
89
+ export type ServerTypeUnpinned<S> = [S] extends [Server<infer R, any>]
90
90
  ? 0 extends 1 & R
91
91
  ? true
92
92
  : false