@adhdev/mesh-shared 1.0.56-rc.8 → 1.0.56

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.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Mesh RPC frame chunking — split/reassemble oversized mesh envelopes.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * The mesh DataChannel path (`daemon-mesh-manager`) writes every RPC envelope as a
6
+ * SINGLE `dc.sendMessage(JSON.stringify(envelope))` frame. That was fine while every
7
+ * mesh arg was small text, but a coordinator dispatching an IMAGE to a worker puts a
8
+ * multi-MB base64 part inside `args` — far past what one DataChannel frame carries, so
9
+ * the send throws or the frame is dropped and the dispatch silently dies.
10
+ *
11
+ * The dashboard P2P path already solved exactly this problem and has been running in
12
+ * production: `packages/daemon-cloud/src/daemon-p2p/data-channel-router.ts` (send +
13
+ * reassemble) and `packages/web-cloud/src/p2p.ts` (browser side). This module is a PORT
14
+ * of that proven scheme onto the mesh envelope shape — deliberately NOT a new protocol.
15
+ * The boundary values are carried over unchanged (see the constants below for why each
16
+ * one is what it is).
17
+ *
18
+ * It lives in mesh-shared because it is pure string/JSON work with no transport, no
19
+ * Node API and no DOM API — the same reason the normalizers live here. `daemon-cloud`
20
+ * (sender/receiver) is the only current consumer, but keeping it in the pure leaf means
21
+ * the standalone side can reassemble with the identical code rather than a hand-synced
22
+ * copy, which is the drift bug class this package was created to kill.
23
+ *
24
+ * FAILURE POLICY (DoD): reassembly never degrades silently. A malformed, out-of-range,
25
+ * over-budget or unparseable chunk stream yields an explicit typed failure that the
26
+ * caller turns into an RPC error — never a partially-applied or truncated envelope.
27
+ */
28
+
29
+ /**
30
+ * Inline ceiling: an envelope whose JSON is at or below this goes out as one frame,
31
+ * exactly as before this module existed. 60_000 (not 65_536) matches the dashboard
32
+ * path and leaves headroom under the common 64KiB SCTP message limit so the frame is
33
+ * never the thing that trips a transport-level cap.
34
+ */
35
+ export const MESH_MAX_INLINE_FRAME_BYTES = 60_000
36
+
37
+ /**
38
+ * Per-chunk payload slice, in CHARACTERS. Ported unchanged from the dashboard path.
39
+ *
40
+ * Why 16_000 chars is safe under a 60_000-BYTE ceiling, with ~3.75x of headroom:
41
+ * `splitMeshFrame` slices the ALREADY-SERIALIZED outer frame, so the text being cut
42
+ * is JSON output — control characters are pre-expanded to `\u00XX` ASCII before they
43
+ * ever reach the slicer. Re-escaping that slice inside the chunk envelope can at worst
44
+ * double the backslashes. Measured worst cases for a 16_000-char slice:
45
+ * backslashes / astral emoji → 32,073 bytes
46
+ * Korean (3-byte, unescaped) → 48,073 bytes ← densest observed
47
+ * plain ASCII → 16,073 bytes
48
+ * All are under MESH_MAX_INLINE_FRAME_BYTES. `splitMeshFrame` still MEASURES each
49
+ * envelope and shrinks on overflow, so the guarantee is enforced rather than assumed
50
+ * if either constant is ever retuned — but at these values that path is unreachable,
51
+ * which is exactly what `assertMeshChunkConstantsAreSafe` pins down.
52
+ */
53
+ export const MESH_CHUNK_PAYLOAD_CHARS = 16_000
54
+
55
+ /**
56
+ * Hard cap on chunk count for one frame. 1024 × ~16KB ≈ 16MB of transferable payload,
57
+ * which comfortably covers a screenshot while bounding what a single peer can make the
58
+ * receiver buffer. Exceeding it is an explicit refusal, never a truncated send.
59
+ */
60
+ export const MESH_MAX_CHUNKS = 1024
61
+
62
+ /**
63
+ * Reassembly budget for one frame, in bytes. Bounds receiver memory independently of
64
+ * chunk count so a peer cannot send 1024 maximally-large chunks to force an oversized
65
+ * allocation. Mirrors the dashboard receiver's MAX_REASSEMBLED_JSON_BYTES.
66
+ */
67
+ export const MESH_MAX_REASSEMBLED_BYTES = 16_000_000
68
+
69
+ /**
70
+ * How long a partially-received frame is retained. A sender that dies mid-stream must
71
+ * not pin receiver memory forever; the partial is swept and the frame simply never
72
+ * completes (the RPC's own deadline then reports it).
73
+ */
74
+ export const MESH_CHUNK_TTL_MS = 60_000
75
+
76
+ /** Envelope `kind` for a chunk of a larger mesh frame. */
77
+ export const MESH_CHUNK_KIND = 'rpc_chunk'
78
+
79
+ export interface MeshChunkEnvelope {
80
+ v: number
81
+ kind: typeof MESH_CHUNK_KIND
82
+ /** Groups the chunks of one logical frame. */
83
+ chunkId: string
84
+ /** 0-based position of this chunk. */
85
+ index: number
86
+ /** Total chunk count for this frame; constant across the group. */
87
+ total: number
88
+ /** The slice of the original envelope JSON. */
89
+ data: string
90
+ }
91
+
92
+ export type MeshChunkSplitResult =
93
+ | { ok: true; chunks: MeshChunkEnvelope[] }
94
+ | { ok: false; reason: 'too_many_chunks' | 'chunk_too_large'; detail: string }
95
+
96
+ export type MeshChunkAcceptResult =
97
+ /** Chunk stored; the frame is not complete yet. */
98
+ | { status: 'partial'; received: number; total: number }
99
+ /** Final chunk landed and the frame parsed cleanly. */
100
+ | { status: 'complete'; frame: unknown }
101
+ /** Explicitly rejected — the caller must surface this, never ignore it. */
102
+ | { status: 'failed'; reason: MeshChunkFailureReason; detail: string; chunkId: string }
103
+
104
+ export type MeshChunkFailureReason =
105
+ | 'malformed_chunk'
106
+ | 'too_many_chunks'
107
+ | 'inconsistent_total'
108
+ | 'duplicate_chunk_mismatch'
109
+ | 'budget_exceeded'
110
+ | 'reassembled_parse_failed'
111
+
112
+ /** UTF-8 byte length without assuming Buffer or TextEncoder is present. */
113
+ export function meshUtf8ByteLength(value: string): number {
114
+ if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value).byteLength
115
+ let bytes = 0
116
+ for (let i = 0; i < value.length; i += 1) {
117
+ const code = value.charCodeAt(i)
118
+ if (code < 0x80) bytes += 1
119
+ else if (code < 0x800) bytes += 2
120
+ else if (code >= 0xd800 && code <= 0xdbff) { bytes += 4; i += 1 }
121
+ else bytes += 3
122
+ }
123
+ return bytes
124
+ }
125
+
126
+ /**
127
+ * Worst-case size of one chunk envelope at the current constants.
128
+ *
129
+ * Exported so the safety margin documented on MESH_CHUNK_PAYLOAD_CHARS is a CHECKED
130
+ * invariant rather than a comment that rots. The densest slice the splitter can produce
131
+ * is 3-byte unescaped text (JSON.stringify leaves non-ASCII as-is), so that is what this
132
+ * measures. If someone raises MESH_CHUNK_PAYLOAD_CHARS or lowers the frame ceiling past
133
+ * the safe point, the accompanying test fails loudly instead of the overflow only
134
+ * showing up as dropped frames on a live mesh.
135
+ */
136
+ export function measureWorstCaseChunkEnvelopeBytes(): number {
137
+ const densest = '한'.repeat(MESH_CHUNK_PAYLOAD_CHARS)
138
+ return meshUtf8ByteLength(JSON.stringify(
139
+ buildChunkEnvelope(Number.MAX_SAFE_INTEGER, 'x'.repeat(64), MESH_MAX_CHUNKS, MESH_MAX_CHUNKS, densest),
140
+ ))
141
+ }
142
+
143
+ /** True when the serialized frame must be chunked rather than sent inline. */
144
+ export function meshFrameNeedsChunking(json: string): boolean {
145
+ return meshUtf8ByteLength(json) > MESH_MAX_INLINE_FRAME_BYTES
146
+ }
147
+
148
+ function buildChunkEnvelope(
149
+ version: number, chunkId: string, index: number, total: number, data: string,
150
+ ): MeshChunkEnvelope {
151
+ return { v: version, kind: MESH_CHUNK_KIND, chunkId, index, total, data }
152
+ }
153
+
154
+ /**
155
+ * Split a serialized mesh envelope into chunk envelopes.
156
+ *
157
+ * Each slice is measured AS ITS FINAL SERIALIZED ENVELOPE and shrunk (×0.8, as in the
158
+ * dashboard implementation) until it fits the inline ceiling — so multi-byte UTF-8 and
159
+ * the envelope overhead are both accounted for rather than assumed away. `total` is
160
+ * stamped only after the full split is known, so every chunk in a group agrees.
161
+ */
162
+ export function splitMeshFrame(json: string, chunkId: string, version: number): MeshChunkSplitResult {
163
+ const slices: string[] = []
164
+ let offset = 0
165
+ while (offset < json.length) {
166
+ let end = Math.min(json.length, offset + MESH_CHUNK_PAYLOAD_CHARS)
167
+ // Measure against MESH_MAX_CHUNKS as the `total` placeholder: it is the widest the
168
+ // field can serialize to, so a slice that fits here still fits once the real (never
169
+ // larger) total is stamped in below.
170
+ while (end > offset) {
171
+ const candidate = json.slice(offset, end)
172
+ const probe = JSON.stringify(buildChunkEnvelope(version, chunkId, slices.length, MESH_MAX_CHUNKS, candidate))
173
+ if (meshUtf8ByteLength(probe) <= MESH_MAX_INLINE_FRAME_BYTES) break
174
+ const shrunk = Math.max(1, Math.floor((end - offset) * 0.8))
175
+ if (offset + shrunk >= end) { end -= 1; continue }
176
+ end = offset + shrunk
177
+ }
178
+ if (end <= offset) {
179
+ return { ok: false, reason: 'chunk_too_large', detail: 'a single character did not fit the chunk envelope budget' }
180
+ }
181
+ slices.push(json.slice(offset, end))
182
+ if (slices.length > MESH_MAX_CHUNKS) {
183
+ return {
184
+ ok: false,
185
+ reason: 'too_many_chunks',
186
+ detail: `frame needs more than ${MESH_MAX_CHUNKS} chunks (${meshUtf8ByteLength(json)} bytes)`,
187
+ }
188
+ }
189
+ offset = end
190
+ }
191
+ if (slices.length === 0) {
192
+ return { ok: false, reason: 'chunk_too_large', detail: 'refusing to chunk an empty frame' }
193
+ }
194
+ const total = slices.length
195
+ return { ok: true, chunks: slices.map((data, index) => buildChunkEnvelope(version, chunkId, index, total, data)) }
196
+ }
197
+
198
+ interface MeshChunkBuffer {
199
+ total: number
200
+ chunks: string[]
201
+ received: number
202
+ bytesReceived: number
203
+ createdAt: number
204
+ }
205
+
206
+ /**
207
+ * Receiver-side reassembly buffer, one per peer connection.
208
+ *
209
+ * Keyed by chunkId only — callers construct one assembler per peer, so chunk groups
210
+ * from different peers can never collide in the same map.
211
+ */
212
+ export class MeshChunkAssembler {
213
+ private readonly buffers = new Map<string, MeshChunkBuffer>()
214
+
215
+ constructor(private readonly now: () => number = () => Date.now()) {}
216
+
217
+ /** True when the frame is a chunk envelope this assembler should handle. */
218
+ static isChunkFrame(frame: unknown): boolean {
219
+ return !!frame && typeof frame === 'object' && (frame as { kind?: unknown }).kind === MESH_CHUNK_KIND
220
+ }
221
+
222
+ /** Drop partials older than the TTL so a dead sender cannot pin memory. */
223
+ private sweep(): void {
224
+ const now = this.now()
225
+ for (const [key, entry] of Array.from(this.buffers.entries())) {
226
+ if (now - entry.createdAt > MESH_CHUNK_TTL_MS) this.buffers.delete(key)
227
+ }
228
+ }
229
+
230
+ /** Discard any partial state for a peer (call on disconnect). */
231
+ reset(): void {
232
+ this.buffers.clear()
233
+ }
234
+
235
+ /** Number of frames currently mid-reassembly — for tests and diagnostics. */
236
+ get pendingCount(): number {
237
+ return this.buffers.size
238
+ }
239
+
240
+ /**
241
+ * Accept one chunk envelope.
242
+ *
243
+ * Never throws and never returns a partially-applied frame: the result is exactly one
244
+ * of partial / complete / failed, and `failed` carries a typed reason the transport
245
+ * turns into an explicit RPC error.
246
+ */
247
+ accept(frame: unknown): MeshChunkAcceptResult {
248
+ this.sweep()
249
+ const raw = frame as Partial<MeshChunkEnvelope> | null
250
+ const chunkId = typeof raw?.chunkId === 'string' ? raw.chunkId : ''
251
+ const index = Number(raw?.index)
252
+ const total = Number(raw?.total)
253
+ const data = typeof raw?.data === 'string' ? raw.data : ''
254
+
255
+ if (!chunkId || !Number.isInteger(index) || !Number.isInteger(total)
256
+ || index < 0 || total <= 0 || index >= total || !data) {
257
+ return {
258
+ status: 'failed',
259
+ reason: 'malformed_chunk',
260
+ detail: `malformed chunk envelope (chunkId=${chunkId || '-'} index=${raw?.index} total=${raw?.total})`,
261
+ chunkId,
262
+ }
263
+ }
264
+ if (total > MESH_MAX_CHUNKS) {
265
+ this.buffers.delete(chunkId)
266
+ return {
267
+ status: 'failed',
268
+ reason: 'too_many_chunks',
269
+ detail: `chunk total ${total} exceeds the ${MESH_MAX_CHUNKS} cap`,
270
+ chunkId,
271
+ }
272
+ }
273
+
274
+ let entry = this.buffers.get(chunkId)
275
+ if (!entry) {
276
+ entry = { total, chunks: new Array(total).fill(''), received: 0, bytesReceived: 0, createdAt: this.now() }
277
+ this.buffers.set(chunkId, entry)
278
+ } else if (entry.total !== total) {
279
+ // The sender disagrees with itself about the frame's shape — the stream is
280
+ // corrupt; drop it loudly rather than reassembling a mixed frame.
281
+ this.buffers.delete(chunkId)
282
+ return {
283
+ status: 'failed',
284
+ reason: 'inconsistent_total',
285
+ detail: `chunk ${index} declares total ${total} but the group was opened with ${entry.total}`,
286
+ chunkId,
287
+ }
288
+ }
289
+
290
+ const existing = entry.chunks[index]
291
+ if (existing) {
292
+ // A benign retransmit repeats identical bytes. Different bytes for the same slot
293
+ // means the ordering/identity guarantee is broken — refuse instead of picking one.
294
+ if (existing === data) return { status: 'partial', received: entry.received, total: entry.total }
295
+ this.buffers.delete(chunkId)
296
+ return {
297
+ status: 'failed',
298
+ reason: 'duplicate_chunk_mismatch',
299
+ detail: `chunk ${index} arrived twice with different content`,
300
+ chunkId,
301
+ }
302
+ }
303
+
304
+ const chunkBytes = meshUtf8ByteLength(data)
305
+ if (entry.bytesReceived + chunkBytes > MESH_MAX_REASSEMBLED_BYTES) {
306
+ this.buffers.delete(chunkId)
307
+ return {
308
+ status: 'failed',
309
+ reason: 'budget_exceeded',
310
+ detail: `reassembled frame would exceed ${MESH_MAX_REASSEMBLED_BYTES} bytes`,
311
+ chunkId,
312
+ }
313
+ }
314
+
315
+ entry.chunks[index] = data
316
+ entry.received += 1
317
+ entry.bytesReceived += chunkBytes
318
+ if (entry.received < entry.total) {
319
+ return { status: 'partial', received: entry.received, total: entry.total }
320
+ }
321
+
322
+ this.buffers.delete(chunkId)
323
+ try {
324
+ return { status: 'complete', frame: JSON.parse(entry.chunks.join('')) }
325
+ } catch (error) {
326
+ // Every slot is filled yet the join is not valid JSON — the frame is unusable.
327
+ // Explicit failure, never a silent drop.
328
+ return {
329
+ status: 'failed',
330
+ reason: 'reassembled_parse_failed',
331
+ detail: `reassembled frame is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
332
+ chunkId,
333
+ }
334
+ }
335
+ }
336
+ }