@znight/websocket 0.0.0 → 0.6.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.
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/dist/bun.d.mts +4 -0
- package/dist/bun.mjs +112 -0
- package/dist/bun.mjs.map +1 -0
- package/dist/compiler.d.mts +90 -0
- package/dist/compiler.mjs +864 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.d.mts +501 -0
- package/dist/index.mjs +2088 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server-CI_Bh9o1.d.mts +331 -0
- package/dist/server-D40_bC88.mjs +72 -0
- package/dist/server-D40_bC88.mjs.map +1 -0
- package/dist/testing.d.mts +60 -0
- package/dist/testing.mjs +189 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/transport-xnJqvB93.d.mts +63 -0
- package/dist/ws.d.mts +9 -0
- package/dist/ws.mjs +143 -0
- package/dist/ws.mjs.map +1 -0
- package/package.json +102 -1
- package/src/client.ts +246 -0
- package/src/compiler/analyze.ts +850 -0
- package/src/compiler/constants.ts +102 -0
- package/src/compiler/data.ts +78 -0
- package/src/compiler/generate.ts +262 -0
- package/src/compiler/index.ts +53 -0
- package/src/compiler/validate.ts +81 -0
- package/src/connection.ts +455 -0
- package/src/context.ts +43 -0
- package/src/decorators.ts +83 -0
- package/src/dispatcher.ts +343 -0
- package/src/envelope.ts +136 -0
- package/src/exceptions.ts +91 -0
- package/src/index.ts +89 -0
- package/src/metadata.ts +74 -0
- package/src/options.ts +343 -0
- package/src/plugin.ts +820 -0
- package/src/rate-limit.ts +27 -0
- package/src/rooms.ts +185 -0
- package/src/server.ts +125 -0
- package/src/testing/index.ts +279 -0
- package/src/tokens.ts +23 -0
- package/src/transport.ts +72 -0
- package/src/transports/bun.ts +163 -0
- package/src/transports/memory.ts +174 -0
- package/src/transports/ws.ts +237 -0
- package/src/validate.ts +31 -0
package/src/transport.ts
ADDED
|
@@ -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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|