@znight/websocket 0.0.0 → 0.6.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/dist/bun.d.mts +4 -0
  4. package/dist/bun.mjs +112 -0
  5. package/dist/bun.mjs.map +1 -0
  6. package/dist/compiler.d.mts +90 -0
  7. package/dist/compiler.mjs +864 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/index.d.mts +501 -0
  10. package/dist/index.mjs +2088 -0
  11. package/dist/index.mjs.map +1 -0
  12. package/dist/server-CI_Bh9o1.d.mts +331 -0
  13. package/dist/server-D40_bC88.mjs +72 -0
  14. package/dist/server-D40_bC88.mjs.map +1 -0
  15. package/dist/testing.d.mts +60 -0
  16. package/dist/testing.mjs +189 -0
  17. package/dist/testing.mjs.map +1 -0
  18. package/dist/transport-xnJqvB93.d.mts +63 -0
  19. package/dist/ws.d.mts +9 -0
  20. package/dist/ws.mjs +143 -0
  21. package/dist/ws.mjs.map +1 -0
  22. package/package.json +102 -1
  23. package/src/client.ts +246 -0
  24. package/src/compiler/analyze.ts +850 -0
  25. package/src/compiler/constants.ts +102 -0
  26. package/src/compiler/data.ts +78 -0
  27. package/src/compiler/generate.ts +262 -0
  28. package/src/compiler/index.ts +53 -0
  29. package/src/compiler/validate.ts +81 -0
  30. package/src/connection.ts +455 -0
  31. package/src/context.ts +43 -0
  32. package/src/decorators.ts +83 -0
  33. package/src/dispatcher.ts +343 -0
  34. package/src/envelope.ts +136 -0
  35. package/src/exceptions.ts +91 -0
  36. package/src/index.ts +89 -0
  37. package/src/metadata.ts +74 -0
  38. package/src/options.ts +343 -0
  39. package/src/plugin.ts +820 -0
  40. package/src/rate-limit.ts +27 -0
  41. package/src/rooms.ts +185 -0
  42. package/src/server.ts +125 -0
  43. package/src/testing/index.ts +279 -0
  44. package/src/tokens.ts +23 -0
  45. package/src/transport.ts +72 -0
  46. package/src/transports/bun.ts +163 -0
  47. package/src/transports/memory.ts +174 -0
  48. package/src/transports/ws.ts +237 -0
  49. package/src/validate.ts +31 -0
@@ -0,0 +1,72 @@
1
+ /** The transport contract (RFC 0003 §8.3): what completes a handshake and carries the frames. */
2
+ import type { BunUpgrade, HeaderValue, MemoryUpgrade, NodeUpgrade } from '@znight/http'
3
+
4
+ /** Server-wide settings given once at bootstrap. */
5
+ export interface TransportSettings {
6
+ /** The largest inbound message any gateway accepts, in bytes. */
7
+ readonly maxPayload: number
8
+ /** The largest outbound buffer any gateway tolerates, in bytes. */
9
+ readonly backpressureLimit: number
10
+ /** Heartbeat interval in ms, `0` when disabled. */
11
+ readonly heartbeatMs: number
12
+ }
13
+
14
+ /** What {@link WebSocketTransport.accept} receives besides the native upgrade. */
15
+ export interface AcceptInit {
16
+ /** The selected subprotocol, `''` when none. */
17
+ readonly protocol: string
18
+ /** Extra headers of the `101` response. */
19
+ readonly headers: Readonly<Record<string, HeaderValue>>
20
+ /** The largest inbound message of the gateway, in bytes. */
21
+ readonly maxPayload: number
22
+ }
23
+
24
+ /** The socket events of one connection, given to the transport by the plugin. */
25
+ export interface SocketEvents {
26
+ message(frame: string | Uint8Array, binary: boolean): void
27
+ pong(): void
28
+ /**
29
+ * The outbound buffer may have shrunk (after a write, or Bun's `drain`): the connection compares it
30
+ * with its own threshold, and polls the buffer while `conn.drained()` waits.
31
+ */
32
+ drain(): void
33
+ close(code: number, reason: string): void
34
+ }
35
+
36
+ /** The result of {@link TransportSocket.send}. */
37
+ export type SendStatus = 'sent' | 'queued' | 'dropped'
38
+
39
+ /** One accepted connection, the only object holding native handles. */
40
+ export interface TransportSocket {
41
+ send(frame: string | Uint8Array, binary: boolean): SendStatus
42
+ readonly bufferedAmount: number
43
+ ping(): void
44
+ /** Stops reading; `false` when the transport cannot pause. */
45
+ pause(): boolean
46
+ resume(): void
47
+ close(code: number, reason: string): void
48
+ terminate(): void
49
+ /** Negotiated extensions (`permessage-deflate`...), `''` when none. */
50
+ readonly extensions: string
51
+ }
52
+
53
+ /** A transport: `ws` on `node:http` servers, Bun's built-in WebSocket on `Bun.serve()`, or memory. */
54
+ export interface WebSocketTransport {
55
+ readonly name: string
56
+ /** Which native upgrade it completes. */
57
+ readonly accepts: 'node' | 'bun' | 'memory'
58
+ configure(settings: TransportSettings): void
59
+ accept(
60
+ native: NodeUpgrade | BunUpgrade | MemoryUpgrade,
61
+ init: AcceptInit,
62
+ events: SocketEvents,
63
+ ): TransportSocket | Promise<TransportSocket>
64
+ /**
65
+ * Loads what the transport needs before the server listens (the `ws` package), so that a missing
66
+ * dependency fails the startup (ZNIGHT051) rather than the first handshake.
67
+ */
68
+ ready?(): Promise<void>
69
+ /** `Bun.serve()` transports only: the settings of its `websocket` option. */
70
+ bunWebSocket?(): Readonly<Record<string, unknown>>
71
+ close(): Promise<void>
72
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The Bun transport (RFC 0003 §8.3): completes the upgrades of `Bun.serve()` (`honoAdapter()` on
3
+ * Bun) with Bun's built-in WebSocket server. No import: it only uses the server it is given.
4
+ *
5
+ * ```ts
6
+ * import { bunTransport } from '@znight/websocket/bun'
7
+ * ```
8
+ */
9
+ import type { BunSocketHandlers, BunUpgrade, MemoryUpgrade, NodeUpgrade } from '@znight/http'
10
+ import type {
11
+ AcceptInit,
12
+ SendStatus,
13
+ SocketEvents,
14
+ TransportSettings,
15
+ TransportSocket,
16
+ WebSocketTransport,
17
+ } from '../transport.ts'
18
+
19
+ import { ErrorCode, ZnightError } from '@znight/core'
20
+
21
+ /** The part of Bun's `ServerWebSocket` the transport uses. */
22
+ interface BunSocket {
23
+ send(data: string | Uint8Array): number
24
+ getBufferedAmount(): number
25
+ readonly extensions?: string
26
+ ping(): void
27
+ pause?(): void
28
+ resume?(): void
29
+ close(code: number, reason: string): void
30
+ terminate(): void
31
+ }
32
+
33
+ /** The part of Bun's `Server` the transport uses. */
34
+ interface BunServer {
35
+ upgrade(request: Request, options: { data: BunSocketHandlers; headers?: Record<string, string> }): boolean
36
+ }
37
+
38
+ /** One Bun connection; bound before `server.upgrade()`, since `open` may run inside it. */
39
+ class BunTransportSocket implements TransportSocket {
40
+ readonly #events: SocketEvents
41
+ readonly #registry: Set<BunTransportSocket>
42
+ #ws: BunSocket | undefined
43
+ #closed = false
44
+
45
+ constructor(events: SocketEvents, registry: Set<BunTransportSocket>) {
46
+ this.#events = events
47
+ this.#registry = registry
48
+ }
49
+
50
+ get bufferedAmount(): number {
51
+ return this.#ws?.getBufferedAmount() ?? 0
52
+ }
53
+
54
+ get extensions(): string {
55
+ return this.#ws?.extensions ?? ''
56
+ }
57
+
58
+ send(frame: string | Uint8Array, _binary: boolean): SendStatus {
59
+ if (this.#ws === undefined || this.#closed) return 'dropped'
60
+ const result = this.#ws.send(frame)
61
+ if (result > 0) return 'sent'
62
+ return result === -1 ? 'queued' : 'dropped'
63
+ }
64
+
65
+ ping(): void {
66
+ this.#ws?.ping()
67
+ }
68
+
69
+ pause(): boolean {
70
+ if (this.#ws?.pause === undefined) return false
71
+ this.#ws.pause()
72
+ return true
73
+ }
74
+
75
+ resume(): void {
76
+ this.#ws?.resume?.()
77
+ }
78
+
79
+ close(code: number, reason: string): void {
80
+ this.#ws?.close(code, reason)
81
+ }
82
+
83
+ terminate(): void {
84
+ this.#ws?.terminate()
85
+ }
86
+
87
+ onOpen(ws: unknown): void {
88
+ this.#ws = ws as BunSocket
89
+ this.#registry.add(this)
90
+ }
91
+
92
+ onMessage(message: string | Uint8Array): void {
93
+ this.#events.message(message, typeof message !== 'string')
94
+ }
95
+
96
+ onClose(code: number, reason: string): void {
97
+ if (this.#closed) return
98
+ this.#closed = true
99
+ this.#registry.delete(this)
100
+ this.#events.close(code, reason)
101
+ }
102
+
103
+ onDrain(): void {
104
+ this.#events.drain()
105
+ }
106
+
107
+ onPong(): void {
108
+ this.#events.pong()
109
+ }
110
+ }
111
+
112
+ /** The Bun transport: completes the `bun` upgrades of `Bun.serve()`. */
113
+ export function bunTransport(): WebSocketTransport {
114
+ let settings: TransportSettings = { maxPayload: 1024 * 1024, backpressureLimit: 1024 * 1024, heartbeatMs: 0 }
115
+ const sockets = new Set<BunTransportSocket>()
116
+ return {
117
+ name: 'bun',
118
+ accepts: 'bun',
119
+ configure(next: TransportSettings): void {
120
+ settings = next
121
+ },
122
+ bunWebSocket(): Readonly<Record<string, unknown>> {
123
+ return {
124
+ maxPayloadLength: settings.maxPayload,
125
+ backpressureLimit: settings.backpressureLimit,
126
+ closeOnBackpressureLimit: false,
127
+ sendPings: settings.heartbeatMs > 0,
128
+ idleTimeout: settings.heartbeatMs > 0 ? Math.min(960, Math.ceil((settings.heartbeatMs * 2) / 1000)) : 0,
129
+ }
130
+ },
131
+ accept(native: NodeUpgrade | BunUpgrade | MemoryUpgrade, init: AcceptInit, events: SocketEvents): TransportSocket {
132
+ if (native.kind !== 'bun') {
133
+ throw new ZnightError(
134
+ ErrorCode.WebSocketTransportUnavailable,
135
+ `bunTransport() completes Bun.serve() upgrades, not ${native.kind} upgrades`,
136
+ { hints: ['Use wsTransport() with the node:http adapters'] },
137
+ )
138
+ }
139
+ const socket = new BunTransportSocket(events, sockets)
140
+ const handlers: BunSocketHandlers = {
141
+ open: ws => socket.onOpen(ws),
142
+ message: (_ws, message) => socket.onMessage(message),
143
+ close: (_ws, code, reason) => socket.onClose(code, reason),
144
+ drain: () => socket.onDrain(),
145
+ pong: () => socket.onPong(),
146
+ }
147
+ const headers: Record<string, string> = {}
148
+ for (const [name, value] of Object.entries(init.headers)) {
149
+ headers[name] = typeof value === 'string' ? value : value.join(', ')
150
+ }
151
+ if (init.protocol !== '') headers['sec-websocket-protocol'] = init.protocol
152
+ const server = native.server as BunServer
153
+ if (!server.upgrade(native.request, { data: handlers, headers })) {
154
+ throw new Error('Bun.serve() refused the WebSocket upgrade')
155
+ }
156
+ return socket
157
+ },
158
+ close(): Promise<void> {
159
+ for (const socket of [...sockets]) socket.terminate()
160
+ return Promise.resolve()
161
+ },
162
+ }
163
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The in-memory transport (RFC 0003 §8.3): an in-process pair used by `WebSocketPlugin.connect()`
3
+ * and the tests. Frames are delivered synchronously, nothing is buffered, reading pauses with a
4
+ * queue, and there is no heartbeat.
5
+ */
6
+ import type { BunUpgrade, MemoryUpgrade, NodeUpgrade } from '@znight/http'
7
+ import type { AcceptInit, SendStatus, SocketEvents, TransportSocket, WebSocketTransport } from '../transport.ts'
8
+
9
+ /** How a memory connection closed. */
10
+ export interface MemoryCloseInfo {
11
+ readonly code: number
12
+ readonly reason: string
13
+ }
14
+
15
+ /**
16
+ * The client side of an in-memory connection: pass it as the `peer` of a memory upgrade
17
+ * (`{ kind: 'memory', peer: client }`), then read `received` and send frames.
18
+ */
19
+ export class MemoryClient {
20
+ /** Frames received from the server, in order. */
21
+ readonly received: (string | Uint8Array)[] = []
22
+ /** Called for each frame received from the server. */
23
+ onmessage: ((frame: string | Uint8Array) => void) | undefined
24
+ /** Called once when the connection closes. */
25
+ onclose: ((info: MemoryCloseInfo) => void) | undefined
26
+ #protocol = ''
27
+ #closed: MemoryCloseInfo | undefined
28
+ #server: MemorySocket | undefined
29
+
30
+ /** The subprotocol the server selected, `''` when none. */
31
+ get protocol(): string {
32
+ return this.#protocol
33
+ }
34
+
35
+ /** How the connection closed, `undefined` while open. */
36
+ get closed(): MemoryCloseInfo | undefined {
37
+ return this.#closed
38
+ }
39
+
40
+ /** Whether the server accepted the connection. */
41
+ get open(): boolean {
42
+ return this.#server !== undefined && this.#closed === undefined
43
+ }
44
+
45
+ /** Sends a frame to the server (a string is a text frame). */
46
+ send(frame: string | Uint8Array): void {
47
+ if (this.#server === undefined || this.#closed !== undefined) throw new Error('The connection is not open')
48
+ this.#server.receive(frame)
49
+ }
50
+
51
+ /** Closes the connection from the client side. */
52
+ close(code = 1000, reason = ''): void {
53
+ if (this.#server === undefined || this.#closed !== undefined) return
54
+ this.#server.closeFromPeer(code, reason)
55
+ }
56
+
57
+ /** @internal Bound by the memory transport. */
58
+ attach(server: MemorySocket, protocol: string): void {
59
+ if (this.#server !== undefined) throw new Error('This MemoryClient is already connected')
60
+ this.#server = server
61
+ this.#protocol = protocol
62
+ }
63
+
64
+ /** @internal */
65
+ deliver(frame: string | Uint8Array): void {
66
+ this.received.push(frame)
67
+ this.onmessage?.(frame)
68
+ }
69
+
70
+ /** @internal */
71
+ closedWith(info: MemoryCloseInfo): void {
72
+ if (this.#closed !== undefined) return
73
+ this.#closed = info
74
+ this.onclose?.(info)
75
+ }
76
+ }
77
+
78
+ /** The server side of an in-memory connection. */
79
+ class MemorySocket implements TransportSocket {
80
+ readonly bufferedAmount = 0
81
+ readonly extensions = ''
82
+ readonly #client: MemoryClient
83
+ readonly #events: SocketEvents
84
+ readonly #queue: (string | Uint8Array)[] = []
85
+ #paused = false
86
+ #closed = false
87
+
88
+ constructor(client: MemoryClient, events: SocketEvents) {
89
+ this.#client = client
90
+ this.#events = events
91
+ }
92
+
93
+ send(frame: string | Uint8Array, _binary: boolean): SendStatus {
94
+ if (this.#closed) return 'dropped'
95
+ this.#client.deliver(frame)
96
+ return 'sent'
97
+ }
98
+
99
+ ping(): void {
100
+ if (!this.#closed) this.#events.pong()
101
+ }
102
+
103
+ pause(): boolean {
104
+ this.#paused = true
105
+ return true
106
+ }
107
+
108
+ resume(): void {
109
+ this.#paused = false
110
+ while (!this.#paused && !this.#closed && this.#queue.length > 0) this.#dispatch(this.#queue.shift() as string)
111
+ }
112
+
113
+ close(code: number, reason: string): void {
114
+ this.#finish(code, reason)
115
+ }
116
+
117
+ terminate(): void {
118
+ this.#finish(1006, '')
119
+ }
120
+
121
+ receive(frame: string | Uint8Array): void {
122
+ if (this.#closed) return
123
+ if (this.#paused) this.#queue.push(frame)
124
+ else this.#dispatch(frame)
125
+ }
126
+
127
+ closeFromPeer(code: number, reason: string): void {
128
+ this.#finish(code, reason)
129
+ }
130
+
131
+ #dispatch(frame: string | Uint8Array): void {
132
+ this.#events.message(frame, typeof frame !== 'string')
133
+ }
134
+
135
+ #finish(code: number, reason: string): void {
136
+ if (this.#closed) return
137
+ this.#closed = true
138
+ this.#queue.length = 0
139
+ this.#client.closedWith({ code, reason })
140
+ this.#events.close(code, reason)
141
+ }
142
+ }
143
+
144
+ /** The in-memory transport: completes `{ kind: 'memory', peer }` upgrades whose peer is a {@link MemoryClient}. */
145
+ export function memoryTransport(): WebSocketTransport {
146
+ const sockets = new Set<MemorySocket>()
147
+ return {
148
+ name: 'memory',
149
+ accepts: 'memory',
150
+ configure: () => undefined,
151
+ accept(native: NodeUpgrade | BunUpgrade | MemoryUpgrade, init: AcceptInit, events: SocketEvents): TransportSocket {
152
+ if (native.kind !== 'memory' || !(native.peer instanceof MemoryClient)) {
153
+ throw new Error('The memory transport only completes memory upgrades whose peer is a MemoryClient')
154
+ }
155
+ const tracked: SocketEvents = {
156
+ message: (frame, binary) => events.message(frame, binary),
157
+ pong: () => events.pong(),
158
+ drain: () => events.drain(),
159
+ close: (code, reason) => {
160
+ sockets.delete(socket)
161
+ events.close(code, reason)
162
+ },
163
+ }
164
+ const socket = new MemorySocket(native.peer, tracked)
165
+ sockets.add(socket)
166
+ native.peer.attach(socket, init.protocol)
167
+ return socket
168
+ },
169
+ close(): Promise<void> {
170
+ for (const socket of [...sockets]) socket.terminate()
171
+ return Promise.resolve()
172
+ },
173
+ }
174
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The `ws` transport (RFC 0003 §8.3): completes the upgrades of every `node:http` adapter, on
3
+ * Node.js and on Bun. `ws` is an optional peer dependency, loaded lazily on the first connection.
4
+ *
5
+ * ```ts
6
+ * import { wsTransport } from '@znight/websocket/ws'
7
+ * ```
8
+ */
9
+ import type { BunUpgrade, HeaderValue, MemoryUpgrade, NodeUpgrade } from '@znight/http'
10
+ import type {
11
+ AcceptInit,
12
+ SendStatus,
13
+ SocketEvents,
14
+ TransportSettings,
15
+ TransportSocket,
16
+ WebSocketTransport,
17
+ } from '../transport.ts'
18
+
19
+ import { ErrorCode, ZnightError } from '@znight/core'
20
+ import { HttpException } from '@znight/http'
21
+
22
+ /** The part of a `ws` `WebSocket` the transport uses (no `@types/ws`). */
23
+ interface WsSocket {
24
+ /** `1` while open. */
25
+ readonly readyState: number
26
+ readonly bufferedAmount: number
27
+ readonly extensions: string
28
+ send(data: string | Uint8Array, options: { binary: boolean }, callback: (error?: Error) => void): void
29
+ ping(): void
30
+ pause(): void
31
+ resume(): void
32
+ close(code: number, reason: string): void
33
+ terminate(): void
34
+ on(event: 'message', listener: (data: Buffer | ArrayBuffer | Buffer[], binary: boolean) => void): void
35
+ on(event: 'pong', listener: () => void): void
36
+ on(event: 'close', listener: (code: number, reason: Buffer) => void): void
37
+ on(event: 'error', listener: (error: Error) => void): void
38
+ }
39
+
40
+ /** The part of a `ws` `WebSocketServer` the transport uses. */
41
+ interface WsServer {
42
+ handleUpgrade(request: unknown, socket: unknown, head: Uint8Array, callback: (ws: WsSocket) => void): void
43
+ on(event: 'headers', listener: (headers: string[], request: unknown) => void): void
44
+ close(): void
45
+ }
46
+
47
+ /** The part of the handshake's `net.Socket` the transport watches. */
48
+ interface NodeSocket {
49
+ readonly destroyed: boolean
50
+ readonly writable: boolean
51
+ once(event: 'close', listener: () => void): void
52
+ removeListener(event: 'close', listener: () => void): void
53
+ }
54
+
55
+ /** The part of the handshake's `IncomingMessage` the transport rewrites. */
56
+ interface NodeRequest {
57
+ znightProtocol?: string
58
+ readonly headers: Record<string, string | string[] | undefined>
59
+ }
60
+
61
+ interface WsModule {
62
+ readonly WebSocketServer: new (options: Record<string, unknown>) => WsServer
63
+ }
64
+
65
+ /** Options of {@link wsTransport}. */
66
+ export interface WsTransportOptions {
67
+ /** `permessage-deflate` (`ws` option). Default: `false`. */
68
+ readonly perMessageDeflate?: boolean | Record<string, unknown>
69
+ }
70
+
71
+ let loading: Promise<WsModule> | undefined
72
+
73
+ function loadWs(): Promise<WsModule> {
74
+ const name = 'ws'
75
+ loading ??= (import(name) as Promise<WsModule & { default?: WsModule }>).then(
76
+ module => (module.WebSocketServer === undefined && module.default !== undefined ? module.default : module),
77
+ (cause: unknown) => {
78
+ loading = undefined
79
+ throw new ZnightError(
80
+ ErrorCode.WebSocketTransportUnavailable,
81
+ 'The "ws" package is required to serve WebSocket gateways on Node.js',
82
+ { hints: ['Install it: npm install ws'], cause },
83
+ )
84
+ },
85
+ )
86
+ return loading
87
+ }
88
+
89
+ function toFrame(data: Buffer | ArrayBuffer | Buffer[], binary: boolean): string | Uint8Array {
90
+ let bytes: Uint8Array
91
+ if (Array.isArray(data)) bytes = Buffer.concat(data)
92
+ else if (data instanceof ArrayBuffer) bytes = new Uint8Array(data)
93
+ else bytes = data
94
+ return binary ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : Buffer.from(bytes).toString('utf8')
95
+ }
96
+
97
+ /** One `ws` connection. */
98
+ class WsTransportSocket implements TransportSocket {
99
+ readonly #ws: WsSocket
100
+ readonly #limit: number
101
+ /** `drain` after every write: the connection compares the buffer with its own threshold. */
102
+ readonly #events: SocketEvents
103
+
104
+ constructor(ws: WsSocket, limit: number, events: SocketEvents) {
105
+ this.#ws = ws
106
+ this.#limit = limit
107
+ this.#events = events
108
+ }
109
+
110
+ get bufferedAmount(): number {
111
+ return this.#ws.bufferedAmount
112
+ }
113
+
114
+ get extensions(): string {
115
+ return this.#ws.extensions
116
+ }
117
+
118
+ send(frame: string | Uint8Array, binary: boolean): SendStatus {
119
+ if (this.#ws.readyState !== 1) return 'dropped'
120
+ this.#ws.send(frame, { binary }, () => this.#events.drain())
121
+ return this.#ws.bufferedAmount <= this.#limit ? 'sent' : 'queued'
122
+ }
123
+
124
+ ping(): void {
125
+ this.#ws.ping()
126
+ }
127
+
128
+ pause(): boolean {
129
+ this.#ws.pause()
130
+ return true
131
+ }
132
+
133
+ resume(): void {
134
+ this.#ws.resume()
135
+ }
136
+
137
+ close(code: number, reason: string): void {
138
+ this.#ws.close(code, reason)
139
+ }
140
+
141
+ terminate(): void {
142
+ this.#ws.terminate()
143
+ }
144
+ }
145
+
146
+ /** The `ws` transport: completes the `node` upgrades of `node:http` servers. */
147
+ export function wsTransport(options: WsTransportOptions = {}): WebSocketTransport {
148
+ let settings: TransportSettings = { maxPayload: 1024 * 1024, backpressureLimit: 1024 * 1024, heartbeatMs: 0 }
149
+ let server: WsServer | undefined
150
+ const pendingHeaders = new WeakMap<object, Readonly<Record<string, HeaderValue>>>()
151
+ const sockets = new Set<WsSocket>()
152
+
153
+ async function serverOf(): Promise<WsServer> {
154
+ if (server !== undefined) return server
155
+ const { WebSocketServer } = await loadWs()
156
+ server ??= new WebSocketServer({
157
+ noServer: true,
158
+ maxPayload: settings.maxPayload,
159
+ perMessageDeflate: options.perMessageDeflate ?? false,
160
+ clientTracking: false,
161
+ handleProtocols: (protocols: Set<string>, request: object) => {
162
+ const selected = (request as { znightProtocol?: string }).znightProtocol ?? ''
163
+ return selected !== '' && protocols.has(selected) ? selected : false
164
+ },
165
+ })
166
+ server.on('headers', (lines, request) => {
167
+ const extra = pendingHeaders.get(request as object)
168
+ if (extra === undefined) return
169
+ for (const [name, value] of Object.entries(extra)) {
170
+ for (const item of typeof value === 'string' ? [value] : value) lines.push(`${name}: ${item}`)
171
+ }
172
+ })
173
+ return server
174
+ }
175
+
176
+ return {
177
+ name: 'ws',
178
+ accepts: 'node',
179
+ configure(next: TransportSettings): void {
180
+ settings = next
181
+ },
182
+ async ready(): Promise<void> {
183
+ await serverOf()
184
+ },
185
+ async accept(
186
+ native: NodeUpgrade | BunUpgrade | MemoryUpgrade,
187
+ init: AcceptInit,
188
+ events: SocketEvents,
189
+ ): Promise<TransportSocket> {
190
+ if (native.kind !== 'node') {
191
+ throw new ZnightError(
192
+ ErrorCode.WebSocketTransportUnavailable,
193
+ `wsTransport() completes node:http upgrades, not ${native.kind} upgrades`,
194
+ { hints: ['Use bunTransport() with honoAdapter() on Bun'] },
195
+ )
196
+ }
197
+ const wss = await serverOf()
198
+ const request = native.request as NodeRequest
199
+ const raw = native.socket as NodeSocket
200
+ // the plugin selected the subprotocol: ws would abort (400, no callback) on a header it cannot parse
201
+ if (init.protocol === '') delete request.headers['sec-websocket-protocol']
202
+ else request.headers['sec-websocket-protocol'] = init.protocol
203
+ request.znightProtocol = init.protocol
204
+ pendingHeaders.set(request, init.headers)
205
+ // ws destroys the socket without calling back when the client left or a header is malformed
206
+ const ws = await new Promise<WsSocket>((resolve, reject) => {
207
+ const aborted = (): void => reject(new HttpException(400, 'The WebSocket handshake was aborted'))
208
+ if (raw.destroyed || !raw.writable) {
209
+ aborted()
210
+ return
211
+ }
212
+ raw.once('close', aborted)
213
+ wss.handleUpgrade(request, raw, native.head, accepted => {
214
+ raw.removeListener('close', aborted)
215
+ resolve(accepted)
216
+ })
217
+ }).finally(() => pendingHeaders.delete(request))
218
+ sockets.add(ws)
219
+ const socket = new WsTransportSocket(ws, settings.backpressureLimit, events)
220
+ ws.on('error', () => undefined) // reported through 'close'; an unhandled 'error' would kill the process
221
+ ws.on('message', (data, binary) => events.message(toFrame(data, binary), binary))
222
+ ws.on('pong', () => events.pong())
223
+ ws.on('close', (code, reason) => {
224
+ sockets.delete(ws)
225
+ events.close(code, reason.toString('utf8'))
226
+ })
227
+ return socket
228
+ },
229
+ close(): Promise<void> {
230
+ for (const ws of sockets) ws.terminate()
231
+ sockets.clear()
232
+ server?.close()
233
+ server = undefined
234
+ return Promise.resolve()
235
+ },
236
+ }
237
+ }
@@ -0,0 +1,31 @@
1
+ /** Payload validation called by the generated `invoke` functions (RFC 0003 §7.3, "Compiler contract"). */
2
+ import type { InferSchemaOutput, StandardSchemaV1 } from '@znight/http'
3
+
4
+ import { ValidationException, validate } from '@znight/http'
5
+
6
+ import { WsValidationException } from './exceptions.ts'
7
+
8
+ /**
9
+ * Validates a message payload with a Standard Schema and returns the schema output, synchronously
10
+ * when the schema is synchronous.
11
+ *
12
+ * @throws WsValidationException (`BAD_REQUEST`, `details: [{ path, message }]`) when the payload is invalid.
13
+ */
14
+ export function validatePayload<S extends StandardSchemaV1>(
15
+ schema: S,
16
+ value: unknown,
17
+ ): InferSchemaOutput<S> | Promise<InferSchemaOutput<S>> {
18
+ try {
19
+ const result = validate(schema, value, 'payload')
20
+ return result instanceof Promise ? result.catch(rethrow) : result
21
+ } catch (error) {
22
+ return rethrow(error)
23
+ }
24
+ }
25
+
26
+ function rethrow(error: unknown): never {
27
+ if (error instanceof ValidationException) {
28
+ throw new WsValidationException(error.details as readonly { path: string; message: string }[])
29
+ }
30
+ throw error
31
+ }