@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.
- 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/range.d.ts.map +1 -1
- package/dist/range.js +4 -2
- package/dist/range.js.map +1 -1
- package/dist/server/body.d.ts +5 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +26 -9
- package/dist/server/body.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.d.ts +12 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +132 -20
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +1 -0
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +1 -1
- package/dist/server/respond.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 +43 -9
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +131 -6
- 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/range.ts +3 -1
- package/src/server/body.ts +25 -8
- package/src/server/plugin.ts +1 -1
- package/src/server/registry.ts +19 -4
- package/src/server/request-context.ts +138 -22
- package/src/server/respond.ts +1 -1
- package/src/server/server-types.ts +15 -0
- 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
|
+
}
|
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/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" }
|
package/src/server/body.ts
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
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
|
|
357
|
-
//
|
|
358
|
-
if (maxBytes === undefined || TRANSPORT_CAPPED.has(request)
|
|
359
|
-
|
|
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.
|
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
|