@hedystia/ws 2.3.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.
@@ -0,0 +1,208 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * Payload accepted by every `send`/`publish` method.
4
+ *
5
+ * @remarks
6
+ * Matches the WHATWG `WebSocket.send` signature plus the `Uint8Array`
7
+ * convenience accepted by Bun and the `ws` package.
8
+ */
9
+ type WSMessage = string | ArrayBuffer | Uint8Array;
10
+ /**
11
+ * Bag of arbitrary, user-supplied state attached to a connection on
12
+ * upgrade and exposed to handlers as `ws.data`.
13
+ *
14
+ * @typeParam K - String key
15
+ * @typeParam V - Stored value
16
+ */
17
+ type WSData = Record<string, any>;
18
+ /**
19
+ * The per-connection wrapper passed to every handler.
20
+ *
21
+ * @remarks
22
+ * The interface intentionally mirrors `Bun.ServerWebSocket` so that the
23
+ * same handler code works on Bun (native) and on Node.js (via
24
+ * {@link WebSocketServer}). Topic-based pub/sub is implemented in
25
+ * user-space when running outside Bun.
26
+ *
27
+ * @typeParam Data - Shape of the user-attached `data` field
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const handlers: WebSocketHandlers<{ user: string }> = {
32
+ * open: (ws) => ws.subscribe(`user:${ws.data.user}`),
33
+ * message: (ws, msg) => ws.publish(`user:${ws.data.user}`, msg),
34
+ * };
35
+ * ```
36
+ */
37
+ interface ServerWebSocket<Data extends WSData = WSData> {
38
+ /** User-supplied state attached to the socket on upgrade. */
39
+ readonly data: Data;
40
+ /** Standard WHATWG ready-state (`0` connecting, `1` open, `2` closing, `3` closed). */
41
+ readonly readyState: number;
42
+ /** Remote IP, taken from `X-Forwarded-For` when present. */
43
+ readonly remoteAddress: string;
44
+ /**
45
+ * Send a message to this socket only.
46
+ *
47
+ * @param message - Payload to send
48
+ * @param compress - Whether to compress (honoured on Bun, ignored on Node)
49
+ * @returns Number of bytes written (best-effort on Node)
50
+ */
51
+ send(message: WSMessage, compress?: boolean): number;
52
+ /**
53
+ * Close the connection.
54
+ *
55
+ * @param code - Close code (defaults to 1000)
56
+ * @param reason - Optional human-readable reason
57
+ */
58
+ close(code?: number, reason?: string): void;
59
+ /**
60
+ * Subscribe this socket to a topic so it receives subsequent
61
+ * {@link ServerWebSocket.publish | publish} or
62
+ * {@link WebSocketServer.publish | server.publish} broadcasts.
63
+ *
64
+ * @param topic - Topic name
65
+ */
66
+ subscribe(topic: string): void;
67
+ /**
68
+ * Unsubscribe this socket from a previously joined topic.
69
+ *
70
+ * @param topic - Topic name
71
+ */
72
+ unsubscribe(topic: string): void;
73
+ /**
74
+ * Broadcast a message to every other socket subscribed to `topic`.
75
+ *
76
+ * @remarks
77
+ * The sender is excluded by default — matching Bun's default behaviour.
78
+ *
79
+ * @param topic - Topic name
80
+ * @param message - Payload to broadcast
81
+ * @param compress - Whether to compress (honoured on Bun, ignored on Node)
82
+ */
83
+ publish(topic: string, message: WSMessage, compress?: boolean): void;
84
+ /**
85
+ * Check whether this socket is currently subscribed to `topic`.
86
+ *
87
+ * @param topic - Topic name
88
+ * @returns `true` when subscribed
89
+ */
90
+ isSubscribed(topic: string): boolean;
91
+ /**
92
+ * Batch multiple writes inside `cb`.
93
+ *
94
+ * @remarks
95
+ * On Bun this corresponds to `corked()`; on Node it is a no-op alias
96
+ * that simply invokes `cb(this)` synchronously.
97
+ *
98
+ * @param cb - Function invoked with the same socket
99
+ */
100
+ cork(cb: (ws: ServerWebSocket<Data>) => void): void;
101
+ }
102
+ /**
103
+ * Bun-style compression dictionary identifier.
104
+ *
105
+ * @remarks
106
+ * Used by Bun's `perMessageDeflate` configuration. The `@hedystia/ws`
107
+ * server forwards the value to the underlying implementation, which only
108
+ * Bun interprets natively; Node falls back to defaults when the value is
109
+ * not a recognised `ws` shape.
110
+ */
111
+ type Compressor = "disable" | "shared" | "dedicated" | "3KB" | "4KB" | "8KB" | "16KB" | "32KB" | "64KB" | "128KB" | "256KB";
112
+ /**
113
+ * Per-message deflate configuration.
114
+ *
115
+ * @remarks
116
+ * Accepts either a boolean (`true` enables defaults, `false` disables it)
117
+ * or a free-form object whose shape is forwarded verbatim to the underlying
118
+ * implementation. Bun's {@link Compressor} strings (`"3KB"`, `"shared"`, …)
119
+ * and the [`ws`](https://github.com/websockets/ws) package's
120
+ * `PerMessageDeflateOptions` (`zlibDeflateOptions`, `threshold`, …) are
121
+ * both supported by simply matching whatever the runtime expects.
122
+ */
123
+ type PerMessageDeflate = boolean | (Record<string, any> & {
124
+ compress?: boolean | Compressor;
125
+ decompress?: boolean | Compressor;
126
+ });
127
+ /**
128
+ * Construction options for {@link WebSocketServer}.
129
+ */
130
+ interface WebSocketServerOptions {
131
+ /** Maximum allowed payload in bytes. Defaults to the underlying library's default. */
132
+ maxPayload?: number;
133
+ /** Per-message deflate configuration. */
134
+ perMessageDeflate?: PerMessageDeflate;
135
+ }
136
+ /**
137
+ * Lifecycle handlers passed to {@link WebSocketServer}.
138
+ *
139
+ * @typeParam Data - Shape of the user-attached `data` field
140
+ */
141
+ interface WebSocketHandlers<Data extends WSData = WSData> {
142
+ /** Called for every inbound message. */
143
+ message: (ws: ServerWebSocket<Data>, message: WSMessage) => void | Promise<void>;
144
+ /** Called once the handshake completes successfully. */
145
+ open?: (ws: ServerWebSocket<Data>) => void | Promise<void>;
146
+ /** Called after the connection closes (clean or otherwise). */
147
+ close?: (ws: ServerWebSocket<Data>, code: number, reason: string) => void | Promise<void>;
148
+ /** Called when the underlying transport raises an error. */
149
+ error?: (ws: ServerWebSocket<Data>, error: Error) => void | Promise<void>;
150
+ /**
151
+ * Called when back-pressure is relieved.
152
+ *
153
+ * @remarks
154
+ * Only fired by Bun; Node-backed servers never invoke it.
155
+ */
156
+ drain?: (ws: ServerWebSocket<Data>) => void | Promise<void>;
157
+ }
158
+ /**
159
+ * Options accepted by {@link createWebSocket}.
160
+ */
161
+ interface ClientWebSocketOptions {
162
+ /** Sub-protocols negotiated during the handshake. */
163
+ protocols?: string | string[];
164
+ /**
165
+ * Custom request headers.
166
+ *
167
+ * @remarks
168
+ * Honoured on Node via the `ws` package; runtimes that ship a WHATWG
169
+ * `WebSocket` (Bun, Deno, browsers, Node ≥ 22) ignore them — matching
170
+ * standard WebSocket semantics.
171
+ */
172
+ headers?: Record<string, string>;
173
+ }
174
+ /**
175
+ * Raw upgrade tuple consumed by {@link WebSocketServer.upgrade}.
176
+ *
177
+ * @remarks
178
+ * Mirrors what `node:http`'s `'upgrade'` event emits and what the `ws`
179
+ * package's `WebSocketServer.handleUpgrade` consumes.
180
+ */
181
+ interface UpgradeRequest {
182
+ /** Raw `IncomingMessage`-like object exposing `headers`, `method`, `url`. */
183
+ rawRequest: any;
184
+ /** Raw duplex socket (e.g. `node:net.Socket`). */
185
+ socket: any;
186
+ /** Initial buffer captured by the HTTP parser. */
187
+ head: Buffer | Uint8Array;
188
+ }
189
+ /**
190
+ * Options forwarded to {@link WebSocketServer.upgrade}.
191
+ *
192
+ * @typeParam Data - Shape of the user-attached `data` field
193
+ */
194
+ interface UpgradeOptions<Data extends WSData = WSData> {
195
+ /** Initial value of `ws.data` for the new connection. */
196
+ data?: Data;
197
+ /**
198
+ * Extra response headers.
199
+ *
200
+ * @remarks
201
+ * Forwarded to the underlying handshake when supported (Bun); ignored on
202
+ * Node-backed upgrades, which control headers via the `ws` package.
203
+ */
204
+ headers?: Record<string, string> | Headers;
205
+ }
206
+ //#endregion
207
+ export { ClientWebSocketOptions, ServerWebSocket, UpgradeOptions, UpgradeRequest, WSData, WSMessage, WebSocketHandlers, WebSocketServerOptions };
208
+ //# sourceMappingURL=types.d.mts.map
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@hedystia/ws",
3
+ "version": "2.3.1",
4
+ "description": "Universal WebSocket primitives for Hedystia (Bun, Node.js, Deno)",
5
+ "homepage": "https://docs.hedystia.com/plugins/websocket",
6
+ "devDependencies": {
7
+ "@types/bun": "^1.3.11",
8
+ "@types/ws": "^8.5.13",
9
+ "typescript": "6.0.2"
10
+ },
11
+ "dependencies": {
12
+ "ws": "^8.18.0"
13
+ },
14
+ "private": false,
15
+ "keywords": [
16
+ "hedystia",
17
+ "websocket",
18
+ "ws",
19
+ "bun",
20
+ "nodejs",
21
+ "node",
22
+ "deno",
23
+ "realtime",
24
+ "framework"
25
+ ],
26
+ "license": "MIT",
27
+ "scripts": {
28
+ "build": "tsdown --config-loader unrun",
29
+ "release:pkg": "bun publish --provenance --access public",
30
+ "dev": "tsdown --watch"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/Hedystia/Hedystia"
35
+ },
36
+ "author": {
37
+ "name": "Zastinian",
38
+ "email": "contact@zastinian.com",
39
+ "url": "https://github.com/Zastinian"
40
+ },
41
+ "type": "commonjs",
42
+ "types": "./dist/index.d.cts",
43
+ "main": "./dist/index.cjs",
44
+ "module": "./dist/index.mjs",
45
+ "exports": {
46
+ ".": {
47
+ "types": "./dist/index.d.cts",
48
+ "import": "./dist/index.mjs",
49
+ "require": "./dist/index.cjs"
50
+ },
51
+ "./client": {
52
+ "types": "./dist/client.d.cts",
53
+ "import": "./dist/client.mjs",
54
+ "require": "./dist/client.cjs"
55
+ },
56
+ "./server": {
57
+ "types": "./dist/server.d.cts",
58
+ "import": "./dist/server.mjs",
59
+ "require": "./dist/server.cjs"
60
+ }
61
+ }
62
+ }
package/readme.md ADDED
@@ -0,0 +1,102 @@
1
+ # @hedystia/ws
2
+
3
+ Universal WebSocket primitives for [Hedystia](https://docs.hedystia.com).
4
+
5
+ The package ships **no HTTP server**. It only exposes WebSocket pieces that
6
+ work the same way on **Bun**, **Node.js** and **Deno**:
7
+
8
+ - A runtime-aware **client constructor**.
9
+ - A portable **`WebSocketServer`** that consumes raw HTTP upgrade tuples
10
+ and offers topic-based pub/sub, mirroring `Bun.ServerWebSocket`.
11
+ - Shared **types** and **runtime detection** helpers.
12
+
13
+ The HTTP layer (Bun.serve / `node:http`) is intentionally left to the
14
+ caller (`hedystia` server uses it internally).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ bun add @hedystia/ws
20
+ # or
21
+ npm install @hedystia/ws
22
+ ```
23
+
24
+ ## Client
25
+
26
+ ```ts
27
+ import { createWebSocket } from "@hedystia/ws/client";
28
+
29
+ const ws = createWebSocket("ws://localhost:3000", {
30
+ headers: { authorization: "Bearer ..." },
31
+ });
32
+
33
+ ws.onopen = () => ws.send("hi");
34
+ ws.onmessage = (event) => console.log(event.data);
35
+ ```
36
+
37
+ `createWebSocket()` uses `globalThis.WebSocket` when available (Bun, Deno,
38
+ browsers, Node ≥ 22) and falls back to the [`ws`](https://github.com/websockets/ws)
39
+ package on older Node releases. Custom request headers are honoured on Node
40
+ and ignored elsewhere — matching WHATWG semantics.
41
+
42
+ A small ergonomic wrapper is also provided:
43
+
44
+ ```ts
45
+ import { WebSocketClient } from "@hedystia/ws/client";
46
+
47
+ const client = new WebSocketClient("ws://localhost:3000");
48
+ client.onmessage = (e) => console.log(e.data);
49
+ ```
50
+
51
+ ## Server
52
+
53
+ The `WebSocketServer` does **not** open a port. Plug it into any HTTP
54
+ runtime that exposes raw upgrade tuples (`req`, `socket`, `head`).
55
+
56
+ ```ts
57
+ import { createServer } from "node:http";
58
+ import { WebSocketServer } from "@hedystia/ws/server";
59
+
60
+ const wss = new WebSocketServer({
61
+ open: (ws) => ws.send("welcome"),
62
+ message: (ws, message) => ws.publish("room", message),
63
+ });
64
+
65
+ const http = createServer((_req, res) => res.end("ok"));
66
+
67
+ http.on("upgrade", (req, socket, head) => {
68
+ wss.upgrade({ rawRequest: req, socket, head }, { data: { user: "anon" } });
69
+ });
70
+
71
+ http.listen(3000);
72
+
73
+ // Broadcast from anywhere
74
+ wss.publish("room", "hello world");
75
+ ```
76
+
77
+ The wrapper exposed to handlers implements:
78
+
79
+ | Method | Behaviour |
80
+ |--------|-----------|
81
+ | `ws.send(msg)` | Send to a single socket |
82
+ | `ws.subscribe(topic)` | Join a topic |
83
+ | `ws.unsubscribe(topic)` | Leave a topic |
84
+ | `ws.publish(topic, msg)` | Broadcast to peers (excluding self) |
85
+ | `ws.isSubscribed(topic)` | Membership check |
86
+ | `ws.close(code, reason)` | Close the connection |
87
+ | `ws.cork(cb)` | No-op alias for batching writes |
88
+ | `ws.data` | User-supplied state attached on upgrade |
89
+
90
+ ## Runtime detection
91
+
92
+ ```ts
93
+ import { detectRuntime, isBun, isNode, isDeno } from "@hedystia/ws";
94
+
95
+ if (detectRuntime() === "bun") {
96
+ // ...
97
+ }
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT
package/src/client.ts ADDED
@@ -0,0 +1,161 @@
1
+ import type { ClientWebSocketOptions } from "./types";
2
+
3
+ export type { ClientWebSocketOptions } from "./types";
4
+
5
+ /**
6
+ * Resolve the best `WebSocket` constructor for the current runtime.
7
+ *
8
+ * @remarks
9
+ * - Bun, Deno, browsers and Node ≥ 22 expose `globalThis.WebSocket`.
10
+ * - Older Node falls back to the [`ws`](https://github.com/websockets/ws)
11
+ * package, which mirrors the WHATWG `WebSocket` API.
12
+ *
13
+ * @returns A `WebSocket` constructor compatible with the WHATWG interface.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { resolveWebSocket } from "@hedystia/ws/client";
18
+ *
19
+ * const WS = resolveWebSocket();
20
+ * const socket = new WS("ws://localhost:3000");
21
+ * ```
22
+ */
23
+ export function resolveWebSocket(): typeof WebSocket {
24
+ if (typeof globalThis !== "undefined" && (globalThis as any).WebSocket) {
25
+ return (globalThis as any).WebSocket as typeof WebSocket;
26
+ }
27
+ const mod = require("ws");
28
+ return (mod.WebSocket ?? mod) as typeof WebSocket;
29
+ }
30
+
31
+ /**
32
+ * Create a `WebSocket` instance using the best available implementation
33
+ * for the current runtime.
34
+ *
35
+ * @remarks
36
+ * Custom request headers are honoured on Node via the `ws` package; on
37
+ * runtimes that ship a WHATWG-compliant global `WebSocket` (Bun, Deno,
38
+ * browsers, Node ≥ 22) headers are ignored — matching standard semantics.
39
+ *
40
+ * @param url - Absolute WebSocket URL (`ws://` or `wss://`)
41
+ * @param options - Optional protocols / headers
42
+ * @returns A connected (or connecting) `WebSocket` instance.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * import { createWebSocket } from "@hedystia/ws/client";
47
+ *
48
+ * const ws = createWebSocket("ws://localhost:3000", {
49
+ * protocols: "v1",
50
+ * headers: { authorization: "Bearer ..." },
51
+ * });
52
+ *
53
+ * ws.onopen = () => ws.send("hi");
54
+ * ws.onmessage = (event) => console.log(event.data);
55
+ * ```
56
+ */
57
+ export function createWebSocket(url: string, options?: ClientWebSocketOptions): WebSocket {
58
+ const Ctor = resolveWebSocket();
59
+ const isWhatwg =
60
+ typeof globalThis !== "undefined" && (globalThis as any).WebSocket === (Ctor as any);
61
+
62
+ if (isWhatwg) {
63
+ return options?.protocols ? new Ctor(url, options.protocols as any) : new Ctor(url);
64
+ }
65
+
66
+ const init: any = {};
67
+ if (options?.headers) {
68
+ init.headers = options.headers;
69
+ }
70
+ return new (Ctor as any)(url, options?.protocols, init);
71
+ }
72
+
73
+ /**
74
+ * Lightweight runtime-agnostic wrapper that mirrors a small, predictable
75
+ * subset of the WHATWG WebSocket interface.
76
+ *
77
+ * @remarks
78
+ * Useful for higher-level code that wants to assign event handlers by
79
+ * property (`socket.onmessage = ...`) without caring whether the underlying
80
+ * implementation comes from `globalThis.WebSocket` or the `ws` package.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * import { WebSocketClient } from "@hedystia/ws/client";
85
+ *
86
+ * const client = new WebSocketClient("ws://localhost:3000");
87
+ * client.onopen = () => client.send("hello");
88
+ * client.onmessage = (event) => console.log(event.data);
89
+ * ```
90
+ */
91
+ export class WebSocketClient {
92
+ /**
93
+ * Underlying WebSocket instance produced by {@link createWebSocket}.
94
+ *
95
+ * @readonly
96
+ */
97
+ readonly socket: WebSocket;
98
+
99
+ /**
100
+ * Create a new client and immediately initiate the connection.
101
+ *
102
+ * @param url - Absolute WebSocket URL (`ws://` or `wss://`)
103
+ * @param options - Optional protocols / headers, see {@link ClientWebSocketOptions}
104
+ */
105
+ constructor(url: string, options?: ClientWebSocketOptions) {
106
+ this.socket = createWebSocket(url, options);
107
+ }
108
+
109
+ /**
110
+ * Current connection state, mirroring {@link WebSocket.readyState}.
111
+ *
112
+ * @returns `0` connecting, `1` open, `2` closing, `3` closed.
113
+ */
114
+ get readyState(): number {
115
+ return this.socket.readyState;
116
+ }
117
+
118
+ /**
119
+ * Send a payload to the server.
120
+ *
121
+ * @param data - WHATWG-compatible payload
122
+ */
123
+ send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
124
+ this.socket.send(data as any);
125
+ }
126
+
127
+ /**
128
+ * Close the underlying socket.
129
+ *
130
+ * @param code - Close code (defaults to 1000)
131
+ * @param reason - Optional human-readable reason
132
+ */
133
+ close(code?: number, reason?: string): void {
134
+ this.socket.close(code, reason);
135
+ }
136
+
137
+ /**
138
+ * Assign the open-event listener.
139
+ */
140
+ set onopen(cb: ((ev: Event) => void) | null) {
141
+ (this.socket as any).onopen = cb;
142
+ }
143
+ /**
144
+ * Assign the message-event listener.
145
+ */
146
+ set onmessage(cb: ((ev: MessageEvent) => void) | null) {
147
+ (this.socket as any).onmessage = cb;
148
+ }
149
+ /**
150
+ * Assign the close-event listener.
151
+ */
152
+ set onclose(cb: ((ev: CloseEvent) => void) | null) {
153
+ (this.socket as any).onclose = cb;
154
+ }
155
+ /**
156
+ * Assign the error-event listener.
157
+ */
158
+ set onerror(cb: ((ev: Event) => void) | null) {
159
+ (this.socket as any).onerror = cb;
160
+ }
161
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export type { ClientWebSocketOptions } from "./client";
2
+ export { createWebSocket, resolveWebSocket, WebSocketClient } from "./client";
3
+ export type { Runtime } from "./runtime";
4
+ export { detectRuntime, isBrowser, isBun, isDeno, isNode } from "./runtime";
5
+ export type {
6
+ ServerWebSocket,
7
+ UpgradeOptions,
8
+ UpgradeRequest,
9
+ WebSocketHandlers,
10
+ WebSocketServerOptions,
11
+ WSData,
12
+ WSMessage,
13
+ } from "./server";
14
+
15
+ import { WebSocketServer } from "./server";
16
+
17
+ export { WebSocketServer };
18
+
19
+ export default WebSocketServer;
package/src/runtime.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * String identifier of the JavaScript runtime hosting the current process.
3
+ */
4
+ export type Runtime = "bun" | "node" | "deno" | "browser" | "unknown";
5
+
6
+ /**
7
+ * Detect the host runtime by probing well-known globals.
8
+ *
9
+ * @returns A {@link Runtime} discriminator for the active environment.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { detectRuntime } from "@hedystia/ws";
14
+ *
15
+ * if (detectRuntime() === "bun") {
16
+ * // ...use Bun-specific APIs
17
+ * }
18
+ * ```
19
+ */
20
+ export function detectRuntime(): Runtime {
21
+ if (typeof globalThis === "undefined") {
22
+ return "unknown";
23
+ }
24
+ const g = globalThis as any;
25
+ if (g.Bun?.serve) {
26
+ return "bun";
27
+ }
28
+ if (g.Deno) {
29
+ return "deno";
30
+ }
31
+ if (g.process?.versions?.node) {
32
+ return "node";
33
+ }
34
+ if (typeof g.window !== "undefined" && typeof g.document !== "undefined") {
35
+ return "browser";
36
+ }
37
+ return "unknown";
38
+ }
39
+
40
+ /**
41
+ * Convenience predicate.
42
+ *
43
+ * @returns `true` when running on Bun.
44
+ */
45
+ export const isBun = (): boolean => detectRuntime() === "bun";
46
+
47
+ /**
48
+ * Convenience predicate.
49
+ *
50
+ * @returns `true` when running on Node.js.
51
+ */
52
+ export const isNode = (): boolean => detectRuntime() === "node";
53
+
54
+ /**
55
+ * Convenience predicate.
56
+ *
57
+ * @returns `true` when running on Deno.
58
+ */
59
+ export const isDeno = (): boolean => detectRuntime() === "deno";
60
+
61
+ /**
62
+ * Convenience predicate.
63
+ *
64
+ * @returns `true` when running inside a browser-like environment.
65
+ */
66
+ export const isBrowser = (): boolean => detectRuntime() === "browser";