Project

SDK wishlist

Changes to the VDO.Ninja SDK that would simplify or strengthen ninja-p2p.

Things @vdoninja/sdk could add that would directly remove workarounds in ninja-p2p. Every item came from friction actually hit while building, with the evidence and the workaround in place at the time.

Status: all nine SDK items are published, with one TypeScript interop caveat and one VDO.Ninja decision still open. The current SDK 1.5.4 floor includes the declaration files that were missing from 1.4.1, and its Node runtime works with both @roamhq/wrtc and node-datachannel.

Verified against a local v1.4.1 build on 2026-07-25 with two live Node peers over real signalling — not by reading the source. Results and two corrections to the SDK's own Node caveats are in Verification at the end.

ninja-p2p declares @vdoninja/sdk ^1.5.4 and uses its runtime APIs directly. A narrow local type shim remains because the Node default type import is not constructable under ESM + Node16 TypeScript resolution. Runtime feature detection also remains so a room containing an older already-installed peer automatically uses the verified base64 swarm fallback for that peer.

0.2.3 compatibility review (September 2026)#

Published SDK 1.6.0 was tested on Windows with Node 22.14.0 and @roamhq/wrtc 0.8.0: the four-peer messaging/file/image/shared-folder validator and the 2 MiB, 2,048-chunk binary swarm validator passed. The existing ^1.5.4 dependency range already permits this update. The browser remains pinned to its previously reviewed 1.5.4 build; these Node checks do not validate a browser SDK upgrade or re-measure network recovery.

SDK 1.6.0 adds OBS browser-source state events. Those can support a future optional production bridge, but do not change this package's room/inbox/file contract. The adjacent SDK checkout also contains peer-metadata corrections in commit b7410be; those corrections are not in the npm 1.6.0 package tested. Keep published-version compatibility separate from results against that checkout.

# Item Status API
1 Binary send path landed sendBinary() / binaryReceived
2 Teardown completion signal landed await disconnect() / teardownComplete
3 Backpressure signal landed getBufferedAmount() / bufferedAmountLow
4 Multiple channels per peer landed openChannel() / getChannel() / channelOpen
5 Unordered / partial reliability landed ordered / maxRetransmits / maxPacketLifeTime
6 Max message size landed getMaxMessageSize()
7 Per-peer connection quality landed getPeerQuality()
8 Unambiguous lifecycle events landed disconnected detail: intentional, reason, phase
9 TypeScript definitions landed; ESM caveat declarations ship in 1.5.2; named Node types work
10 Room/salt derivation contract open —

1. A real binary send path — landed#

Was. Everything sent was JSON-stringified before it reached the data channel (vdoninja-sdk.js:5983), and sendData wrapped the payload as { pipe: data } first. An ArrayBuffer serialised to {}. Swarm transfer therefore base64-encoded every chunk: ~33% of all bandwidth, permanently.

Now. sendBinary(bytes, uuid, options) puts bytes on the wire untouched and the receiver gets binaryReceived with a real Uint8Array.

The design detail that matters: binary travels on a dedicated x-bin channel, not the control channel. VDO.Ninja renders any binary payload arriving on the control channel as a WebP image frame, so the obvious implementation would have visibly corrupted every VDO.Ninja viewer instead of being harmlessly ignored. The x- prefix is reserved on both sides, so a VDO.Ninja peer ignores the lane entirely.

Measured: 11.4 MB of 60 KB frames delivered byte-identical at 6.4 MB/s in Node. Our base64 swarm path measured 1.1 MB/s.

2. Teardown that tells you when it is finished — landed#

Was. disconnect() returned void and did its real work inside a promise chain it never handed back (vdoninja-sdk.js:924). The disconnected event was not usable as a completion signal because the socket-close handler fired the same event much earlier. Exiting on it tore the process down mid-cleanup, which crashed the native WebRTC module and corrupted CLI exit codes.

Now. disconnect() returns a promise that resolves when cleanup genuinely completes, and teardownComplete is emitted exactly once from that one place.

Measured: resolved in 53 ms with teardownComplete observed. Our workaround — polling sdk.connections.size and sdk.signaling with a 5 s cap — can go.

3. A backpressure signal — landed#

Was. bufferedAmount was read internally for the bye-flush but never exposed, so flow control was guesswork and our in-flight caps were hand-tuned against one link.

Now. getBufferedAmount(uuid, label) plus a bufferedAmountLow event, and sendBinary applies backpressure itself unless waitForDrain: false.

Measured, and it works in Node — see Correction 2.

4. More than one data channel per peer — landed#

Was. Exactly one channel, createDataChannel('sendChannel', { ordered: true }) at vdoninja-sdk.js:1881. A 64 KB chunk head-of-line-blocked any control message queued behind it.

Now. openChannel(uuid, label, options), with the label forced into the reserved x- namespace so it is safe toward a browser peer. Incoming reserved channels surface as channelOpen with the raw RTCDataChannel handed over — the application owns the protocol on it.

5. Unordered / partially-reliable delivery — landed#

ordered, maxRetransmits and maxPacketLifeTime pass through to createDataChannel, with the mutually-exclusive pair rejected up front rather than left to throw inside WebRTC.

Worth noting we have not adopted this yet. Chunked transfer indexes and hashes every chunk, so ordering is redundant — but unordered delivery interacts with backpressure and retransmission in ways worth measuring on a lossy link before turning it on, and every link we have measured so far is local.

6. Tell callers the maximum message size — landed#

getMaxMessageSize(uuid) returns the negotiated SCTP limit, or null if the transport has not reported one. See Correction 1 — it is not null under @roamhq/wrtc, which the SDK docs assume.

7. Per-peer connection quality — landed#

getPeerQuality(uuid) returns { rttMs, lossRate, candidatePairType, relayed, availableOutgoingBitrate, bytesSent, bytesReceived }. This lets a new peer be ranked on its first request instead of its tenth, which is exactly the gap in our swarm scoring.

lossRate is null on a data-only peer, which is correct and documented — loss is derived from inbound RTP and a data-only peer carries none.

8. Unambiguous lifecycle events — landed#

disconnected now carries { intentional, reason, willReconnect, phase }, with phase: 'socket' for the close and phase: 'teardown' for completion. We were logging "SDK will attempt reconnect" during deliberate shutdowns; willReconnect answers that directly and our local suppression flag can go.

9. TypeScript definitions — landed, with an ESM caveat#

vdoninja-sdk.d.ts exists in the SDK source and the package metadata points to it, with internals deliberately omitted. The npm 1.4.1 tarball does not contain the file; SDK 1.5.2 fixes that packaging gap and ships both browser and Node declarations.

One interop issue remains: a default type import from @vdoninja/sdk/node is treated as a namespace rather than a constructable class in an ESM project using Node16 module resolution. Named type imports work. ninja-p2p keeps its narrow shim because exporting the upstream default type would pass this ESM problem to consumers.

10. A documented room and hash derivation contract — still open#

Room hashing uses password + salt with a default salt of "vdo.ninja" (vdoninja-sdk.js:677). It is what makes a Social Stream Ninja listener possible from a third-party client — but only because we read the source to confirm the salts matched.

docs/compatibility.md now says salt behaviour "remains unchanged", which is a commitment not to break it. What is still missing is the derivation itself stated plainly, so a third-party client can implement against a document rather than against a source file.

This one is genuinely a VDO.Ninja decision, not an SDK one: publishing it as stable constrains VDO.Ninja. Recording it here as open rather than pressing.


Verification#

The compatibility pass documented here used the published SDK 1.5.2 package:

  • 358 tests and the TypeScript build passed on Node 20, 22, and 24.
  • The full four-peer validator passed with direct files, images, and shared folders using @roamhq/wrtc.
  • The same live validator and the 2 MiB / 2,048-chunk binary swarm passed with node-datachannel only, including an exact sha256 match.

Two Node peers, real signalling, @roamhq/wrtc, local build of v1.4.1.

sendBinary            60,000 bytes  ->  sha256 identical, instanceof Uint8Array true
bulk                  200 x 60 KB   ->  11.44 MB in 1.79 s = 6.38 MB/s, 200/200 frames
getMaxMessageSize()   262144
await disconnect()    resolved 53 ms, teardownComplete fired
getPeerQuality()      { rttMs: 0, lossRate: null, candidatePairType: 'host/host',
                        relayed: false, bytesSent: 11500347 }

Two caveats in the SDK's Node docs did not survive measurement.

Correction 1: @roamhq/wrtc does expose pc.sctp#

vdoninja-sdk.js:2847 and README-NODE.md both state that @roamhq/wrtc does not expose pc.sctp at all, so getMaxMessageSize returns null there.

Measured: pc.sctp is present and pc.sctp.maxMessageSize is 262144.

This matters beyond a doc fix. The advice that follows from "returns null" is to assume 65536; the real negotiated limit is 4× that. We were sending 64 KB chunks on a guess, and can now ask.

Correction 2: bufferedAmount does work in Node, just not the event#

README-NODE.md states bufferedAmount "stays 0 on @roamhq/wrtc no matter how much is queued — measured at 0 after handing it 2.4MB", and concludes that getBufferedAmount() always returns 0, waitForDrain is a no-op, and Node callers must keep an application-level cap.

Measured on the binary lane:

peak bufferedAmount, waitForDrain: false  ->  4,380,000
peak bufferedAmount, waitForDrain: true   ->  1,080,000   (high-water mark 1,048,576)

It tracks accurately, and waitForDrain is doing real work — the cap holds within 3% of the configured high-water mark.

Root cause of the original measurement. getBufferedAmount(uuid) with no label reads connection.dataChannel, the control channel. sendBinary queues on x-bin. So the reading was of an idle lane while a different one filled. Run side by side during the same burst:

peak getBufferedAmount(uuid)        [control lane]  ->  0
peak getBufferedAmount(uuid, 'bin') [binary lane]   ->  4,380,000

What is genuinely missing under wrtc is the bufferedamountlow event: it never fires, so bufferedAmountLow never emits there. It does not matter, because _waitForChannelDrain polls at 50 ms as a fallback and that is what carries the backpressure. Worth narrowing the caveat to the event rather than the value — "keep an application-level cap in Node" is advice Node callers do not need.


Deliberately not asking for#

To be clear about scope, none of these are wanted:

  • server-assisted routing, coordination, or relay logic — the whole point here is that no server is involved beyond signalling and TURN
  • chunking, resume, or file-transfer semantics inside the SDK; that belongs in the layer above and we already have it
  • retry, queueing, or delivery guarantees — coordination transport is the right contract
  • anything requiring accounts, identity, or persistence

Adjacent, still true#

@roamhq/wrtc segfaults on normal process exit once a data channel has existed, after everything has been closed correctly:

bash
node -e "const w=require('@roamhq/wrtc');const pc=new w.RTCPeerConnection();
         const dc=pc.createDataChannel('x');dc.close();pc.close();"
# Segmentation fault, exit 139

Not the SDK's bug, and now documented in README-NODE.md. We exit explicitly after awaiting teardown, which avoids it.