@nifrajs/core 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/capabilities.d.ts +54 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +240 -5
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-adapters.d.ts +241 -0
- package/dist/durable-adapters.d.ts.map +1 -0
- package/dist/durable-adapters.js +697 -0
- package/dist/durable-adapters.js.map +1 -0
- package/dist/durable-execution.d.ts +349 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +1173 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/effect-lifecycle.d.ts +42 -0
- package/dist/effect-lifecycle.d.ts.map +1 -0
- package/dist/effect-lifecycle.js +66 -0
- package/dist/effect-lifecycle.js.map +1 -0
- package/dist/effect-scope.d.ts +75 -0
- package/dist/effect-scope.d.ts.map +1 -0
- package/dist/effect-scope.js +177 -0
- package/dist/effect-scope.js.map +1 -0
- package/dist/idempotency-plugin.d.ts +5 -0
- package/dist/idempotency-plugin.d.ts.map +1 -1
- package/dist/idempotency-plugin.js +8 -0
- package/dist/idempotency-plugin.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/capability-runtime.d.ts +62 -1
- package/dist/internal/capability-runtime.d.ts.map +1 -1
- package/dist/internal/capability-runtime.js +11 -2
- package/dist/internal/capability-runtime.js.map +1 -1
- package/dist/internal/effect-execution.d.ts +16 -0
- package/dist/internal/effect-execution.d.ts.map +1 -0
- package/dist/internal/effect-execution.js +50 -0
- package/dist/internal/effect-execution.js.map +1 -0
- package/dist/internal/safe-equal.d.ts +20 -0
- package/dist/internal/safe-equal.d.ts.map +1 -0
- package/dist/internal/safe-equal.js +31 -0
- package/dist/internal/safe-equal.js.map +1 -0
- package/dist/ledger.d.ts +14 -6
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +31 -5
- package/dist/ledger.js.map +1 -1
- package/dist/reconciliation-worker.d.ts +108 -0
- package/dist/reconciliation-worker.d.ts.map +1 -0
- package/dist/reconciliation-worker.js +239 -0
- package/dist/reconciliation-worker.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +1 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +61 -37
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/server.d.ts +12 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +33 -2
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts +28 -0
- package/dist/server/transport-lane.d.ts.map +1 -0
- package/dist/server/transport-lane.js +106 -0
- package/dist/server/transport-lane.js.map +1 -0
- package/dist/server/websocket.d.ts +6 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +10 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/transport-codec-rich.d.ts +8 -0
- package/dist/transport-codec-rich.d.ts.map +1 -0
- package/dist/transport-codec-rich.js +14 -0
- package/dist/transport-codec-rich.js.map +1 -0
- package/dist/transport-codec.d.ts +31 -0
- package/dist/transport-codec.d.ts.map +1 -0
- package/dist/transport-codec.js +190 -0
- package/dist/transport-codec.js.map +1 -0
- package/dist/transport-plugin.d.ts +3 -0
- package/dist/transport-plugin.d.ts.map +1 -0
- package/dist/transport-plugin.js +3 -0
- package/dist/transport-plugin.js.map +1 -0
- package/dist/wire.d.ts +58 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +445 -0
- package/dist/wire.js.map +1 -0
- package/package.json +46 -1
- package/src/capabilities.ts +306 -5
- package/src/durable-adapters.ts +1039 -0
- package/src/durable-execution.ts +1676 -0
- package/src/effect-lifecycle.ts +120 -0
- package/src/effect-scope.ts +267 -0
- package/src/idempotency-plugin.ts +10 -0
- package/src/index.ts +1 -1
- package/src/internal/capability-runtime.ts +87 -1
- package/src/internal/effect-execution.ts +70 -0
- package/src/internal/safe-equal.ts +27 -0
- package/src/ledger.ts +45 -11
- package/src/reconciliation-worker.ts +372 -0
- package/src/server/idempotency-lane.ts +68 -34
- package/src/server/respond.ts +1 -0
- package/src/server/server.ts +46 -0
- package/src/server/transport-lane.ts +141 -0
- package/src/server/websocket.ts +15 -1
- package/src/transport-codec-rich.ts +20 -0
- package/src/transport-codec.ts +250 -0
- package/src/transport-plugin.ts +2 -0
- package/src/wire.ts +474 -0
package/src/server/server.ts
CHANGED
|
@@ -6,12 +6,17 @@ import {
|
|
|
6
6
|
NIFRA_DEADLINE_HEADER,
|
|
7
7
|
type RequestBudget,
|
|
8
8
|
} from "../budget.ts"
|
|
9
|
+
import type { EffectLifecycleObserver } from "../effect-lifecycle.ts"
|
|
9
10
|
import { FrameworkError, RouteConfigError } from "../errors.ts"
|
|
10
11
|
import {
|
|
12
|
+
type AroundCapabilityOptions,
|
|
11
13
|
CAPABILITY_GUARD,
|
|
14
|
+
type CapabilityInterceptor,
|
|
12
15
|
type CapabilityUseEvent,
|
|
13
16
|
createCapabilityGuard,
|
|
17
|
+
DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS,
|
|
14
18
|
normalizeRouteCapabilities,
|
|
19
|
+
type RegisteredCapabilityInterceptor,
|
|
15
20
|
} from "../internal/capability-runtime.ts"
|
|
16
21
|
import {
|
|
17
22
|
type AssuranceDeclaration,
|
|
@@ -517,6 +522,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
517
522
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
518
523
|
private readonly capacityGate: AdmissionController | undefined
|
|
519
524
|
private readonly onCapabilityUse: ((event: CapabilityUseEvent) => void) | undefined
|
|
525
|
+
private readonly capabilityInterceptors: RegisteredCapabilityInterceptor[]
|
|
526
|
+
private readonly capabilityObservers: EffectLifecycleObserver[]
|
|
520
527
|
/** The installed effect-ledger runtime (owns the sink + per-route resolution + settle), or
|
|
521
528
|
* `undefined` when the effect-ledger plugin is not installed. */
|
|
522
529
|
private effectLedgerRuntime: EffectLedgerRuntime | undefined
|
|
@@ -579,6 +586,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
579
586
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
580
587
|
this.capacityGate = options.admission
|
|
581
588
|
this.onCapabilityUse = options.onCapabilityUse
|
|
589
|
+
this.capabilityInterceptors = []
|
|
590
|
+
this.capabilityObservers = []
|
|
582
591
|
// The effect-ledger runtime is installed by `.use(effectLedger())`; a bare app never imports it, so
|
|
583
592
|
// the ledger machinery tree-shakes out. Capability-declaring routes simply carry no ledger without it.
|
|
584
593
|
this.effectLedgerRuntime = undefined
|
|
@@ -659,6 +668,40 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
659
668
|
return this
|
|
660
669
|
}
|
|
661
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Asynchronously admit each subsequent `executeCapability()` call before its owned effect runs.
|
|
673
|
+
* Interceptors receive token-only metadata plus an abort signal and must call `next()` exactly once
|
|
674
|
+
* to admit. Returning without `next()`, timing out, aborting, or throwing fails the effect closed.
|
|
675
|
+
*/
|
|
676
|
+
aroundCapability(
|
|
677
|
+
interceptor: CapabilityInterceptor,
|
|
678
|
+
options: AroundCapabilityOptions = {},
|
|
679
|
+
): this {
|
|
680
|
+
this.assertConfigurable("aroundCapability()")
|
|
681
|
+
if (typeof interceptor !== "function") {
|
|
682
|
+
throw new TypeError("aroundCapability interceptor must be a function")
|
|
683
|
+
}
|
|
684
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS
|
|
685
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
686
|
+
throw new RangeError("aroundCapability timeoutMs must be a positive safe integer")
|
|
687
|
+
}
|
|
688
|
+
// JS timers wrap larger delays to ~1ms, which would unexpectedly deny every effect.
|
|
689
|
+
this.capabilityInterceptors.push(
|
|
690
|
+
Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }),
|
|
691
|
+
)
|
|
692
|
+
return this
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Observe token-only admission/execution lifecycle events for subsequent capability routes. */
|
|
696
|
+
observeCapability(observer: EffectLifecycleObserver): this {
|
|
697
|
+
this.assertConfigurable("observeCapability()")
|
|
698
|
+
if (typeof observer !== "function") {
|
|
699
|
+
throw new TypeError("observeCapability observer must be a function")
|
|
700
|
+
}
|
|
701
|
+
this.capabilityObservers.push(observer)
|
|
702
|
+
return this
|
|
703
|
+
}
|
|
704
|
+
|
|
662
705
|
/** Transform the handler's result before it is serialized. Order-scoped. */
|
|
663
706
|
afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
|
|
664
707
|
this.assertConfigurable("afterHandle()")
|
|
@@ -1120,6 +1163,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1120
1163
|
method,
|
|
1121
1164
|
path,
|
|
1122
1165
|
this.onCapabilityUse,
|
|
1166
|
+
this.idempotencyRuntime?.trackEffect,
|
|
1167
|
+
Object.freeze([...this.capabilityInterceptors]),
|
|
1168
|
+
Object.freeze([...this.capabilityObservers]),
|
|
1123
1169
|
)
|
|
1124
1170
|
}
|
|
1125
1171
|
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** Opt-in versioned transport integration. Kept outside the server kernel's bare dependency graph. */
|
|
2
|
+
import type { TransportCodec, TransportCodecRegistry } from "../transport-codec.ts"
|
|
3
|
+
import { readBoundedBytes } from "./body.ts"
|
|
4
|
+
import { jsonError } from "./http.ts"
|
|
5
|
+
import { isResponseResult } from "./runtime-core.ts"
|
|
6
|
+
import type { AnyServer, IdentityPlugin } from "./server.ts"
|
|
7
|
+
|
|
8
|
+
interface TransportBodySource {
|
|
9
|
+
readonly headers: Pick<Headers, "get">
|
|
10
|
+
readonly body: ReadableStream<Uint8Array> | null
|
|
11
|
+
arrayBuffer(): Promise<ArrayBuffer>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type TransportDecodeResult =
|
|
15
|
+
| { readonly matched: false }
|
|
16
|
+
| { readonly matched: true; readonly value: unknown }
|
|
17
|
+
| { readonly matched: true; readonly response: Response }
|
|
18
|
+
|
|
19
|
+
export interface TransportRuntime {
|
|
20
|
+
responseCodec(accept: string | null): TransportCodec
|
|
21
|
+
decodeRequest(
|
|
22
|
+
source: TransportBodySource,
|
|
23
|
+
contentType: string,
|
|
24
|
+
maxBytes: number,
|
|
25
|
+
): Promise<TransportDecodeResult>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TransportCodecsOptions {
|
|
29
|
+
/** Maximum encoded request bytes. Keep aligned with `server({ maxBodyBytes })`. */
|
|
30
|
+
readonly maxBytes?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function transportCodecs(
|
|
34
|
+
registry: TransportCodecRegistry,
|
|
35
|
+
options: TransportCodecsOptions = {},
|
|
36
|
+
): IdentityPlugin {
|
|
37
|
+
const maxBytes = options.maxBytes ?? 1_000_000
|
|
38
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
39
|
+
throw new RangeError("transport maxBytes must be a non-negative safe integer")
|
|
40
|
+
}
|
|
41
|
+
const runtime: TransportRuntime = Object.freeze({
|
|
42
|
+
responseCodec(accept: string | null): TransportCodec {
|
|
43
|
+
try {
|
|
44
|
+
return registry.negotiate(accept)
|
|
45
|
+
} catch {
|
|
46
|
+
return registry.fallback
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async decodeRequest(
|
|
50
|
+
source: TransportBodySource,
|
|
51
|
+
contentType: string,
|
|
52
|
+
maxBytes: number,
|
|
53
|
+
): Promise<TransportDecodeResult> {
|
|
54
|
+
let codec: TransportCodec
|
|
55
|
+
try {
|
|
56
|
+
codec = registry.forContentType(contentType)
|
|
57
|
+
} catch {
|
|
58
|
+
return { matched: false }
|
|
59
|
+
}
|
|
60
|
+
if (codec.id === "json" && codec.version === 1 && codec.mediaType === "application/json")
|
|
61
|
+
return { matched: false }
|
|
62
|
+
const read = await readBoundedBytes(source, maxBytes)
|
|
63
|
+
if (!read.ok) {
|
|
64
|
+
return {
|
|
65
|
+
matched: true,
|
|
66
|
+
response: jsonError(
|
|
67
|
+
read.status,
|
|
68
|
+
read.status === 413 ? "payload_too_large" : "bad_request",
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
return {
|
|
74
|
+
matched: true,
|
|
75
|
+
value: codec.decode(new TextDecoder("utf-8", { fatal: true }).decode(read.bytes)),
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
return {
|
|
79
|
+
matched: true,
|
|
80
|
+
response: jsonError(400, "invalid_transport_payload"),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
const apply = <S extends AnyServer>(app: S): S => {
|
|
86
|
+
app.onRequest(async (request) => {
|
|
87
|
+
const contentType = request.headers.get("content-type") ?? ""
|
|
88
|
+
let requestCodec: TransportCodec
|
|
89
|
+
try {
|
|
90
|
+
requestCodec = registry.forContentType(contentType)
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
requestCodec.id === "json" &&
|
|
96
|
+
requestCodec.version === 1 &&
|
|
97
|
+
requestCodec.mediaType === "application/json"
|
|
98
|
+
) {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
const replacement = request.clone()
|
|
102
|
+
const decoded = await runtime.decodeRequest(request, contentType, maxBytes)
|
|
103
|
+
if (!decoded.matched) return undefined
|
|
104
|
+
if ("response" in decoded) return decoded.response
|
|
105
|
+
|
|
106
|
+
// Keep an untouched body branch while the bounded decoder consumes the other tee branch.
|
|
107
|
+
// Existing JSON validation lanes can then use their native `request.json()` fast path while
|
|
108
|
+
// receiving the already-decoded rich value without importing codec machinery into the kernel.
|
|
109
|
+
const headers: Record<string, string> = {}
|
|
110
|
+
replacement.headers.forEach((value, name) => {
|
|
111
|
+
headers[name] = value
|
|
112
|
+
})
|
|
113
|
+
headers["content-type"] = "application/json"
|
|
114
|
+
headers["content-length"] = "2"
|
|
115
|
+
delete headers["transfer-encoding"]
|
|
116
|
+
const normalized = new Request(replacement.url, {
|
|
117
|
+
method: replacement.method,
|
|
118
|
+
headers,
|
|
119
|
+
body: "{}",
|
|
120
|
+
signal: replacement.signal as never,
|
|
121
|
+
})
|
|
122
|
+
Object.defineProperty(normalized, "json", {
|
|
123
|
+
value: () => Promise.resolve(decoded.value),
|
|
124
|
+
})
|
|
125
|
+
return normalized
|
|
126
|
+
})
|
|
127
|
+
app.afterHandle((result, context) => {
|
|
128
|
+
if (result === undefined || result instanceof Response || isResponseResult(result))
|
|
129
|
+
return result
|
|
130
|
+
const codec = runtime.responseCodec(context.req.headers.get("accept"))
|
|
131
|
+
const headers = new Headers(context.set.headers)
|
|
132
|
+
headers.set("content-type", codec.mediaType)
|
|
133
|
+
return new Response(codec.encode(result), {
|
|
134
|
+
status: context.set.status ?? 200,
|
|
135
|
+
headers,
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
return app
|
|
139
|
+
}
|
|
140
|
+
return Object.assign(apply, { pluginName: "nifra:transport-codecs" }) as IdentityPlugin
|
|
141
|
+
}
|
package/src/server/websocket.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
ValidationOutcome,
|
|
17
17
|
} from "../schema/standard.ts"
|
|
18
18
|
import { validateStandard } from "../schema/standard.ts"
|
|
19
|
+
import { decodeTransportFrame, type TransportCodecRegistry } from "../transport-codec.ts"
|
|
19
20
|
|
|
20
21
|
type MaybePromise<T> = T | Promise<T>
|
|
21
22
|
|
|
@@ -167,6 +168,11 @@ export interface WebSocketHandler<
|
|
|
167
168
|
* boundary; outbound honesty is the handler author's code, checked by tests.
|
|
168
169
|
*/
|
|
169
170
|
sendSchema?: Send
|
|
171
|
+
/** Decode versioned transport frames before inbound schema validation. Omit for legacy JSON. */
|
|
172
|
+
transport?: {
|
|
173
|
+
readonly registry: TransportCodecRegistry
|
|
174
|
+
readonly maxBytes?: number
|
|
175
|
+
}
|
|
170
176
|
open?(ws: NifraWebSocket<Data>): MaybePromise<void>
|
|
171
177
|
message?(ws: NifraWebSocket<Data>, data: WsMessageInput<Schema>): MaybePromise<void>
|
|
172
178
|
/** Inbound frame that failed JSON parse or `messageSchema` validation (only fires when a schema is
|
|
@@ -317,7 +323,15 @@ export function wrapWebSocketMessageValidation(handler: WebSocketHandler): WebSo
|
|
|
317
323
|
const validatingMessage = (ws: NifraWebSocket, raw: WebSocketData): MaybePromise<void> => {
|
|
318
324
|
let parsed: unknown
|
|
319
325
|
try {
|
|
320
|
-
|
|
326
|
+
const text = typeof raw === "string" ? raw : WS_MESSAGE_DECODER.decode(raw)
|
|
327
|
+
parsed =
|
|
328
|
+
handler.transport === undefined
|
|
329
|
+
? JSON.parse(text)
|
|
330
|
+
: decodeTransportFrame(text, handler.transport.registry, {
|
|
331
|
+
...(handler.transport.maxBytes === undefined
|
|
332
|
+
? {}
|
|
333
|
+
: { maxBytes: handler.transport.maxBytes }),
|
|
334
|
+
})
|
|
321
335
|
} catch {
|
|
322
336
|
return onInvalid?.(ws, [{ message: "invalid JSON" }], raw)
|
|
323
337
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Opt-in rich-wire transport adapter. Kept separate so a bare server never bundles rich values. */
|
|
2
|
+
import type { TransportCodec } from "./transport-codec.ts"
|
|
3
|
+
import { parse as parseWire, stringify as stringifyWire, type WireDecodeLimits } from "./wire.ts"
|
|
4
|
+
|
|
5
|
+
export interface RichWireCodecOptions {
|
|
6
|
+
readonly limits?: WireDecodeLimits
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function richWireCodec(options: RichWireCodecOptions = {}): TransportCodec {
|
|
10
|
+
const limits = Object.freeze({ ...(options.limits ?? {}) })
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
id: "wire",
|
|
13
|
+
version: 1,
|
|
14
|
+
mediaType: "application/vnd.nifra.wire+json;v=1",
|
|
15
|
+
encode: stringifyWire,
|
|
16
|
+
decode(text: string): unknown {
|
|
17
|
+
return parseWire(text, limits)
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioned, bounded codecs shared by HTTP bodies, loader streams, and WebSocket frames. Plain JSON
|
|
3
|
+
* remains the zero-configuration fast path; rich wire is explicit and preserves non-JSON values.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_BYTES = 16 * 1024 * 1024
|
|
7
|
+
const TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/
|
|
8
|
+
|
|
9
|
+
export interface TransportDecodeOptions {
|
|
10
|
+
readonly maxBytes?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TransportCodec {
|
|
14
|
+
readonly id: string
|
|
15
|
+
readonly version: number
|
|
16
|
+
readonly mediaType: string
|
|
17
|
+
encode(value: unknown): string
|
|
18
|
+
decode(text: string): unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TransportCodecError extends TypeError {
|
|
22
|
+
constructor(message: string) {
|
|
23
|
+
super(message)
|
|
24
|
+
this.name = "TransportCodecError"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function maxBytesOf(options: TransportDecodeOptions): number {
|
|
29
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES
|
|
30
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
31
|
+
throw new RangeError("transport maxBytes must be a non-negative safe integer")
|
|
32
|
+
return maxBytes
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function byteLength(text: string): number {
|
|
36
|
+
return new TextEncoder().encode(text).byteLength
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertBounded(text: string, options: TransportDecodeOptions): void {
|
|
40
|
+
if (byteLength(text) > maxBytesOf(options)) {
|
|
41
|
+
throw new TransportCodecError("transport payload exceeds maxBytes")
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const plainJsonCodec: TransportCodec = Object.freeze({
|
|
46
|
+
id: "json",
|
|
47
|
+
version: 1,
|
|
48
|
+
mediaType: "application/json",
|
|
49
|
+
encode(value: unknown): string {
|
|
50
|
+
const encoded = JSON.stringify(value)
|
|
51
|
+
if (encoded === undefined) throw new TransportCodecError("value is not JSON serializable")
|
|
52
|
+
return encoded
|
|
53
|
+
},
|
|
54
|
+
decode(text: string): unknown {
|
|
55
|
+
return JSON.parse(text)
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export interface TransportCodecRegistry {
|
|
60
|
+
readonly fallback: TransportCodec
|
|
61
|
+
forContentType(contentType: string | null): TransportCodec
|
|
62
|
+
negotiate(accept: string | null): TransportCodec
|
|
63
|
+
byIdentity(id: string, version: number): TransportCodec
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function canonicalMediaType(value: string): string {
|
|
67
|
+
return value
|
|
68
|
+
.split(";")
|
|
69
|
+
.map((part) => part.trim().toLowerCase())
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(";")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createTransportCodecRegistry(
|
|
75
|
+
codecs: readonly TransportCodec[],
|
|
76
|
+
fallback: TransportCodec = plainJsonCodec,
|
|
77
|
+
): TransportCodecRegistry {
|
|
78
|
+
if (codecs.length === 0) throw new TypeError("transport codec registry cannot be empty")
|
|
79
|
+
const byMedia = new Map<string, TransportCodec>()
|
|
80
|
+
const byId = new Map<string, TransportCodec>()
|
|
81
|
+
for (const codec of codecs) {
|
|
82
|
+
if (!TOKEN.test(codec.id)) throw new TypeError("transport codec id is invalid")
|
|
83
|
+
if (!Number.isSafeInteger(codec.version) || codec.version < 1)
|
|
84
|
+
throw new TypeError("transport codec version must be a positive safe integer")
|
|
85
|
+
if (typeof codec.encode !== "function" || typeof codec.decode !== "function")
|
|
86
|
+
throw new TypeError("transport codec must provide encode and decode")
|
|
87
|
+
const media = canonicalMediaType(codec.mediaType)
|
|
88
|
+
const identity = `${codec.id}@${codec.version}`
|
|
89
|
+
if (byMedia.has(media) || byId.has(identity))
|
|
90
|
+
throw new TypeError("duplicate transport codec registration")
|
|
91
|
+
byMedia.set(media, codec)
|
|
92
|
+
byId.set(identity, codec)
|
|
93
|
+
}
|
|
94
|
+
if (!byId.has(`${fallback.id}@${fallback.version}`))
|
|
95
|
+
throw new TypeError("transport fallback must be registered")
|
|
96
|
+
|
|
97
|
+
const forContentType = (contentType: string | null): TransportCodec => {
|
|
98
|
+
if (contentType === null || contentType.trim() === "") return fallback
|
|
99
|
+
const canonical = canonicalMediaType(contentType)
|
|
100
|
+
const direct = byMedia.get(canonical)
|
|
101
|
+
if (direct !== undefined) return direct
|
|
102
|
+
const base = canonical.split(";")[0]
|
|
103
|
+
if (base === "application/json") {
|
|
104
|
+
const json = byId.get("json@1")
|
|
105
|
+
if (json !== undefined) return json
|
|
106
|
+
}
|
|
107
|
+
throw new TransportCodecError(`unsupported transport content type: ${contentType}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
fallback,
|
|
112
|
+
forContentType,
|
|
113
|
+
negotiate(accept: string | null) {
|
|
114
|
+
if (accept === null || accept.trim() === "" || accept.trim() === "*/*") return fallback
|
|
115
|
+
const candidates = accept
|
|
116
|
+
.split(",")
|
|
117
|
+
.map((candidate: string) => {
|
|
118
|
+
const quality = /;\s*q=([01](?:\.\d+)?)\s*$/iu.exec(candidate)
|
|
119
|
+
const q = quality === null ? 1 : Number(quality[1])
|
|
120
|
+
return {
|
|
121
|
+
media: quality === null ? candidate.trim() : candidate.slice(0, quality.index).trim(),
|
|
122
|
+
q,
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
.filter(
|
|
126
|
+
(candidate: { readonly media: string; readonly q: number }) =>
|
|
127
|
+
Number.isFinite(candidate.q) && candidate.q > 0 && candidate.q <= 1,
|
|
128
|
+
)
|
|
129
|
+
.sort(
|
|
130
|
+
(
|
|
131
|
+
a: { readonly media: string; readonly q: number },
|
|
132
|
+
b: { readonly media: string; readonly q: number },
|
|
133
|
+
) => b.q - a.q,
|
|
134
|
+
)
|
|
135
|
+
for (const candidate of candidates) {
|
|
136
|
+
try {
|
|
137
|
+
return forContentType(candidate.media)
|
|
138
|
+
} catch {
|
|
139
|
+
// Try the next advertised representation.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
throw new TransportCodecError("no acceptable transport codec")
|
|
143
|
+
},
|
|
144
|
+
byIdentity(id: string, version: number) {
|
|
145
|
+
const codec = byId.get(`${id}@${version}`)
|
|
146
|
+
if (codec === undefined)
|
|
147
|
+
throw new TransportCodecError(`unsupported transport codec: ${id}@${version}`)
|
|
148
|
+
return codec
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const defaultTransportCodecs: TransportCodecRegistry = createTransportCodecRegistry([
|
|
154
|
+
plainJsonCodec,
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
export function encodeTransportResponse(
|
|
158
|
+
value: unknown,
|
|
159
|
+
codec: TransportCodec = plainJsonCodec,
|
|
160
|
+
init: ResponseInit = {},
|
|
161
|
+
): Response {
|
|
162
|
+
const headers = new Headers(init.headers as ConstructorParameters<typeof Headers>[0])
|
|
163
|
+
headers.set("content-type", codec.mediaType)
|
|
164
|
+
headers.set("vary", appendVary(headers.get("vary"), "accept"))
|
|
165
|
+
return new Response(codec.encode(value), { ...init, headers })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function appendVary(current: string | null, name: string): string {
|
|
169
|
+
if (current === null || current.trim() === "") return name
|
|
170
|
+
const values = current.split(",").map((value) => value.trim().toLowerCase())
|
|
171
|
+
return values.includes(name.toLowerCase()) ? current : `${current}, ${name}`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function readBoundedText(
|
|
175
|
+
response: Response,
|
|
176
|
+
options: TransportDecodeOptions,
|
|
177
|
+
): Promise<string> {
|
|
178
|
+
const maxBytes = maxBytesOf(options)
|
|
179
|
+
const declared = response.headers.get("content-length")
|
|
180
|
+
if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes)
|
|
181
|
+
throw new TransportCodecError("transport payload exceeds maxBytes")
|
|
182
|
+
if (response.body === null) return ""
|
|
183
|
+
const reader = response.body.getReader()
|
|
184
|
+
const chunks: Uint8Array[] = []
|
|
185
|
+
let total = 0
|
|
186
|
+
try {
|
|
187
|
+
for (;;) {
|
|
188
|
+
const { done, value } = await reader.read()
|
|
189
|
+
if (done) break
|
|
190
|
+
total += value.byteLength
|
|
191
|
+
if (!Number.isSafeInteger(total) || total > maxBytes) {
|
|
192
|
+
await reader.cancel()
|
|
193
|
+
throw new TransportCodecError("transport payload exceeds maxBytes")
|
|
194
|
+
}
|
|
195
|
+
chunks.push(value)
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
reader.releaseLock()
|
|
199
|
+
}
|
|
200
|
+
const bytes = new Uint8Array(total)
|
|
201
|
+
let offset = 0
|
|
202
|
+
for (const chunk of chunks) {
|
|
203
|
+
bytes.set(chunk, offset)
|
|
204
|
+
offset += chunk.byteLength
|
|
205
|
+
}
|
|
206
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function decodeTransportResponse(
|
|
210
|
+
response: Response,
|
|
211
|
+
registry: TransportCodecRegistry = defaultTransportCodecs,
|
|
212
|
+
options: TransportDecodeOptions = {},
|
|
213
|
+
): Promise<unknown> {
|
|
214
|
+
const codec = registry.forContentType(response.headers.get("content-type"))
|
|
215
|
+
const text = await readBoundedText(response, options)
|
|
216
|
+
return text === "" ? undefined : codec.decode(text)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface TransportFrame {
|
|
220
|
+
readonly codec: string
|
|
221
|
+
readonly version: number
|
|
222
|
+
readonly payload: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function encodeTransportFrame(
|
|
226
|
+
value: unknown,
|
|
227
|
+
codec: TransportCodec = plainJsonCodec,
|
|
228
|
+
): string {
|
|
229
|
+
return JSON.stringify({ codec: codec.id, version: codec.version, payload: codec.encode(value) })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function decodeTransportFrame(
|
|
233
|
+
frame: string,
|
|
234
|
+
registry: TransportCodecRegistry = defaultTransportCodecs,
|
|
235
|
+
options: TransportDecodeOptions = {},
|
|
236
|
+
): unknown {
|
|
237
|
+
assertBounded(frame, options)
|
|
238
|
+
const envelope = JSON.parse(frame) as Partial<TransportFrame>
|
|
239
|
+
if (
|
|
240
|
+
envelope === null ||
|
|
241
|
+
typeof envelope !== "object" ||
|
|
242
|
+
typeof envelope.codec !== "string" ||
|
|
243
|
+
!Number.isSafeInteger(envelope.version) ||
|
|
244
|
+
typeof envelope.payload !== "string"
|
|
245
|
+
) {
|
|
246
|
+
throw new TransportCodecError("malformed transport frame")
|
|
247
|
+
}
|
|
248
|
+
assertBounded(envelope.payload, options)
|
|
249
|
+
return registry.byIdentity(envelope.codec, envelope.version as number).decode(envelope.payload)
|
|
250
|
+
}
|