@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
package/src/plugin.ts ADDED
@@ -0,0 +1,820 @@
1
+ /**
2
+ * `WebSocketPlugin` (RFC 0003 §5.9, §8.9): serves the gateways of `metadata.extensions.websocket`
3
+ * through the upgrade routes of `HttpPlugin`. The handshake runs through the HTTP pipeline (request
4
+ * scope, hooks, guards); the plugin adds the origin, subprotocol and capacity checks, completes the
5
+ * upgrade with a transport, and keeps the request scope open for the lifetime of the connection.
6
+ */
7
+ import type {
8
+ Container,
9
+ Plugin,
10
+ PluginContext,
11
+ PluginOptions,
12
+ PluginRootsContext,
13
+ ProviderKey,
14
+ Token,
15
+ WebSocketConnectionEvent,
16
+ ZnightMetadata,
17
+ } from '@znight/core'
18
+ import type {
19
+ HttpContext,
20
+ HttpMetadata,
21
+ HttpPlugin,
22
+ PendingUpgrade,
23
+ PluginHttpUpgrades,
24
+ PluginHttpUpgradesContext,
25
+ PluginUpgradeRoute,
26
+ PluginUpgrades,
27
+ RouteMetaEntry,
28
+ } from '@znight/http'
29
+ import type { WsConnectOptions } from './client.ts'
30
+ import type { ConnectionLimits, WsCloseInfo, WsStats } from './connection.ts'
31
+ import type { WsExceptionMapper, WsGuard } from './context.ts'
32
+ import type { HandlerPlan } from './dispatcher.ts'
33
+ import type { GatewayDefinition, WebSocketMetadata } from './metadata.ts'
34
+ import type { ResolvedWebSocketOptions, WebSocketPluginOptions } from './options.ts'
35
+ import type { SocketEvents, TransportSocket, WebSocketTransport } from './transport.ts'
36
+
37
+ import { AsyncLocalStorage } from 'node:async_hooks'
38
+ import { displayName, ErrorCode, resolveOptions, ZnightError } from '@znight/core'
39
+ import { BadRequestException, ForbiddenException, HttpException } from '@znight/http'
40
+
41
+ import { WsClient, WsHandshakeError } from './client.ts'
42
+ import { StatsCounter, WsConnectionImpl } from './connection.ts'
43
+ import { GatewayDispatcher } from './dispatcher.ts'
44
+ import { resolveWebSocketOptions } from './options.ts'
45
+ import { TokenBucket } from './rate-limit.ts'
46
+ import { RoomRegistry } from './rooms.ts'
47
+ import { WsServer } from './server.ts'
48
+ import { WsTokens } from './tokens.ts'
49
+ import { bunTransport } from './transports/bun.ts'
50
+ import { MemoryClient, memoryTransport } from './transports/memory.ts'
51
+ import { wsTransport } from './transports/ws.ts'
52
+
53
+ /** A served gateway, as listed by {@link WebSocketPlugin.gateways}. */
54
+ export interface WsGatewaySummary {
55
+ /** The path, HTTP prefix included. */
56
+ readonly path: string
57
+ /** Display name of the gateway class. */
58
+ readonly gateway: string
59
+ readonly protocol: 'events' | 'raw'
60
+ /** Handled events (`events` gateways), in declaration order. */
61
+ readonly events: readonly string[]
62
+ }
63
+
64
+ /** One gateway, ready to accept connections (internal). */
65
+ interface GatewayPlan {
66
+ readonly definition: GatewayDefinition
67
+ readonly name: string
68
+ /** Path, HTTP prefix included. */
69
+ readonly path: string
70
+ readonly rooms: RoomRegistry<WsConnectionImpl>
71
+ readonly dispatcher: GatewayDispatcher
72
+ readonly maxPayload: number
73
+ readonly limits: ConnectionLimits
74
+ readonly rateLimit: GatewayDefinition['rateLimit']
75
+ readonly instance: (conn: WsConnectionImpl) => object | Promise<object>
76
+ readonly requestScoped: boolean
77
+ }
78
+
79
+ type ContextRunner = <R>(fn: () => R) => R
80
+
81
+ /** The current async context (the handshake's request scope), to run the socket events in it. */
82
+ function captureContext(): ContextRunner {
83
+ const snapshot = (AsyncLocalStorage as { snapshot?: () => ContextRunner }).snapshot
84
+ return typeof snapshot === 'function' ? snapshot() : run => run()
85
+ }
86
+
87
+ const CONNECTION_HOOK = 'websocket:connection'
88
+
89
+ /**
90
+ * The WebSocket plugin: `WebSocketPlugin.configure(options)` (name `websocket`). Requires
91
+ * `HttpPlugin`: gateways are served on its server and port, whatever the plugin order.
92
+ *
93
+ * ```ts
94
+ * await Application.create({
95
+ * metadata: __ZNIGHT_METADATA__,
96
+ * plugins: [HttpPlugin.configure({ port: 3000 }), WebSocketPlugin.configure({ maxPayload: '64kb' })],
97
+ * })
98
+ * ```
99
+ */
100
+ export class WebSocketPlugin implements Plugin, PluginHttpUpgrades {
101
+ readonly name = 'websocket'
102
+ /** The `WsServer` of the application (also injectable). */
103
+ readonly server: WsServer = new WsServer()
104
+ readonly #options: PluginOptions<WebSocketPluginOptions>
105
+ readonly #connections = new Map<WsConnectionImpl, number>()
106
+ /** Connections whose heartbeat the plugin drives (the `ws` transport). */
107
+ readonly #beating = new Set<WsConnectionImpl>()
108
+ #stats = new StatsCounter()
109
+ #ctx: PluginContext | undefined
110
+ #resolved: ResolvedWebSocketOptions | undefined
111
+ #plans: readonly GatewayPlan[] | undefined
112
+ #transport: WebSocketTransport | undefined
113
+ #memory: WebSocketTransport | undefined
114
+ #heartbeat: ReturnType<typeof setInterval> | undefined
115
+ #draining = false
116
+ /** Handshakes past their guards and not yet registered: they count against `maxConnections`. */
117
+ #accepting = 0
118
+ #drain: Promise<void> | undefined
119
+ #active = false
120
+
121
+ private constructor(options: PluginOptions<WebSocketPluginOptions>) {
122
+ this.#options = options
123
+ }
124
+
125
+ /**
126
+ * Creates the plugin. `options` may be a function of the container, evaluated once the container
127
+ * is initialised: `WebSocketPlugin.configure(c => ({ origins: c.get(ServerConfig).webOrigins }))`.
128
+ */
129
+ static configure(options: PluginOptions<WebSocketPluginOptions> = {}): WebSocketPlugin {
130
+ return new WebSocketPlugin(options)
131
+ }
132
+
133
+ /** Name of the transport (`ws`, `bun`), `undefined` before `HttpPlugin` collected the upgrade routes. */
134
+ get transport(): string | undefined {
135
+ return this.#transport?.name
136
+ }
137
+
138
+ /** The served gateways (after bootstrap). */
139
+ get gateways(): readonly WsGatewaySummary[] {
140
+ return (this.#plans ?? []).map(plan =>
141
+ Object.freeze({
142
+ path: plan.path,
143
+ gateway: plan.name,
144
+ protocol: plan.definition.protocol,
145
+ events: Object.freeze(plan.definition.handlers.flatMap(handler => handler.event ?? [])),
146
+ }),
147
+ )
148
+ }
149
+
150
+ /** Open connections of every gateway. */
151
+ get connections(): number {
152
+ return this.#connections.size
153
+ }
154
+
155
+ /** Totals since the application started. */
156
+ stats(): WsStats {
157
+ return this.#stats.snapshot(this.#connections.size)
158
+ }
159
+
160
+ /**
161
+ * Providers `TestApp` creates at boot: the classes of the `messageGuards` and `exceptionMappers`
162
+ * options, and the gateways of the metadata with their guards.
163
+ */
164
+ initRoots(context?: PluginRootsContext): readonly ProviderKey[] {
165
+ const roots = new Set<ProviderKey>()
166
+ try {
167
+ const options: unknown = this.#options
168
+ if (options !== null && typeof options === 'object') {
169
+ for (const key of ['messageGuards', 'exceptionMappers'] as const) {
170
+ const refs: unknown = (options as WebSocketPluginOptions)[key]
171
+ if (Array.isArray(refs)) for (const ref of refs) if (typeof ref === 'function') roots.add(ref as ProviderKey)
172
+ }
173
+ }
174
+ const gateways: unknown = (context?.metadata.extensions?.websocket as { gateways?: unknown } | undefined)
175
+ ?.gateways
176
+ if (Array.isArray(gateways)) {
177
+ for (const gateway of gateways as Partial<GatewayDefinition>[]) {
178
+ if (typeof gateway?.token !== 'string') continue
179
+ roots.add(gateway.token)
180
+ const handlers = Array.isArray(gateway.handlers) ? gateway.handlers : []
181
+ const tokens = [
182
+ ...(gateway.handshakeGuards ?? []),
183
+ ...(gateway.messageGuards ?? []),
184
+ ...handlers.flatMap(handler => handler?.guards ?? []),
185
+ ]
186
+ for (const token of tokens) if (typeof token === 'string') roots.add(token)
187
+ }
188
+ }
189
+ } catch {
190
+ // initRoots never throws: onBootstrap reports malformed options and metadata
191
+ }
192
+ return [...roots]
193
+ }
194
+
195
+ register(ctx: PluginContext): void {
196
+ if (this.#active) {
197
+ throw new ZnightError(
198
+ ErrorCode.DuplicateProvider,
199
+ 'This WebSocketPlugin is already used by another application',
200
+ {
201
+ hints: ['Call WebSocketPlugin.configure() once per application'],
202
+ },
203
+ )
204
+ }
205
+ if (ctx.getPlugin('http') === undefined) {
206
+ throw new ZnightError(
207
+ ErrorCode.WebSocketTransportUnavailable,
208
+ 'WebSocketPlugin needs HttpPlugin: gateways are served by the HTTP server',
209
+ { hints: ['Add HttpPlugin.configure() to the plugins (without controllers for a WebSocket-only service)'] },
210
+ )
211
+ }
212
+ this.#active = true
213
+ this.#ctx = ctx
214
+ this.#resolved = undefined
215
+ this.#plans = undefined
216
+ this.#transport = undefined
217
+ this.#memory = undefined
218
+ this.#draining = false
219
+ this.#drain = undefined
220
+ this.#stats = new StatsCounter()
221
+ this.server.reset()
222
+ ctx.container.registerExternal({ token: WsTokens.Connection, scope: 'request', providedBy: 'WebSocketPlugin' })
223
+ ctx.container.registerValue(WsTokens.Server, this.server)
224
+ ctx.container.registerValue(WsTokens.Plugin, this)
225
+ }
226
+
227
+ onBootstrap(ctx: PluginContext): void {
228
+ this.#ctx = ctx
229
+ this.#resolve()
230
+ gatewaysOf(ctx.metadata)
231
+ }
232
+
233
+ /**
234
+ * The `PluginHttpUpgrades` contract of `@znight/http`: one upgrade route per gateway, and the
235
+ * `Bun.serve()` settings of the Bun transport. Called once by `HttpPlugin.onBootstrap`. No route
236
+ * (and no transport) without gateways.
237
+ */
238
+ httpUpgrades(context: PluginHttpUpgradesContext): PluginUpgrades {
239
+ const plans = this.#prepare(context)
240
+ if (plans.length === 0) return { routes: [] }
241
+ const extensions = this.#context().metadata.extensions
242
+ const groups = {
243
+ ...(extensions?.http as HttpMetadata | undefined)?.groups,
244
+ ...(extensions?.websocket as WebSocketMetadata | undefined)?.groups,
245
+ }
246
+ const options = this.#resolve()
247
+ const routes = plans.map(
248
+ (plan): PluginUpgradeRoute => ({
249
+ path: plan.definition.path,
250
+ name: plan.name,
251
+ owner: plan.definition.token,
252
+ meta: metaEntries(plan.definition.meta, groups),
253
+ guards: plan.definition.handshakeGuards,
254
+ timeoutMs: options.handshakeTimeoutMs,
255
+ precheck: ctx => this.#precheck(plan, ctx),
256
+ accept: (ctx, upgrade) => this.#accept(plan, ctx, upgrade),
257
+ }),
258
+ )
259
+ const bunWebSocket = this.#transport?.bunWebSocket?.()
260
+ return bunWebSocket === undefined ? { routes } : { routes, bunWebSocket }
261
+ }
262
+
263
+ async onStart(): Promise<void> {
264
+ const transport = this.#transport
265
+ if (transport === undefined) return
266
+ await transport.ready?.()
267
+ const intervalMs = this.#resolve().heartbeatMs
268
+ if (intervalMs > 0 && transport.accepts === 'node') {
269
+ this.#heartbeat = setInterval(() => {
270
+ for (const conn of this.#beating) conn.heartbeat()
271
+ }, intervalMs)
272
+ this.#heartbeat.unref?.()
273
+ }
274
+ }
275
+
276
+ /**
277
+ * The `PluginHttpRoutes` contract: `HttpPlugin` starts draining, whatever the plugin order.
278
+ * Refuses new handshakes and closes every connection gracefully (see {@link onStop}).
279
+ */
280
+ onHttpDrain(): Promise<void> {
281
+ return this.#shutdown()
282
+ }
283
+
284
+ /**
285
+ * Refuses handshakes (`503`), closes every connection with `shutdown.code`, waits for close
286
+ * frames, running handlers and `@OnDisconnect` up to `shutdown.timeoutMs`, then terminates the
287
+ * rest. Idempotent: the same drain as {@link onHttpDrain}.
288
+ */
289
+ onStop(): Promise<void> {
290
+ return this.#shutdown()
291
+ }
292
+
293
+ async onDispose(): Promise<void> {
294
+ this.#stopHeartbeat()
295
+ try {
296
+ // an application that never started (in-memory tests) still closes its connections gracefully
297
+ await this.#shutdown()
298
+ await Promise.all([this.#transport?.close(), this.#memory?.close()])
299
+ } finally {
300
+ this.#active = false
301
+ this.#beating.clear()
302
+ this.#connections.clear()
303
+ this.#plans = undefined
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Opens an in-memory connection (tests): the real handshake through the HTTP pipeline (guards,
309
+ * hooks, origin and subprotocol checks) with an in-memory request, then the in-memory transport.
310
+ * `path` is the URL path, HTTP prefix included. Rejects with {@link WsHandshakeError} when the
311
+ * handshake is refused.
312
+ *
313
+ * ```ts
314
+ * await using alice = await plugin.connect('/chat', { protocols: ['chat.v1'] })
315
+ * expect(await alice.request('room.join', { room: 'general' })).toEqual({ members: 1 })
316
+ * ```
317
+ */
318
+ async connect(path: string, options: WsConnectOptions = {}): Promise<WsClient> {
319
+ const http = this.#ctx?.getPlugin<HttpPlugin>('http')
320
+ if (http === undefined || this.#plans === undefined) {
321
+ throw new ZnightError(ErrorCode.LifecycleFailed, 'WebSocketPlugin is not bootstrapped yet', {
322
+ hints: ['connect() is available once Application.create() resolved'],
323
+ })
324
+ }
325
+ const headers: Record<string, string> = { ...options.headers }
326
+ if (options.protocols !== undefined && options.protocols.length > 0) {
327
+ headers['sec-websocket-protocol'] = options.protocols.join(', ')
328
+ }
329
+ if (options.origin !== undefined) headers['origin'] = options.origin
330
+ const peer = new MemoryClient()
331
+ const refused = await http.upgradeInMemory(
332
+ {
333
+ method: 'GET',
334
+ url: path,
335
+ headers,
336
+ ...(options.query === undefined ? {} : { query: options.query }),
337
+ ...(options.cookies === undefined ? {} : { cookies: options.cookies }),
338
+ },
339
+ { kind: 'memory', peer },
340
+ )
341
+ if (refused !== undefined) throw new WsHandshakeError(refused)
342
+ return new WsClient(peer)
343
+ }
344
+
345
+ // ------------------------------------------------------------------------------------------------
346
+
347
+ #context(): PluginContext {
348
+ const ctx = this.#ctx
349
+ if (ctx === undefined) {
350
+ throw new ZnightError(ErrorCode.LifecycleFailed, 'WebSocketPlugin is not registered in an application')
351
+ }
352
+ return ctx
353
+ }
354
+
355
+ #resolve(): ResolvedWebSocketOptions {
356
+ if (this.#resolved !== undefined) return this.#resolved
357
+ const container: Container = this.#context().container
358
+ this.#resolved = resolveWebSocketOptions(resolveOptions(this.#options, container))
359
+ return this.#resolved
360
+ }
361
+
362
+ /** Builds the gateway plans and selects the transport, once. */
363
+ #prepare(context: PluginHttpUpgradesContext): readonly GatewayPlan[] {
364
+ if (this.#plans !== undefined) return this.#plans
365
+ const ctx = this.#context()
366
+ const options = this.#resolve()
367
+ const definitions = gatewaysOf(ctx.metadata)
368
+ if (definitions.length === 0) {
369
+ this.#plans = []
370
+ return this.#plans
371
+ }
372
+ const transport = selectTransport(options, context)
373
+ let maxPayload = options.maxPayload
374
+ let backpressureLimit = options.backpressure.limit
375
+ for (const definition of definitions) {
376
+ maxPayload = Math.max(maxPayload, definition.maxPayload ?? 0)
377
+ backpressureLimit = Math.max(backpressureLimit, definition.backpressure?.limit ?? 0)
378
+ }
379
+ transport.configure({ maxPayload, backpressureLimit, heartbeatMs: options.heartbeatMs })
380
+ this.#transport = transport
381
+ const refs = new Refs(ctx.container, ctx.metadata)
382
+ const globalGuards = options.messageGuards.map(ref => refs.guard(ref, 'global message guard'))
383
+ const mappers = options.exceptionMappers.map(ref => refs.mapper(ref))
384
+ const plans = definitions.map(definition => {
385
+ const name = displayName(definition.token)
386
+ const path = joinPath(context.prefix, definition.path)
387
+ const classGuards = definition.messageGuards.map(token => refs.guard(token, `message guard of ${name}`))
388
+ const handlers: HandlerPlan[] = definition.handlers.map(handler => ({
389
+ definition: handler,
390
+ guards: [
391
+ ...globalGuards,
392
+ ...classGuards,
393
+ ...handler.guards.map(token => refs.guard(token, `message guard of ${name}.${handler.method}`)),
394
+ ],
395
+ meta: Object.freeze({ ...definition.meta, ...handler.meta }),
396
+ }))
397
+ const requestScoped = ctx.metadata.providers[definition.token]?.scope === 'request'
398
+ const instance = gatewayInstance(ctx.container, definition.token, requestScoped)
399
+ const rooms = new RoomRegistry<WsConnectionImpl>(options.codec)
400
+ const backpressure = definition.backpressure
401
+ const gatewayMaxPayload = definition.maxPayload ?? options.maxPayload
402
+ const plan: GatewayPlan = {
403
+ definition,
404
+ name,
405
+ path,
406
+ rooms,
407
+ maxPayload: gatewayMaxPayload,
408
+ limits: {
409
+ maxPayload: gatewayMaxPayload,
410
+ maxPendingMessages: options.maxPendingMessages,
411
+ concurrency: definition.concurrency ?? 1,
412
+ backpressureLimit: backpressure?.limit ?? options.backpressure.limit,
413
+ onBackpressure: backpressure?.onLimit ?? options.backpressure.onLimit,
414
+ closeTimeoutMs: options.closeTimeoutMs,
415
+ },
416
+ rateLimit: definition.rateLimit,
417
+ instance,
418
+ requestScoped,
419
+ dispatcher: new GatewayDispatcher({
420
+ definition,
421
+ path,
422
+ handlers,
423
+ instance,
424
+ handlerTimeoutMs: options.handlerTimeoutMs,
425
+ exposeErrors: options.exposeErrors,
426
+ hooks: ctx.hooks,
427
+ name,
428
+ mappers,
429
+ codec: options.codec,
430
+ ...(process.env['NODE_ENV'] === 'production'
431
+ ? {}
432
+ : {
433
+ ignoredResult: (handler: string, event: string | undefined) =>
434
+ ctx.logger.debug(
435
+ `WebSocketPlugin: the result of ${name}.${handler} is ignored: "${event}" was sent without id (fire and forget)`,
436
+ { gateway: name, handler, event },
437
+ ),
438
+ }),
439
+ logError: (message, error, conn) => {
440
+ ctx.logger.error(`WebSocketPlugin: ${message}`, {
441
+ err: error,
442
+ gateway: name,
443
+ path,
444
+ connectionId: conn.id,
445
+ requestId: conn.requestId,
446
+ })
447
+ },
448
+ closed: (conn, info) => this.#closed(plan, conn, info),
449
+ }),
450
+ }
451
+ this.server.register({
452
+ type: classOf(ctx.metadata, definition.token),
453
+ declaredPath: definition.path,
454
+ path,
455
+ rooms,
456
+ })
457
+ return plan
458
+ })
459
+ this.#plans = plans
460
+ ctx.logger.debug('WebSocket gateways ready', { gateways: plans.length, transport: transport.name })
461
+ return plans
462
+ }
463
+
464
+ /** Origin, subprotocol and capacity checks, before the guards. */
465
+ async #precheck(plan: GatewayPlan, ctx: HttpContext): Promise<void> {
466
+ if (this.#draining) throw unavailable('The server is shutting down')
467
+ const options = this.#resolve()
468
+ const origin = ctx.header('origin')
469
+ if (origin !== undefined) {
470
+ let allowed = false
471
+ try {
472
+ allowed = (await options.origins.allows(origin, ctx)) === true
473
+ } catch {
474
+ allowed = false
475
+ }
476
+ if (!allowed) throw new ForbiddenException('Origin not allowed')
477
+ }
478
+ selectProtocol(plan, ctx)
479
+ if (this.#full(options)) throw unavailable('Too many WebSocket connections')
480
+ }
481
+
482
+ #full(options: ResolvedWebSocketOptions): boolean {
483
+ return this.#connections.size + this.#accepting >= options.maxConnections
484
+ }
485
+
486
+ /** After the guards: completes the upgrade and starts the connection in the handshake's scope. */
487
+ async #accept(plan: GatewayPlan, ctx: HttpContext, upgrade: PendingUpgrade): Promise<void> {
488
+ if (this.#draining) throw unavailable('The server is shutting down')
489
+ const options = this.#resolve()
490
+ // checked again and reserved synchronously: concurrent handshakes all passed the precheck
491
+ if (this.#full(options)) throw unavailable('Too many WebSocket connections')
492
+ this.#accepting++
493
+ let opened: { conn: WsConnectionImpl; socket: TransportSocket }
494
+ try {
495
+ opened = await this.#open(plan, ctx, upgrade)
496
+ } finally {
497
+ this.#accepting--
498
+ }
499
+ const { conn, socket } = opened
500
+ const ws = this.#context()
501
+ const openedAt = performance.now()
502
+ this.#connections.set(conn, openedAt)
503
+ if (upgrade.native.kind !== 'memory' && this.#transport?.accepts === 'node') this.#beating.add(conn)
504
+ this.#stats.opened++
505
+ if (ws.hooks.hasListeners(CONNECTION_HOOK)) {
506
+ ws.hooks.emit(CONNECTION_HOOK, connectionEvent('open', plan, conn, openedAt, undefined))
507
+ }
508
+ conn.start(socket)
509
+ upgrade.hold(conn.finished)
510
+ // the drain began while the handshake completed: it did not see this connection
511
+ if (this.#draining) conn.closeWith(options.shutdown.code, options.shutdown.reason, 'shutdown')
512
+ }
513
+
514
+ /** Creates the connection (and a request-scoped gateway) and completes the handshake. */
515
+ async #open(
516
+ plan: GatewayPlan,
517
+ ctx: HttpContext,
518
+ upgrade: PendingUpgrade,
519
+ ): Promise<{ conn: WsConnectionImpl; socket: TransportSocket }> {
520
+ const ws = this.#context()
521
+ const protocol = selectProtocol(plan, ctx)
522
+ const rate = plan.rateLimit
523
+ const conn = new WsConnectionImpl({
524
+ id: crypto.randomUUID(),
525
+ gateway: plan.path,
526
+ handshake: ctx,
527
+ protocol,
528
+ limits:
529
+ rate === undefined
530
+ ? plan.limits
531
+ : {
532
+ ...plan.limits,
533
+ rateLimit: { bucket: new TokenBucket(rate.messages, rate.intervalMs), onLimit: rate.onLimit },
534
+ },
535
+ rooms: plan.rooms,
536
+ driver: plan.dispatcher,
537
+ stats: this.#stats,
538
+ })
539
+ ws.container.currentScope()?.set(WsTokens.Connection, conn)
540
+ // a request-scoped gateway is created at the handshake: a factory error answers 500
541
+ if (plan.requestScoped) await plan.instance(conn)
542
+ const memory = upgrade.native.kind === 'memory'
543
+ let transport = this.#transport as WebSocketTransport
544
+ if (memory) {
545
+ this.#memory ??= memoryTransport()
546
+ transport = this.#memory
547
+ }
548
+ const run = captureContext()
549
+ const events = conn.socketEvents()
550
+ // socket events arrive outside of any async context: dispatch them in the connection's scope
551
+ const scoped: SocketEvents = {
552
+ message: (frame, binary) => run(() => events.message(frame, binary)),
553
+ pong: () => events.pong(),
554
+ drain: () => events.drain(),
555
+ close: (code, reason) => run(() => events.close(code, reason)),
556
+ }
557
+ // after every await: a handshake answered 408, or refused by a drain, is not completed
558
+ if (upgrade.expired) throw new HttpException(408, 'WebSocket handshake timed out')
559
+ if (this.#draining) throw unavailable('The server is shutting down')
560
+ const socket = await transport.accept(
561
+ upgrade.native,
562
+ { protocol, headers: upgrade.headers, maxPayload: plan.maxPayload },
563
+ scoped,
564
+ )
565
+ return { conn, socket }
566
+ }
567
+
568
+ /** A connection fully closed (after `@OnDisconnect`, rooms left). */
569
+ #closed(plan: GatewayPlan, conn: WsConnectionImpl, info: WsCloseInfo): void {
570
+ const openedAt = this.#connections.get(conn) ?? performance.now()
571
+ this.#connections.delete(conn)
572
+ this.#beating.delete(conn)
573
+ this.#stats.closed++
574
+ const hooks = this.#ctx?.hooks
575
+ if (hooks?.hasListeners(CONNECTION_HOOK)) {
576
+ hooks.emit(CONNECTION_HOOK, connectionEvent('close', plan, conn, openedAt, info))
577
+ }
578
+ }
579
+
580
+ #shutdown(): Promise<void> {
581
+ this.#drain ??= this.#closeAll()
582
+ return this.#drain
583
+ }
584
+
585
+ async #closeAll(): Promise<void> {
586
+ this.#draining = true
587
+ this.#stopHeartbeat()
588
+ const options = this.#resolved
589
+ const connections = [...this.#connections.keys()]
590
+ if (options === undefined || connections.length === 0) return
591
+ const { code, reason, timeoutMs } = options.shutdown
592
+ for (const conn of connections) conn.closeWith(code, reason, 'shutdown')
593
+ const all = Promise.all(connections.map(conn => conn.finished))
594
+ if (await within(all, timeoutMs)) return
595
+ for (const conn of connections) if (conn.readyState !== 'closed') conn.terminateWith('shutdown')
596
+ await within(all, options.closeTimeoutMs)
597
+ }
598
+
599
+ #stopHeartbeat(): void {
600
+ if (this.#heartbeat !== undefined) clearInterval(this.#heartbeat)
601
+ this.#heartbeat = undefined
602
+ }
603
+ }
604
+
605
+ /** Resolves when `task` settles (`true`) or after `ms` (`false`). */
606
+ async function within(task: Promise<unknown>, ms: number): Promise<boolean> {
607
+ let timer: ReturnType<typeof setTimeout> | undefined
608
+ const timeout = new Promise<boolean>(resolve => {
609
+ timer = setTimeout(() => resolve(false), ms)
610
+ })
611
+ try {
612
+ return await Promise.race([task.then(() => true), timeout])
613
+ } finally {
614
+ clearTimeout(timer)
615
+ }
616
+ }
617
+
618
+ function unavailable(message: string): HttpException {
619
+ return new HttpException(503, message, undefined, { headers: { 'retry-after': '1' } })
620
+ }
621
+
622
+ function connectionEvent(
623
+ type: 'open' | 'close',
624
+ plan: GatewayPlan,
625
+ conn: WsConnectionImpl,
626
+ openedAt: number,
627
+ info: WsCloseInfo | undefined,
628
+ ): WebSocketConnectionEvent {
629
+ return {
630
+ type,
631
+ gateway: plan.name,
632
+ path: plan.path,
633
+ connectionId: conn.id,
634
+ requestId: conn.requestId,
635
+ connection: conn,
636
+ openedAt,
637
+ ...(info === undefined ? {} : { code: info.code, reason: info.reason, initiator: info.initiator }),
638
+ }
639
+ }
640
+
641
+ /** The subprotocol of a handshake: the first of the gateway's `protocols` the client offers (400 otherwise). */
642
+ function selectProtocol(plan: GatewayPlan, ctx: HttpContext): string {
643
+ const accepted = plan.definition.protocols
644
+ if (accepted === undefined || accepted.length === 0) return ''
645
+ const offered = (ctx.header('sec-websocket-protocol') ?? '')
646
+ .split(',')
647
+ .map(item => item.trim())
648
+ .filter(item => item !== '')
649
+ const selected = accepted.find(protocol => offered.includes(protocol))
650
+ if (selected === undefined) throw new BadRequestException('Unsupported WebSocket subprotocol')
651
+ return selected
652
+ }
653
+
654
+ /** The transport of the `transport` option for the native upgrades of the adapter (ZNIGHT051). */
655
+ function selectTransport(options: ResolvedWebSocketOptions, context: PluginHttpUpgradesContext): WebSocketTransport {
656
+ const kind = context.upgrades
657
+ if (kind === undefined) {
658
+ throw new ZnightError(
659
+ ErrorCode.WebSocketTransportUnavailable,
660
+ `WebSocketPlugin: the ${context.adapter} adapter cannot serve WebSocket upgrades`,
661
+ { hints: ['Use a built-in adapter (node, fastify, express, hono) on a server Znight starts'] },
662
+ )
663
+ }
664
+ let transport = options.transport
665
+ if (transport === 'auto') transport = kind === 'bun' ? bunTransport() : wsTransport()
666
+ if (transport.accepts !== kind) {
667
+ throw new ZnightError(
668
+ ErrorCode.WebSocketTransportUnavailable,
669
+ `WebSocketPlugin: the ${transport.name} transport completes ${transport.accepts} upgrades, but the ${context.adapter} adapter produces ${kind} upgrades`,
670
+ {
671
+ hints:
672
+ kind === 'bun'
673
+ ? ["Use bunTransport() (or transport: 'auto') with honoAdapter() on Bun"]
674
+ : ["Use wsTransport() (or transport: 'auto') with the node:http adapters"],
675
+ },
676
+ )
677
+ }
678
+ return transport
679
+ }
680
+
681
+ /** `prefix` (`''`, `/api`) joined with a normalized gateway path (`/chat`, `/`). */
682
+ function joinPath(prefix: string, path: string): string {
683
+ if (prefix === '' || prefix === '/') return path
684
+ const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix
685
+ return path === '/' ? base : `${base}${path}`
686
+ }
687
+
688
+ /** Route decorator metadata as upgrade route entries, with the groups of `metadata.extensions.http`. */
689
+ function metaEntries(
690
+ meta: Readonly<Record<string, unknown>>,
691
+ groups: NonNullable<HttpMetadata['groups']>,
692
+ ): RouteMetaEntry[] {
693
+ return Object.keys(meta)
694
+ .sort()
695
+ .map(key => {
696
+ for (const [group, definition] of Object.entries(groups)) {
697
+ if (definition.keys.includes(key)) return { key, value: meta[key], group, required: definition.required }
698
+ }
699
+ return { key, value: meta[key] }
700
+ })
701
+ }
702
+
703
+ function classOf(metadata: ZnightMetadata, token: Token): abstract new (...args: never[]) => object {
704
+ return metadata.providers[token]?.class as abstract new (
705
+ ...args: never[]
706
+ ) => object
707
+ }
708
+
709
+ /** The gateway instance of a connection: the singleton, or the request-scoped one of its handshake. */
710
+ function gatewayInstance(
711
+ container: Container,
712
+ token: Token,
713
+ requestScoped: boolean,
714
+ ): (conn: WsConnectionImpl) => object | Promise<object> {
715
+ if (requestScoped) return () => container.get(token) as object | Promise<object>
716
+ let instance: object | undefined
717
+ return () => {
718
+ instance ??= container.get(token) as object
719
+ return instance
720
+ }
721
+ }
722
+
723
+ /** `metadata.extensions.websocket`, checked (ZNIGHT050): `[]` without gateways. */
724
+ function gatewaysOf(metadata: ZnightMetadata): readonly GatewayDefinition[] {
725
+ const section: unknown = metadata.extensions?.websocket
726
+ if (section === undefined) return []
727
+ const gateways = (section as Partial<WebSocketMetadata> | null)?.gateways
728
+ if (!Array.isArray(gateways)) throw malformed('"gateways" must be an array')
729
+ for (const gateway of gateways as Partial<GatewayDefinition>[]) {
730
+ const where = typeof gateway?.token === 'string' ? gateway.token : JSON.stringify(gateway)
731
+ if (gateway === null || typeof gateway !== 'object' || typeof gateway.token !== 'string') {
732
+ throw malformed(`invalid gateway ${where}`)
733
+ }
734
+ if (!Object.hasOwn(metadata.providers, gateway.token)) {
735
+ throw malformed(`the gateway ${where} is not a provider of the metadata`)
736
+ }
737
+ if (typeof gateway.path !== 'string' || !gateway.path.startsWith('/')) throw malformed(`invalid path of ${where}`)
738
+ if (gateway.protocol !== 'events' && gateway.protocol !== 'raw') throw malformed(`invalid protocol of ${where}`)
739
+ if (
740
+ !Array.isArray(gateway.handlers) ||
741
+ !Array.isArray(gateway.handshakeGuards) ||
742
+ !Array.isArray(gateway.messageGuards)
743
+ ) {
744
+ throw malformed(`handlers, handshakeGuards and messageGuards of ${where} must be arrays`)
745
+ }
746
+ for (const lifecycle of [gateway.onConnect, gateway.onDisconnect]) {
747
+ if (lifecycle !== undefined && typeof lifecycle?.invoke !== 'function') {
748
+ throw malformed(`invalid lifecycle handler of ${where}`)
749
+ }
750
+ }
751
+ for (const handler of gateway.handlers) {
752
+ if (
753
+ handler === null ||
754
+ typeof handler !== 'object' ||
755
+ typeof handler.invoke !== 'function' ||
756
+ !Array.isArray(handler.guards)
757
+ ) {
758
+ throw malformed(`invalid handler of ${where}`)
759
+ }
760
+ }
761
+ }
762
+ return gateways as GatewayDefinition[]
763
+ }
764
+
765
+ function malformed(reason: string): ZnightError {
766
+ return new ZnightError(
767
+ ErrorCode.InvalidGateway,
768
+ `WebSocketPlugin: malformed metadata.extensions.websocket: ${reason}`,
769
+ {
770
+ hints: ['Regenerate the container module (znight generate) with the same @znight/websocket version'],
771
+ },
772
+ )
773
+ }
774
+
775
+ /**
776
+ * Message guards and exception mappers resolved once at bootstrap: singletons to their instance,
777
+ * request-scoped and transient providers per call, in the connection's scope.
778
+ */
779
+ class Refs {
780
+ readonly #container: Container
781
+ readonly #metadata: ZnightMetadata
782
+
783
+ constructor(container: Container, metadata: ZnightMetadata) {
784
+ this.#container = container
785
+ this.#metadata = metadata
786
+ }
787
+
788
+ guard(ref: unknown, what: string): WsGuard {
789
+ const resolve = this.#resolve<WsGuard>(ref, what, 'canHandle')
790
+ return typeof resolve === 'function' ? { canHandle: ctx => resolve().canHandle(ctx) } : resolve
791
+ }
792
+
793
+ mapper(ref: unknown): WsExceptionMapper {
794
+ const resolve = this.#resolve<WsExceptionMapper>(ref, 'exception mapper', 'map')
795
+ return typeof resolve === 'function' ? { map: (error, ctx) => resolve().map(error, ctx) } : resolve
796
+ }
797
+
798
+ /** The instance, or a function resolving it per call (request-scoped and transient providers). */
799
+ #resolve<T extends object>(ref: unknown, what: string, method: string): T | (() => T) {
800
+ if (typeof ref !== 'function' && typeof ref !== 'string') return ref as T
801
+ const token = typeof ref === 'string' ? ref : this.#container.tokenOf(ref as ProviderKey)
802
+ if (token === undefined || !this.#container.has(token)) {
803
+ const name = typeof ref === 'string' ? displayName(ref) : (ref as { name?: string }).name || '<anonymous>'
804
+ throw new ZnightError(ErrorCode.MissingProvider, `WebSocketPlugin: the ${what} ${name} is not a provider`, {
805
+ hints: ['Add @Injectable() to the class, or pass an instance'],
806
+ })
807
+ }
808
+ const definition = Object.hasOwn(this.#metadata.providers, token) ? this.#metadata.providers[token] : undefined
809
+ const cls: unknown = definition?.class
810
+ if (typeof cls === 'function' && typeof (cls.prototype as Record<string, unknown>)?.[method] !== 'function') {
811
+ throw new ZnightError(
812
+ ErrorCode.InvalidGateway,
813
+ `WebSocketPlugin: the ${what} ${displayName(token)} has no ${method}() method`,
814
+ )
815
+ }
816
+ const scope = definition?.scope
817
+ if (scope === 'request' || scope === 'transient') return () => this.#container.get(token) as T
818
+ return this.#container.get(token) as T
819
+ }
820
+ }