@znight/websocket 0.0.0-stage → 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 +226 -2
  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 +100 -4
  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,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
+ }