@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
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A WebSocket connection (RFC 0003 §5.4, §8.4, §8.5): the inbound queue with its concurrency and
|
|
3
|
+
* read pausing, the outbound backpressure policy, rooms, and the close sequence. What a message
|
|
4
|
+
* means (decoding, handlers, guards, replies) is given by the plugin through {@link ConnectionDriver}.
|
|
5
|
+
*/
|
|
6
|
+
import type { HttpContext } from '@znight/http'
|
|
7
|
+
import type { TokenBucket } from './rate-limit.ts'
|
|
8
|
+
import type { Broadcast, ChannelMember, RoomRegistry } from './rooms.ts'
|
|
9
|
+
import type { SendStatus, SocketEvents, TransportSocket } from './transport.ts'
|
|
10
|
+
|
|
11
|
+
import { WsCloseCode } from './exceptions.ts'
|
|
12
|
+
|
|
13
|
+
/** Who closed a connection. */
|
|
14
|
+
export type WsCloseInitiator = 'client' | 'server' | 'shutdown' | 'heartbeat' | 'backpressure' | 'error'
|
|
15
|
+
|
|
16
|
+
/** How a connection closed. */
|
|
17
|
+
export interface WsCloseInfo {
|
|
18
|
+
readonly code: number
|
|
19
|
+
readonly reason: string
|
|
20
|
+
readonly initiator: WsCloseInitiator
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The result of a send: written, buffered by the transport, or dropped. */
|
|
24
|
+
export type WsSendResult = SendStatus
|
|
25
|
+
|
|
26
|
+
/** How often a pending `conn.drained()` re-reads the outbound buffer (ms). */
|
|
27
|
+
const DRAIN_POLL_MS = 10
|
|
28
|
+
|
|
29
|
+
/** `conn.drained()` rejects with it once the connection is closed. */
|
|
30
|
+
export class WsClosedError extends Error {
|
|
31
|
+
constructor(message = 'The WebSocket connection is closed') {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'WsClosedError'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A WebSocket connection, as handlers and services see it. */
|
|
38
|
+
export interface WsConnection<
|
|
39
|
+
TData extends object = Record<string, unknown>,
|
|
40
|
+
E extends object = Record<string, unknown>,
|
|
41
|
+
> {
|
|
42
|
+
readonly id: string
|
|
43
|
+
/** Path pattern of the gateway, prefix included (`/api/chat`). */
|
|
44
|
+
readonly gateway: string
|
|
45
|
+
/** The request id of the handshake. */
|
|
46
|
+
readonly requestId: string
|
|
47
|
+
/** The handshake, detached from its socket. */
|
|
48
|
+
readonly handshake: HttpContext
|
|
49
|
+
readonly params: Readonly<Record<string, string>>
|
|
50
|
+
/** The negotiated subprotocol, `''` when none. */
|
|
51
|
+
readonly protocol: string
|
|
52
|
+
/** Per-connection state, an empty object at first. */
|
|
53
|
+
readonly data: TData
|
|
54
|
+
readonly rooms: ReadonlySet<string>
|
|
55
|
+
readonly readyState: 'open' | 'closing' | 'closed'
|
|
56
|
+
readonly bufferedAmount: number
|
|
57
|
+
/** Aborted when the connection starts closing. */
|
|
58
|
+
readonly signal: AbortSignal
|
|
59
|
+
/** How the connection closed: set once the socket closed, before `@OnDisconnect`. */
|
|
60
|
+
readonly closeInfo: WsCloseInfo | undefined
|
|
61
|
+
emit<K extends keyof E & string>(event: K, data: E[K]): WsSendResult
|
|
62
|
+
send(frame: string | Uint8Array): WsSendResult
|
|
63
|
+
join(room: string | readonly string[]): void
|
|
64
|
+
leave(room: string): void
|
|
65
|
+
/** The other members of `room`. */
|
|
66
|
+
to(room: string | readonly string[]): Broadcast<E>
|
|
67
|
+
/** Resolves once the outbound buffer is below `limit / 4`; rejects with {@link WsClosedError} once closed. */
|
|
68
|
+
drained(): Promise<void>
|
|
69
|
+
close(code?: number, reason?: string): void
|
|
70
|
+
terminate(): void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Limits of one connection, resolved from the gateway and the plugin options. */
|
|
74
|
+
export interface ConnectionLimits {
|
|
75
|
+
readonly maxPayload: number
|
|
76
|
+
readonly maxPendingMessages: number
|
|
77
|
+
readonly concurrency: number
|
|
78
|
+
readonly backpressureLimit: number
|
|
79
|
+
readonly onBackpressure: 'close' | 'drop'
|
|
80
|
+
readonly closeTimeoutMs: number
|
|
81
|
+
readonly rateLimit?: { readonly bucket: TokenBucket; readonly onLimit: 'reject' | 'close' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What the plugin does with a connection (the gateway semantics). */
|
|
85
|
+
export interface ConnectionDriver {
|
|
86
|
+
/** Runs `@OnConnect`; a rejection closes the connection with the code it returns. */
|
|
87
|
+
connect(conn: WsConnectionImpl): Promise<void>
|
|
88
|
+
/** Handles one inbound message (decode, guards, handler, reply). Never rejects. */
|
|
89
|
+
dispatch(conn: WsConnectionImpl, frame: string | Uint8Array, binary: boolean): Promise<void>
|
|
90
|
+
/** A message refused by the rate limit with `onLimit: 'reject'`. */
|
|
91
|
+
rateLimited(conn: WsConnectionImpl, frame: string | Uint8Array, binary: boolean): void
|
|
92
|
+
/** The close code of an `@OnConnect` failure. */
|
|
93
|
+
connectFailure(error: unknown): { readonly code: number; readonly reason: string }
|
|
94
|
+
/** Runs `@OnDisconnect` (errors handled by the driver). */
|
|
95
|
+
disconnect(conn: WsConnectionImpl): Promise<void>
|
|
96
|
+
/** Once fully closed (rooms left): hooks, stats, scope release. */
|
|
97
|
+
closed(conn: WsConnectionImpl, info: WsCloseInfo): void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Totals of the plugin (`WebSocketPlugin.stats()`), updated by the connections. */
|
|
101
|
+
export interface WsStats {
|
|
102
|
+
/** Open connections. */
|
|
103
|
+
readonly connections: number
|
|
104
|
+
readonly opened: number
|
|
105
|
+
readonly closed: number
|
|
106
|
+
readonly messagesIn: number
|
|
107
|
+
readonly messagesOut: number
|
|
108
|
+
readonly bytesIn: number
|
|
109
|
+
readonly bytesOut: number
|
|
110
|
+
/** Outbound frames dropped by a `drop` backpressure policy or refused by the transport. */
|
|
111
|
+
readonly dropped: number
|
|
112
|
+
/** Connections closed with `1013` (slow consumer). */
|
|
113
|
+
readonly slowConsumerCloses: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The mutable counters behind {@link WsStats} (internal). */
|
|
117
|
+
export class StatsCounter {
|
|
118
|
+
opened = 0
|
|
119
|
+
closed = 0
|
|
120
|
+
messagesIn = 0
|
|
121
|
+
messagesOut = 0
|
|
122
|
+
bytesIn = 0
|
|
123
|
+
bytesOut = 0
|
|
124
|
+
dropped = 0
|
|
125
|
+
slowConsumerCloses = 0
|
|
126
|
+
|
|
127
|
+
/** A frozen snapshot, with the number of open connections. */
|
|
128
|
+
snapshot(connections: number): WsStats {
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
connections,
|
|
131
|
+
opened: this.opened,
|
|
132
|
+
closed: this.closed,
|
|
133
|
+
messagesIn: this.messagesIn,
|
|
134
|
+
messagesOut: this.messagesOut,
|
|
135
|
+
bytesIn: this.bytesIn,
|
|
136
|
+
bytesOut: this.bytesOut,
|
|
137
|
+
dropped: this.dropped,
|
|
138
|
+
slowConsumerCloses: this.slowConsumerCloses,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What a connection is created with. */
|
|
144
|
+
export interface ConnectionInit {
|
|
145
|
+
readonly id: string
|
|
146
|
+
readonly gateway: string
|
|
147
|
+
readonly handshake: HttpContext
|
|
148
|
+
readonly protocol: string
|
|
149
|
+
readonly limits: ConnectionLimits
|
|
150
|
+
readonly rooms: RoomRegistry<WsConnectionImpl>
|
|
151
|
+
readonly driver: ConnectionDriver
|
|
152
|
+
/** Counters shared by the connections of a plugin. */
|
|
153
|
+
readonly stats?: StatsCounter
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sizeOf(frame: string | Uint8Array): number {
|
|
157
|
+
return typeof frame === 'string' ? Buffer.byteLength(frame) : frame.byteLength
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The implementation of {@link WsConnection} (internal to the package). */
|
|
161
|
+
export class WsConnectionImpl implements WsConnection, ChannelMember {
|
|
162
|
+
readonly id: string
|
|
163
|
+
readonly gateway: string
|
|
164
|
+
readonly handshake: HttpContext
|
|
165
|
+
readonly protocol: string
|
|
166
|
+
readonly data: Record<string, unknown> = {}
|
|
167
|
+
readonly roomSet: Set<string> = new Set()
|
|
168
|
+
readonly #limits: ConnectionLimits
|
|
169
|
+
readonly #registry: RoomRegistry<WsConnectionImpl>
|
|
170
|
+
readonly #driver: ConnectionDriver
|
|
171
|
+
readonly #stats: StatsCounter | undefined
|
|
172
|
+
readonly #abort = new AbortController()
|
|
173
|
+
readonly #queue: { readonly frame: string | Uint8Array; readonly binary: boolean }[] = []
|
|
174
|
+
readonly #running = new Set<Promise<void>>()
|
|
175
|
+
readonly #drainWaiters: { resolve(): void; reject(error: Error): void }[] = []
|
|
176
|
+
readonly #finished: PromiseWithResolvers<WsCloseInfo> = Promise.withResolvers()
|
|
177
|
+
#socket: TransportSocket | undefined
|
|
178
|
+
/** Polls the buffer while `drained()` waits: a transport may only report a drain at its own limit. */
|
|
179
|
+
#drainPoll: ReturnType<typeof setInterval> | undefined
|
|
180
|
+
#state: 'open' | 'closing' | 'closed' = 'open'
|
|
181
|
+
#connecting = true
|
|
182
|
+
#connected = false
|
|
183
|
+
#paused = false
|
|
184
|
+
#closing: WsCloseInfo | undefined
|
|
185
|
+
#closeInfo: WsCloseInfo | undefined
|
|
186
|
+
#closeTimer: ReturnType<typeof setTimeout> | undefined
|
|
187
|
+
/** Seen alive (a frame or a pong) since the last heartbeat tick. */
|
|
188
|
+
#alive = true
|
|
189
|
+
|
|
190
|
+
constructor(init: ConnectionInit) {
|
|
191
|
+
this.id = init.id
|
|
192
|
+
this.gateway = init.gateway
|
|
193
|
+
this.handshake = init.handshake
|
|
194
|
+
this.protocol = init.protocol
|
|
195
|
+
this.#limits = init.limits
|
|
196
|
+
this.#registry = init.rooms
|
|
197
|
+
this.#driver = init.driver
|
|
198
|
+
this.#stats = init.stats
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
get requestId(): string {
|
|
202
|
+
return this.handshake.requestId
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get params(): Readonly<Record<string, string>> {
|
|
206
|
+
return this.handshake.params
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get rooms(): ReadonlySet<string> {
|
|
210
|
+
return this.roomSet
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
get readyState(): 'open' | 'closing' | 'closed' {
|
|
214
|
+
return this.#state
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
get bufferedAmount(): number {
|
|
218
|
+
return this.#socket?.bufferedAmount ?? 0
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get signal(): AbortSignal {
|
|
222
|
+
return this.#abort.signal
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
get closeInfo(): WsCloseInfo | undefined {
|
|
226
|
+
return this.#closeInfo
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Resolves once the connection is fully closed. */
|
|
230
|
+
get finished(): Promise<WsCloseInfo> {
|
|
231
|
+
return this.#finished.promise
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The socket events to give to the transport. */
|
|
235
|
+
socketEvents(): SocketEvents {
|
|
236
|
+
return {
|
|
237
|
+
message: (frame, binary) => this.#receive(frame, binary),
|
|
238
|
+
pong: () => {
|
|
239
|
+
this.#alive = true
|
|
240
|
+
},
|
|
241
|
+
drain: () => this.#drain(),
|
|
242
|
+
close: (code, reason) => this.#socketClosed(code, reason),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Binds the accepted socket, joins the gateway and runs `@OnConnect` as the first task. */
|
|
247
|
+
start(socket: TransportSocket): void {
|
|
248
|
+
this.#socket = socket
|
|
249
|
+
this.#registry.add(this)
|
|
250
|
+
const task = this.#driver.connect(this).then(
|
|
251
|
+
() => {
|
|
252
|
+
this.#connected = true
|
|
253
|
+
this.#connecting = false
|
|
254
|
+
this.#pump()
|
|
255
|
+
},
|
|
256
|
+
(error: unknown) => {
|
|
257
|
+
this.#connecting = false
|
|
258
|
+
const { code, reason } = this.#driver.connectFailure(error)
|
|
259
|
+
this.#beginClose(code, reason, 'server', true)
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
this.#track(task)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
emit(event: string, data: unknown): WsSendResult {
|
|
266
|
+
return this.sendFrame(this.#registry.codec.encode({ kind: 'event', event, data }))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
send(frame: string | Uint8Array): WsSendResult {
|
|
270
|
+
return this.sendFrame(frame)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Sends an encoded frame through the backpressure policy. */
|
|
274
|
+
sendFrame(frame: string | Uint8Array): WsSendResult {
|
|
275
|
+
const socket = this.#socket
|
|
276
|
+
if (this.#state !== 'open' || socket === undefined) return 'dropped'
|
|
277
|
+
const size = sizeOf(frame)
|
|
278
|
+
const stats = this.#stats
|
|
279
|
+
if (socket.bufferedAmount + size > this.#limits.backpressureLimit) {
|
|
280
|
+
if (stats !== undefined) stats.dropped++
|
|
281
|
+
if (this.#limits.onBackpressure === 'drop') return 'dropped'
|
|
282
|
+
if (stats !== undefined) stats.slowConsumerCloses++
|
|
283
|
+
this.#beginClose(WsCloseCode.TryAgainLater, 'Slow consumer', 'backpressure', true)
|
|
284
|
+
return 'dropped'
|
|
285
|
+
}
|
|
286
|
+
const status = socket.send(frame, typeof frame !== 'string')
|
|
287
|
+
if (stats !== undefined) {
|
|
288
|
+
if (status === 'dropped') stats.dropped++
|
|
289
|
+
else {
|
|
290
|
+
stats.messagesOut++
|
|
291
|
+
stats.bytesOut += size
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return status
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
join(room: string | readonly string[]): void {
|
|
298
|
+
for (const name of typeof room === 'string' ? [room] : room) this.#registry.join(this, name)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
leave(room: string): void {
|
|
302
|
+
this.#registry.leave(this, room)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
to(room: string | readonly string[]): Broadcast {
|
|
306
|
+
return this.#registry.select(room, [this.id])
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
drained(): Promise<void> {
|
|
310
|
+
if (this.#state !== 'open') return Promise.reject(new WsClosedError())
|
|
311
|
+
if (this.bufferedAmount <= this.#limits.backpressureLimit / 4) return Promise.resolve()
|
|
312
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>()
|
|
313
|
+
this.#drainWaiters.push({ resolve, reject })
|
|
314
|
+
if (this.#drainPoll === undefined) {
|
|
315
|
+
this.#drainPoll = setInterval(() => this.#drain(), DRAIN_POLL_MS)
|
|
316
|
+
this.#drainPoll.unref?.()
|
|
317
|
+
}
|
|
318
|
+
return promise
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
close(code: number = WsCloseCode.Normal, reason = ''): void {
|
|
322
|
+
this.#beginClose(code, reason, 'server', true)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Closes with an initiator (shutdown, heartbeat...). */
|
|
326
|
+
closeWith(code: number, reason: string, initiator: WsCloseInitiator): void {
|
|
327
|
+
this.#beginClose(code, reason, initiator, true)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
terminate(): void {
|
|
331
|
+
this.terminateWith('server')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Terminates without close frame (heartbeat timeout, shutdown deadline...): local code `1006`. */
|
|
335
|
+
terminateWith(initiator: WsCloseInitiator): void {
|
|
336
|
+
this.#beginClose(1006, '', initiator, false)
|
|
337
|
+
this.#socket?.terminate()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* One heartbeat tick (RFC 0003 §8.7): a connection not seen alive since the previous tick is
|
|
342
|
+
* terminated, the others are marked and pinged.
|
|
343
|
+
*/
|
|
344
|
+
heartbeat(): void {
|
|
345
|
+
if (this.#state !== 'open' || this.#socket === undefined) return
|
|
346
|
+
if (!this.#alive) {
|
|
347
|
+
this.terminateWith('heartbeat')
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
this.#alive = false
|
|
351
|
+
this.#socket.ping()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#receive(frame: string | Uint8Array, binary: boolean): void {
|
|
355
|
+
if (this.#state !== 'open') return
|
|
356
|
+
this.#alive = true
|
|
357
|
+
const size = sizeOf(frame)
|
|
358
|
+
const stats = this.#stats
|
|
359
|
+
if (stats !== undefined) {
|
|
360
|
+
stats.messagesIn++
|
|
361
|
+
stats.bytesIn += size
|
|
362
|
+
}
|
|
363
|
+
if (size > this.#limits.maxPayload) {
|
|
364
|
+
this.#beginClose(WsCloseCode.MessageTooBig, 'Message too big', 'server', true)
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
const rate = this.#limits.rateLimit
|
|
368
|
+
if (rate !== undefined && !rate.bucket.take()) {
|
|
369
|
+
if (rate.onLimit === 'close') this.#beginClose(4429, 'Rate limited', 'server', true)
|
|
370
|
+
else this.#driver.rateLimited(this, frame, binary)
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
this.#queue.push({ frame, binary })
|
|
374
|
+
const max = this.#limits.maxPendingMessages
|
|
375
|
+
if (this.#queue.length >= max && !this.#paused) {
|
|
376
|
+
this.#paused = this.#socket?.pause() ?? false
|
|
377
|
+
if (!this.#paused && this.#queue.length >= max * 2) {
|
|
378
|
+
this.#beginClose(WsCloseCode.PolicyViolation, 'Too many pending messages', 'server', true)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
this.#pump()
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#pump(): void {
|
|
386
|
+
while (
|
|
387
|
+
this.#state === 'open' &&
|
|
388
|
+
!this.#connecting &&
|
|
389
|
+
this.#running.size < this.#limits.concurrency &&
|
|
390
|
+
this.#queue.length > 0
|
|
391
|
+
) {
|
|
392
|
+
const next = this.#queue.shift() as { readonly frame: string | Uint8Array; readonly binary: boolean }
|
|
393
|
+
this.#track(this.#driver.dispatch(this, next.frame, next.binary))
|
|
394
|
+
}
|
|
395
|
+
if (this.#paused && this.#queue.length <= this.#limits.maxPendingMessages / 2) {
|
|
396
|
+
this.#paused = false
|
|
397
|
+
this.#socket?.resume()
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Counts a running task until it settles, then dispatches the next messages. */
|
|
402
|
+
#track(task: Promise<void>): void {
|
|
403
|
+
const tracked: Promise<void> = task
|
|
404
|
+
.catch(() => undefined)
|
|
405
|
+
.then(() => {
|
|
406
|
+
this.#running.delete(tracked)
|
|
407
|
+
this.#pump()
|
|
408
|
+
})
|
|
409
|
+
this.#running.add(tracked)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#drain(): void {
|
|
413
|
+
if (this.bufferedAmount > this.#limits.backpressureLimit / 4) return
|
|
414
|
+
this.#stopDrainPoll()
|
|
415
|
+
for (const waiter of this.#drainWaiters.splice(0)) waiter.resolve()
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#stopDrainPoll(): void {
|
|
419
|
+
if (this.#drainPoll !== undefined) clearInterval(this.#drainPoll)
|
|
420
|
+
this.#drainPoll = undefined
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#beginClose(code: number, reason: string, initiator: WsCloseInitiator, frame: boolean): void {
|
|
424
|
+
if (this.#state !== 'open') return
|
|
425
|
+
this.#state = 'closing'
|
|
426
|
+
this.#closing = { code, reason, initiator }
|
|
427
|
+
this.#abort.abort(new WsClosedError())
|
|
428
|
+
this.#queue.length = 0
|
|
429
|
+
if (!frame) return
|
|
430
|
+
this.#socket?.close(code, reason)
|
|
431
|
+
this.#closeTimer = setTimeout(() => this.#socket?.terminate(), this.#limits.closeTimeoutMs)
|
|
432
|
+
this.#closeTimer.unref?.()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#socketClosed(code: number, reason: string): void {
|
|
436
|
+
if (this.#state === 'closed') return
|
|
437
|
+
if (this.#state === 'open') this.#beginClose(code, reason, 'client', false)
|
|
438
|
+
if (this.#closeTimer !== undefined) clearTimeout(this.#closeTimer)
|
|
439
|
+
void this.#finish()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async #finish(): Promise<void> {
|
|
443
|
+
// known from now on: @OnDisconnect reads it
|
|
444
|
+
const info = this.#closing ?? { code: 1006, reason: '', initiator: 'error' as const }
|
|
445
|
+
this.#closeInfo = info
|
|
446
|
+
while (this.#running.size > 0) await Promise.all([...this.#running])
|
|
447
|
+
if (this.#connected) await this.#driver.disconnect(this).catch(() => undefined)
|
|
448
|
+
this.#registry.remove(this)
|
|
449
|
+
this.#state = 'closed'
|
|
450
|
+
this.#stopDrainPoll()
|
|
451
|
+
for (const waiter of this.#drainWaiters.splice(0)) waiter.reject(new WsClosedError())
|
|
452
|
+
this.#driver.closed(this, info)
|
|
453
|
+
this.#finished.resolve(info)
|
|
454
|
+
}
|
|
455
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** The context of one inbound message and the message guards (RFC 0003 §8.4, §5.6). */
|
|
2
|
+
import type { Token } from '@znight/core'
|
|
3
|
+
import type { WsConnection } from './connection.ts'
|
|
4
|
+
import type { WsException } from './exceptions.ts'
|
|
5
|
+
|
|
6
|
+
/** What handlers (`@Ctx()`) and message guards see of one message. */
|
|
7
|
+
export interface WsMessageContext {
|
|
8
|
+
readonly connection: WsConnection
|
|
9
|
+
/** `undefined` on raw gateways. */
|
|
10
|
+
readonly event: string | undefined
|
|
11
|
+
readonly id: string | number | undefined
|
|
12
|
+
/** The decoded `data` (events) or the frame (raw). */
|
|
13
|
+
readonly data: unknown
|
|
14
|
+
readonly binary: boolean
|
|
15
|
+
readonly bytes: number
|
|
16
|
+
/** `performance.now()` when the message was received. */
|
|
17
|
+
readonly receivedAt: number
|
|
18
|
+
/** Aborted on timeout or when the connection closes. */
|
|
19
|
+
readonly signal: AbortSignal
|
|
20
|
+
/** Per message: guards to handler. */
|
|
21
|
+
readonly state: Map<string | symbol, unknown>
|
|
22
|
+
readonly route: {
|
|
23
|
+
readonly path: string
|
|
24
|
+
readonly gateway: Token
|
|
25
|
+
readonly handler: string | undefined
|
|
26
|
+
readonly event: string | undefined
|
|
27
|
+
readonly meta: Readonly<Record<string, unknown>>
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A guard run before each message handler: `false` answers `FORBIDDEN`. */
|
|
32
|
+
export interface WsGuard {
|
|
33
|
+
canHandle(ctx: WsMessageContext): boolean | Promise<boolean>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Maps an error thrown by a message handler or guard to the {@link WsException} answered to the
|
|
38
|
+
* client (plugin option `exceptionMappers`, run in order before the built-in mapping). `undefined`:
|
|
39
|
+
* not handled, the next mapper or the built-in mapping applies.
|
|
40
|
+
*/
|
|
41
|
+
export interface WsExceptionMapper {
|
|
42
|
+
map(error: unknown, ctx: WsMessageContext): WsException | undefined | Promise<WsException | undefined>
|
|
43
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker decorators of `@znight/websocket` (RFC 0003 §5.2). Like every Znight decorator they do
|
|
3
|
+
* nothing at runtime: the compiler extension reads them from the source code, validates them and
|
|
4
|
+
* generates `metadata.extensions.websocket`. Every argument is _static_ (literals, `const`
|
|
5
|
+
* references, simple expressions), except the schemas and guards, which are values.
|
|
6
|
+
*/
|
|
7
|
+
import type { GatewayOptions } from './options.ts'
|
|
8
|
+
|
|
9
|
+
/** A class decorator marker. */
|
|
10
|
+
export type GatewayClassMarker = (target: abstract new (...args: never[]) => unknown) => void
|
|
11
|
+
|
|
12
|
+
/** A method decorator marker. */
|
|
13
|
+
export type GatewayMethodMarker = (
|
|
14
|
+
target: object,
|
|
15
|
+
propertyKey: string | symbol,
|
|
16
|
+
descriptor?: PropertyDescriptor,
|
|
17
|
+
) => void
|
|
18
|
+
|
|
19
|
+
/** A parameter decorator marker. */
|
|
20
|
+
export type GatewayParameterMarker = (target: object, propertyKey: string | symbol | undefined, index: number) => void
|
|
21
|
+
|
|
22
|
+
/** A class or method decorator marker. */
|
|
23
|
+
export type GatewayClassOrMethodMarker = (target: object, propertyKey?: string | symbol, descriptor?: unknown) => void
|
|
24
|
+
|
|
25
|
+
function marker(): void {
|
|
26
|
+
// intentionally empty: decorators are compile-time markers
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A provider whose methods handle the WebSocket connections of `path` (default `/`), joined to the
|
|
31
|
+
* `HttpPlugin` prefix.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* @Gateway({ path: '/chat', protocols: ['chat.v1'] })
|
|
35
|
+
* export class ChatGateway {}
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function Gateway(_pathOrOptions?: string | GatewayOptions): GatewayClassMarker {
|
|
39
|
+
return marker
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Runs once the handshake succeeded, before any message. Throwing closes the connection. */
|
|
43
|
+
export function OnConnect(): GatewayMethodMarker {
|
|
44
|
+
return marker
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Handles an event (`events` protocol). Without `event`: the default handler (unknown events, every
|
|
49
|
+
* message of a `raw` gateway). Repeatable.
|
|
50
|
+
*/
|
|
51
|
+
export function OnMessage(_event?: string): GatewayMethodMarker {
|
|
52
|
+
return marker
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Runs once per connection whose `@OnConnect` completed, before rooms are left and the scope closes. */
|
|
56
|
+
export function OnDisconnect(): GatewayMethodMarker {
|
|
57
|
+
return marker
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The message `data` (`events`) or the raw frame (`raw`), validated by `schema` (a Standard Schema). */
|
|
61
|
+
export function Payload(_schema?: unknown): GatewayParameterMarker {
|
|
62
|
+
return marker
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `WsConnection` of the message or of the lifecycle method. */
|
|
66
|
+
export function Conn(): GatewayParameterMarker {
|
|
67
|
+
return marker
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The `WsMessageContext` (`@OnMessage` handlers only). */
|
|
71
|
+
export function Ctx(): GatewayParameterMarker {
|
|
72
|
+
return marker
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extra HTTP guards (`CanActivate`) of the handshake, after the `HttpPlugin` global guards. */
|
|
76
|
+
export function UseHandshakeGuard(..._guards: readonly unknown[]): GatewayClassMarker {
|
|
77
|
+
return marker
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Message guards (`WsGuard`) run before each handler: global, then class, then method. */
|
|
81
|
+
export function UseMessageGuard(..._guards: readonly unknown[]): GatewayClassOrMethodMarker {
|
|
82
|
+
return marker
|
|
83
|
+
}
|