@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.
- package/dist/binary.d.ts +32 -0
- package/dist/binary.d.ts.map +1 -1
- package/dist/binary.js +17 -0
- package/dist/binary.js.map +1 -1
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +21 -50
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-execution.d.ts.map +1 -1
- package/dist/durable-execution.js +49 -69
- package/dist/durable-execution.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/durable-approval-protocol.d.ts +13 -0
- package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
- package/dist/internal/durable-approval-protocol.js +66 -0
- package/dist/internal/durable-approval-protocol.js.map +1 -0
- package/dist/internal/durable-effect-protocol.d.ts +14 -0
- package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
- package/dist/internal/durable-effect-protocol.js +16 -0
- package/dist/internal/durable-effect-protocol.js.map +1 -0
- package/dist/internal/durable-saga-protocol.d.ts +8 -0
- package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
- package/dist/internal/durable-saga-protocol.js +21 -0
- package/dist/internal/durable-saga-protocol.js.map +1 -0
- package/dist/internal/route-catalog.d.ts +3 -0
- package/dist/internal/route-catalog.d.ts.map +1 -1
- package/dist/internal/route-catalog.js +5 -0
- package/dist/internal/route-catalog.js.map +1 -1
- package/dist/server/plugin.d.ts +1 -1
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/registry.d.ts +11 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.js +21 -18
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/server-types.d.ts +15 -0
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +37 -9
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +130 -5
- package/dist/server/server.js.map +1 -1
- package/package.json +1 -1
- package/src/binary.ts +35 -0
- package/src/durable-adapters.ts +26 -51
- package/src/durable-execution.ts +46 -88
- package/src/index.ts +1 -1
- package/src/internal/durable-approval-protocol.ts +92 -0
- package/src/internal/durable-effect-protocol.ts +37 -0
- package/src/internal/durable-saga-protocol.ts +55 -0
- package/src/internal/route-catalog.ts +6 -0
- package/src/server/plugin.ts +1 -1
- package/src/server/registry.ts +19 -4
- package/src/server/request-context.ts +23 -20
- package/src/server/server-types.ts +15 -0
- 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
|
+
}
|
package/src/durable-adapters.ts
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
121
|
+
current === undefined ||
|
|
122
|
+
transition.result.state !== "consumed" ||
|
|
123
|
+
transition.next === undefined
|
|
148
124
|
)
|
|
149
|
-
return
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
package/src/durable-execution.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
826
|
-
if (
|
|
827
|
-
|
|
828
|
-
|
|
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 =
|
|
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
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
+
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/server/plugin.ts
CHANGED
|
@@ -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
|
package/src/server/registry.ts
CHANGED
|
@@ -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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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(
|