@aethervtt/shard-net-websocket 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-websocket
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,14 @@
1
+ import type { NetTransport } from '@aethervtt/shard-net';
2
+ import { type SocketLike } from './socket.js';
3
+ export interface WebSocketTransportOptions {
4
+ /** Defaults to 1 MiB. Larger reliable messages are split by the net core. */
5
+ maxMessage?: number;
6
+ /** The WebSocket class. Defaults to the global one (browsers, Node 22+). */
7
+ WebSocket?: new (url: string) => SocketLike;
8
+ }
9
+ /**
10
+ * Connects to a Shard server over WebSocket (0086). Every message is reliable underneath, so
11
+ * `caps.unreliable` is false. Servers use `webSocketServer` from `@aethervtt/shard-net-websocket/server`.
12
+ */
13
+ export declare function webSocketTransport(options?: WebSocketTransportOptions): NetTransport;
14
+ export { SocketConnection, type SocketLike } from './socket.js';
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { openSocket, SocketConnection } from "./socket.js";
3
+ function webSocketTransport(options = {}) {
4
+ const maxMessage = options.maxMessage ?? 1 << 20;
5
+ const Socket = options.WebSocket ?? globalThis.WebSocket;
6
+ return {
7
+ name: "websocket",
8
+ caps: { unreliable: false, maxMessage },
9
+ async connect(address) {
10
+ if (!Socket) {
11
+ throw new ShardError("net/no-websocket", "This runtime has no global WebSocket", {
12
+ hint: "Pass one: webSocketTransport({ WebSocket })."
13
+ });
14
+ }
15
+ const socket = new Socket(address);
16
+ await openSocket(socket, address);
17
+ return new SocketConnection(socket, 0, maxMessage);
18
+ }
19
+ };
20
+ }
21
+ import { SocketConnection as SocketConnection2 } from "./socket.js";
22
+ export {
23
+ SocketConnection2 as SocketConnection,
24
+ webSocketTransport
25
+ };
@@ -0,0 +1,17 @@
1
+ import type { NetListener, NetTransport } from '@aethervtt/shard-net';
2
+ export interface WebSocketServerOptions {
3
+ /** 0 picks a free port: read it from the listener's `port`. */
4
+ port?: number;
5
+ host?: string;
6
+ maxMessage?: number;
7
+ }
8
+ export interface WebSocketListener extends NetListener {
9
+ readonly port: number;
10
+ }
11
+ /**
12
+ * A Node WebSocket server for Shard (0086), through `ws`. It also connects out like
13
+ * `webSocketTransport`, so one transport serves both roles in tests.
14
+ */
15
+ export declare function webSocketServer(options?: WebSocketServerOptions): NetTransport & {
16
+ listen(): Promise<WebSocketListener>;
17
+ };
package/dist/server.js ADDED
@@ -0,0 +1,51 @@
1
+ import { WebSocketServer } from "ws";
2
+ import { webSocketTransport } from "./index.js";
3
+ import { SocketConnection } from "./socket.js";
4
+ function webSocketServer(options = {}) {
5
+ const maxMessage = options.maxMessage ?? 1 << 20;
6
+ const client = webSocketTransport({ maxMessage });
7
+ return {
8
+ name: "websocket",
9
+ caps: client.caps,
10
+ connect: client.connect,
11
+ async listen() {
12
+ const server = new WebSocketServer({
13
+ port: options.port ?? 0,
14
+ host: options.host ?? "127.0.0.1",
15
+ maxPayload: maxMessage + 1
16
+ });
17
+ await new Promise((resolve, reject) => {
18
+ server.once("listening", resolve);
19
+ server.once("error", reject);
20
+ });
21
+ const fns = [];
22
+ let nextPeer = 1;
23
+ server.on("connection", (socket) => {
24
+ const connection = new SocketConnection(
25
+ socket,
26
+ nextPeer++,
27
+ maxMessage
28
+ );
29
+ for (const fn of fns) fn(connection);
30
+ });
31
+ const address = server.address();
32
+ return {
33
+ port: typeof address === "object" && address ? address.port : 0,
34
+ onConnection(fn) {
35
+ fns.push(fn);
36
+ return () => {
37
+ const i = fns.indexOf(fn);
38
+ if (i !== -1) fns.splice(i, 1);
39
+ };
40
+ },
41
+ close: () => new Promise((resolve) => {
42
+ for (const socket of server.clients) socket.terminate();
43
+ server.close(() => resolve());
44
+ })
45
+ };
46
+ }
47
+ };
48
+ }
49
+ export {
50
+ webSocketServer
51
+ };
@@ -0,0 +1,40 @@
1
+ import { ShardError } from '@aethervtt/shard-core';
2
+ import type { Channel, ConnectionStats, NetConnection, PeerId } from '@aethervtt/shard-net';
3
+ /** What both a browser `WebSocket` and a `ws` socket offer. */
4
+ export interface SocketLike {
5
+ readonly readyState: number;
6
+ binaryType: string;
7
+ send(data: Uint8Array): void;
8
+ close(code?: number, reason?: string): void;
9
+ addEventListener(type: 'message', fn: (event: {
10
+ data: unknown;
11
+ }) => void): void;
12
+ addEventListener(type: 'close', fn: (event: {
13
+ code: number;
14
+ reason: string;
15
+ }) => void): void;
16
+ addEventListener(type: 'error', fn: (event: unknown) => void): void;
17
+ }
18
+ /**
19
+ * One WebSocket as a net connection. Each frame starts with a channel byte; both channels are
20
+ * reliable underneath (`caps.unreliable` is false).
21
+ */
22
+ export declare class SocketConnection implements NetConnection {
23
+ readonly peer: PeerId;
24
+ private readonly socket;
25
+ private readonly maxMessage;
26
+ private readonly messageFns;
27
+ private readonly closeFns;
28
+ private readonly counters;
29
+ private closed;
30
+ private closing;
31
+ constructor(socket: SocketLike, peer: PeerId, maxMessage: number);
32
+ send(channel: Channel, bytes: Uint8Array): void;
33
+ onMessage(fn: (channel: Channel, bytes: Uint8Array) => void): () => void;
34
+ onClose(fn: (reason: ShardError | undefined) => void): () => void;
35
+ close(reason?: string): void;
36
+ private shut;
37
+ stats(): ConnectionStats;
38
+ }
39
+ /** Opens a socket and resolves once it's open. */
40
+ export declare function openSocket(socket: SocketLike, address: string): Promise<void>;
package/dist/socket.js ADDED
@@ -0,0 +1,103 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ const OPEN = 1;
3
+ const CHANNELS = ["reliable", "unreliable"];
4
+ class SocketConnection {
5
+ peer;
6
+ socket;
7
+ maxMessage;
8
+ messageFns = [];
9
+ closeFns = [];
10
+ counters = { rtt: 0, sentBytes: 0, receivedBytes: 0, lost: 0 };
11
+ closed = false;
12
+ closing = false;
13
+ constructor(socket, peer, maxMessage) {
14
+ this.socket = socket;
15
+ this.peer = peer;
16
+ this.maxMessage = maxMessage;
17
+ socket.binaryType = "arraybuffer";
18
+ socket.addEventListener("message", (event) => {
19
+ const data = event.data;
20
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : void 0;
21
+ if (!bytes || bytes.length < 1) return;
22
+ this.counters.receivedBytes += bytes.length;
23
+ const channel = CHANNELS[bytes[0]] ?? "reliable";
24
+ const body = bytes.subarray(1);
25
+ for (const fn of this.messageFns) fn(channel, body);
26
+ });
27
+ socket.addEventListener("close", (event) => {
28
+ this.shut(
29
+ this.closing ? void 0 : new ShardError(
30
+ "net/transport-closed",
31
+ event.reason || `WebSocket closed (${event.code})`
32
+ )
33
+ );
34
+ });
35
+ }
36
+ send(channel, bytes) {
37
+ if (this.closed || this.socket.readyState !== OPEN) return;
38
+ if (bytes.length > this.maxMessage) {
39
+ throw new ShardError(
40
+ "net/message-too-large",
41
+ `A ${bytes.length}-byte message is over ${this.maxMessage} bytes`,
42
+ {
43
+ hint: "Reliable messages are split automatically; keep unreliable ones under maxMessage."
44
+ }
45
+ );
46
+ }
47
+ const frame = new Uint8Array(bytes.length + 1);
48
+ frame[0] = channel === "reliable" ? 0 : 1;
49
+ frame.set(bytes, 1);
50
+ this.counters.sentBytes += frame.length;
51
+ this.socket.send(frame);
52
+ }
53
+ onMessage(fn) {
54
+ this.messageFns.push(fn);
55
+ return () => remove(this.messageFns, fn);
56
+ }
57
+ onClose(fn) {
58
+ this.closeFns.push(fn);
59
+ return () => remove(this.closeFns, fn);
60
+ }
61
+ close(reason) {
62
+ if (this.closed) return;
63
+ this.closing = true;
64
+ this.socket.close(1e3, reason?.slice(0, 120));
65
+ this.shut(void 0);
66
+ }
67
+ shut(reason) {
68
+ if (this.closed) return;
69
+ this.closed = true;
70
+ for (const fn of [...this.closeFns]) fn(reason);
71
+ }
72
+ stats() {
73
+ return { ...this.counters };
74
+ }
75
+ }
76
+ function remove(list, item) {
77
+ const i = list.indexOf(item);
78
+ if (i !== -1) list.splice(i, 1);
79
+ }
80
+ function openSocket(socket, address) {
81
+ return new Promise((resolve, reject) => {
82
+ if (socket.readyState === OPEN) return resolve();
83
+ let settled = false;
84
+ const open = socket;
85
+ open.addEventListener("open", () => {
86
+ settled = true;
87
+ resolve();
88
+ });
89
+ socket.addEventListener("error", () => {
90
+ if (settled) return;
91
+ settled = true;
92
+ reject(
93
+ new ShardError("net/connect-failed", `Couldn't open a WebSocket to ${address}`, {
94
+ hint: "Check the server is listening and the URL (ws:// or wss://) is right."
95
+ })
96
+ );
97
+ });
98
+ });
99
+ }
100
+ export {
101
+ SocketConnection,
102
+ openSocket
103
+ };
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-net-websocket",
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-websocket"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "shard": {
37
+ "buildSha": "7c8c46fee30f86eff262af53b9249bdae6ce4927"
38
+ }
39
+ }