@nifrajs/core 1.13.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 +37 -1
- package/dist/assurance.d.ts +11 -2
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- 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-ledger.d.ts +9 -0
- package/dist/effect-ledger.d.ts.map +1 -0
- package/dist/effect-ledger.js +8 -0
- package/dist/effect-ledger.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 +13 -0
- package/dist/idempotency-plugin.d.ts.map +1 -0
- package/dist/idempotency-plugin.js +16 -0
- package/dist/idempotency-plugin.js.map +1 -0
- package/dist/index.d.ts +6 -35
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -31
- package/dist/index.js.map +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/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +56 -0
- package/dist/mcp.js.map +1 -0
- package/dist/node-direct.d.ts +5 -0
- package/dist/node-direct.d.ts.map +1 -0
- package/dist/node-direct.js +27 -0
- package/dist/node-direct.js.map +1 -0
- 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/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +2 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/client-ip.d.ts +35 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/client-ip.js +42 -0
- package/dist/server/client-ip.js.map +1 -0
- package/dist/server/context.d.ts +47 -2
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +14 -4
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +8 -2
- package/dist/server/contract.js.map +1 -1
- package/dist/server/http.d.ts +14 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +45 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/idempotency-lane.d.ts +45 -0
- package/dist/server/idempotency-lane.d.ts.map +1 -0
- package/dist/server/idempotency-lane.js +205 -0
- package/dist/server/idempotency-lane.js.map +1 -0
- package/dist/server/install.d.ts +21 -0
- package/dist/server/install.d.ts.map +1 -0
- package/dist/server/install.js +21 -0
- package/dist/server/install.js.map +1 -0
- package/dist/server/ledger-lane.d.ts +53 -0
- package/dist/server/ledger-lane.d.ts.map +1 -0
- package/dist/server/ledger-lane.js +82 -0
- package/dist/server/ledger-lane.js.map +1 -0
- package/dist/server/mcp-hook.d.ts +28 -0
- package/dist/server/mcp-hook.d.ts.map +1 -0
- package/dist/server/mcp-hook.js +2 -0
- package/dist/server/mcp-hook.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts +9 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -0
- package/dist/server/node-outcome-hook.js +2 -0
- package/dist/server/node-outcome-hook.js.map +1 -0
- package/dist/server/node-outcome.d.ts +37 -0
- package/dist/server/node-outcome.d.ts.map +1 -0
- package/dist/server/node-outcome.js +75 -0
- package/dist/server/node-outcome.js.map +1 -0
- package/dist/server/plugin.d.ts +78 -0
- package/dist/server/plugin.d.ts.map +1 -0
- package/dist/server/plugin.js +57 -0
- package/dist/server/plugin.js.map +1 -0
- package/dist/server/query.d.ts +18 -0
- package/dist/server/query.d.ts.map +1 -0
- package/dist/server/query.js +102 -0
- package/dist/server/query.js.map +1 -0
- package/dist/server/registry.d.ts +31 -6
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +51 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/request-context.js +184 -0
- package/dist/server/request-context.js.map +1 -0
- package/dist/server/respond.d.ts +17 -0
- package/dist/server/respond.d.ts.map +1 -0
- package/dist/server/respond.js +81 -0
- package/dist/server/respond.js.map +1 -0
- package/dist/server/runtime-core.d.ts +36 -0
- package/dist/server/runtime-core.d.ts.map +1 -0
- package/dist/server/runtime-core.js +51 -0
- package/dist/server/runtime-core.js.map +1 -0
- package/dist/server/server-types.d.ts +203 -0
- package/dist/server/server-types.d.ts.map +1 -0
- package/dist/server/server-types.js +2 -0
- package/dist/server/server-types.js.map +1 -0
- package/dist/server/server.d.ts +119 -345
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +247 -833
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse-hook.d.ts +10 -0
- package/dist/server/sse-hook.d.ts.map +1 -0
- package/dist/server/sse-hook.js +2 -0
- package/dist/server/sse-hook.js.map +1 -0
- package/dist/server/sse.d.ts +7 -23
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -0
- package/dist/server/sse.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 +23 -1
- 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/server/ws-hook.d.ts +8 -11
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server/ws-hook.js +7 -14
- package/dist/server/ws-hook.js.map +1 -1
- package/dist/server.d.ts +21 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +16 -0
- package/dist/server.js.map +1 -0
- 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/dist/ws.d.ts +16 -7
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +25 -10
- package/dist/ws.js.map +1 -1
- package/package.json +122 -6
- package/src/assurance.ts +36 -1
- package/src/capabilities.ts +306 -5
- package/src/durable-adapters.ts +1039 -0
- package/src/durable-execution.ts +1676 -0
- package/src/effect-ledger.ts +9 -0
- package/src/effect-lifecycle.ts +120 -0
- package/src/effect-scope.ts +267 -0
- package/src/idempotency-plugin.ts +17 -0
- package/src/index.ts +6 -133
- 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/mcp.ts +65 -0
- package/src/node-direct.ts +37 -0
- package/src/reconciliation-worker.ts +372 -0
- package/src/reflection.ts +7 -0
- package/src/server/client-ip.ts +60 -0
- package/src/server/context.ts +50 -2
- package/src/server/contract.ts +26 -7
- package/src/server/http.ts +58 -0
- package/src/server/idempotency-lane.ts +321 -0
- package/src/server/install.ts +28 -0
- package/src/server/ledger-lane.ts +125 -0
- package/src/server/mcp-hook.ts +41 -0
- package/src/server/node-outcome-hook.ts +9 -0
- package/src/server/node-outcome.ts +112 -0
- package/src/server/plugin.ts +92 -0
- package/src/server/query.ts +119 -0
- package/src/server/registry.ts +36 -6
- package/src/server/request-context.ts +271 -0
- package/src/server/respond.ts +94 -0
- package/src/server/runtime-core.ts +74 -0
- package/src/server/server-types.ts +204 -0
- package/src/server/server.ts +448 -1314
- package/src/server/sse-hook.ts +14 -0
- package/src/server/sse.ts +26 -0
- package/src/server/transport-lane.ts +141 -0
- package/src/server/websocket.ts +35 -1
- package/src/server/ws-hook.ts +8 -19
- package/src/server.ts +92 -0
- 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/ws.ts +33 -10
- package/dist/invariants.d.ts +0 -70
- package/dist/invariants.d.ts.map +0 -1
- package/dist/invariants.js +0 -487
- package/dist/invariants.js.map +0 -1
- package/src/invariants.ts +0 -604
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `SseRuntime` contract - the streaming implementation that `.use(streaming())` installs (from
|
|
3
|
+
* `@nifrajs/core/sse`). `server.ts` type-imports only this, so the ReadableStream framing code stays
|
|
4
|
+
* out of a bare server bundle until a route opts in.
|
|
5
|
+
*/
|
|
6
|
+
import type { SSEContext, SSEInit, TypedSSEStream } from "./sse.ts"
|
|
7
|
+
|
|
8
|
+
export interface SseRuntime {
|
|
9
|
+
response<Event>(
|
|
10
|
+
context: SSEContext,
|
|
11
|
+
run: (stream: TypedSSEStream<Event>) => void | Promise<void>,
|
|
12
|
+
init?: SSEInit,
|
|
13
|
+
): Response
|
|
14
|
+
}
|
package/src/server/sse.ts
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
* stream.signal.addEventListener("abort", () => { off(); resolve() }, { once: true }))
|
|
22
22
|
* }, { keepAlive: 15_000 }))
|
|
23
23
|
*/
|
|
24
|
+
import { INSTALL_SSE } from "./install.ts"
|
|
25
|
+
import type { IdentityPlugin } from "./plugin.ts"
|
|
26
|
+
import type { AnyServer } from "./server.ts"
|
|
27
|
+
import type { SseRuntime } from "./sse-hook.ts"
|
|
24
28
|
|
|
25
29
|
/** One SSE frame. Every field is optional; `data` may be multi-line (emitted as multiple `data:` lines). */
|
|
26
30
|
export interface SSEMessage {
|
|
@@ -188,3 +192,25 @@ export function sse(
|
|
|
188
192
|
if (!headers.has("x-accel-buffering")) headers.set("x-accel-buffering", "no") // defeat proxy buffering
|
|
189
193
|
return new Response(body, { status: init.status ?? 200, headers })
|
|
190
194
|
}
|
|
195
|
+
|
|
196
|
+
/** The install seam a server exposes so the `streaming()` plugin can hand it the runtime. */
|
|
197
|
+
interface SseInstallable {
|
|
198
|
+
[INSTALL_SSE](runtime: SseRuntime): void
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Enable `.sse()` streaming routes: `.use(streaming())` installs the SSE runtime. Without it, an
|
|
203
|
+
* `.sse()` route is a registration error, so the ReadableStream framing stays out of non-SSE bundles.
|
|
204
|
+
* The `sse()` / `typedSSEStream()` helpers ship from this same subpath for use inside handlers.
|
|
205
|
+
*/
|
|
206
|
+
export function streaming(): IdentityPlugin {
|
|
207
|
+
const apply = <S extends AnyServer>(app: S): S => {
|
|
208
|
+
;(app as unknown as SseInstallable)[INSTALL_SSE]({
|
|
209
|
+
response(context, run, init) {
|
|
210
|
+
return sse(context, (stream) => run(typedSSEStream(stream)), init)
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
return app
|
|
214
|
+
}
|
|
215
|
+
return Object.assign(apply, { pluginName: "nifra:streaming" }) as IdentityPlugin
|
|
216
|
+
}
|
|
@@ -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
|
|
|
@@ -130,6 +131,7 @@ export interface WebSocketHandler<
|
|
|
130
131
|
Data = unknown,
|
|
131
132
|
Env = unknown,
|
|
132
133
|
Schema extends StandardSchemaV1 | undefined = undefined,
|
|
134
|
+
Send extends StandardSchemaV1 | undefined = undefined,
|
|
133
135
|
> {
|
|
134
136
|
/**
|
|
135
137
|
* Cross-site WebSocket hijacking (CSWSH) guard — checked BEFORE `upgrade()`. A browser does not
|
|
@@ -159,6 +161,18 @@ export interface WebSocketHandler<
|
|
|
159
161
|
* non-JSON or schema-invalid frame is routed to `onInvalidMessage` (or dropped if that's omitted).
|
|
160
162
|
*/
|
|
161
163
|
messageSchema?: Schema
|
|
164
|
+
/**
|
|
165
|
+
* The **outbound** frame contract (server → client), a Standard Schema. Purely type-level: it types
|
|
166
|
+
* the frames the typed client's `.ws()` handle receives, and documents what this route pushes. The
|
|
167
|
+
* server does NOT runtime-validate its own sends - the inbound `messageSchema` guards the trust
|
|
168
|
+
* boundary; outbound honesty is the handler author's code, checked by tests.
|
|
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
|
+
}
|
|
162
176
|
open?(ws: NifraWebSocket<Data>): MaybePromise<void>
|
|
163
177
|
message?(ws: NifraWebSocket<Data>, data: WsMessageInput<Schema>): MaybePromise<void>
|
|
164
178
|
/** Inbound frame that failed JSON parse or `messageSchema` validation (only fires when a schema is
|
|
@@ -189,8 +203,20 @@ export type WebSocketUpgradeOutcome =
|
|
|
189
203
|
readonly data: unknown
|
|
190
204
|
/** The app's pub/sub registry — the adapter wires `ws.subscribe` + close-cleanup to it. */
|
|
191
205
|
readonly pubsub: TopicRegistry
|
|
206
|
+
/** The installed runtime's {@link attachWebSocket}, carried on the outcome so an adapter can wire
|
|
207
|
+
* a standard socket without a static import of the WS implementation (which would defeat the
|
|
208
|
+
* `.use(websocket())` tree-shaking). `@nifrajs/workers` may import `attachWebSocket` directly instead. */
|
|
209
|
+
readonly attach: WsAttach
|
|
192
210
|
}
|
|
193
211
|
|
|
212
|
+
/** The socket-wiring signature the upgrade outcome carries (the installed runtime's `attach`). */
|
|
213
|
+
export type WsAttach = (
|
|
214
|
+
socket: StandardWebSocket,
|
|
215
|
+
handler: WebSocketHandler,
|
|
216
|
+
data: unknown,
|
|
217
|
+
options: { openNow: boolean; pubsub: TopicRegistry },
|
|
218
|
+
) => NifraWebSocket
|
|
219
|
+
|
|
194
220
|
/**
|
|
195
221
|
* A standard server-side `WebSocket` — the half returned by Deno's `Deno.upgradeWebSocket` and the
|
|
196
222
|
* Workers `WebSocketPair`. {@link attachWebSocket} wires one to a nifra handler, so the Deno and Workers
|
|
@@ -297,7 +323,15 @@ export function wrapWebSocketMessageValidation(handler: WebSocketHandler): WebSo
|
|
|
297
323
|
const validatingMessage = (ws: NifraWebSocket, raw: WebSocketData): MaybePromise<void> => {
|
|
298
324
|
let parsed: unknown
|
|
299
325
|
try {
|
|
300
|
-
|
|
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
|
+
})
|
|
301
335
|
} catch {
|
|
302
336
|
return onInvalid?.(ws, [{ message: "invalid JSON" }], raw)
|
|
303
337
|
}
|
package/src/server/ws-hook.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* `@nifrajs/core/ws`
|
|
4
|
-
*
|
|
5
|
-
* apps that never call `app.ws()`
|
|
2
|
+
* WebSocket runtime contract. `server.ts` type-imports only this; the `.use(websocket())` plugin
|
|
3
|
+
* (from `@nifrajs/core/ws`) installs the implementation on a server instance via the `INSTALL_WS`
|
|
4
|
+
* symbol seam. That keeps the WS dispatcher, pub/sub registry, Bun socket plumbing, and
|
|
5
|
+
* message-schema validation out of the base server bundle for apps that never call `app.ws()`
|
|
6
|
+
* (~1.5 KB gzipped on the minimal-app benchmark).
|
|
6
7
|
*
|
|
7
|
-
* Only type
|
|
8
|
+
* Only type declarations below - this module must stay weightless, it is always in the base bundle.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import type {
|
|
@@ -25,13 +26,13 @@ export interface BunWsHandlers {
|
|
|
25
26
|
|
|
26
27
|
/** What `@nifrajs/core/ws` registers: everything `server.ts` and `toFetchHandler` need at runtime. */
|
|
27
28
|
export interface WsRuntime {
|
|
28
|
-
/** `wrapWebSocketMessageValidation`
|
|
29
|
+
/** `wrapWebSocketMessageValidation` - applied once at `app.ws()` registration. */
|
|
29
30
|
wrapHandler(handler: WebSocketHandler): WebSocketHandler
|
|
30
31
|
/** One in-process pub/sub registry per app (backs `ws.subscribe` + `app.publish`). */
|
|
31
32
|
createTopics(): TopicRegistry
|
|
32
33
|
/** The Bun `websocket` config for `listen()` when the app has WS routes. */
|
|
33
34
|
bunHandlers(topics: TopicRegistry): BunWsHandlers
|
|
34
|
-
/** `attachWebSocket`
|
|
35
|
+
/** `attachWebSocket` - wires a standard server socket (Workers `WebSocketPair`) to a handler. */
|
|
35
36
|
attach(
|
|
36
37
|
socket: StandardWebSocket,
|
|
37
38
|
handler: WebSocketHandler,
|
|
@@ -39,15 +40,3 @@ export interface WsRuntime {
|
|
|
39
40
|
options: { openNow: boolean; pubsub: TopicRegistry },
|
|
40
41
|
): NifraWebSocket
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
let runtime: WsRuntime | undefined
|
|
44
|
-
|
|
45
|
-
/** Called by `@nifrajs/core/ws` on import. Last registration wins (idempotent in practice — there is
|
|
46
|
-
* one implementation; double-import is a no-op re-set of the same functions). */
|
|
47
|
-
export function setWsRuntime(impl: WsRuntime): void {
|
|
48
|
-
runtime = impl
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function getWsRuntime(): WsRuntime | undefined {
|
|
52
|
-
return runtime
|
|
53
|
-
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lean server entry for the common Nifra runtime.
|
|
3
|
+
*
|
|
4
|
+
* Use `@nifrajs/core/server` or the equivalent lean package root when building an HTTP app.
|
|
5
|
+
* Optional systems such as
|
|
6
|
+
* causality, invariants, manifests, reflection, and capability tooling live at
|
|
7
|
+
* their dedicated subpaths. Keeping this entry curated is intentional: do not
|
|
8
|
+
* export an opt-in module here unless the Server implementation already loads it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
|
|
12
|
+
export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
|
|
13
|
+
export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
|
|
14
|
+
export type {
|
|
15
|
+
InferInput,
|
|
16
|
+
InferOutput,
|
|
17
|
+
StandardIssue,
|
|
18
|
+
StandardResult,
|
|
19
|
+
StandardSchemaV1,
|
|
20
|
+
StandardTypes,
|
|
21
|
+
ValidationOutcome,
|
|
22
|
+
} from "./schema/standard.ts"
|
|
23
|
+
export type {
|
|
24
|
+
Context,
|
|
25
|
+
Params,
|
|
26
|
+
Platform,
|
|
27
|
+
Prettify,
|
|
28
|
+
ResponseControls,
|
|
29
|
+
RouteSchema,
|
|
30
|
+
} from "./server/context.ts"
|
|
31
|
+
export {
|
|
32
|
+
type CookieOptions,
|
|
33
|
+
parseCookies,
|
|
34
|
+
serializeCookie,
|
|
35
|
+
signValue,
|
|
36
|
+
unsignValue,
|
|
37
|
+
} from "./server/cookies.ts"
|
|
38
|
+
export {
|
|
39
|
+
commonSecretPatterns,
|
|
40
|
+
jsonLogger,
|
|
41
|
+
type LogFields,
|
|
42
|
+
type Logger,
|
|
43
|
+
type RedactOptions,
|
|
44
|
+
redactLogFields,
|
|
45
|
+
silentLogger,
|
|
46
|
+
} from "./server/logger.ts"
|
|
47
|
+
export type { Registry, RouteInfo } from "./server/registry.ts"
|
|
48
|
+
export {
|
|
49
|
+
type AdmissionController,
|
|
50
|
+
type AdmissionDecision,
|
|
51
|
+
type AnyServer,
|
|
52
|
+
type DurableObjectNamespaceLike,
|
|
53
|
+
defineIdentityPlugin,
|
|
54
|
+
definePlugin,
|
|
55
|
+
defineRouterPlugin,
|
|
56
|
+
type ExecutionContext,
|
|
57
|
+
type Handler,
|
|
58
|
+
type IdentityPlugin,
|
|
59
|
+
type McpPromptDescriptor,
|
|
60
|
+
type McpResourceDescriptor,
|
|
61
|
+
type Middleware,
|
|
62
|
+
type NifraPlugin,
|
|
63
|
+
type NodeServeOutcome,
|
|
64
|
+
type OnRequestResult,
|
|
65
|
+
type PromptArgument,
|
|
66
|
+
type PromptMessage,
|
|
67
|
+
type ResponseFinalization,
|
|
68
|
+
type RouteDescriptor,
|
|
69
|
+
type RunningServer,
|
|
70
|
+
type ScheduledController,
|
|
71
|
+
type ScheduledHandler,
|
|
72
|
+
Server,
|
|
73
|
+
type ServerOptions,
|
|
74
|
+
server,
|
|
75
|
+
type ToolAnnotations,
|
|
76
|
+
toFetchHandler,
|
|
77
|
+
} from "./server/server.ts"
|
|
78
|
+
export type {
|
|
79
|
+
SSEContext,
|
|
80
|
+
SSEInit,
|
|
81
|
+
SSEMessage,
|
|
82
|
+
SSEStream,
|
|
83
|
+
TypedSSEStream,
|
|
84
|
+
} from "./server/sse.ts"
|
|
85
|
+
export type {
|
|
86
|
+
NifraWebSocket,
|
|
87
|
+
StandardWebSocket,
|
|
88
|
+
WebSocketContext,
|
|
89
|
+
WebSocketData,
|
|
90
|
+
WebSocketHandler,
|
|
91
|
+
WebSocketUpgradeOutcome,
|
|
92
|
+
} from "./server/websocket.ts"
|
|
@@ -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
|
+
}
|