@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.
- package/LICENSE +21 -0
- package/README.md +226 -2
- 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 +100 -4
- 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/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
|
+
}
|