@nifrajs/core 2.0.0 → 2.2.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.
Files changed (112) hide show
  1. package/README.md +27 -0
  2. package/dist/capabilities.d.ts +54 -2
  3. package/dist/capabilities.d.ts.map +1 -1
  4. package/dist/capabilities.js +240 -5
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/durable-adapters.d.ts +241 -0
  7. package/dist/durable-adapters.d.ts.map +1 -0
  8. package/dist/durable-adapters.js +697 -0
  9. package/dist/durable-adapters.js.map +1 -0
  10. package/dist/durable-execution.d.ts +349 -0
  11. package/dist/durable-execution.d.ts.map +1 -0
  12. package/dist/durable-execution.js +1173 -0
  13. package/dist/durable-execution.js.map +1 -0
  14. package/dist/effect-lifecycle.d.ts +42 -0
  15. package/dist/effect-lifecycle.d.ts.map +1 -0
  16. package/dist/effect-lifecycle.js +66 -0
  17. package/dist/effect-lifecycle.js.map +1 -0
  18. package/dist/effect-scope.d.ts +75 -0
  19. package/dist/effect-scope.d.ts.map +1 -0
  20. package/dist/effect-scope.js +177 -0
  21. package/dist/effect-scope.js.map +1 -0
  22. package/dist/idempotency-plugin.d.ts +5 -0
  23. package/dist/idempotency-plugin.d.ts.map +1 -1
  24. package/dist/idempotency-plugin.js +8 -0
  25. package/dist/idempotency-plugin.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/internal/capability-runtime.d.ts +62 -1
  29. package/dist/internal/capability-runtime.d.ts.map +1 -1
  30. package/dist/internal/capability-runtime.js +11 -2
  31. package/dist/internal/capability-runtime.js.map +1 -1
  32. package/dist/internal/effect-execution.d.ts +16 -0
  33. package/dist/internal/effect-execution.d.ts.map +1 -0
  34. package/dist/internal/effect-execution.js +50 -0
  35. package/dist/internal/effect-execution.js.map +1 -0
  36. package/dist/internal/safe-equal.d.ts +20 -0
  37. package/dist/internal/safe-equal.d.ts.map +1 -0
  38. package/dist/internal/safe-equal.js +31 -0
  39. package/dist/internal/safe-equal.js.map +1 -0
  40. package/dist/ledger.d.ts +14 -6
  41. package/dist/ledger.d.ts.map +1 -1
  42. package/dist/ledger.js +31 -5
  43. package/dist/ledger.js.map +1 -1
  44. package/dist/reconciliation-worker.d.ts +108 -0
  45. package/dist/reconciliation-worker.d.ts.map +1 -0
  46. package/dist/reconciliation-worker.js +239 -0
  47. package/dist/reconciliation-worker.js.map +1 -0
  48. package/dist/router/pattern.d.ts +26 -1
  49. package/dist/router/pattern.d.ts.map +1 -1
  50. package/dist/router/pattern.js +164 -8
  51. package/dist/router/pattern.js.map +1 -1
  52. package/dist/router/router.d.ts.map +1 -1
  53. package/dist/router/router.js +49 -1
  54. package/dist/router/router.js.map +1 -1
  55. package/dist/server/idempotency-lane.d.ts +1 -0
  56. package/dist/server/idempotency-lane.d.ts.map +1 -1
  57. package/dist/server/idempotency-lane.js +61 -37
  58. package/dist/server/idempotency-lane.js.map +1 -1
  59. package/dist/server/respond.d.ts.map +1 -1
  60. package/dist/server/respond.js.map +1 -1
  61. package/dist/server/server.d.ts +12 -0
  62. package/dist/server/server.d.ts.map +1 -1
  63. package/dist/server/server.js +33 -2
  64. package/dist/server/server.js.map +1 -1
  65. package/dist/server/transport-lane.d.ts +28 -0
  66. package/dist/server/transport-lane.d.ts.map +1 -0
  67. package/dist/server/transport-lane.js +106 -0
  68. package/dist/server/transport-lane.js.map +1 -0
  69. package/dist/server/websocket.d.ts +6 -0
  70. package/dist/server/websocket.d.ts.map +1 -1
  71. package/dist/server/websocket.js +10 -1
  72. package/dist/server/websocket.js.map +1 -1
  73. package/dist/transport-codec-rich.d.ts +8 -0
  74. package/dist/transport-codec-rich.d.ts.map +1 -0
  75. package/dist/transport-codec-rich.js +14 -0
  76. package/dist/transport-codec-rich.js.map +1 -0
  77. package/dist/transport-codec.d.ts +31 -0
  78. package/dist/transport-codec.d.ts.map +1 -0
  79. package/dist/transport-codec.js +214 -0
  80. package/dist/transport-codec.js.map +1 -0
  81. package/dist/transport-plugin.d.ts +3 -0
  82. package/dist/transport-plugin.d.ts.map +1 -0
  83. package/dist/transport-plugin.js +3 -0
  84. package/dist/transport-plugin.js.map +1 -0
  85. package/dist/wire.d.ts +58 -0
  86. package/dist/wire.d.ts.map +1 -0
  87. package/dist/wire.js +445 -0
  88. package/dist/wire.js.map +1 -0
  89. package/package.json +46 -1
  90. package/src/capabilities.ts +306 -5
  91. package/src/durable-adapters.ts +1039 -0
  92. package/src/durable-execution.ts +1676 -0
  93. package/src/effect-lifecycle.ts +120 -0
  94. package/src/effect-scope.ts +267 -0
  95. package/src/idempotency-plugin.ts +10 -0
  96. package/src/index.ts +1 -1
  97. package/src/internal/capability-runtime.ts +87 -1
  98. package/src/internal/effect-execution.ts +70 -0
  99. package/src/internal/safe-equal.ts +27 -0
  100. package/src/ledger.ts +45 -11
  101. package/src/reconciliation-worker.ts +372 -0
  102. package/src/router/pattern.ts +179 -8
  103. package/src/router/router.ts +75 -1
  104. package/src/server/idempotency-lane.ts +68 -34
  105. package/src/server/respond.ts +1 -0
  106. package/src/server/server.ts +46 -0
  107. package/src/server/transport-lane.ts +141 -0
  108. package/src/server/websocket.ts +15 -1
  109. package/src/transport-codec-rich.ts +20 -0
  110. package/src/transport-codec.ts +284 -0
  111. package/src/transport-plugin.ts +2 -0
  112. package/src/wire.ts +474 -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
+ }
@@ -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
- parsed = JSON.parse(typeof raw === "string" ? raw : WS_MESSAGE_DECODER.decode(raw))
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,284 @@
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, options?: ErrorOptions) {
23
+ super(message, options)
24
+ this.name = "TransportCodecError"
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Run a decode step and normalize whatever it throws into {@link TransportCodecError}.
30
+ *
31
+ * Decoding is the one place this module touches attacker-controlled bytes, and the primitives it
32
+ * delegates to raise their own native types — `JSON.parse` throws `SyntaxError`, a BYO codec throws
33
+ * whatever it likes. Every other failure here is a `TransportCodecError`, so letting those escape
34
+ * would mean a malformed payload — the single most likely hostile input — is the one case that slips
35
+ * past a caller catching the documented error type. The original is kept as `cause`, so the parse
36
+ * failure stays diagnosable; a codec already speaking the contract is re-thrown untouched.
37
+ */
38
+ function decodeOrThrow<T>(decode: () => T, message: string): T {
39
+ try {
40
+ return decode()
41
+ } catch (cause) {
42
+ if (cause instanceof TransportCodecError) throw cause
43
+ throw new TransportCodecError(message, { cause })
44
+ }
45
+ }
46
+
47
+ function maxBytesOf(options: TransportDecodeOptions): number {
48
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES
49
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
50
+ throw new RangeError("transport maxBytes must be a non-negative safe integer")
51
+ return maxBytes
52
+ }
53
+
54
+ function byteLength(text: string): number {
55
+ return new TextEncoder().encode(text).byteLength
56
+ }
57
+
58
+ function assertBounded(text: string, options: TransportDecodeOptions): void {
59
+ if (byteLength(text) > maxBytesOf(options)) {
60
+ throw new TransportCodecError("transport payload exceeds maxBytes")
61
+ }
62
+ }
63
+
64
+ export const plainJsonCodec: TransportCodec = Object.freeze({
65
+ id: "json",
66
+ version: 1,
67
+ mediaType: "application/json",
68
+ encode(value: unknown): string {
69
+ const encoded = JSON.stringify(value)
70
+ if (encoded === undefined) throw new TransportCodecError("value is not JSON serializable")
71
+ return encoded
72
+ },
73
+ decode(text: string): unknown {
74
+ return JSON.parse(text)
75
+ },
76
+ })
77
+
78
+ export interface TransportCodecRegistry {
79
+ readonly fallback: TransportCodec
80
+ forContentType(contentType: string | null): TransportCodec
81
+ negotiate(accept: string | null): TransportCodec
82
+ byIdentity(id: string, version: number): TransportCodec
83
+ }
84
+
85
+ function canonicalMediaType(value: string): string {
86
+ return value
87
+ .split(";")
88
+ .map((part) => part.trim().toLowerCase())
89
+ .filter(Boolean)
90
+ .join(";")
91
+ }
92
+
93
+ export function createTransportCodecRegistry(
94
+ codecs: readonly TransportCodec[],
95
+ fallback: TransportCodec = plainJsonCodec,
96
+ ): TransportCodecRegistry {
97
+ if (codecs.length === 0) throw new TypeError("transport codec registry cannot be empty")
98
+ const byMedia = new Map<string, TransportCodec>()
99
+ const byId = new Map<string, TransportCodec>()
100
+ for (const codec of codecs) {
101
+ if (!TOKEN.test(codec.id)) throw new TypeError("transport codec id is invalid")
102
+ if (!Number.isSafeInteger(codec.version) || codec.version < 1)
103
+ throw new TypeError("transport codec version must be a positive safe integer")
104
+ if (typeof codec.encode !== "function" || typeof codec.decode !== "function")
105
+ throw new TypeError("transport codec must provide encode and decode")
106
+ const media = canonicalMediaType(codec.mediaType)
107
+ const identity = `${codec.id}@${codec.version}`
108
+ if (byMedia.has(media) || byId.has(identity))
109
+ throw new TypeError("duplicate transport codec registration")
110
+ byMedia.set(media, codec)
111
+ byId.set(identity, codec)
112
+ }
113
+ if (!byId.has(`${fallback.id}@${fallback.version}`))
114
+ throw new TypeError("transport fallback must be registered")
115
+
116
+ const forContentType = (contentType: string | null): TransportCodec => {
117
+ if (contentType === null || contentType.trim() === "") return fallback
118
+ const canonical = canonicalMediaType(contentType)
119
+ const direct = byMedia.get(canonical)
120
+ if (direct !== undefined) return direct
121
+ const base = canonical.split(";")[0]
122
+ if (base === "application/json") {
123
+ const json = byId.get("json@1")
124
+ if (json !== undefined) return json
125
+ }
126
+ throw new TransportCodecError(`unsupported transport content type: ${contentType}`)
127
+ }
128
+
129
+ return Object.freeze({
130
+ fallback,
131
+ forContentType,
132
+ negotiate(accept: string | null) {
133
+ if (accept === null || accept.trim() === "" || accept.trim() === "*/*") return fallback
134
+ const candidates = accept
135
+ .split(",")
136
+ .map((candidate: string) => {
137
+ const quality = /;\s*q=([01](?:\.\d+)?)\s*$/iu.exec(candidate)
138
+ const q = quality === null ? 1 : Number(quality[1])
139
+ return {
140
+ media: quality === null ? candidate.trim() : candidate.slice(0, quality.index).trim(),
141
+ q,
142
+ }
143
+ })
144
+ .filter(
145
+ (candidate: { readonly media: string; readonly q: number }) =>
146
+ Number.isFinite(candidate.q) && candidate.q > 0 && candidate.q <= 1,
147
+ )
148
+ .sort(
149
+ (
150
+ a: { readonly media: string; readonly q: number },
151
+ b: { readonly media: string; readonly q: number },
152
+ ) => b.q - a.q,
153
+ )
154
+ for (const candidate of candidates) {
155
+ try {
156
+ return forContentType(candidate.media)
157
+ } catch {
158
+ // Try the next advertised representation.
159
+ }
160
+ }
161
+ throw new TransportCodecError("no acceptable transport codec")
162
+ },
163
+ byIdentity(id: string, version: number) {
164
+ const codec = byId.get(`${id}@${version}`)
165
+ if (codec === undefined)
166
+ throw new TransportCodecError(`unsupported transport codec: ${id}@${version}`)
167
+ return codec
168
+ },
169
+ })
170
+ }
171
+
172
+ export const defaultTransportCodecs: TransportCodecRegistry = createTransportCodecRegistry([
173
+ plainJsonCodec,
174
+ ])
175
+
176
+ export function encodeTransportResponse(
177
+ value: unknown,
178
+ codec: TransportCodec = plainJsonCodec,
179
+ init: ResponseInit = {},
180
+ ): Response {
181
+ const headers = new Headers(init.headers as ConstructorParameters<typeof Headers>[0])
182
+ headers.set("content-type", codec.mediaType)
183
+ headers.set("vary", appendVary(headers.get("vary"), "accept"))
184
+ return new Response(codec.encode(value), { ...init, headers })
185
+ }
186
+
187
+ function appendVary(current: string | null, name: string): string {
188
+ if (current === null || current.trim() === "") return name
189
+ const values = current.split(",").map((value) => value.trim().toLowerCase())
190
+ return values.includes(name.toLowerCase()) ? current : `${current}, ${name}`
191
+ }
192
+
193
+ async function readBoundedText(
194
+ response: Response,
195
+ options: TransportDecodeOptions,
196
+ ): Promise<string> {
197
+ const maxBytes = maxBytesOf(options)
198
+ const declared = response.headers.get("content-length")
199
+ if (declared !== null && /^\d+$/u.test(declared) && Number(declared) > maxBytes)
200
+ throw new TransportCodecError("transport payload exceeds maxBytes")
201
+ if (response.body === null) return ""
202
+ const reader = response.body.getReader()
203
+ const chunks: Uint8Array[] = []
204
+ let total = 0
205
+ try {
206
+ for (;;) {
207
+ const { done, value } = await reader.read()
208
+ if (done) break
209
+ total += value.byteLength
210
+ if (!Number.isSafeInteger(total) || total > maxBytes) {
211
+ await reader.cancel()
212
+ throw new TransportCodecError("transport payload exceeds maxBytes")
213
+ }
214
+ chunks.push(value)
215
+ }
216
+ } finally {
217
+ reader.releaseLock()
218
+ }
219
+ const bytes = new Uint8Array(total)
220
+ let offset = 0
221
+ for (const chunk of chunks) {
222
+ bytes.set(chunk, offset)
223
+ offset += chunk.byteLength
224
+ }
225
+ return decodeOrThrow(
226
+ () => new TextDecoder("utf-8", { fatal: true }).decode(bytes),
227
+ "transport payload is not valid UTF-8",
228
+ )
229
+ }
230
+
231
+ export async function decodeTransportResponse(
232
+ response: Response,
233
+ registry: TransportCodecRegistry = defaultTransportCodecs,
234
+ options: TransportDecodeOptions = {},
235
+ ): Promise<unknown> {
236
+ const codec = registry.forContentType(response.headers.get("content-type"))
237
+ const text = await readBoundedText(response, options)
238
+ if (text === "") return undefined
239
+ return decodeOrThrow(
240
+ () => codec.decode(text),
241
+ `malformed transport body for codec ${codec.id}@${codec.version}`,
242
+ )
243
+ }
244
+
245
+ interface TransportFrame {
246
+ readonly codec: string
247
+ readonly version: number
248
+ readonly payload: string
249
+ }
250
+
251
+ export function encodeTransportFrame(
252
+ value: unknown,
253
+ codec: TransportCodec = plainJsonCodec,
254
+ ): string {
255
+ return JSON.stringify({ codec: codec.id, version: codec.version, payload: codec.encode(value) })
256
+ }
257
+
258
+ export function decodeTransportFrame(
259
+ frame: string,
260
+ registry: TransportCodecRegistry = defaultTransportCodecs,
261
+ options: TransportDecodeOptions = {},
262
+ ): unknown {
263
+ assertBounded(frame, options)
264
+ const envelope = decodeOrThrow(
265
+ () => JSON.parse(frame),
266
+ "malformed transport frame",
267
+ ) as Partial<TransportFrame>
268
+ if (
269
+ envelope === null ||
270
+ typeof envelope !== "object" ||
271
+ typeof envelope.codec !== "string" ||
272
+ !Number.isSafeInteger(envelope.version) ||
273
+ typeof envelope.payload !== "string"
274
+ ) {
275
+ throw new TransportCodecError("malformed transport frame")
276
+ }
277
+ const payload = envelope.payload
278
+ assertBounded(payload, options)
279
+ const codec = registry.byIdentity(envelope.codec, envelope.version as number)
280
+ return decodeOrThrow(
281
+ () => codec.decode(payload),
282
+ `malformed transport frame payload for codec ${codec.id}@${codec.version}`,
283
+ )
284
+ }
@@ -0,0 +1,2 @@
1
+ /** Opt-in server integration for versioned transport codecs. */
2
+ export { transportCodecs } from "./server/transport-lane.ts"