@nifrajs/core 2.13.0 → 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 (54) 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/server/plugin.d.ts +1 -1
  30. package/dist/server/plugin.d.ts.map +1 -1
  31. package/dist/server/registry.d.ts +11 -2
  32. package/dist/server/registry.d.ts.map +1 -1
  33. package/dist/server/request-context.js +21 -18
  34. package/dist/server/request-context.js.map +1 -1
  35. package/dist/server/server-types.d.ts +15 -0
  36. package/dist/server/server-types.d.ts.map +1 -1
  37. package/dist/server/server.d.ts +37 -9
  38. package/dist/server/server.d.ts.map +1 -1
  39. package/dist/server/server.js +130 -5
  40. package/dist/server/server.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/binary.ts +35 -0
  43. package/src/durable-adapters.ts +26 -51
  44. package/src/durable-execution.ts +46 -88
  45. package/src/index.ts +1 -1
  46. package/src/internal/durable-approval-protocol.ts +92 -0
  47. package/src/internal/durable-effect-protocol.ts +37 -0
  48. package/src/internal/durable-saga-protocol.ts +55 -0
  49. package/src/internal/route-catalog.ts +6 -0
  50. package/src/server/plugin.ts +1 -1
  51. package/src/server/registry.ts +19 -4
  52. package/src/server/request-context.ts +23 -20
  53. package/src/server/server-types.ts +15 -0
  54. package/src/server/server.ts +171 -9
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.13.0" 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
  }
@@ -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
@@ -1,3 +1,4 @@
1
+ import type { BinaryResponse, RawResponse } from "../binary.ts"
1
2
  import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
2
3
  import type { Params, RouteSchema } from "./context.ts"
3
4
 
@@ -79,12 +80,26 @@ export type RouteInfoFor<Path extends string, S extends RouteSchema, Output> = {
79
80
  readonly sse: RegistrySse<S>
80
81
  }
81
82
 
82
- /** The client-visible output of a handler: its awaited return, minus raw `Response`. */
83
- export type OutputOf<H extends (...args: never[]) => unknown> = Exclude<
84
- Awaited<ReturnType<H>>,
85
- Response
83
+ /**
84
+ * The client-visible output of a handler: its awaited return, with an UNbranded raw `Response` removed
85
+ * (a route returning a bare `Response` has no describable body). A response branded by `bytes()`
86
+ * ({@link BinaryResponse}) or `raw()` ({@link RawResponse}) is KEPT, so the brand reaches the client's
87
+ * `Jsonify` and types `data` as `Blob` / `Jsonify<T>` rather than collapsing to `never`.
88
+ */
89
+ export type OutputOf<H extends (...args: never[]) => unknown> = KeepBrandedResponse<
90
+ Awaited<ReturnType<H>>
86
91
  >
87
92
 
93
+ /** Preserve a `bytes()`/`raw()`-branded response; drop only a bare `Response`. Distributes over a
94
+ * union return, so `Foo | BinaryResponse` keeps both arms. */
95
+ type KeepBrandedResponse<O> = O extends BinaryResponse
96
+ ? O
97
+ : O extends RawResponse<unknown>
98
+ ? O
99
+ : O extends Response
100
+ ? never
101
+ : O
102
+
88
103
  /**
89
104
  * The registry entry for a WebSocket route (stored under the pseudo-method key `"WS"`, so WS routes
90
105
  * ride the same path → method map as HTTP routes - no extra Server generic). `in` is the client →
@@ -499,31 +499,34 @@ export function readBoundedJsonSource<T>(
499
499
  return onResult === undefined
500
500
  ? Promise.resolve(jsonError(413, "payload_too_large"))
501
501
  : Promise.resolve(jsonError(413, "payload_too_large")).then(onResult)
502
+ // Trusted framing (Bun's native route table, Deno.serve): the runtime's HTTP parser already
503
+ // delimited the bytes, so the declared length IS the transport frame and chunked routing is
504
+ // moot - the over-cap 413 above already enforced the byte cap. Take the runtime's fused
505
+ // decode+parse straight away, before the transfer-encoding probe that only exists to route
506
+ // *untrusted* Node/adapter sources to the streaming lane. Skipping that probe drops one
507
+ // `Headers.get()` from every Bun/Deno JSON POST; Node/adapter sources are never marked and fall
508
+ // through below. The parsed-value poisoning walk still applies.
509
+ if (hasTrustedBodyFraming(req)) {
510
+ return raw.json().then(
511
+ (parsed) => {
512
+ let guarded: unknown
513
+ try {
514
+ guarded = guardParsedValue(parsed, protoPoisoning)
515
+ } catch {
516
+ return onResult === undefined
517
+ ? jsonError(400, "invalid_json")
518
+ : onResult(jsonError(400, "invalid_json"))
519
+ }
520
+ return onResult === undefined ? guarded : onResult(guarded)
521
+ },
522
+ () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
523
+ )
524
+ }
502
525
  const chunked =
503
526
  (sourceHeader === undefined
504
527
  ? req.headers.get("transfer-encoding")
505
528
  : sourceHeader.call(req, "transfer-encoding")) !== null
506
529
  if (!chunked) {
507
- // Bun's compiled native route table receives a Request whose bytes were already delimited by
508
- // Bun's HTTP parser. Keep the exact-byte path for every portable/adapted source, but preserve
509
- // Bun's fused decode+parse on this trusted ingress; the declared length is the transport frame
510
- // there, not an attacker-controlled hint. The parsed-value poisoning walk still applies.
511
- if (hasTrustedBodyFraming(req)) {
512
- return raw.json().then(
513
- (parsed) => {
514
- let guarded: unknown
515
- try {
516
- guarded = guardParsedValue(parsed, protoPoisoning)
517
- } catch {
518
- return onResult === undefined
519
- ? jsonError(400, "invalid_json")
520
- : onResult(jsonError(400, "invalid_json"))
521
- }
522
- return onResult === undefined ? guarded : onResult(guarded)
523
- },
524
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
525
- )
526
- }
527
530
  const jsonWithByteLength = raw.jsonWithByteLength
528
531
  if (jsonWithByteLength !== undefined) {
529
532
  return jsonWithByteLength.call(raw).then(