@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
|
@@ -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
|
+
}
|