@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +200 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +189 -0
- package/dist/index.mjs.map +1 -1
- package/dist/mesh-tool-names.d.ts +2 -2
- package/dist/node-facts.d.ts +64 -5
- package/dist/rpc-chunking.d.ts +162 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/mesh-tool-names.ts +1 -0
- package/src/node-facts.ts +65 -5
- package/src/rpc-chunking.ts +336 -0
|
@@ -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
|
+
}
|