@znight/websocket 0.0.0 → 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 +227 -0
  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 +102 -1
  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,850 @@
1
+ /**
2
+ * `analyzeClass` of the websocket extension (RFC 0003 §6.2): reads `@Gateway` classes, their
3
+ * lifecycle methods, `@OnMessage` handlers, parameters, guards and route decorators, registers the
4
+ * gateway provider and reports what one class can tell (ZNIGHT005, ZNIGHT007, ZNIGHT017, ZNIGHT050).
5
+ *
6
+ * Handlers may be declared on base classes: the gateway inherits the websocket methods of its
7
+ * analysable bases (the nearest declaration of a method carrying websocket decorators wins).
8
+ */
9
+ import type {
10
+ AnalyzeContext,
11
+ ArgInfo,
12
+ ClassInfo,
13
+ ClassRef,
14
+ DecoratorInfo,
15
+ Location,
16
+ MethodInfo,
17
+ ParameterInfo,
18
+ StaticValue,
19
+ TypeInfo,
20
+ ValueRef,
21
+ } from '@znight/compiler'
22
+ import type { MetaEntry } from '@znight/http/compiler'
23
+ import type { GatewayOptionsData, HandlerData, LifecycleData, WebSocketData, WsParamData } from './data.ts'
24
+
25
+ import { readRouteMeta } from '@znight/http/compiler'
26
+ import { checkRoutePath, normalizePath } from '@znight/http/path'
27
+
28
+ import {
29
+ EVENT_NAME,
30
+ INVALID_DECORATOR,
31
+ INVALID_GATEWAY,
32
+ INVALID_ROUTE,
33
+ METHOD_DECORATORS,
34
+ PARAMETER_DECORATORS,
35
+ parseSize,
36
+ RUNTIME_MODULE,
37
+ SUBPROTOCOL,
38
+ UNVALIDATED_INPUT,
39
+ WsDecorator,
40
+ } from './constants.ts'
41
+
42
+ type Context = AnalyzeContext<WebSocketData>
43
+
44
+ /** The websocket declarations of one method. */
45
+ interface MemberAnalysis {
46
+ readonly method: string
47
+ readonly declaredBy: string
48
+ readonly onConnect: LifecycleData | undefined
49
+ readonly onDisconnect: LifecycleData | undefined
50
+ readonly handlers: readonly HandlerData[]
51
+ /** The method has a role decorator (valid or not). */
52
+ readonly role: boolean
53
+ }
54
+
55
+ /** Types a `@Payload()` without schema may have (RFC 0003 §5.5). */
56
+ const BINARY_TYPES: ReadonlySet<string> = new Set([
57
+ 'globalThis#Uint8Array',
58
+ 'globalThis#ArrayBuffer',
59
+ 'globalThis#Buffer',
60
+ 'node:buffer#Buffer',
61
+ 'buffer#Buffer',
62
+ ])
63
+
64
+ const OPTION_KEYS: ReadonlySet<string> = new Set([
65
+ 'path',
66
+ 'scope',
67
+ 'protocol',
68
+ 'protocols',
69
+ 'maxPayload',
70
+ 'concurrency',
71
+ 'rateLimit',
72
+ 'backpressure',
73
+ ])
74
+
75
+ /** Maximum depth of the base class chain walked for inherited handlers. */
76
+ const MAX_CHAIN = 32
77
+
78
+ /** Analyses a class carrying at least one decorator of the extension. */
79
+ export function analyzeWebSocketClass(cls: ClassInfo, ctx: Context): void {
80
+ const gateways = cls.decorators.filter(decorator => decorator.identity === WsDecorator.Gateway)
81
+ const gateway = gateways[0]
82
+ if (gateway === undefined) {
83
+ // analysed anyway: errors on the methods are reported once, here, and validate() reports
84
+ // the decorators of a class that is not a base of a gateway
85
+ analyzeMembers(cls, ctx)
86
+ const decorators = [
87
+ ...cls.decorators.filter(isOwnClassDecorator),
88
+ ...cls.methods.flatMap(method => method.decorators.filter(isOwnMethodDecorator)),
89
+ ].map(decorator => ({ name: decorator.name, location: decorator.location }))
90
+ if (decorators.length > 0) {
91
+ ctx.store({
92
+ kind: 'members',
93
+ identity: cls.identity,
94
+ displayName: cls.displayName,
95
+ abstract: cls.abstract,
96
+ decorators,
97
+ })
98
+ }
99
+ return
100
+ }
101
+ for (const duplicate of gateways.slice(1)) {
102
+ report(ctx, INVALID_DECORATOR, duplicate.location, `@Gateway is applied several times on ${cls.displayName}`, [
103
+ 'Keep a single @Gateway: a class serves one path',
104
+ ])
105
+ }
106
+ const read = readGatewayOptions(gateway, cls, ctx)
107
+ const token = ctx.registerProvider(cls, { scope: read.scope })
108
+ const chain = classChain(cls, ctx)
109
+
110
+ const merged = new Map<string, MemberAnalysis>()
111
+ for (let index = chain.length - 1; index >= 0; index--) {
112
+ const owner = chain[index] as ClassInfo
113
+ for (const [name, member] of analyzeMembers(owner, ctx, index === 0 ? undefined : cls)) merged.set(name, member)
114
+ }
115
+ const members = [...merged.values()]
116
+ const handlers = members.flatMap(member => member.handlers)
117
+ const onConnect = single(ctx, cls, members, 'onConnect', '@OnConnect')
118
+ const onDisconnect = single(ctx, cls, members, 'onDisconnect', '@OnDisconnect')
119
+ checkHandlers(cls, read.options.protocol, handlers, ctx)
120
+ if (!members.some(member => member.role)) {
121
+ report(
122
+ ctx,
123
+ INVALID_GATEWAY,
124
+ gateway.location,
125
+ `@Gateway ${cls.displayName} has no handler`,
126
+ ['Decorate a public method with @OnMessage("event"), @OnConnect() or @OnDisconnect()'],
127
+ 'warning',
128
+ )
129
+ }
130
+
131
+ ctx.store({
132
+ kind: 'gateway',
133
+ token,
134
+ identity: cls.identity,
135
+ displayName: cls.displayName,
136
+ path: read.path,
137
+ options: read.options,
138
+ meta: readRouteMeta(ctx, cls),
139
+ handshakeGuards: guardRefs(ctx, cls, cls.decorators, WsDecorator.UseHandshakeGuard),
140
+ messageGuards: guardRefs(ctx, cls, cls.decorators, WsDecorator.UseMessageGuard),
141
+ onConnect,
142
+ onDisconnect,
143
+ handlers,
144
+ chain: chain.map(owner => owner.identity),
145
+ location: gateway.location,
146
+ })
147
+ }
148
+
149
+ function isOwnClassDecorator(decorator: DecoratorInfo): boolean {
150
+ return decorator.identity === WsDecorator.UseHandshakeGuard || decorator.identity === WsDecorator.UseMessageGuard
151
+ }
152
+
153
+ function isOwnMethodDecorator(decorator: DecoratorInfo): boolean {
154
+ return decorator.identity !== undefined && METHOD_DECORATORS.has(decorator.identity)
155
+ }
156
+
157
+ function report(
158
+ ctx: Context,
159
+ code: string,
160
+ location: Location,
161
+ message: string,
162
+ hints?: readonly string[],
163
+ severity: 'error' | 'warning' = 'error',
164
+ related?: readonly { readonly message: string; readonly location: Location }[],
165
+ ): void {
166
+ ctx.report({
167
+ code: code as never,
168
+ severity,
169
+ message,
170
+ location,
171
+ ...(hints === undefined ? {} : { hints }),
172
+ ...(related === undefined ? {} : { related }),
173
+ })
174
+ }
175
+
176
+ // ------------------------------------------------------------------------------------------ options
177
+
178
+ interface ReadOptions {
179
+ readonly path: string | undefined
180
+ readonly scope: 'singleton' | 'request'
181
+ readonly options: GatewayOptionsData
182
+ }
183
+
184
+ function isRecord(value: StaticValue): value is Readonly<Record<string, StaticValue>> {
185
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
186
+ const prototype: unknown = Object.getPrototypeOf(value)
187
+ return prototype === Object.prototype || prototype === null
188
+ }
189
+
190
+ function describe(value: StaticValue): string {
191
+ if (value === undefined) return 'undefined'
192
+ if (value === null) return 'null'
193
+ if (typeof value === 'string') return JSON.stringify(value)
194
+ if (typeof value !== 'object') return String(value)
195
+ if (Array.isArray(value)) return 'an array'
196
+ return isRecord(value) ? 'an object' : 'a reference'
197
+ }
198
+
199
+ function isPositiveInteger(value: unknown): value is number {
200
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
201
+ }
202
+
203
+ /** `@Gateway()`, `@Gateway('/chat')` or `@Gateway({ path, scope, protocol, ... })`. */
204
+ function readGatewayOptions(decorator: DecoratorInfo, cls: ClassInfo, ctx: Context): ReadOptions {
205
+ const prefix = `@Gateway ${cls.displayName}`
206
+ const fallback: ReadOptions = { path: undefined, scope: 'singleton', options: { protocol: 'events' } }
207
+ const arg = decorator.args[0]
208
+ if (!decorator.called) return fallback // reported by the compiler
209
+ if (arg === undefined) return { ...fallback, path: '/' }
210
+ if (!arg.evaluated) return fallback // not statically evaluable: reported by the compiler
211
+ const value = arg.value
212
+ if (typeof value === 'string') return { ...fallback, path: checkPath(ctx, prefix, value, arg.location) }
213
+ if (value === undefined) return { ...fallback, path: '/' }
214
+ if (!isRecord(value)) {
215
+ report(
216
+ ctx,
217
+ INVALID_DECORATOR,
218
+ arg.location,
219
+ `${prefix}: expected a path or an options object, got ${describe(value)}`,
220
+ ["Write @Gateway('/chat') or @Gateway({ path: '/chat', protocol: 'raw' })"],
221
+ )
222
+ return fallback
223
+ }
224
+ const fail = (message: string, hints?: readonly string[]): void => {
225
+ report(ctx, INVALID_GATEWAY, arg.location, `${prefix}: ${message}`, hints)
226
+ }
227
+ for (const key of Object.keys(value)) {
228
+ if (!OPTION_KEYS.has(key)) fail(`unknown option "${key}"`, [`Known options: ${[...OPTION_KEYS].join(', ')}`])
229
+ }
230
+ let path: string | undefined = '/'
231
+ const rawPath = value['path']
232
+ if (rawPath !== undefined) {
233
+ if (typeof rawPath === 'string') path = checkPath(ctx, prefix, rawPath, arg.location)
234
+ else {
235
+ fail(`"path" must be a string, got ${describe(rawPath)}`)
236
+ path = undefined
237
+ }
238
+ }
239
+ let scope: 'singleton' | 'request' = 'singleton'
240
+ const rawScope = value['scope']
241
+ if (rawScope === 'request') scope = 'request'
242
+ else if (rawScope === 'transient') {
243
+ fail("a gateway cannot be 'transient'", [
244
+ "Use scope: 'request' for one instance per connection, or the default singleton",
245
+ ])
246
+ } else if (rawScope !== undefined && rawScope !== 'singleton') {
247
+ fail(`"scope" must be 'singleton' or 'request', got ${describe(rawScope)}`)
248
+ }
249
+ let protocol: 'events' | 'raw' = 'events'
250
+ const rawProtocol = value['protocol']
251
+ if (rawProtocol === 'raw') protocol = 'raw'
252
+ else if (rawProtocol !== undefined && rawProtocol !== 'events') {
253
+ fail(`"protocol" must be 'events' or 'raw', got ${describe(rawProtocol)}`)
254
+ }
255
+ const options: { -readonly [K in keyof GatewayOptionsData]: GatewayOptionsData[K] } = { protocol }
256
+ const protocols = value['protocols']
257
+ if (protocols !== undefined) {
258
+ if (
259
+ !Array.isArray(protocols) ||
260
+ protocols.length === 0 ||
261
+ !protocols.every(item => typeof item === 'string' && SUBPROTOCOL.test(item)) ||
262
+ new Set(protocols).size !== protocols.length
263
+ ) {
264
+ fail(`"protocols" must be a non-empty list of distinct subprotocol tokens, got ${describe(protocols)}`, [
265
+ "protocols: ['chat.v1']",
266
+ ])
267
+ } else {
268
+ options.protocols = protocols as string[]
269
+ }
270
+ }
271
+ const maxPayload = value['maxPayload']
272
+ if (maxPayload !== undefined) {
273
+ const bytes = parseSize(maxPayload)
274
+ if (bytes === undefined)
275
+ fail(`"maxPayload" must be a positive byte size ('64kb', 65536), got ${describe(maxPayload)}`)
276
+ else options.maxPayload = bytes
277
+ }
278
+ const concurrency = value['concurrency']
279
+ if (concurrency !== undefined) {
280
+ if (isPositiveInteger(concurrency)) options.concurrency = concurrency
281
+ else fail(`"concurrency" must be a positive integer, got ${describe(concurrency)}`)
282
+ }
283
+ const rateLimit = value['rateLimit']
284
+ if (rateLimit !== undefined) {
285
+ const onLimit = isRecord(rateLimit) ? (rateLimit['onLimit'] ?? 'reject') : undefined
286
+ if (
287
+ !isRecord(rateLimit) ||
288
+ !isPositiveInteger(rateLimit['messages']) ||
289
+ !isPositiveInteger(rateLimit['intervalMs']) ||
290
+ (onLimit !== 'reject' && onLimit !== 'close') ||
291
+ Object.keys(rateLimit).some(key => key !== 'messages' && key !== 'intervalMs' && key !== 'onLimit')
292
+ ) {
293
+ fail(`"rateLimit" must be { messages, intervalMs, onLimit?: 'reject' | 'close' }, got ${describe(rateLimit)}`, [
294
+ 'rateLimit: { messages: 30, intervalMs: 1000 }',
295
+ ])
296
+ } else {
297
+ options.rateLimit = {
298
+ messages: rateLimit['messages'] as number,
299
+ intervalMs: rateLimit['intervalMs'] as number,
300
+ onLimit: onLimit as 'reject' | 'close',
301
+ }
302
+ }
303
+ }
304
+ const backpressure = value['backpressure']
305
+ if (backpressure !== undefined) {
306
+ const limit = isRecord(backpressure) ? backpressure['limit'] : undefined
307
+ const onLimit = isRecord(backpressure) ? backpressure['onLimit'] : undefined
308
+ const bytes = limit === undefined ? undefined : parseSize(limit)
309
+ if (
310
+ !isRecord(backpressure) ||
311
+ (limit !== undefined && bytes === undefined) ||
312
+ (onLimit !== undefined && onLimit !== 'close' && onLimit !== 'drop') ||
313
+ Object.keys(backpressure).some(key => key !== 'limit' && key !== 'onLimit')
314
+ ) {
315
+ fail(`"backpressure" must be { limit?: ByteSize, onLimit?: 'close' | 'drop' }, got ${describe(backpressure)}`)
316
+ } else {
317
+ options.backpressure = {
318
+ ...(bytes === undefined ? {} : { limit: bytes }),
319
+ ...(onLimit === undefined ? {} : { onLimit: onLimit as 'close' | 'drop' }),
320
+ }
321
+ }
322
+ }
323
+ return { path, scope, options }
324
+ }
325
+
326
+ /** The normalised path, or `undefined` when it breaks the route grammar (ZNIGHT007). */
327
+ function checkPath(ctx: Context, prefix: string, raw: string, location: Location): string | undefined {
328
+ const path = normalizePath(raw)
329
+ const issue = checkRoutePath(path)
330
+ if (issue === undefined) return path
331
+ report(ctx, INVALID_ROUTE, location, `${prefix}: invalid path "${raw}": ${issue.message}`, [
332
+ "Gateway paths use the route grammar: '/chat', '/docs/:docId', '/feeds/*'",
333
+ ])
334
+ return undefined
335
+ }
336
+
337
+ /** The class and its analysable base classes (nearest first). */
338
+ function classChain(cls: ClassInfo, ctx: Context): ClassInfo[] {
339
+ const chain = [cls]
340
+ const seen = new Set([cls.identity])
341
+ let current = cls
342
+ while (current.superClass !== undefined && chain.length < MAX_CHAIN) {
343
+ const base = ctx.classInfo(current.superClass.identity)
344
+ if (base === undefined || seen.has(base.identity)) break
345
+ chain.push(base)
346
+ seen.add(base.identity)
347
+ current = base
348
+ }
349
+ return chain
350
+ }
351
+
352
+ /** `@UseHandshakeGuard` / `@UseMessageGuard` arguments, bound with a gateway → guard edge. */
353
+ function guardRefs(ctx: Context, from: ClassInfo, decorators: readonly DecoratorInfo[], identity: string): ClassRef[] {
354
+ const refs: ClassRef[] = []
355
+ for (const decorator of decorators) {
356
+ if (decorator.identity !== identity) continue
357
+ if (decorator.called && decorator.args.length === 0) {
358
+ report(ctx, INVALID_DECORATOR, decorator.location, `@${decorator.name} requires at least one guard`, [
359
+ `@${decorator.name}(MyGuard)`,
360
+ ])
361
+ }
362
+ for (const arg of decorator.args) {
363
+ const ref = ctx.resolveClassRef(arg, { from, role: 'guard' })
364
+ if (ref) refs.push(ref)
365
+ }
366
+ }
367
+ return refs
368
+ }
369
+
370
+ /** The single `@OnConnect` / `@OnDisconnect` of a gateway (ZNIGHT050 for several). */
371
+ function single(
372
+ ctx: Context,
373
+ cls: ClassInfo,
374
+ members: readonly MemberAnalysis[],
375
+ key: 'onConnect' | 'onDisconnect',
376
+ name: string,
377
+ ): LifecycleData | undefined {
378
+ const found = members.flatMap(member => (member[key] === undefined ? [] : [member[key] as LifecycleData]))
379
+ const first = found[0]
380
+ for (const extra of found.slice(1)) {
381
+ report(
382
+ ctx,
383
+ INVALID_GATEWAY,
384
+ extra.location,
385
+ `${cls.displayName} has several ${name} methods: ${found.map(item => `${item.declaredBy}.${item.method}`).join(', ')}`,
386
+ [`Keep a single ${name} method per gateway`],
387
+ 'error',
388
+ [{ message: `First ${name} method`, location: (first as LifecycleData).location }],
389
+ )
390
+ }
391
+ return first
392
+ }
393
+
394
+ /** Events handled twice, several default handlers, events on raw gateways (ZNIGHT050). */
395
+ function checkHandlers(
396
+ cls: ClassInfo,
397
+ protocol: 'events' | 'raw',
398
+ handlers: readonly HandlerData[],
399
+ ctx: Context,
400
+ ): void {
401
+ const label = (handler: HandlerData): string => `${handler.declaredBy}.${handler.method}`
402
+ if (protocol === 'raw') {
403
+ for (const handler of handlers) {
404
+ if (handler.event === undefined) continue
405
+ report(
406
+ ctx,
407
+ INVALID_GATEWAY,
408
+ handler.location,
409
+ `@OnMessage("${handler.event}") on the raw gateway ${cls.displayName}`,
410
+ ['A raw gateway has no events: use @OnMessage() for its single handler, or protocol: "events"'],
411
+ )
412
+ }
413
+ const first = handlers[0]
414
+ for (const extra of handlers.slice(1)) {
415
+ report(
416
+ ctx,
417
+ INVALID_GATEWAY,
418
+ extra.location,
419
+ `The raw gateway ${cls.displayName} has several handlers: ${handlers.map(label).join(', ')}`,
420
+ ['A raw gateway has a single @OnMessage() handler receiving every frame'],
421
+ 'error',
422
+ [{ message: 'First handler', location: (first as HandlerData).location }],
423
+ )
424
+ }
425
+ return
426
+ }
427
+ let fallback: HandlerData | undefined
428
+ const byEvent = new Map<string, HandlerData>()
429
+ for (const handler of handlers) {
430
+ if (handler.event === undefined) {
431
+ if (fallback === undefined) {
432
+ fallback = handler
433
+ continue
434
+ }
435
+ report(
436
+ ctx,
437
+ INVALID_GATEWAY,
438
+ handler.location,
439
+ `${cls.displayName} has several default handlers: ${label(fallback)} and ${label(handler)}`,
440
+ ['Give one of them an event: @OnMessage("message.send")'],
441
+ 'error',
442
+ [{ message: 'First default handler', location: fallback.location }],
443
+ )
444
+ continue
445
+ }
446
+ const first = byEvent.get(handler.event)
447
+ if (first === undefined) {
448
+ byEvent.set(handler.event, handler)
449
+ continue
450
+ }
451
+ report(
452
+ ctx,
453
+ INVALID_GATEWAY,
454
+ handler.location,
455
+ `${cls.displayName} handles "${handler.event}" twice: ${label(first)} and ${label(handler)}`,
456
+ ['An event has a single handler per gateway: merge the methods, or rename one of the events'],
457
+ 'error',
458
+ [{ message: 'first handled here', location: first.location }],
459
+ )
460
+ }
461
+ }
462
+
463
+ // ------------------------------------------------------------------------------------------- members
464
+
465
+ /**
466
+ * Websocket methods declared by `cls` itself, by name. `host` is the gateway when `cls` is one of
467
+ * its bases (its methods were already reported when the base was analysed: no second report).
468
+ */
469
+ function analyzeMembers(cls: ClassInfo, ctx: Context, host?: ClassInfo): Map<string, MemberAnalysis> {
470
+ const members = new Map<string, MemberAnalysis>()
471
+ const quiet = host === undefined ? ctx : silenced(ctx)
472
+ for (const method of cls.methods) {
473
+ const member = analyzeMethod(cls, method, quiet, host ?? cls)
474
+ if (member !== undefined) members.set(method.name, member)
475
+ }
476
+ return members
477
+ }
478
+
479
+ /** The context without reports (a base class analysed again for a gateway). */
480
+ function silenced(ctx: Context): Context {
481
+ return new Proxy(ctx, {
482
+ get(target, key, receiver) {
483
+ if (key === 'report') return () => undefined
484
+ const value: unknown = Reflect.get(target, key, receiver)
485
+ return typeof value === 'function' ? value.bind(target) : value
486
+ },
487
+ })
488
+ }
489
+
490
+ function analyzeMethod(cls: ClassInfo, method: MethodInfo, ctx: Context, host: ClassInfo): MemberAnalysis | undefined {
491
+ const onConnect = method.decorators.filter(decorator => decorator.identity === WsDecorator.OnConnect)
492
+ const onDisconnect = method.decorators.filter(decorator => decorator.identity === WsDecorator.OnDisconnect)
493
+ const onMessage = method.decorators.filter(decorator => decorator.identity === WsDecorator.OnMessage)
494
+ const guards = method.decorators.filter(decorator => decorator.identity === WsDecorator.UseMessageGuard)
495
+ const decoratedParams = method.params.some(param => param.decorators.some(isParameterDecorator))
496
+ if (onConnect.length + onDisconnect.length + onMessage.length + guards.length === 0 && !decoratedParams) {
497
+ return undefined
498
+ }
499
+ const where = `${cls.displayName}.${method.name}`
500
+ const roles = [onConnect[0], onDisconnect[0], onMessage[0]].filter(item => item !== undefined) as DecoratorInfo[]
501
+ const primary = roles[0]
502
+ if (primary === undefined) {
503
+ for (const guard of guards) {
504
+ report(
505
+ ctx,
506
+ INVALID_DECORATOR,
507
+ guard.location,
508
+ `@UseMessageGuard has no effect on ${where}: it is not an @OnMessage handler`,
509
+ ['Add @OnMessage("event") to the method, or put the guard on the gateway class'],
510
+ )
511
+ }
512
+ reportStrayParameters(method, where, ctx)
513
+ return {
514
+ method: method.name,
515
+ declaredBy: cls.displayName,
516
+ onConnect: undefined,
517
+ onDisconnect: undefined,
518
+ handlers: [],
519
+ role: false,
520
+ }
521
+ }
522
+ checkMethodShape(method, primary, where, ctx)
523
+ if (roles.length > 1) {
524
+ report(
525
+ ctx,
526
+ INVALID_GATEWAY,
527
+ (roles[1] as DecoratorInfo).location,
528
+ `${where} has several roles (${roles.map(role => `@${role.name}`).join(', ')})`,
529
+ ['A method is either @OnConnect, @OnDisconnect or an @OnMessage handler: split it'],
530
+ )
531
+ }
532
+ for (const duplicate of [...onConnect.slice(1), ...onDisconnect.slice(1)]) {
533
+ report(ctx, INVALID_DECORATOR, duplicate.location, `@${duplicate.name} is applied several times on ${where}`, [
534
+ `Keep a single @${duplicate.name}`,
535
+ ])
536
+ }
537
+ const location = primary.location
538
+ if (onMessage.length === 0) {
539
+ for (const guard of guards) {
540
+ report(
541
+ ctx,
542
+ INVALID_DECORATOR,
543
+ guard.location,
544
+ `@UseMessageGuard has no effect on ${where}: it is not an @OnMessage handler`,
545
+ ['Message guards run before @OnMessage handlers; guard the handshake with @UseHandshakeGuard on the class'],
546
+ )
547
+ }
548
+ const lifecycle: LifecycleData = {
549
+ method: method.name,
550
+ params: analyzeParams(method, where, 'lifecycle', ctx),
551
+ declaredBy: cls.displayName,
552
+ location,
553
+ }
554
+ return {
555
+ method: method.name,
556
+ declaredBy: cls.displayName,
557
+ onConnect: onConnect.length > 0 ? lifecycle : undefined,
558
+ onDisconnect: onConnect.length === 0 ? lifecycle : undefined,
559
+ handlers: [],
560
+ role: true,
561
+ }
562
+ }
563
+ const params = analyzeParams(method, where, 'message', ctx)
564
+ const meta: readonly MetaEntry[] = readRouteMeta(ctx, cls, method)
565
+ const methodGuards = guardRefs(ctx, host, method.decorators, WsDecorator.UseMessageGuard)
566
+ const handlers = onMessage.flatMap(decorator => {
567
+ const event = eventOf(decorator, where, ctx)
568
+ if (event === false) return []
569
+ return [
570
+ Object.freeze({
571
+ method: method.name,
572
+ event,
573
+ meta,
574
+ guards: methodGuards,
575
+ params,
576
+ declaredBy: cls.displayName,
577
+ location: decorator.location,
578
+ }),
579
+ ]
580
+ })
581
+ return {
582
+ method: method.name,
583
+ declaredBy: cls.displayName,
584
+ onConnect: undefined,
585
+ onDisconnect: undefined,
586
+ handlers,
587
+ role: true,
588
+ }
589
+ }
590
+
591
+ /** The event of `@OnMessage(event?)`: a string, `undefined` (default handler) or `false` (invalid). */
592
+ function eventOf(decorator: DecoratorInfo, where: string, ctx: Context): string | undefined | false {
593
+ const arg = decorator.args[0]
594
+ if (arg === undefined || (arg.evaluated && arg.value === undefined)) return undefined
595
+ if (!arg.evaluated) return false
596
+ const value = arg.value
597
+ if (typeof value !== 'string') {
598
+ report(
599
+ ctx,
600
+ INVALID_DECORATOR,
601
+ arg.location,
602
+ `@OnMessage on ${where} expects an event name (a string), got ${describe(value)}`,
603
+ ['Use @OnMessage() for the default handler'],
604
+ )
605
+ return false
606
+ }
607
+ if (value.startsWith('$')) {
608
+ report(ctx, INVALID_GATEWAY, arg.location, `Event name "${value}" is reserved (${where})`, [
609
+ `Names starting with "$" are protocol events${value === '$ping' ? '; "$ping" is answered by Znight itself' : ''}`,
610
+ ])
611
+ return false
612
+ }
613
+ if (!EVENT_NAME.test(value)) {
614
+ report(ctx, INVALID_GATEWAY, arg.location, `Invalid event name "${value}" (${where})`, [
615
+ 'An event name starts with a letter, then up to 127 letters, digits or _ . : / -',
616
+ ])
617
+ return false
618
+ }
619
+ return value
620
+ }
621
+
622
+ /** Handlers are called by the generated code: `gateway.method(...)`. */
623
+ function checkMethodShape(method: MethodInfo, decorator: DecoratorInfo, where: string, ctx: Context): void {
624
+ const name = `@${decorator.name}`
625
+ let problem: readonly [string, string] | undefined
626
+ if (method.kind !== 'method') problem = [`${name} cannot be applied to the accessor ${where}`, 'Use a regular method']
627
+ else if (method.static) problem = [`${name} cannot be applied to the static method ${where}`, 'Remove `static`']
628
+ else if (method.privateName)
629
+ problem = [`${name} cannot be applied to the #private method ${where}`, 'Use a public method']
630
+ else if (method.visibility !== 'public') {
631
+ problem = [
632
+ `${where} must be public: ${name} methods are called by the generated code`,
633
+ `Remove the '${method.visibility}' modifier`,
634
+ ]
635
+ } else if (method.computed)
636
+ problem = [`${name} cannot be applied to ${where}: computed names are not supported`, 'Use a plain method name']
637
+ else if (method.generator)
638
+ problem = [`${name} cannot be applied to the generator method ${where}`, 'Use a regular (or async) method']
639
+ if (problem !== undefined) report(ctx, INVALID_DECORATOR, decorator.location, problem[0], [problem[1]])
640
+ }
641
+
642
+ // ---------------------------------------------------------------------------------------- parameters
643
+
644
+ function analyzeParams(
645
+ method: MethodInfo,
646
+ where: string,
647
+ role: 'message' | 'lifecycle',
648
+ ctx: Context,
649
+ ): readonly WsParamData[] {
650
+ let payload: DecoratorInfo | undefined
651
+ return Object.freeze(
652
+ method.params.map(param => {
653
+ const data = analyzeParam(param, where, role, ctx)
654
+ if (data.kind === 'payload' || data.kind === 'validated') {
655
+ const decorator = parameterDecorators(param)[0] as DecoratorInfo
656
+ if (payload !== undefined) {
657
+ report(ctx, INVALID_GATEWAY, decorator.location, `${where} has two @Payload parameters`, [
658
+ 'A message has one payload: keep a single @Payload()',
659
+ ])
660
+ }
661
+ payload ??= decorator
662
+ }
663
+ return data
664
+ }),
665
+ )
666
+ }
667
+
668
+ function analyzeParam(param: ParameterInfo, where: string, role: 'message' | 'lifecycle', ctx: Context): WsParamData {
669
+ const absent: WsParamData = { kind: 'undefined', optional: param.optional || param.hasDefault }
670
+ const decorators = parameterDecorators(param)
671
+ const decorator = decorators[0]
672
+ if (decorator === undefined) {
673
+ const foreign = foreignParameterDecorator(param)
674
+ if (foreign !== undefined) {
675
+ report(
676
+ ctx,
677
+ INVALID_DECORATOR,
678
+ foreign.decorator.location,
679
+ `@${foreign.decorator.name} on parameter '${param.name}' of ${where} is imported from '${foreign.module}': gateway handlers use ${foreign.name} from '${RUNTIME_MODULE}'`,
680
+ [`Import it from '${RUNTIME_MODULE}': import { ${foreign.name} } from '${RUNTIME_MODULE}'`],
681
+ )
682
+ return absent
683
+ }
684
+ if (param.optional || param.hasDefault || param.rest) return absent
685
+ report(
686
+ ctx,
687
+ INVALID_GATEWAY,
688
+ param.location,
689
+ `Parameter '${param.name}' of ${where} has no @Payload(), @Conn() or @Ctx() decorator`,
690
+ [
691
+ role === 'message'
692
+ ? 'Every handler parameter is extracted from the message: @Payload(schema?), @Conn() or @Ctx()'
693
+ : '@OnConnect and @OnDisconnect methods receive the connection: @Conn()',
694
+ ],
695
+ )
696
+ return absent
697
+ }
698
+ if (decorators.length > 1) {
699
+ report(
700
+ ctx,
701
+ INVALID_DECORATOR,
702
+ (decorators[1] as DecoratorInfo).location,
703
+ `Parameter '${param.name}' of ${where} has several websocket decorators`,
704
+ ['Keep a single one'],
705
+ )
706
+ }
707
+ if (param.rest) {
708
+ report(
709
+ ctx,
710
+ INVALID_DECORATOR,
711
+ decorator.location,
712
+ `@${decorator.name} cannot be applied to the rest parameter '...${param.name}' of ${where}`,
713
+ )
714
+ return absent
715
+ }
716
+ if (decorator.identity === WsDecorator.Conn) return { kind: 'connection' }
717
+ if (role === 'lifecycle') {
718
+ report(
719
+ ctx,
720
+ INVALID_GATEWAY,
721
+ decorator.location,
722
+ `@${decorator.name} on ${where}: only @OnMessage handlers receive a message`,
723
+ ['@OnConnect and @OnDisconnect methods take @Conn() only'],
724
+ )
725
+ return absent
726
+ }
727
+ if (decorator.identity === WsDecorator.Ctx) return { kind: 'context' }
728
+ return payloadParam(param, decorator, where, ctx)
729
+ }
730
+
731
+ /** `@Payload()` / `@Payload(schema)`. */
732
+ function payloadParam(param: ParameterInfo, decorator: DecoratorInfo, where: string, ctx: Context): WsParamData {
733
+ const arg: ArgInfo | undefined = decorator.args[0]
734
+ if (arg === undefined || (arg.evaluated && arg.value === undefined)) {
735
+ checkUnvalidated(param, decorator, where, ctx)
736
+ return { kind: 'payload' }
737
+ }
738
+ if (arg.ref !== undefined) {
739
+ if (isClassReference(arg.ref)) {
740
+ report(
741
+ ctx,
742
+ INVALID_DECORATOR,
743
+ arg.location,
744
+ `@Payload(${arg.text}) on ${where}: '${arg.ref.name}' is a class, not a Standard Schema`,
745
+ ['Pass a Standard Schema (Zod, Valibot, ArkType...): export const SendMessage = z.object({ ... })'],
746
+ )
747
+ return { kind: 'undefined', optional: false }
748
+ }
749
+ return { kind: 'validated', schema: arg.ref }
750
+ }
751
+ if (!arg.evaluated) return { kind: 'undefined', optional: false }
752
+ report(
753
+ ctx,
754
+ INVALID_DECORATOR,
755
+ arg.location,
756
+ `@Payload on ${where} expects a Standard Schema (Zod, Valibot, ArkType...), got ${describe(arg.value)}`,
757
+ ['Declare the schema with `export const SendMessage = z.object({ ... })` and pass it: @Payload(SendMessage)'],
758
+ )
759
+ return { kind: 'undefined', optional: false }
760
+ }
761
+
762
+ /** ZNIGHT017: a structured payload injected without schema is trusted blindly. */
763
+ function checkUnvalidated(param: ParameterInfo, decorator: DecoratorInfo, where: string, ctx: Context): void {
764
+ const { type } = ctx.types.stripNullish(param.type)
765
+ if (isSafePayloadType(type)) return
766
+ const typed =
767
+ type.kind === 'opaque' && type.reason === 'missing-annotation'
768
+ ? 'has no type annotation'
769
+ : `is typed '${ctx.types.format(param.type)}'`
770
+ ctx.report({
771
+ code: UNVALIDATED_INPUT as never,
772
+ severity: ctx.options.strictValidation ? 'error' : 'warning',
773
+ message: `Unvalidated message payload: parameter '${param.name}' of ${where} ${typed} but @Payload() has no schema`,
774
+ location: decorator.location,
775
+ hints: [
776
+ 'Validate it with a Standard Schema (Zod, Valibot, ArkType...): @Payload(SendMessage)',
777
+ "Or declare the parameter as 'unknown' (or 'string | Uint8Array') and check it in the handler",
778
+ ],
779
+ })
780
+ }
781
+
782
+ /** Parameter decorators of a method that has no websocket role. */
783
+ function reportStrayParameters(method: MethodInfo, where: string, ctx: Context): void {
784
+ for (const param of method.params) {
785
+ for (const decorator of parameterDecorators(param)) {
786
+ report(
787
+ ctx,
788
+ INVALID_DECORATOR,
789
+ decorator.location,
790
+ `@${decorator.name} has no effect on parameter '${param.name}' of ${where}: it is not a gateway handler`,
791
+ ['Add @OnMessage("event"), @OnConnect() or @OnDisconnect() to the method, or remove the decorator'],
792
+ )
793
+ }
794
+ }
795
+ }
796
+
797
+ interface ForeignDecorator {
798
+ readonly decorator: DecoratorInfo
799
+ readonly module: string
800
+ readonly name: string
801
+ }
802
+
803
+ /** A parameter decorator named like a websocket one (`Ctx`, `Payload`) imported from another package. */
804
+ function foreignParameterDecorator(param: ParameterInfo): ForeignDecorator | undefined {
805
+ for (const decorator of param.decorators) {
806
+ const identity = decorator.identity
807
+ const separator = identity?.lastIndexOf('#') ?? -1
808
+ if (identity === undefined || separator <= 0) continue
809
+ const module = identity.slice(0, separator)
810
+ const name = identity.slice(separator + 1)
811
+ if (module === RUNTIME_MODULE || !module.startsWith('@znight/')) continue
812
+ if (PARAMETER_DECORATORS.has(`${RUNTIME_MODULE}#${name}`)) return { decorator, module, name }
813
+ }
814
+ return undefined
815
+ }
816
+
817
+ /** Websocket decorators of a parameter, de-duplicated by position. */
818
+ function parameterDecorators(param: ParameterInfo): DecoratorInfo[] {
819
+ const seen = new Set<number>()
820
+ return param.decorators.filter(decorator => {
821
+ if (!isParameterDecorator(decorator) || seen.has(decorator.location.start)) return false
822
+ seen.add(decorator.location.start)
823
+ return true
824
+ })
825
+ }
826
+
827
+ function isParameterDecorator(decorator: DecoratorInfo): boolean {
828
+ return decorator.identity !== undefined && PARAMETER_DECORATORS.has(decorator.identity)
829
+ }
830
+
831
+ function isClassReference(ref: ValueRef): boolean {
832
+ const symbol = ref.symbol
833
+ return symbol.kind === 'declaration' && symbol.declaration.kind === 'class'
834
+ }
835
+
836
+ /** `string`, `Uint8Array`, `string | Uint8Array`, literals and `unknown` need no schema. */
837
+ function isSafePayloadType(type: TypeInfo): boolean {
838
+ switch (type.kind) {
839
+ case 'primitive':
840
+ return type.name !== 'any' && type.name !== 'object'
841
+ case 'literal':
842
+ return true
843
+ case 'union':
844
+ return type.types.every(isSafePayloadType)
845
+ case 'reference':
846
+ return BINARY_TYPES.has(type.identity)
847
+ default:
848
+ return false
849
+ }
850
+ }