@aethervtt/shard-net-webrtc 0.0.0-stage → 0.3.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Dias
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
- # Temporary Holding Version
1
+ # @aethervtt/shard-net-webrtc
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
+ Part of [Shard](https://github.com/aethervtt/shard), a WebGPU-first 2D/3D game engine in TypeScript. See the repository for documentation.
@@ -0,0 +1,56 @@
1
+ import { ShardError } from '@aethervtt/shard-core';
2
+ import type { Channel, ConnectionStats, NetConnection, PeerId } from '@aethervtt/shard-net';
3
+ import type { SignalData } from './protocol.js';
4
+ /**
5
+ * One peer as a net connection (0091): two negotiated data channels, `reliable` (id 0, ordered)
6
+ * and `unreliable` (id 1, unordered, no retransmits). Negotiated ids mean both sides create the
7
+ * same channels and neither waits for `ondatachannel`.
8
+ */
9
+ export declare class WebRtcConnection implements NetConnection {
10
+ readonly peer: PeerId;
11
+ readonly pc: RTCPeerConnection;
12
+ /** Resolves when both channels are open; rejects if the connection fails first. */
13
+ readonly opened: Promise<void>;
14
+ private readonly channels;
15
+ private readonly maxMessage;
16
+ private readonly messageFns;
17
+ private readonly closeFns;
18
+ private readonly counters;
19
+ private closed;
20
+ private closing;
21
+ private readonly cleanup;
22
+ constructor(pc: RTCPeerConnection, peer: PeerId, maxMessage: number);
23
+ /** Runs when the connection ends either way (closes the peer's lobby socket). */
24
+ onEnd(fn: () => void): void;
25
+ private fail;
26
+ send(channel: Channel, bytes: Uint8Array): void;
27
+ onMessage(fn: (channel: Channel, bytes: Uint8Array) => void): () => void;
28
+ onClose(fn: (reason: ShardError | undefined) => void): () => void;
29
+ /**
30
+ * Closes gracefully: no more sends, then the channels close once what's queued has gone out (a
31
+ * goodbye message arrives before the close), and the peer connection after them.
32
+ */
33
+ close(): void;
34
+ private shut;
35
+ stats(): ConnectionStats;
36
+ }
37
+ export declare function failed(message: string): ShardError;
38
+ /**
39
+ * Runs one side of the offer/answer exchange. The joiner offers; the host answers. Candidates
40
+ * that arrive before the remote description wait for it.
41
+ */
42
+ export declare class Negotiation {
43
+ private readonly pc;
44
+ private readonly send;
45
+ private readonly pending;
46
+ private remoteSet;
47
+ private chain;
48
+ error: ShardError | undefined;
49
+ constructor(pc: RTCPeerConnection, send: (data: SignalData) => void);
50
+ /** Joiner: make and send the offer. */
51
+ offer(): void;
52
+ /** Handles what the other side sent, in arrival order. */
53
+ receive(data: SignalData): void;
54
+ private sendDescription;
55
+ private queue;
56
+ }
@@ -0,0 +1,193 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ class WebRtcConnection {
3
+ peer;
4
+ pc;
5
+ /** Resolves when both channels are open; rejects if the connection fails first. */
6
+ opened;
7
+ channels;
8
+ maxMessage;
9
+ messageFns = [];
10
+ closeFns = [];
11
+ counters = { rtt: 0, sentBytes: 0, receivedBytes: 0, lost: 0 };
12
+ closed = false;
13
+ closing = false;
14
+ cleanup = [];
15
+ constructor(pc, peer, maxMessage) {
16
+ this.pc = pc;
17
+ this.peer = peer;
18
+ this.maxMessage = maxMessage;
19
+ const reliable = pc.createDataChannel("reliable", { negotiated: true, id: 0, ordered: true });
20
+ const unreliable = pc.createDataChannel("unreliable", {
21
+ negotiated: true,
22
+ id: 1,
23
+ ordered: false,
24
+ maxRetransmits: 0
25
+ });
26
+ this.channels = [reliable, unreliable];
27
+ let open = 0;
28
+ let settle;
29
+ this.opened = new Promise((resolve, reject) => {
30
+ settle = { resolve, reject };
31
+ });
32
+ this.opened.catch(() => {
33
+ });
34
+ const names = ["reliable", "unreliable"];
35
+ this.channels.forEach((dc, i) => {
36
+ dc.binaryType = "arraybuffer";
37
+ dc.onopen = () => {
38
+ if (++open === 2) settle?.resolve();
39
+ };
40
+ dc.onmessage = (event) => {
41
+ const data = event.data;
42
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : void 0;
43
+ if (!bytes) return;
44
+ this.counters.receivedBytes += bytes.length;
45
+ for (const fn of this.messageFns) fn(names[i], bytes);
46
+ };
47
+ dc.onclose = () => this.fail("A data channel closed");
48
+ });
49
+ pc.onconnectionstatechange = () => {
50
+ const state = pc.connectionState;
51
+ if (state === "failed" || state === "closed") this.fail(`WebRTC connection ${state}`);
52
+ };
53
+ this.cleanup.push(() => settle?.reject(failed("The connection closed before it opened")));
54
+ }
55
+ /** Runs when the connection ends either way (closes the peer's lobby socket). */
56
+ onEnd(fn) {
57
+ this.cleanup.push(fn);
58
+ }
59
+ fail(message) {
60
+ this.shut(this.closing ? void 0 : failed(message));
61
+ }
62
+ send(channel, bytes) {
63
+ if (this.closed) return;
64
+ if (bytes.length > this.maxMessage) {
65
+ throw new ShardError(
66
+ "net/message-too-large",
67
+ `A ${bytes.length}-byte message is over ${this.maxMessage} bytes`,
68
+ {
69
+ hint: "Reliable messages are split automatically; keep unreliable ones under maxMessage."
70
+ }
71
+ );
72
+ }
73
+ const dc = this.channels[channel === "reliable" ? 0 : 1];
74
+ if (dc.readyState !== "open") return;
75
+ this.counters.sentBytes += bytes.length;
76
+ try {
77
+ dc.send(bytes.slice());
78
+ } catch {
79
+ this.fail("A data channel closed while sending");
80
+ }
81
+ }
82
+ onMessage(fn) {
83
+ this.messageFns.push(fn);
84
+ return () => remove(this.messageFns, fn);
85
+ }
86
+ onClose(fn) {
87
+ this.closeFns.push(fn);
88
+ return () => remove(this.closeFns, fn);
89
+ }
90
+ /**
91
+ * Closes gracefully: no more sends, then the channels close once what's queued has gone out (a
92
+ * goodbye message arrives before the close), and the peer connection after them.
93
+ */
94
+ close() {
95
+ if (this.closed) return;
96
+ this.closing = true;
97
+ this.shut(void 0, true);
98
+ }
99
+ shut(reason, graceful = false) {
100
+ if (this.closed) return;
101
+ this.closed = true;
102
+ const teardown = () => {
103
+ for (const dc of this.channels) dc.close();
104
+ setTimeout(() => this.pc.close(), graceful ? LINGER_MS : 0);
105
+ };
106
+ if (graceful) drain(this.channels, teardown);
107
+ else teardown();
108
+ for (const fn of this.cleanup.splice(0)) fn();
109
+ for (const fn of [...this.closeFns]) fn(reason);
110
+ }
111
+ stats() {
112
+ return { ...this.counters };
113
+ }
114
+ }
115
+ const LINGER_MS = 250;
116
+ const DRAIN_LIMIT_MS = 1e3;
117
+ function drain(channels, done) {
118
+ const start = Date.now();
119
+ const check = () => {
120
+ const queued = channels.some((dc) => dc.readyState === "open" && dc.bufferedAmount > 0);
121
+ if (!queued || Date.now() - start > DRAIN_LIMIT_MS) done();
122
+ else setTimeout(check, 10);
123
+ };
124
+ check();
125
+ }
126
+ function failed(message) {
127
+ return new ShardError("net/webrtc-failed", message, {
128
+ hint: "Peers on different networks need STUN (and TURN behind strict NATs) in iceServers."
129
+ });
130
+ }
131
+ function remove(list, item) {
132
+ const i = list.indexOf(item);
133
+ if (i !== -1) list.splice(i, 1);
134
+ }
135
+ class Negotiation {
136
+ pc;
137
+ send;
138
+ pending = [];
139
+ remoteSet = false;
140
+ chain = Promise.resolve();
141
+ error;
142
+ constructor(pc, send) {
143
+ this.pc = pc;
144
+ this.send = send;
145
+ pc.onicecandidate = (event) => {
146
+ const c = event.candidate;
147
+ if (!c?.candidate) return;
148
+ send({
149
+ candidate: { candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex }
150
+ });
151
+ };
152
+ }
153
+ /** Joiner: make and send the offer. */
154
+ offer() {
155
+ this.queue(async () => {
156
+ const offer = await this.pc.createOffer();
157
+ await this.pc.setLocalDescription(offer);
158
+ this.sendDescription();
159
+ });
160
+ }
161
+ /** Handles what the other side sent, in arrival order. */
162
+ receive(data) {
163
+ this.queue(async () => {
164
+ if ("sdp" in data) {
165
+ await this.pc.setRemoteDescription(data.sdp);
166
+ this.remoteSet = true;
167
+ for (const c of this.pending.splice(0)) await this.pc.addIceCandidate(c);
168
+ if (data.sdp.type === "offer") {
169
+ const answer = await this.pc.createAnswer();
170
+ await this.pc.setLocalDescription(answer);
171
+ this.sendDescription();
172
+ }
173
+ } else if (this.remoteSet) {
174
+ await this.pc.addIceCandidate(data.candidate);
175
+ } else this.pending.push(data.candidate);
176
+ });
177
+ }
178
+ sendDescription() {
179
+ const d = this.pc.localDescription;
180
+ if (d) this.send({ sdp: { type: d.type, sdp: d.sdp } });
181
+ }
182
+ queue(step) {
183
+ this.chain = this.chain.then(step).catch((err) => {
184
+ this.error = failed(err instanceof Error ? err.message : String(err));
185
+ this.pc.close();
186
+ });
187
+ }
188
+ }
189
+ export {
190
+ Negotiation,
191
+ WebRtcConnection,
192
+ failed
193
+ };
@@ -0,0 +1,26 @@
1
+ import type { NetSessionProvider, TransportCaps } from '@aethervtt/shard-net';
2
+ import { type LobbyWebSocketClass } from './lobby.js';
3
+ export interface WebRtcLobbyOptions {
4
+ /** The lobby server, `ws://` or `wss://`. */
5
+ url: string;
6
+ /** STUN and TURN servers. Without them peers connect only on the same network. */
7
+ iceServers?: RTCIceServer[];
8
+ /** Default: the global one (browsers). Node passes one, e.g. from `node-datachannel/polyfill`. */
9
+ RTCPeerConnection?: typeof RTCPeerConnection;
10
+ /** Default: the global one (browsers, Node 22+). */
11
+ WebSocket?: LobbyWebSocketClass;
12
+ /** Largest data-channel message. Default 64 KiB. */
13
+ maxMessage?: number;
14
+ /** How long a join may take to open its channels, in ms. Default 15 s. */
15
+ connectTimeout?: number;
16
+ }
17
+ /**
18
+ * Peer-to-peer sessions over WebRTC, introduced by a lobby (0091). Hosting gets a room code;
19
+ * joining with it opens a direct connection to the host. Pass it as `netPlugin({ session })`.
20
+ */
21
+ export declare function webRtcLobby(options: WebRtcLobbyOptions): NetSessionProvider & {
22
+ readonly caps: TransportCaps;
23
+ };
24
+ export { Negotiation, WebRtcConnection } from './connection.js';
25
+ export type { LobbyWebSocket, LobbyWebSocketClass } from './lobby.js';
26
+ export type { FromLobby, RoomInfo, SignalData, ToLobby } from './protocol.js';
package/dist/index.js ADDED
@@ -0,0 +1,135 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { failed, Negotiation, WebRtcConnection } from "./connection.js";
3
+ import { LobbySocket } from "./lobby.js";
4
+ function webRtcLobby(options) {
5
+ const maxMessage = options.maxMessage ?? 64 * 1024;
6
+ const timeout = options.connectTimeout ?? 15e3;
7
+ const config = { iceServers: options.iceServers ?? [] };
8
+ const Socket = options.WebSocket ?? globalThis.WebSocket;
9
+ const Peer = options.RTCPeerConnection ?? globalThis.RTCPeerConnection;
10
+ const open = () => {
11
+ if (!Socket) {
12
+ throw new ShardError("net/no-websocket", "This runtime has no global WebSocket", {
13
+ hint: "Pass one: webRtcLobby({ WebSocket })."
14
+ });
15
+ }
16
+ return LobbySocket.open(options.url, Socket);
17
+ };
18
+ const peer = () => {
19
+ if (!Peer) {
20
+ throw new ShardError("net/no-webrtc", "This runtime has no RTCPeerConnection", {
21
+ hint: "In Node, pass one: webRtcLobby({ RTCPeerConnection }) from node-datachannel/polyfill."
22
+ });
23
+ }
24
+ return new Peer(config);
25
+ };
26
+ return {
27
+ name: "webrtc-lobby",
28
+ caps: { unreliable: true, maxMessage },
29
+ async host(listen) {
30
+ const lobby = await open();
31
+ lobby.send({
32
+ t: "host",
33
+ ...listen.meta ? { meta: listen.meta } : {},
34
+ ...listen.maxPeers ? { maxPeers: listen.maxPeers } : {}
35
+ });
36
+ const { code } = await lobby.next("hosted");
37
+ const fns = [];
38
+ const joining = /* @__PURE__ */ new Map();
39
+ let nextPeer = 1;
40
+ lobby.on("joined", ({ id }) => {
41
+ const pc = peer();
42
+ const connection = new WebRtcConnection(pc, nextPeer++, maxMessage);
43
+ const negotiation = new Negotiation(pc, (data) => lobby.send({ t: "signal", to: id, data }));
44
+ joining.set(id, negotiation);
45
+ connection.opened.then(
46
+ () => {
47
+ joining.delete(id);
48
+ for (const fn of fns) fn(connection);
49
+ },
50
+ () => joining.delete(id)
51
+ );
52
+ setTimeout(() => {
53
+ if (joining.get(id) === negotiation) connection.close();
54
+ }, timeout);
55
+ });
56
+ lobby.on("signal", ({ from, data }) => joining.get(from)?.receive(data));
57
+ lobby.on("left", ({ id }) => joining.delete(id));
58
+ const listener = {
59
+ onConnection(fn) {
60
+ fns.push(fn);
61
+ return () => {
62
+ const i = fns.indexOf(fn);
63
+ if (i !== -1) fns.splice(i, 1);
64
+ };
65
+ },
66
+ async close() {
67
+ lobby.close();
68
+ }
69
+ };
70
+ return { code, listener };
71
+ },
72
+ async join(code) {
73
+ const lobby = await open();
74
+ lobby.send({ t: "join", code });
75
+ try {
76
+ await lobby.next("joining");
77
+ } catch (err) {
78
+ lobby.close();
79
+ throw err;
80
+ }
81
+ const pc = peer();
82
+ const connection = new WebRtcConnection(pc, 0, maxMessage);
83
+ const negotiation = new Negotiation(pc, (data) => lobby.send({ t: "signal", data }));
84
+ lobby.on("signal", ({ data }) => negotiation.receive(data));
85
+ connection.onEnd(() => lobby.close());
86
+ lobby.onClose(() => {
87
+ if (!opened) connection.close();
88
+ });
89
+ let opened = false;
90
+ negotiation.offer();
91
+ let timer;
92
+ try {
93
+ await Promise.race([
94
+ connection.opened,
95
+ new Promise((_, reject) => {
96
+ timer = setTimeout(
97
+ () => reject(
98
+ negotiation.error ?? failed(`No connection to the host within ${timeout} ms`)
99
+ ),
100
+ timeout
101
+ );
102
+ })
103
+ ]);
104
+ opened = true;
105
+ } catch (err) {
106
+ connection.close();
107
+ throw negotiation.error ?? err;
108
+ } finally {
109
+ clearTimeout(timer);
110
+ }
111
+ return connection;
112
+ },
113
+ async list(filter) {
114
+ const lobby = await open();
115
+ try {
116
+ lobby.send({ t: "list" });
117
+ const { rooms } = await lobby.next("rooms");
118
+ return rooms.filter((r) => !filter || Object.entries(filter).every(([k, v]) => r.meta[k] === v)).map((r) => ({
119
+ code: r.code,
120
+ meta: r.meta,
121
+ peers: r.peers,
122
+ ...r.maxPeers === null ? {} : { maxPeers: r.maxPeers }
123
+ }));
124
+ } finally {
125
+ lobby.close();
126
+ }
127
+ }
128
+ };
129
+ }
130
+ import { Negotiation as Negotiation2, WebRtcConnection as WebRtcConnection2 } from "./connection.js";
131
+ export {
132
+ Negotiation2 as Negotiation,
133
+ WebRtcConnection2 as WebRtcConnection,
134
+ webRtcLobby
135
+ };
@@ -0,0 +1,34 @@
1
+ import type { FromLobby, ToLobby } from './protocol.js';
2
+ /** What a browser `WebSocket` (and Node 22's global one) offers. */
3
+ export interface LobbyWebSocket {
4
+ readonly readyState: number;
5
+ send(data: string): void;
6
+ close(): void;
7
+ addEventListener(type: 'open', fn: () => void): void;
8
+ addEventListener(type: 'message', fn: (event: {
9
+ data: unknown;
10
+ }) => void): void;
11
+ addEventListener(type: 'close', fn: () => void): void;
12
+ addEventListener(type: 'error', fn: () => void): void;
13
+ }
14
+ export type LobbyWebSocketClass = new (url: string) => LobbyWebSocket;
15
+ /** A JSON connection to the lobby, with waiting for one reply by type. */
16
+ export declare class LobbySocket {
17
+ private readonly socket;
18
+ private readonly handlers;
19
+ private readonly closeFns;
20
+ closed: boolean;
21
+ private constructor();
22
+ static open(url: string, Socket: LobbyWebSocketClass): Promise<LobbySocket>;
23
+ send(message: ToLobby): void;
24
+ on<T extends FromLobby['t']>(type: T, fn: (message: Extract<FromLobby, {
25
+ t: T;
26
+ }>) => void): void;
27
+ onClose(fn: () => void): void;
28
+ /** The next message of `type`; an `error` message or a close rejects. */
29
+ next<T extends FromLobby['t']>(type: T): Promise<Extract<FromLobby, {
30
+ t: T;
31
+ }>>;
32
+ private off;
33
+ close(): void;
34
+ }
package/dist/lobby.js ADDED
@@ -0,0 +1,95 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ class LobbySocket {
3
+ socket;
4
+ handlers = /* @__PURE__ */ new Map();
5
+ closeFns = [];
6
+ closed = false;
7
+ constructor(socket) {
8
+ this.socket = socket;
9
+ socket.addEventListener("message", (event) => {
10
+ let message;
11
+ try {
12
+ message = JSON.parse(String(event.data));
13
+ } catch {
14
+ return;
15
+ }
16
+ for (const fn of [...this.handlers.get(message.t) ?? []]) fn(message);
17
+ for (const fn of [...this.handlers.get("*") ?? []]) fn(message);
18
+ });
19
+ socket.addEventListener("close", () => {
20
+ this.closed = true;
21
+ for (const fn of this.closeFns.splice(0)) fn();
22
+ });
23
+ }
24
+ static open(url, Socket) {
25
+ return new Promise((resolve, reject) => {
26
+ const socket = new Socket(url);
27
+ let settled = false;
28
+ socket.addEventListener("open", () => {
29
+ settled = true;
30
+ resolve(new LobbySocket(socket));
31
+ });
32
+ const fail = () => {
33
+ if (settled) return;
34
+ settled = true;
35
+ reject(
36
+ new ShardError("net/connect-failed", `Couldn't reach the lobby at ${url}`, {
37
+ hint: "Check the lobby server is running and the URL (ws:// or wss://)."
38
+ })
39
+ );
40
+ };
41
+ socket.addEventListener("error", fail);
42
+ socket.addEventListener("close", fail);
43
+ });
44
+ }
45
+ send(message) {
46
+ if (!this.closed) this.socket.send(JSON.stringify(message));
47
+ }
48
+ on(type, fn) {
49
+ const list = this.handlers.get(type) ?? [];
50
+ list.push(fn);
51
+ this.handlers.set(type, list);
52
+ }
53
+ onClose(fn) {
54
+ if (this.closed) fn();
55
+ else this.closeFns.push(fn);
56
+ }
57
+ /** The next message of `type`; an `error` message or a close rejects. */
58
+ next(type) {
59
+ return new Promise((resolve, reject) => {
60
+ const done = () => {
61
+ this.off(type, onMessage);
62
+ this.off("error", onError);
63
+ };
64
+ const onMessage = (m) => {
65
+ done();
66
+ resolve(m);
67
+ };
68
+ const onError = (m) => {
69
+ done();
70
+ const e = m;
71
+ reject(new ShardError(e.code, e.message));
72
+ };
73
+ this.on(type, onMessage);
74
+ this.on("error", onError);
75
+ this.onClose(() => {
76
+ done();
77
+ reject(new ShardError("net/lobby-closed", "The lobby connection closed"));
78
+ });
79
+ });
80
+ }
81
+ off(type, fn) {
82
+ const list = this.handlers.get(type);
83
+ const i = list?.indexOf(fn) ?? -1;
84
+ if (i !== -1) list.splice(i, 1);
85
+ }
86
+ close() {
87
+ if (this.closed) return;
88
+ this.closed = true;
89
+ this.socket.close();
90
+ for (const fn of this.closeFns.splice(0)) fn();
91
+ }
92
+ }
93
+ export {
94
+ LobbySocket
95
+ };
@@ -0,0 +1,63 @@
1
+ import type { JsonObject } from '@aethervtt/shard-core';
2
+ export interface RoomInfo {
3
+ code: string;
4
+ meta: JsonObject;
5
+ /** Players connected through the lobby (each keeps its lobby socket while in the game). */
6
+ peers: number;
7
+ maxPeers: number | null;
8
+ }
9
+ /** Offers, answers and network candidates, passed between two peers through the lobby. */
10
+ export type SignalData = {
11
+ sdp: {
12
+ type: string;
13
+ sdp: string;
14
+ };
15
+ } | {
16
+ candidate: {
17
+ candidate: string;
18
+ sdpMid: string | null;
19
+ sdpMLineIndex: number | null;
20
+ };
21
+ };
22
+ export type ToLobby = {
23
+ t: 'host';
24
+ meta?: JsonObject;
25
+ maxPeers?: number;
26
+ } | {
27
+ t: 'join';
28
+ code: string;
29
+ }
30
+ /** From a host: `to` is a joiner's id. From a joiner: always to its room's host. */
31
+ | {
32
+ t: 'signal';
33
+ to?: number;
34
+ data: SignalData;
35
+ } | {
36
+ t: 'list';
37
+ };
38
+ export type FromLobby = {
39
+ t: 'hosted';
40
+ code: string;
41
+ } | {
42
+ t: 'joined';
43
+ id: number;
44
+ } | {
45
+ t: 'joining';
46
+ id: number;
47
+ } | {
48
+ t: 'left';
49
+ id: number;
50
+ } | {
51
+ t: 'signal';
52
+ from: number;
53
+ data: SignalData;
54
+ } | {
55
+ t: 'rooms';
56
+ rooms: RoomInfo[];
57
+ } | {
58
+ t: 'error';
59
+ code: string;
60
+ message: string;
61
+ } | {
62
+ t: 'closed';
63
+ };
File without changes
@@ -0,0 +1,18 @@
1
+ import type { RoomInfo } from './protocol.js';
2
+ export interface LobbyServerOptions {
3
+ /** 0 picks a free port: read it from `port`. */
4
+ port?: number;
5
+ host?: string;
6
+ /** Rooms at most. Default 10,000. */
7
+ maxRooms?: number;
8
+ }
9
+ export interface LobbyServer {
10
+ readonly port: number;
11
+ rooms(): RoomInfo[];
12
+ close(): Promise<void>;
13
+ }
14
+ /**
15
+ * The lobby (0091): room codes, a room list, and signaling between a room's host and its joiners.
16
+ * Game traffic never passes through it.
17
+ */
18
+ export declare function lobbyServer(options?: LobbyServerOptions): Promise<LobbyServer>;
package/dist/server.js ADDED
@@ -0,0 +1,113 @@
1
+ import { randomInt } from "node:crypto";
2
+ import { WebSocketServer } from "ws";
3
+ const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
4
+ async function lobbyServer(options = {}) {
5
+ const server = new WebSocketServer({
6
+ port: options.port ?? 0,
7
+ host: options.host ?? "127.0.0.1",
8
+ maxPayload: 64 * 1024
9
+ });
10
+ await new Promise((resolve, reject) => {
11
+ server.once("listening", resolve);
12
+ server.once("error", reject);
13
+ });
14
+ const rooms = /* @__PURE__ */ new Map();
15
+ const maxRooms = options.maxRooms ?? 1e4;
16
+ let nextId = 1;
17
+ const send = (client, message) => {
18
+ if (client.socket.readyState === 1) client.socket.send(JSON.stringify(message));
19
+ };
20
+ const error = (client, code, message) => send(client, { t: "error", code, message });
21
+ const info = (room) => ({
22
+ code: room.code,
23
+ meta: room.meta,
24
+ peers: room.joiners.size,
25
+ maxPeers: room.maxPeers
26
+ });
27
+ const newCode = () => {
28
+ for (; ; ) {
29
+ let code = "";
30
+ for (let i = 0; i < 6; i++) code += ALPHABET[randomInt(ALPHABET.length)];
31
+ if (!rooms.has(code)) return code;
32
+ }
33
+ };
34
+ server.on("connection", (socket) => {
35
+ const client = { id: nextId++, socket, room: void 0 };
36
+ socket.on("message", (raw) => {
37
+ let message;
38
+ try {
39
+ message = JSON.parse(String(raw));
40
+ } catch {
41
+ error(client, "net/bad-lobby-message", "Lobby messages are JSON");
42
+ return;
43
+ }
44
+ switch (message.t) {
45
+ case "host": {
46
+ if (client.room) return error(client, "net/bad-lobby-message", "Already in a room");
47
+ if (rooms.size >= maxRooms) return error(client, "net/room-full", "The lobby is full");
48
+ const room = {
49
+ code: newCode(),
50
+ host: client,
51
+ meta: message.meta ?? {},
52
+ maxPeers: message.maxPeers ?? null,
53
+ joiners: /* @__PURE__ */ new Map()
54
+ };
55
+ rooms.set(room.code, room);
56
+ client.room = room;
57
+ return send(client, { t: "hosted", code: room.code });
58
+ }
59
+ case "join": {
60
+ if (client.room) return error(client, "net/bad-lobby-message", "Already in a room");
61
+ const room = rooms.get(String(message.code).toUpperCase());
62
+ if (!room) return error(client, "net/room-not-found", `No room ${message.code}`);
63
+ if (room.maxPeers !== null && room.joiners.size >= room.maxPeers) {
64
+ return error(client, "net/room-full", `Room ${room.code} is full`);
65
+ }
66
+ room.joiners.set(client.id, client);
67
+ client.room = room;
68
+ send(room.host, { t: "joined", id: client.id });
69
+ return send(client, { t: "joining", id: client.id });
70
+ }
71
+ case "signal": {
72
+ const room = client.room;
73
+ if (!room) return error(client, "net/bad-lobby-message", "Join or host a room first");
74
+ if (room.host === client) {
75
+ const to = room.joiners.get(Number(message.to));
76
+ if (to) send(to, { t: "signal", from: client.id, data: message.data });
77
+ } else send(room.host, { t: "signal", from: client.id, data: message.data });
78
+ return;
79
+ }
80
+ case "list":
81
+ return send(client, { t: "rooms", rooms: [...rooms.values()].map(info) });
82
+ default:
83
+ return error(client, "net/bad-lobby-message", "Unknown lobby message");
84
+ }
85
+ });
86
+ socket.on("close", () => {
87
+ const room = client.room;
88
+ if (!room) return;
89
+ if (room.host === client) {
90
+ rooms.delete(room.code);
91
+ for (const joiner of room.joiners.values()) {
92
+ send(joiner, { t: "closed" });
93
+ joiner.room = void 0;
94
+ }
95
+ } else {
96
+ room.joiners.delete(client.id);
97
+ send(room.host, { t: "left", id: client.id });
98
+ }
99
+ });
100
+ });
101
+ const address = server.address();
102
+ return {
103
+ port: typeof address === "object" && address ? address.port : 0,
104
+ rooms: () => [...rooms.values()].map(info),
105
+ close: () => new Promise((resolve) => {
106
+ for (const socket of server.clients) socket.terminate();
107
+ server.close(() => resolve());
108
+ })
109
+ };
110
+ }
111
+ export {
112
+ lobbyServer
113
+ };
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-net-webrtc",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./server": {
13
+ "types": "./dist/server.d.ts",
14
+ "default": "./dist/server.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "dependencies": {
23
+ "@aethervtt/shard-core": "0.3.0",
24
+ "@aethervtt/shard-net": "0.3.0",
25
+ "ws": "^8.21.3"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/aethervtt/shard.git",
30
+ "directory": "packages/net-webrtc"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "shard": {
37
+ "buildSha": "7c8c46fee30f86eff262af53b9249bdae6ce4927"
38
+ }
39
+ }