@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
package/src/wire.ts ADDED
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Rich-type wire codec: round-trips values that plain JSON silently drops or corrupts, across any
3
+ * JSON transport (RPC bodies, loader payloads, WebSocket frames).
4
+ *
5
+ * `JSON.stringify` turns `undefined` into a missing key, `Date` into a lossy string, `NaN`/`Infinity`
6
+ * into `null`, loses `-0`, and throws on `BigInt` - and it has no notion of `Map`, `Set`, `RegExp`,
7
+ * `URL`, `ArrayBuffer`, or typed arrays. It also flattens a shared reference into two copies and throws
8
+ * on a cycle. A typed client that infers `Date` from the server type then receives a `string` at runtime
9
+ * is exactly the drift this framework exists to remove. `encode` maps a value to a JSON-safe structure
10
+ * that `decode` reconstructs exactly - including cycles and preserved reference identity; `stringify` /
11
+ * `parse` are the drop-in `JSON.*` equivalents.
12
+ *
13
+ * The wire form is `{ r, n }`: `r` is the root (an inline value or a `ref`), `n` is a flat table of
14
+ * container/rich nodes addressed by index. Because every object is interned in `n` and referenced by
15
+ * index, a shared object is encoded once (and decodes to a single shared instance), and a cycle is a
16
+ * back-reference rather than infinite recursion. Functions and symbols are rejected, never silently
17
+ * dropped. Decoding validates every tag and index, so a malformed payload throws {@link WireDecodeError}
18
+ * rather than producing a corrupt value.
19
+ *
20
+ * Not covered by design (matches `JSON`): class identity (a class instance decodes to a plain object
21
+ * carrying its own enumerable properties), non-enumerable and symbol keys, and getters (read once).
22
+ */
23
+
24
+ /** Discriminator key on a tagged wire node. Never collides with user data: user objects live under a
25
+ * node's `v` field, so their own keys - including one literally named `$w` - are never re-interpreted. */
26
+ const TAG = "$w"
27
+
28
+ /** The JSON-safe encoded form produced by {@link encode} and consumed by {@link decode}. */
29
+ export interface Wire {
30
+ /** The root value: an inline primitive/scalar, or a `{ [TAG]: "ref", i }` into {@link Wire.n}. */
31
+ readonly r: unknown
32
+ /** Interned container and rich-type nodes, addressed by index from `ref`s. */
33
+ readonly n: readonly unknown[]
34
+ }
35
+
36
+ /** Resource limits applied while reconstructing transport-controlled wire data. */
37
+ export interface WireDecodeLimits {
38
+ /** Maximum number of interned nodes. Default 10,000. */
39
+ readonly maxNodes?: number
40
+ /** Maximum nesting depth across containers and references. Default 256. */
41
+ readonly maxDepth?: number
42
+ /** Maximum total object/array/map/set entries visited. Default 100,000. */
43
+ readonly maxCollectionEntries?: number
44
+ /** Maximum UTF-8 string plus decoded binary bytes materialized. Default 16 MiB. */
45
+ readonly maxDecodedBytes?: number
46
+ }
47
+
48
+ export const DEFAULT_WIRE_DECODE_LIMITS: Readonly<Required<WireDecodeLimits>> = Object.freeze({
49
+ maxNodes: 10_000,
50
+ maxDepth: 256,
51
+ maxCollectionEntries: 100_000,
52
+ maxDecodedBytes: 16 * 1024 * 1024,
53
+ })
54
+
55
+ /** Thrown by {@link encode} for a value it will not encode (a function or a symbol). */
56
+ export class WireEncodeError extends TypeError {
57
+ constructor(message: string) {
58
+ super(message)
59
+ this.name = "WireEncodeError"
60
+ }
61
+ }
62
+
63
+ /** Thrown by {@link decode} for a wire value carrying an unknown tag, a bad index, or malformed shape. */
64
+ export class WireDecodeError extends TypeError {
65
+ constructor(message: string) {
66
+ super(message)
67
+ this.name = "WireDecodeError"
68
+ }
69
+ }
70
+
71
+ function isRecord(value: unknown): value is Record<string, unknown> {
72
+ return value !== null && typeof value === "object" && !Array.isArray(value)
73
+ }
74
+
75
+ function decodeLimit(value: number | undefined, fallback: number, label: string): number {
76
+ const limit = value ?? fallback
77
+ if (!Number.isSafeInteger(limit) || limit < 0) {
78
+ throw new RangeError(`wire decode ${label} must be a non-negative safe integer`)
79
+ }
80
+ return limit
81
+ }
82
+
83
+ function utf8ByteLength(value: string, stopAfter = Number.MAX_SAFE_INTEGER): number {
84
+ let bytes = 0
85
+ for (let index = 0; index < value.length; index++) {
86
+ const code = value.charCodeAt(index)
87
+ if (code < 0x80) bytes++
88
+ else if (code < 0x800) bytes += 2
89
+ else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
90
+ const next = value.charCodeAt(index + 1)
91
+ if (next >= 0xdc00 && next <= 0xdfff) {
92
+ bytes += 4
93
+ index++
94
+ } else bytes += 3
95
+ } else bytes += 3
96
+ if (bytes > stopAfter) return bytes
97
+ }
98
+ return bytes
99
+ }
100
+
101
+ /** The typed-array kinds the codec round-trips, by constructor name. */
102
+ const TYPED_ARRAYS = {
103
+ Int8Array,
104
+ Uint8Array,
105
+ Uint8ClampedArray,
106
+ Int16Array,
107
+ Uint16Array,
108
+ Int32Array,
109
+ Uint32Array,
110
+ Float32Array,
111
+ Float64Array,
112
+ BigInt64Array,
113
+ BigUint64Array,
114
+ } as const
115
+ type TypedArrayName = keyof typeof TYPED_ARRAYS
116
+ type TypedArray = InstanceType<(typeof TYPED_ARRAYS)[TypedArrayName]>
117
+
118
+ function defineEnumerableOwn(target: object, key: string, value: unknown): void {
119
+ Object.defineProperty(target, key, {
120
+ value,
121
+ enumerable: true,
122
+ configurable: true,
123
+ writable: true,
124
+ })
125
+ }
126
+
127
+ // ─── encode ─────────────────────────────────────────────────────────────────
128
+
129
+ /** Encode any supported value into a JSON-safe {@link Wire} form. */
130
+ export function encode(value: unknown): Wire {
131
+ const nodes: unknown[] = []
132
+ const interned = new Map<object, number>()
133
+
134
+ /** Encode a value to its inline wire form, interning objects into `nodes` and returning a `ref`. */
135
+ const inline = (v: unknown): unknown => {
136
+ if (v === undefined) return { [TAG]: "undef" }
137
+ if (v === null) return null
138
+
139
+ switch (typeof v) {
140
+ case "string":
141
+ case "boolean":
142
+ return v
143
+ case "number":
144
+ if (Object.is(v, -0)) return { [TAG]: "num", v: "-0" }
145
+ if (Number.isNaN(v)) return { [TAG]: "num", v: "NaN" }
146
+ if (v === Infinity) return { [TAG]: "num", v: "Infinity" }
147
+ if (v === -Infinity) return { [TAG]: "num", v: "-Infinity" }
148
+ return v // a finite, signed-zero-free number is already JSON-safe
149
+ case "bigint":
150
+ return { [TAG]: "bigint", v: v.toString() }
151
+ case "function":
152
+ case "symbol":
153
+ throw new WireEncodeError(`cannot encode a ${typeof v}`)
154
+ }
155
+
156
+ const obj = v as object
157
+ const existing = interned.get(obj)
158
+ if (existing !== undefined) return { [TAG]: "ref", i: existing }
159
+
160
+ // Reserve this object's index BEFORE encoding its body, so a self- or mutual-reference inside the
161
+ // body resolves to a `ref` here instead of recursing forever.
162
+ const index = nodes.length
163
+ interned.set(obj, index)
164
+ nodes.push(0) // placeholder, overwritten below
165
+ nodes[index] = body(v, inline)
166
+ return { [TAG]: "ref", i: index }
167
+ }
168
+
169
+ return { r: inline(value), n: nodes }
170
+ }
171
+
172
+ /** Encode the interned body of one object into its tagged node. */
173
+ function body(v: unknown, inline: (x: unknown) => unknown): unknown {
174
+ if (v instanceof Date) {
175
+ const time = v.getTime()
176
+ return { [TAG]: "date", v: Number.isNaN(time) ? null : v.toISOString() }
177
+ }
178
+ if (v instanceof RegExp) return { [TAG]: "regexp", v: [v.source, v.flags] }
179
+ if (v instanceof URL) return { [TAG]: "url", v: v.href }
180
+ if (v instanceof ArrayBuffer) return { [TAG]: "buf", v: bytesToBase64(new Uint8Array(v)) }
181
+ if (v instanceof DataView) {
182
+ return { [TAG]: "dv", v: bytesToBase64(new Uint8Array(v.buffer, v.byteOffset, v.byteLength)) }
183
+ }
184
+ if (ArrayBuffer.isView(v)) {
185
+ const view = v as TypedArray
186
+ const kind = view.constructor.name
187
+ if (!(kind in TYPED_ARRAYS)) throw new WireEncodeError(`cannot encode typed array ${kind}`)
188
+ return {
189
+ [TAG]: "ta",
190
+ k: kind,
191
+ v: bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)),
192
+ }
193
+ }
194
+ if (v instanceof Map) {
195
+ return { [TAG]: "map", v: Array.from(v, ([k, val]) => [inline(k), inline(val)]) }
196
+ }
197
+ if (v instanceof Set) return { [TAG]: "set", v: Array.from(v, (item) => inline(item)) }
198
+ if (Array.isArray(v)) return { [TAG]: "arr", v: v.map((item) => inline(item)) }
199
+
200
+ const record = v as Record<string, unknown>
201
+ const out: Record<string, unknown> = {}
202
+ for (const key of Object.keys(record)) defineEnumerableOwn(out, key, inline(record[key]))
203
+ return { [TAG]: "obj", v: out }
204
+ }
205
+
206
+ // ─── decode ─────────────────────────────────────────────────────────────────
207
+
208
+ /** Reconstruct the original value from a {@link Wire} form produced by {@link encode}. */
209
+ export function decode(wire: Wire, limits: WireDecodeLimits = {}): unknown {
210
+ if (!isRecord(wire) || !Object.hasOwn(wire, "r") || !Array.isArray(wire.n)) {
211
+ throw new WireDecodeError("not a wire value: expected { r, n }")
212
+ }
213
+ const nodes = wire.n
214
+ const maxNodes = decodeLimit(limits.maxNodes, DEFAULT_WIRE_DECODE_LIMITS.maxNodes, "maxNodes")
215
+ const maxDepth = decodeLimit(limits.maxDepth, DEFAULT_WIRE_DECODE_LIMITS.maxDepth, "maxDepth")
216
+ const maxCollectionEntries = decodeLimit(
217
+ limits.maxCollectionEntries,
218
+ DEFAULT_WIRE_DECODE_LIMITS.maxCollectionEntries,
219
+ "maxCollectionEntries",
220
+ )
221
+ const maxDecodedBytes = decodeLimit(
222
+ limits.maxDecodedBytes,
223
+ DEFAULT_WIRE_DECODE_LIMITS.maxDecodedBytes,
224
+ "maxDecodedBytes",
225
+ )
226
+ if (nodes.length > maxNodes) throw new WireDecodeError("wire node limit exceeded")
227
+ const built = new Map<number, unknown>()
228
+ let collectionEntries = 0
229
+ let decodedBytes = 0
230
+
231
+ const enter = (depth: number): void => {
232
+ if (depth > maxDepth) throw new WireDecodeError("wire depth limit exceeded")
233
+ }
234
+ const chargeEntries = (count: number): void => {
235
+ collectionEntries += count
236
+ if (!Number.isSafeInteger(collectionEntries) || collectionEntries > maxCollectionEntries) {
237
+ throw new WireDecodeError("wire collection entry limit exceeded")
238
+ }
239
+ }
240
+ const chargeBytes = (count: number): void => {
241
+ decodedBytes += count
242
+ if (!Number.isSafeInteger(decodedBytes) || decodedBytes > maxDecodedBytes) {
243
+ throw new WireDecodeError("wire decoded byte limit exceeded")
244
+ }
245
+ }
246
+ const chargeString = (value: string): void =>
247
+ chargeBytes(utf8ByteLength(value, maxDecodedBytes - decodedBytes))
248
+
249
+ const inline = (w: unknown, depth: number): unknown => {
250
+ enter(depth)
251
+ if (w === null || typeof w === "boolean") return w
252
+ if (typeof w === "string") {
253
+ chargeString(w)
254
+ return w
255
+ }
256
+ if (typeof w === "number") {
257
+ if (!Number.isFinite(w) || Object.is(w, -0)) {
258
+ throw new WireDecodeError("malformed wire: non-JSON number inline")
259
+ }
260
+ return w
261
+ }
262
+ if (!isRecord(w)) throw new WireDecodeError("malformed wire: invalid inline value")
263
+ const record = w
264
+ const tag = record[TAG]
265
+ if (typeof tag !== "string")
266
+ throw new WireDecodeError("malformed wire: object without a tag inline")
267
+ switch (tag) {
268
+ case "undef":
269
+ return undefined
270
+ case "num": {
271
+ const v = record.v
272
+ if (v === "-0") return -0
273
+ if (v === "NaN") return NaN
274
+ if (v === "Infinity") return Infinity
275
+ if (v === "-Infinity") return -Infinity
276
+ throw new WireDecodeError(`malformed num token: ${String(v)}`)
277
+ }
278
+ case "bigint": {
279
+ if (typeof record.v !== "string" || !/^-?(?:0|[1-9][0-9]*)$/u.test(record.v)) {
280
+ throw new WireDecodeError("malformed bigint token")
281
+ }
282
+ chargeString(record.v)
283
+ return BigInt(record.v)
284
+ }
285
+ case "ref":
286
+ return resolve(record.i, depth)
287
+ default:
288
+ throw new WireDecodeError(`unexpected inline tag: ${tag}`)
289
+ }
290
+ }
291
+
292
+ const resolve = (raw: unknown, depth: number): unknown => {
293
+ enter(depth)
294
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0 || raw >= nodes.length) {
295
+ throw new WireDecodeError(`ref out of range: ${String(raw)}`)
296
+ }
297
+ const index = raw
298
+ const cached = built.get(index)
299
+ if (cached !== undefined || built.has(index)) return cached
300
+
301
+ const node = nodes[index]
302
+ if (!isRecord(node)) throw new WireDecodeError(`malformed node at ${index}`)
303
+ const record = node
304
+ const tag = record[TAG]
305
+
306
+ switch (tag) {
307
+ case "obj": {
308
+ if (!isRecord(record.v)) throw new WireDecodeError(`malformed obj node at ${index}`)
309
+ const out: Record<string, unknown> = {}
310
+ built.set(index, out) // publish the shell first so cycles resolve to it
311
+ const src = record.v
312
+ const keys = Object.keys(src)
313
+ chargeEntries(keys.length)
314
+ for (const key of keys) {
315
+ chargeString(key)
316
+ defineEnumerableOwn(out, key, inline(src[key], depth + 1))
317
+ }
318
+ return out
319
+ }
320
+ case "arr": {
321
+ if (!Array.isArray(record.v)) throw new WireDecodeError(`malformed arr node at ${index}`)
322
+ const out: unknown[] = []
323
+ built.set(index, out)
324
+ chargeEntries(record.v.length)
325
+ for (const item of record.v) out.push(inline(item, depth + 1))
326
+ return out
327
+ }
328
+ case "map": {
329
+ if (!Array.isArray(record.v)) throw new WireDecodeError(`malformed map node at ${index}`)
330
+ const out = new Map<unknown, unknown>()
331
+ built.set(index, out)
332
+ chargeEntries(record.v.length)
333
+ for (const entry of record.v) {
334
+ if (!Array.isArray(entry) || entry.length !== 2) {
335
+ throw new WireDecodeError(`malformed map entry at ${index}`)
336
+ }
337
+ out.set(inline(entry[0], depth + 1), inline(entry[1], depth + 1))
338
+ }
339
+ return out
340
+ }
341
+ case "set": {
342
+ if (!Array.isArray(record.v)) throw new WireDecodeError(`malformed set node at ${index}`)
343
+ const out = new Set<unknown>()
344
+ built.set(index, out)
345
+ chargeEntries(record.v.length)
346
+ for (const item of record.v) out.add(inline(item, depth + 1))
347
+ return out
348
+ }
349
+ case "date": {
350
+ if (record.v !== null && typeof record.v !== "string") {
351
+ throw new WireDecodeError(`malformed date node at ${index}`)
352
+ }
353
+ if (record.v !== null) chargeString(record.v)
354
+ const out = record.v === null ? new Date(NaN) : new Date(record.v)
355
+ if (record.v !== null && (Number.isNaN(out.getTime()) || out.toISOString() !== record.v)) {
356
+ throw new WireDecodeError(`malformed date node at ${index}`)
357
+ }
358
+ built.set(index, out)
359
+ return out
360
+ }
361
+ case "regexp": {
362
+ if (
363
+ !Array.isArray(record.v) ||
364
+ record.v.length !== 2 ||
365
+ typeof record.v[0] !== "string" ||
366
+ typeof record.v[1] !== "string"
367
+ ) {
368
+ throw new WireDecodeError(`malformed regexp node at ${index}`)
369
+ }
370
+ chargeString(record.v[0])
371
+ chargeString(record.v[1])
372
+ let out: RegExp
373
+ try {
374
+ out = new RegExp(record.v[0], record.v[1])
375
+ } catch {
376
+ throw new WireDecodeError(`malformed regexp node at ${index}`)
377
+ }
378
+ built.set(index, out)
379
+ return out
380
+ }
381
+ case "url": {
382
+ if (typeof record.v !== "string") {
383
+ throw new WireDecodeError(`malformed url node at ${index}`)
384
+ }
385
+ chargeString(record.v)
386
+ let out: URL
387
+ try {
388
+ out = new URL(record.v)
389
+ } catch {
390
+ throw new WireDecodeError(`malformed url node at ${index}`)
391
+ }
392
+ built.set(index, out)
393
+ return out
394
+ }
395
+ case "buf": {
396
+ const out = base64ToBytes(record.v, index, chargeBytes).buffer
397
+ built.set(index, out)
398
+ return out
399
+ }
400
+ case "dv": {
401
+ const out = new DataView(base64ToBytes(record.v, index, chargeBytes).buffer)
402
+ built.set(index, out)
403
+ return out
404
+ }
405
+ case "ta": {
406
+ const kind = record.k
407
+ if (typeof kind !== "string" || !(kind in TYPED_ARRAYS)) {
408
+ throw new WireDecodeError(`unknown typed array: ${String(kind)}`)
409
+ }
410
+ const Ctor = TYPED_ARRAYS[kind as TypedArrayName]
411
+ const bytes = base64ToBytes(record.v, index, chargeBytes)
412
+ if (bytes.byteLength % Ctor.BYTES_PER_ELEMENT !== 0) {
413
+ throw new WireDecodeError(`misaligned bytes for ${kind}`)
414
+ }
415
+ // `base64ToBytes` always allocates a fresh, non-shared ArrayBuffer, so this narrowing is sound.
416
+ const out = new Ctor(
417
+ bytes.buffer as ArrayBuffer,
418
+ 0,
419
+ bytes.byteLength / Ctor.BYTES_PER_ELEMENT,
420
+ )
421
+ built.set(index, out)
422
+ return out
423
+ }
424
+ default:
425
+ throw new WireDecodeError(`unknown wire tag: ${String(tag)}`)
426
+ }
427
+ }
428
+
429
+ return inline(wire.r, 0)
430
+ }
431
+
432
+ /** `encode` + `JSON.stringify` in one call - the rich-type equivalent of `JSON.stringify`. */
433
+ export function stringify(value: unknown): string {
434
+ return JSON.stringify(encode(value))
435
+ }
436
+
437
+ /** `JSON.parse` + `decode` in one call - the rich-type equivalent of `JSON.parse`. */
438
+ export function parse(text: string, limits: WireDecodeLimits = {}): unknown {
439
+ return decode(JSON.parse(text) as Wire, limits)
440
+ }
441
+
442
+ // ─── base64 (portable across Bun / browser / Workers / Node / Deno) ───────────
443
+
444
+ function bytesToBase64(bytes: Uint8Array): string {
445
+ let binary = ""
446
+ const CHUNK = 0x8000 // chunk so String.fromCharCode(...) never overflows the call stack
447
+ for (let i = 0; i < bytes.length; i += CHUNK)
448
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK))
449
+ return btoa(binary)
450
+ }
451
+
452
+ function base64ToBytes(
453
+ base64: unknown,
454
+ index: number,
455
+ reserve: (decodedBytes: number) => void,
456
+ ): Uint8Array {
457
+ if (typeof base64 !== "string" || base64.length % 4 !== 0) {
458
+ throw new WireDecodeError(`malformed base64 at ${index}`)
459
+ }
460
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0
461
+ reserve((base64.length / 4) * 3 - padding)
462
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(base64)) {
463
+ throw new WireDecodeError(`malformed base64 at ${index}`)
464
+ }
465
+ let binary: string
466
+ try {
467
+ binary = atob(base64)
468
+ } catch {
469
+ throw new WireDecodeError(`malformed base64 at ${index}`)
470
+ }
471
+ const bytes = new Uint8Array(binary.length)
472
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
473
+ return bytes
474
+ }