@ziplayer/net 0.0.0-stage → 0.0.1

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 (56) hide show
  1. package/README.md +136 -2
  2. package/dist/NetNode.d.ts +124 -0
  3. package/dist/NetNode.js +593 -0
  4. package/dist/NetNode.js.map +1 -0
  5. package/dist/ZiNet.d.ts +165 -0
  6. package/dist/ZiNet.js +599 -0
  7. package/dist/ZiNet.js.map +1 -0
  8. package/dist/crypto/handshake.d.ts +84 -0
  9. package/dist/crypto/handshake.js +204 -0
  10. package/dist/crypto/handshake.js.map +1 -0
  11. package/dist/crypto/identity.d.ts +44 -0
  12. package/dist/crypto/identity.js +92 -0
  13. package/dist/crypto/identity.js.map +1 -0
  14. package/dist/crypto/session.d.ts +56 -0
  15. package/dist/crypto/session.js +148 -0
  16. package/dist/crypto/session.js.map +1 -0
  17. package/dist/debug.d.ts +56 -0
  18. package/dist/debug.js +51 -0
  19. package/dist/debug.js.map +1 -0
  20. package/dist/ext/NetExtension.d.ts +15 -0
  21. package/dist/ext/NetExtension.js +39 -0
  22. package/dist/ext/NetExtension.js.map +1 -0
  23. package/dist/index.d.ts +28 -0
  24. package/dist/index.js +52 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/protocol/frames.d.ts +22 -0
  27. package/dist/protocol/frames.js +45 -0
  28. package/dist/protocol/frames.js.map +1 -0
  29. package/dist/router.d.ts +38 -0
  30. package/dist/router.js +51 -0
  31. package/dist/router.js.map +1 -0
  32. package/dist/search.d.ts +72 -0
  33. package/dist/search.js +94 -0
  34. package/dist/search.js.map +1 -0
  35. package/dist/stream/StreamImporter.d.ts +37 -0
  36. package/dist/stream/StreamImporter.js +138 -0
  37. package/dist/stream/StreamImporter.js.map +1 -0
  38. package/dist/stream/StreamProvider.d.ts +49 -0
  39. package/dist/stream/StreamProvider.js +157 -0
  40. package/dist/stream/StreamProvider.js.map +1 -0
  41. package/dist/transport/MemoryTransport.d.ts +36 -0
  42. package/dist/transport/MemoryTransport.js +84 -0
  43. package/dist/transport/MemoryTransport.js.map +1 -0
  44. package/dist/transport/MqttTransport.d.ts +26 -0
  45. package/dist/transport/MqttTransport.js +104 -0
  46. package/dist/transport/MqttTransport.js.map +1 -0
  47. package/dist/transport/Transport.d.ts +21 -0
  48. package/dist/transport/Transport.js +18 -0
  49. package/dist/transport/Transport.js.map +1 -0
  50. package/dist/voice/Gateway.d.ts +16 -0
  51. package/dist/voice/Gateway.js +35 -0
  52. package/dist/voice/Gateway.js.map +1 -0
  53. package/dist/voice/RemoteVoice.d.ts +29 -0
  54. package/dist/voice/RemoteVoice.js +80 -0
  55. package/dist/voice/RemoteVoice.js.map +1 -0
  56. package/package.json +53 -5
package/README.md CHANGED
@@ -1,3 +1,137 @@
1
- # Temporary Holding Version
1
+ # @ziplayer/net
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connect several ZiPlayer processes ("nodes") over MQTT.
4
+
5
+ - **Stream transfer** – the node whose plugins resolve a track (the _owner streamInfo_) sends the compressed audio (Opus, or the
6
+ `arbitrary` containers official plugins return) to the node that plays it.
7
+ - **Voice hand-off** – the node that called `player.connect()` (the _owner connection_) can hand the Discord voice session to
8
+ another node, which plays instead. The owner keeps the gateway and the queue.
9
+ - **Auto routing** – `routeTracks()` decides per batch of tracks: keep the voice and pull a few foreign tracks (_relay_), or move
10
+ the voice to the node that can play most of them (_handoff_).
11
+
12
+ ```
13
+ npm install @ziplayer/net mqtt
14
+ ```
15
+
16
+ ## Security model
17
+
18
+ | What | Protection |
19
+ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
20
+ | Control messages, voice credentials | Always encrypted end-to-end (ChaCha20-Poly1305), authenticated, replay-protected |
21
+ | Audio (Opus) | Clear by default, `encryptAudio: true` to encrypt |
22
+ | Node identity | Ed25519 keys, **pinned per node**; unknown nodes are rejected |
23
+ | Broker | Untrusted: it only sees ciphertext, audio, node ids and sizes (use TLS to hide those) |
24
+ | Peer input | Only compressed audio is accepted (raw PCM is refused; `allowedStreamTypes` can narrow it to Opus containers); a node never fetches a URL a peer gives it (a provider resolves requested tracks through its own plugins: use `allow`); relayed gateway payloads are limited to op 4 for the delegated guild |
25
+
26
+ The 3-message handshake (`hs1` → `hs2` → `hs3`) is a SIGMA-style exchange built from `node:crypto` (ephemeral X25519 + Ed25519
27
+ signatures over the transcript + HKDF). It is a small custom protocol: **get it reviewed before protecting anything valuable.**
28
+
29
+ ## Pairing nodes
30
+
31
+ Every node needs a key and has to trust the others. Easiest flow:
32
+
33
+ ```js
34
+ const node = new NetNode({ nodeId: "eu-1", keyFile: "./eu-1.key", transport, networkId: "prod" }); // key created if missing
35
+ node.on("peerDiscovered", ({ nodeId, fingerprint }) => console.log(nodeId, fingerprint)); // compare out-of-band
36
+ node.acceptPeer("eu-2", fingerprintYouVerified, ["provide-stream"]); // both sides must accept each other
37
+ ```
38
+
39
+ Or pin keys up front: `trust: [{ nodeId, publicKey: pem, roles: ["provide-stream", "provide-search", "accept-voice", "delegate-voice"] }]`. Roles
40
+ default to `*`; narrow them for least privilege.
41
+
42
+ ## Usage
43
+
44
+ ```js
45
+ const { ZiNet, MqttTransport, discordJsGateway, markTrack } = require("@ziplayer/net");
46
+
47
+ const net = new ZiNet({
48
+ nodeId: "eu-1",
49
+ networkId: "prod",
50
+ keyFile: "./eu-1.key",
51
+ trust: [{ nodeId: "us-1", publicKey: fs.readFileSync("us-1.pub", "utf8") }],
52
+ transport: new MqttTransport({ url: "mqtts://broker:8883", username, password }),
53
+ manager, // local PlayerManager
54
+ gateway: discordJsGateway(client), // only on the node that owns the Discord gateway
55
+ });
56
+ await net.start();
57
+
58
+ // Node that can resolve YouTube: serve streams
59
+ net.provideStreams({ allow: (track) => track.source === "youtube" });
60
+
61
+ // ...and serve search results too (opt-in, role "provide-search")
62
+ net.provideSearch({ allow: (query, peerId) => true, maxResults: 25 });
63
+
64
+ // Node that plays on behalf of others
65
+ net.acceptVoice();
66
+
67
+ // Owner: players get the extension so remote streams and delegated playback work
68
+ const player = await manager.create(guildId, { extensions: [net.extensionClass()] });
69
+ await player.connect(voiceChannel);
70
+
71
+ // Pull one track from another node
72
+ await player.play(markTrack(track, "us-1"));
73
+
74
+ // Or let the router decide (relay vs handoff), then play as usual
75
+ await net.routeTracks(player, tracks);
76
+
77
+ // Manual hand-off / take-back (call while idle)
78
+ await net.delegateVoice(player, "us-1");
79
+ await net.reclaimVoice(player);
80
+ ```
81
+
82
+ If an executor disappears (MQTT last-will), the owner reconnects the voice locally.
83
+
84
+ ## Search across nodes
85
+
86
+ `manager.search()` only knows this node's plugins, so a bot without the YouTube plugin finds nothing for a YouTube URL.
87
+ `net.search()` fixes that: the query is tried on the local plugins first and then on every trusted node that runs `provideSearch()`.
88
+ Tracks that another node found come back already marked with that node as owner, so `player.play(track)` pulls the audio from it.
89
+
90
+ ```js
91
+ const { tracks, node } = await net.search("https://www.youtube.com/watch?v=PqpCRSOUuIE", { requestedBy: userId });
92
+ await player.play(tracks[0]); // audio is streamed from `node` when it is not this node
93
+
94
+ await net.search("yt:lofi hip hop"); // prefix = go straight to the node with that plugin (yt, sc, sp, tts; extend with searchAliases)
95
+ await net.search("lofi", { merge: true, limit: 20 }); // ask every node and merge instead of "first answer wins"
96
+ await net.searchOn("yt-1", "lofi"); // one specific node
97
+ ```
98
+
99
+ Options: `source`, `peers`, `local` (`"first"` default, `"last"`, `"only"`, `"never"`), `merge`, `limit`, `timeoutMs`, `signal`.
100
+ Results are validated on arrival: a peer cannot choose the `owner` of its tracks, and answers are capped (`maxResults`,
101
+ `maxResponseBytes`) to fit one control frame. When nothing is found the error is `NetError` with code `NO_RESULTS` and the
102
+ same `No results found for: …` message as the core.
103
+
104
+ ## Debugging what goes over MQTT
105
+
106
+ Every `NetNode` (also `net.node`) emits a `debug` event. Without listeners it costs nothing.
107
+
108
+ ```js
109
+ const { formatNetDebug } = require("@ziplayer/net");
110
+ net.node.on("debug", (e) => console.log(formatNetDebug(e)));
111
+ ```
112
+
113
+ ```
114
+ [net:bot-1] → MQTT PUBLISH zi/v1/ziplayer-test/node/yt-1/in qos1 188B control(encrypted) [02a1b2c3…]
115
+ [net:bot-1] → control to yt-1 net.search #3 188B {"query":"https://www.youtube.com/watch?v=PqpCRSOUuIE","limit":1}
116
+ [net:bot-1] ← control from yt-1 reply re#3 3120B {"tracks":[{"id":"PqpCRSOUuIE","title":"…"}],…}
117
+ ```
118
+
119
+ - `layer: "wire"`: the exact MQTT message the broker sees (topic, qos, retain, size, first bytes). Control frames are ciphertext.
120
+ - `layer: "message"`: the same traffic decoded: `type`, request `id` / `re`, `body`, `peer`. Handshakes, requests, notifications and replies.
121
+ - `layer: "info"`: routing notes such as `search "lofi" → yt-1`.
122
+ - Audio frames are only reported with `debugAudio: true` (very chatty). Bodies are redacted (`token`, `session_id`, `password`, …)
123
+ unless `debugRedact: false`; treat logs with raw bodies as secrets because voice tokens pass through here.
124
+
125
+ ## Try it for real
126
+
127
+ [`examples/net`](../examples/net) runs a real Discord bot (commands via `messageCreate`) with provider nodes that have different
128
+ real plugins, over Mosquitto, including an automated `!selftest` for stream transfer, relay, hand-off and failover.
129
+
130
+ ## Notes and limits
131
+
132
+ - `PlayerManager` is a process singleton, so one process = one node (test with several processes).
133
+ - Delegate voice while nothing is playing; the next `play()` is driven on the executor.
134
+ - MQTT is fine for control; for audio it adds broker hops and has no back-pressure, so streaming uses credit-based flow control
135
+ and QoS 1. The `Transport` interface lets other carriers (WebSocket, TCP, ...) replace it later.
136
+ - Web/WebAudio voice and multi-output fan-out (mirroring one queue to several remote outputs) are not implemented yet.
137
+ - Core changes this package relies on: `manager.resolveStream()` and `player.disconnect({ silent: true })`.
@@ -0,0 +1,124 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { TrustStore, type NetRole, type NodeIdentity, type TrustedPeer } from "./crypto/identity";
3
+ import { SecureChannel } from "./crypto/session";
4
+ import { type NetDebugEvent } from "./debug";
5
+ import { type AudioFrame } from "./protocol/frames";
6
+ import type { Transport } from "./transport/Transport";
7
+ export interface NetNodeOptions {
8
+ /** Unique id of this node. No `/`, `+` or `#`. */
9
+ nodeId: string;
10
+ /** Isolates independent clusters sharing one broker. */
11
+ networkId?: string;
12
+ /** Ed25519 identity: PEM private key or a loaded identity. Omit it and give `keyFile` to load-or-create one. */
13
+ identity?: string | NodeIdentity;
14
+ /** Where to keep the node's private key. If the file does not exist a new key is created there. */
15
+ keyFile?: string;
16
+ /** Pinned peers. Anyone not listed is rejected during the handshake. */
17
+ trust?: TrustStore | TrustedPeer[];
18
+ transport: Transport;
19
+ /** Encrypt audio frames too. Default `false`: Opus/WebM audio is sent in clear, control data is always encrypted. */
20
+ encryptAudio?: boolean;
21
+ /** MQTT QoS for audio frames (default 1 so container streams never lose bytes). */
22
+ audioQos?: 0 | 1;
23
+ requestTimeoutMs?: number;
24
+ handshakeTimeoutMs?: number;
25
+ sessionMaxAgeMs?: number;
26
+ debug?: (message: string, ...args: unknown[]) => void;
27
+ /** Also emit `debug` events for every audio frame (very chatty). Default `false`. */
28
+ debugAudio?: boolean;
29
+ /** Hide tokens / session ids in the `body` of `debug` message events. Default `true`. */
30
+ debugRedact?: boolean;
31
+ }
32
+ export interface RequestContext {
33
+ from: string;
34
+ session: SecureChannel;
35
+ }
36
+ export type RequestHandler = (body: any, context: RequestContext) => unknown | Promise<unknown>;
37
+ export declare class NetError extends Error {
38
+ readonly code: string;
39
+ constructor(message: string, code?: string);
40
+ }
41
+ /**
42
+ * One ZiPlayer process on the network. Owns the transport, authenticates peers with the 3-message
43
+ * handshake, and exposes encrypted request/notify messaging plus (optionally encrypted) audio frames.
44
+ *
45
+ * Events: `peerOnline(id)`, `peerOffline(id)`, `sessionOpen(id)`, `sessionClose(id)`, `peerDiscovered(info)` and
46
+ * `debug(event: NetDebugEvent)`, which shows exactly what is published to / received from the broker (see `debug.ts`).
47
+ */
48
+ export declare class NetNode extends EventEmitter {
49
+ readonly nodeId: string;
50
+ readonly networkId: string;
51
+ readonly trust: TrustStore;
52
+ /** This node's public key, announced in its presence message so operators can pair nodes. */
53
+ readonly publicKeyPem: string;
54
+ readonly fingerprint: string;
55
+ private readonly discovered;
56
+ private readonly identity;
57
+ /** The transport as given (kept untouched: tests and tooling reach into it, e.g. `MemoryTransport.kill()`). */
58
+ private readonly transport;
59
+ /** What this node really talks through: the same transport, observed for `debug` events. */
60
+ private readonly wire;
61
+ /** @internal */
62
+ readonly options: NetNodeOptions;
63
+ private readonly bySid;
64
+ private readonly byPeer;
65
+ private readonly initiating;
66
+ private readonly initiators;
67
+ private readonly responders;
68
+ private readonly handlers;
69
+ private readonly pending;
70
+ private readonly online;
71
+ private audioHandler?;
72
+ private nextId;
73
+ private unsubs;
74
+ private started;
75
+ constructor(options: NetNodeOptions);
76
+ /** True while somebody listens to `debug`: tracing costs nothing otherwise. */
77
+ get tracing(): boolean;
78
+ /** Emits a `debug` event. Cheap no-op without listeners. */
79
+ trace(event: Omit<NetDebugEvent, "at" | "node">): void;
80
+ /** Free-form `info` debug note (used by routing code such as `ZiNet.search`). */
81
+ traceNote(note: string, extra?: Partial<NetDebugEvent>): void;
82
+ private traceMessage;
83
+ private traceAudio;
84
+ private debug;
85
+ start(): Promise<void>;
86
+ stop(): Promise<void>;
87
+ /** Nodes seen on the network that are not trusted yet. Compare `fingerprint` out-of-band before accepting. */
88
+ discoveredPeers(): Array<{
89
+ nodeId: string;
90
+ fingerprint: string;
91
+ publicKey: string;
92
+ }>;
93
+ /**
94
+ * Trusts a discovered node. `expectedFingerprint` must match the key it announced, so a swapped key is refused.
95
+ * Both sides have to accept each other before they can talk.
96
+ */
97
+ acceptPeer(nodeId: string, expectedFingerprint: string, roles?: NetRole[]): void;
98
+ isOnline(peerId: string): boolean;
99
+ onlinePeers(): string[];
100
+ /** Registers a handler for a message type. `role` is checked against the caller's trust entry. */
101
+ handle(type: string, handler: RequestHandler, options?: {
102
+ role?: NetRole;
103
+ }): () => void;
104
+ request<T = any>(peerId: string, type: string, body?: unknown, options?: {
105
+ timeoutMs?: number;
106
+ signal?: AbortSignal;
107
+ }): Promise<T>;
108
+ notify(peerId: string, type: string, body?: unknown): Promise<void>;
109
+ onAudio(handler: (peerId: string, frame: AudioFrame) => void): void;
110
+ sendAudio(peerId: string, frame: AudioFrame): Promise<void>;
111
+ ensureSession(peerId: string): Promise<SecureChannel>;
112
+ hasSession(peerId: string): boolean;
113
+ private startHandshake;
114
+ private ctx;
115
+ private publishHandshake;
116
+ private install;
117
+ private closeSessions;
118
+ private onPresence;
119
+ private onInbox;
120
+ private onHandshake;
121
+ private sendControl;
122
+ private onControl;
123
+ private onAudioFrame;
124
+ }