@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,102 @@
1
+ /**
2
+ * Constants shared by the compiler extension modules. This module stays free of runtime imports: the
3
+ * extension entry is loaded by the compiler, which must never load the WebSocket runtime (spec §2).
4
+ */
5
+ import type { ErrorCode } from '@znight/core'
6
+
7
+ /** Name of the extension, also the key of `metadata.extensions.websocket`. */
8
+ export const EXTENSION_NAME = 'websocket'
9
+
10
+ /** Module the generated code imports its helpers and types from. */
11
+ export const RUNTIME_MODULE = '@znight/websocket'
12
+
13
+ /** Canonical identities of the decorators owned by the extension. */
14
+ export const WsDecorator: {
15
+ readonly Gateway: '@znight/websocket#Gateway'
16
+ readonly OnConnect: '@znight/websocket#OnConnect'
17
+ readonly OnMessage: '@znight/websocket#OnMessage'
18
+ readonly OnDisconnect: '@znight/websocket#OnDisconnect'
19
+ readonly Payload: '@znight/websocket#Payload'
20
+ readonly Conn: '@znight/websocket#Conn'
21
+ readonly Ctx: '@znight/websocket#Ctx'
22
+ readonly UseHandshakeGuard: '@znight/websocket#UseHandshakeGuard'
23
+ readonly UseMessageGuard: '@znight/websocket#UseMessageGuard'
24
+ } = Object.freeze({
25
+ Gateway: '@znight/websocket#Gateway',
26
+ OnConnect: '@znight/websocket#OnConnect',
27
+ OnMessage: '@znight/websocket#OnMessage',
28
+ OnDisconnect: '@znight/websocket#OnDisconnect',
29
+ Payload: '@znight/websocket#Payload',
30
+ Conn: '@znight/websocket#Conn',
31
+ Ctx: '@znight/websocket#Ctx',
32
+ UseHandshakeGuard: '@znight/websocket#UseHandshakeGuard',
33
+ UseMessageGuard: '@znight/websocket#UseMessageGuard',
34
+ })
35
+
36
+ /** Decorators giving a method a role. */
37
+ export const ROLE_DECORATORS: ReadonlySet<string> = new Set([
38
+ WsDecorator.OnConnect,
39
+ WsDecorator.OnMessage,
40
+ WsDecorator.OnDisconnect,
41
+ ])
42
+
43
+ /** Decorators applied to methods (roles and message guards). */
44
+ export const METHOD_DECORATORS: ReadonlySet<string> = new Set([...ROLE_DECORATORS, WsDecorator.UseMessageGuard])
45
+
46
+ /** Decorators applied to handler parameters. */
47
+ export const PARAMETER_DECORATORS: ReadonlySet<string> = new Set([
48
+ WsDecorator.Payload,
49
+ WsDecorator.Conn,
50
+ WsDecorator.Ctx,
51
+ ])
52
+
53
+ /** ZNIGHT005: invalid decorator usage. */
54
+ export const INVALID_DECORATOR: ErrorCode = 'ZNIGHT005'
55
+ /** ZNIGHT007: invalid or conflicting gateway path. */
56
+ export const INVALID_ROUTE: ErrorCode = 'ZNIGHT007'
57
+ /** ZNIGHT017: non-primitive `@Payload()` without schema. */
58
+ export const UNVALIDATED_INPUT: ErrorCode = 'ZNIGHT017'
59
+ /** ZNIGHT023: a gateway without a decision of a required route decorator group (RFC 0001). */
60
+ export const MISSING_ROUTE_DECISION: ErrorCode = 'ZNIGHT023'
61
+ /** ZNIGHT050: invalid gateway. */
62
+ export const INVALID_GATEWAY: ErrorCode = 'ZNIGHT050'
63
+
64
+ /** Event names of the `events` protocol (the runtime `EVENT_NAME`, checked by the tests). */
65
+ export const EVENT_NAME: RegExp = /^[A-Za-z][\w.:/-]{0,127}$/
66
+
67
+ /** RFC 7230 token: subprotocol names. */
68
+ export const SUBPROTOCOL: RegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
69
+
70
+ /** Deterministic string comparison (never `localeCompare`). */
71
+ export function compareStrings(a: string, b: string): number {
72
+ if (a === b) return 0
73
+ return a < b ? -1 : 1
74
+ }
75
+
76
+ const UNITS: Readonly<Record<string, number>> = {
77
+ b: 1,
78
+ k: 1024,
79
+ kb: 1024,
80
+ kib: 1024,
81
+ m: 1024 ** 2,
82
+ mb: 1024 ** 2,
83
+ mib: 1024 ** 2,
84
+ g: 1024 ** 3,
85
+ gb: 1024 ** 3,
86
+ gib: 1024 ** 3,
87
+ }
88
+
89
+ const SIZE = /^(\d+(?:\.\d+)?|\.\d+)\s*([a-z]*)$/i
90
+
91
+ /** A positive byte size (`'64kb'`, `65536`), the grammar of `ByteSize`; `undefined` otherwise. */
92
+ export function parseSize(value: unknown): number | undefined {
93
+ if (typeof value === 'number') return Number.isSafeInteger(value) && value > 0 ? value : undefined
94
+ if (typeof value !== 'string') return undefined
95
+ const match = SIZE.exec(value.trim())
96
+ if (match === null) return undefined
97
+ const unit = (match[2] as string).toLowerCase()
98
+ const multiplier = unit === '' ? 1 : UNITS[unit]
99
+ if (multiplier === undefined) return undefined
100
+ const bytes = Math.floor(Number(match[1]) * multiplier)
101
+ return Number.isSafeInteger(bytes) && bytes > 0 ? bytes : undefined
102
+ }
@@ -0,0 +1,78 @@
1
+ /** What `analyzeClass` stores for `validate` and `generate` (plain, serialisable data). */
2
+ import type { ClassRef, Location, ValueRef } from '@znight/compiler'
3
+ import type { Token } from '@znight/core'
4
+ import type { MetaEntry } from '@znight/http/compiler'
5
+
6
+ /** A parameter of a handler, as the generated `invoke` passes it. */
7
+ export type WsParamData =
8
+ | { readonly kind: 'payload' }
9
+ | { readonly kind: 'validated'; readonly schema: ValueRef }
10
+ | { readonly kind: 'connection' }
11
+ | { readonly kind: 'context' }
12
+ /** An optional parameter without decorator, or a parameter in error (reported). */
13
+ | { readonly kind: 'undefined'; readonly optional: boolean }
14
+
15
+ /** `@OnConnect` or `@OnDisconnect`. */
16
+ export interface LifecycleData {
17
+ readonly method: string
18
+ readonly params: readonly WsParamData[]
19
+ readonly declaredBy: string
20
+ readonly location: Location
21
+ }
22
+
23
+ /** One `@OnMessage` handler (a method decorated several times gives one entry per event). */
24
+ export interface HandlerData {
25
+ readonly method: string
26
+ /** `undefined`: the default handler. */
27
+ readonly event: string | undefined
28
+ /** Route decorators of the method. */
29
+ readonly meta: readonly MetaEntry[]
30
+ /** `@UseMessageGuard` of the method. */
31
+ readonly guards: readonly ClassRef[]
32
+ readonly params: readonly WsParamData[]
33
+ readonly declaredBy: string
34
+ readonly location: Location
35
+ }
36
+
37
+ /** Static options of `@Gateway`. */
38
+ export interface GatewayOptionsData {
39
+ readonly protocol: 'events' | 'raw'
40
+ readonly protocols?: readonly string[]
41
+ readonly maxPayload?: number
42
+ readonly concurrency?: number
43
+ readonly rateLimit?: { readonly messages: number; readonly intervalMs: number; readonly onLimit: 'reject' | 'close' }
44
+ readonly backpressure?: { readonly limit?: number; readonly onLimit?: 'close' | 'drop' }
45
+ }
46
+
47
+ /** A `@Gateway` class. */
48
+ export interface GatewayData {
49
+ readonly kind: 'gateway'
50
+ readonly token: Token
51
+ readonly identity: string
52
+ readonly displayName: string
53
+ /** Normalised path; `undefined` when invalid (reported). */
54
+ readonly path: string | undefined
55
+ readonly options: GatewayOptionsData
56
+ /** Route decorators of the class. */
57
+ readonly meta: readonly MetaEntry[]
58
+ readonly handshakeGuards: readonly ClassRef[]
59
+ readonly messageGuards: readonly ClassRef[]
60
+ readonly onConnect: LifecycleData | undefined
61
+ readonly onDisconnect: LifecycleData | undefined
62
+ readonly handlers: readonly HandlerData[]
63
+ /** Identities of the class and its analysable bases (nearest first). */
64
+ readonly chain: readonly string[]
65
+ readonly location: Location
66
+ }
67
+
68
+ /** A class carrying websocket decorators without `@Gateway` (a base class, or a mistake). */
69
+ export interface MemberClassData {
70
+ readonly kind: 'members'
71
+ readonly identity: string
72
+ readonly displayName: string
73
+ readonly abstract: boolean
74
+ readonly decorators: readonly { readonly name: string; readonly location: Location }[]
75
+ }
76
+
77
+ /** Everything the extension stores. */
78
+ export type WebSocketData = GatewayData | MemberClassData
@@ -0,0 +1,262 @@
1
+ /**
2
+ * `generate` of the websocket extension (RFC 0003 §7): `metadata.extensions.websocket`, gateways
3
+ * sorted by token, handlers in declaration order (bases first), optional fields omitted.
4
+ *
5
+ * ```ts
6
+ * {
7
+ * gateways: [{
8
+ * token: 'src/chat/chat.gateway.ts#ChatGateway',
9
+ * path: '/chat',
10
+ * protocol: 'events',
11
+ * meta: { authenticated: true },
12
+ * handshakeGuards: [],
13
+ * messageGuards: [],
14
+ * onConnect: { method: 'connected', invoke: (g: ChatGateway, c: WsConnection) => g.connected(c as never) },
15
+ * handlers: [{
16
+ * method: 'join', event: 'room.join', meta: {}, guards: [], params: [...],
17
+ * invoke: async (g: ChatGateway, m: WsMessageContext) => g.join(m.connection as never, await validatePayload(JoinRoom, m.data)),
18
+ * }],
19
+ * }],
20
+ * }
21
+ * ```
22
+ */
23
+ import type { ClassRef, GenerateContext, GenInput, GenNode } from '@znight/compiler'
24
+ import type { MetaEntry } from '@znight/http/compiler'
25
+ import type { GatewayData, HandlerData, LifecycleData, WebSocketData, WsParamData } from './data.ts'
26
+
27
+ import { compareStrings, RUNTIME_MODULE } from './constants.ts'
28
+
29
+ type Context = GenerateContext<WebSocketData>
30
+
31
+ /** Imports of the runtime helpers, created on first use (no unused import in the module). */
32
+ interface Helpers {
33
+ connection(): GenNode
34
+ messageContext(): GenNode
35
+ validatePayload(): GenNode
36
+ }
37
+
38
+ /** Builds the websocket section; `undefined` (no section) without gateways. */
39
+ export function generateWebSocket(ctx: Context): GenNode | undefined {
40
+ const gateways = ctx.stored
41
+ .filter((data): data is GatewayData => data.kind === 'gateway')
42
+ .sort((a, b) => compareStrings(a.token, b.token))
43
+ if (gateways.length === 0) return undefined
44
+ const helpers = lazyHelpers(ctx)
45
+ const entries: [string, GenInput][] = [
46
+ ['gateways', ctx.arr(gateways.map(gateway => gatewayNode(ctx, gateway, helpers)))],
47
+ ]
48
+ const groups = groupsNode(ctx, gateways)
49
+ if (groups !== undefined) entries.push(['groups', groups])
50
+ return ctx.obj(entries)
51
+ }
52
+
53
+ function gatewayNode(ctx: Context, gateway: GatewayData, helpers: Helpers): GenNode {
54
+ let classType: GenNode | undefined
55
+ const gatewayType = (): GenNode => {
56
+ classType ??= ctx.importType(gateway.identity)
57
+ return classType
58
+ }
59
+ const options = gateway.options
60
+ const entries: [string, GenInput][] = [
61
+ ['token', gateway.token],
62
+ ['path', gateway.path ?? '/'],
63
+ ['protocol', options.protocol],
64
+ ]
65
+ if (options.protocols !== undefined) entries.push(['protocols', ctx.lit([...options.protocols])])
66
+ if (options.maxPayload !== undefined) entries.push(['maxPayload', options.maxPayload])
67
+ if (options.concurrency !== undefined) entries.push(['concurrency', options.concurrency])
68
+ if (options.rateLimit !== undefined) entries.push(['rateLimit', ctx.lit({ ...options.rateLimit })])
69
+ if (options.backpressure !== undefined) entries.push(['backpressure', ctx.lit({ ...options.backpressure })])
70
+ entries.push(
71
+ ['meta', metaNode(ctx, gateway.meta)],
72
+ ['handshakeGuards', tokens(ctx, gateway.handshakeGuards)],
73
+ ['messageGuards', tokens(ctx, gateway.messageGuards)],
74
+ )
75
+ if (gateway.onConnect !== undefined) {
76
+ entries.push(['onConnect', lifecycleNode(ctx, gateway.onConnect, gatewayType, helpers)])
77
+ }
78
+ if (gateway.onDisconnect !== undefined) {
79
+ entries.push(['onDisconnect', lifecycleNode(ctx, gateway.onDisconnect, gatewayType, helpers)])
80
+ }
81
+ entries.push(['handlers', ctx.arr(gateway.handlers.map(handler => handlerNode(ctx, handler, gatewayType, helpers)))])
82
+ return ctx.obj(entries)
83
+ }
84
+
85
+ function metaNode(ctx: Context, meta: readonly MetaEntry[]): GenNode {
86
+ return ctx.obj(meta.map(entry => [entry.key, ctx.lit(entry.value)] as const))
87
+ }
88
+
89
+ function tokens(ctx: Context, refs: readonly ClassRef[]): GenNode {
90
+ return ctx.arr(refs.map(ref => ctx.tokenOf(ref) ?? ref.token))
91
+ }
92
+
93
+ /** Trailing parameters left `undefined` are not passed. */
94
+ function trimmed(params: readonly WsParamData[]): WsParamData[] {
95
+ const list = [...params]
96
+ while (list.length > 0 && list[list.length - 1]?.kind === 'undefined') list.pop()
97
+ return list
98
+ }
99
+
100
+ /** `{ method, invoke: (g: Gateway, c: WsConnection) => g.method(c as never) }`. */
101
+ function lifecycleNode(ctx: Context, lifecycle: LifecycleData, gatewayType: () => GenNode, helpers: Helpers): GenNode {
102
+ const params = trimmed(lifecycle.params)
103
+ const invoke =
104
+ params.length === 0
105
+ ? ctx.arrow([['g', gatewayType()]], g => ctx.call(ctx.member(g, lifecycle.method)))
106
+ : ctx.arrow(
107
+ [
108
+ ['g', gatewayType()],
109
+ ['c', helpers.connection()],
110
+ ],
111
+ (g, c) =>
112
+ ctx.call(
113
+ ctx.member(g, lifecycle.method),
114
+ params.map(param =>
115
+ param.kind === 'connection' ? ctx.code`${c as GenNode} as never` : ctx.lit(undefined),
116
+ ),
117
+ ),
118
+ )
119
+ return ctx.obj([
120
+ ['method', lifecycle.method],
121
+ ['invoke', invoke],
122
+ ])
123
+ }
124
+
125
+ function handlerNode(ctx: Context, handler: HandlerData, gatewayType: () => GenNode, helpers: Helpers): GenNode {
126
+ const entries: [string, GenInput][] = [['method', handler.method]]
127
+ if (handler.event !== undefined) entries.push(['event', handler.event])
128
+ entries.push(
129
+ ['meta', metaNode(ctx, handler.meta)],
130
+ ['guards', tokens(ctx, handler.guards)],
131
+ ['params', ctx.arr(handler.params.flatMap((param, index) => paramNode(ctx, param, index)))],
132
+ ['invoke', invokeNode(ctx, handler, gatewayType, helpers)],
133
+ )
134
+ return ctx.obj(entries)
135
+ }
136
+
137
+ /** The informational `params` entry of a parameter (none for a parameter left `undefined`). */
138
+ function paramNode(ctx: Context, param: WsParamData, index: number): GenNode[] {
139
+ switch (param.kind) {
140
+ case 'payload':
141
+ return [
142
+ ctx.obj([
143
+ ['index', index],
144
+ ['source', 'payload'],
145
+ ]),
146
+ ]
147
+ case 'validated':
148
+ return [
149
+ ctx.obj([
150
+ ['index', index],
151
+ ['source', 'payload'],
152
+ ['schema', true],
153
+ ]),
154
+ ]
155
+ case 'connection':
156
+ return [
157
+ ctx.obj([
158
+ ['index', index],
159
+ ['source', 'connection'],
160
+ ]),
161
+ ]
162
+ case 'context':
163
+ return [
164
+ ctx.obj([
165
+ ['index', index],
166
+ ['source', 'context'],
167
+ ]),
168
+ ]
169
+ case 'undefined':
170
+ return []
171
+ }
172
+ }
173
+
174
+ /** `(g: Gateway, m: WsMessageContext) => g.method(...args)`, `async` when a payload is validated. */
175
+ function invokeNode(ctx: Context, handler: HandlerData, gatewayType: () => GenNode, helpers: Helpers): GenNode {
176
+ const params = trimmed(handler.params)
177
+ if (params.length === 0) {
178
+ return ctx.arrow([['g', gatewayType()]], g => ctx.call(ctx.member(g, handler.method)))
179
+ }
180
+ return ctx.arrow(
181
+ [
182
+ ['g', gatewayType()],
183
+ ['m', helpers.messageContext()],
184
+ ],
185
+ (g, m) =>
186
+ ctx.call(
187
+ ctx.member(g, handler.method),
188
+ params.map(param => argumentNode(ctx, param, m as GenNode, helpers)),
189
+ ),
190
+ { async: params.some(param => param.kind === 'validated') },
191
+ )
192
+ }
193
+
194
+ function argumentNode(ctx: Context, param: WsParamData, m: GenNode, helpers: Helpers): GenNode {
195
+ switch (param.kind) {
196
+ case 'payload':
197
+ return ctx.code`${ctx.member(m, 'data')} as never`
198
+ case 'validated':
199
+ return ctx.code`await ${ctx.call(helpers.validatePayload(), [ctx.importValue(param.schema), ctx.member(m, 'data')])}`
200
+ case 'connection':
201
+ return ctx.code`${ctx.member(m, 'connection')} as never`
202
+ case 'context':
203
+ return m
204
+ case 'undefined':
205
+ return ctx.lit(undefined)
206
+ }
207
+ }
208
+
209
+ /**
210
+ * `groups`: the route decorator groups the runtime needs (required, holding several keys, or named
211
+ * differently from their key), sorted; `undefined` when there is none (as `extensions.http`).
212
+ */
213
+ function groupsNode(ctx: Context, gateways: readonly GatewayData[]): GenNode | undefined {
214
+ const groups = new Map<string, { readonly keys: Set<string>; required: boolean }>()
215
+ const visit = (entries: readonly MetaEntry[]): void => {
216
+ for (const entry of entries) {
217
+ const group = groups.get(entry.group)
218
+ if (group === undefined) groups.set(entry.group, { keys: new Set([entry.key]), required: entry.required })
219
+ else {
220
+ group.keys.add(entry.key)
221
+ group.required ||= entry.required
222
+ }
223
+ }
224
+ }
225
+ for (const gateway of gateways) {
226
+ visit(gateway.meta)
227
+ for (const handler of gateway.handlers) visit(handler.meta)
228
+ }
229
+ const needed = [...groups]
230
+ .filter(([name, group]) => group.required || group.keys.size > 1 || !group.keys.has(name))
231
+ .sort(([a], [b]) => compareStrings(a, b))
232
+ if (needed.length === 0) return undefined
233
+ return ctx.obj(
234
+ needed.map(
235
+ ([name, group]) =>
236
+ [
237
+ name,
238
+ ctx.obj([
239
+ ['keys', ctx.lit([...group.keys].sort(compareStrings))],
240
+ ['required', group.required],
241
+ ]),
242
+ ] as const,
243
+ ),
244
+ )
245
+ }
246
+
247
+ function lazyHelpers(ctx: Context): Helpers {
248
+ const cache = new Map<string, GenNode>()
249
+ const get = (name: string, type: boolean): GenNode => {
250
+ let node = cache.get(name)
251
+ if (node === undefined) {
252
+ node = ctx.importFrom(RUNTIME_MODULE, name, type ? { type: true } : {})
253
+ cache.set(name, node)
254
+ }
255
+ return node
256
+ }
257
+ return {
258
+ connection: () => get('WsConnection', true),
259
+ messageContext: () => get('WsMessageContext', true),
260
+ validatePayload: () => get('validatePayload', false),
261
+ }
262
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `@znight/websocket/compiler`: the compiler extension of `@znight/websocket` (RFC 0003 §6, §7).
3
+ *
4
+ * Loaded by `@znight/compiler` through the package manifest (`package.json#znight.compiler`). It
5
+ * reads `@Gateway` classes, their `@OnConnect` / `@OnMessage` / `@OnDisconnect` methods, `@Payload`
6
+ * / `@Conn` / `@Ctx` parameters, guards and route decorators, registers gateways as providers,
7
+ * reports invalid gateways at build time and generates `metadata.extensions.websocket`.
8
+ *
9
+ * This entry only `import type`s from `@znight/compiler`, imports `@znight/http/compiler` and
10
+ * `@znight/http/path` (both runtime-free) and never loads the WebSocket runtime.
11
+ */
12
+ import type { CompilerExtension, DecoratorSpec } from '@znight/compiler'
13
+ import type { WebSocketData } from './data.ts'
14
+
15
+ import { analyzeWebSocketClass } from './analyze.ts'
16
+ import { EXTENSION_NAME, WsDecorator } from './constants.ts'
17
+ import { generateWebSocket } from './generate.ts'
18
+ import { validateWebSocket } from './validate.ts'
19
+
20
+ export type {
21
+ GatewayData,
22
+ GatewayOptionsData,
23
+ HandlerData,
24
+ LifecycleData,
25
+ MemberClassData,
26
+ WebSocketData,
27
+ WsParamData,
28
+ } from './data.ts'
29
+
30
+ /** Decorators owned by the extension (static / value arguments, spec §4.6). */
31
+ const decorators: Readonly<Record<string, DecoratorSpec>> = Object.freeze({
32
+ [WsDecorator.Gateway]: { targets: ['class'], params: ['static'] },
33
+ [WsDecorator.OnConnect]: { targets: ['method'], params: [] },
34
+ [WsDecorator.OnDisconnect]: { targets: ['method'], params: [] },
35
+ [WsDecorator.OnMessage]: { targets: ['method'], params: ['static'] },
36
+ [WsDecorator.Payload]: { targets: ['parameter'], params: ['value'] },
37
+ [WsDecorator.Conn]: { targets: ['parameter'], params: [] },
38
+ [WsDecorator.Ctx]: { targets: ['parameter'], params: [] },
39
+ [WsDecorator.UseHandshakeGuard]: { targets: ['class'], params: [], rest: 'value' },
40
+ [WsDecorator.UseMessageGuard]: { targets: ['class', 'method'], params: [], rest: 'value' },
41
+ } satisfies Record<string, DecoratorSpec>)
42
+
43
+ /** The `@znight/websocket` compiler extension. */
44
+ export const webSocketCompilerExtension: CompilerExtension<WebSocketData> = Object.freeze({
45
+ apiVersion: 1,
46
+ name: EXTENSION_NAME,
47
+ decorators,
48
+ analyzeClass: analyzeWebSocketClass,
49
+ validate: validateWebSocket,
50
+ generate: generateWebSocket,
51
+ })
52
+
53
+ export default webSocketCompilerExtension
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `validate` of the websocket extension (RFC 0003 §6.2): what needs every gateway. Duplicate path
3
+ * shapes (ZNIGHT007), decorators on a class that is neither a gateway nor one of its bases
4
+ * (ZNIGHT005), and the required route decorator groups of RFC 0001 on handshakes (ZNIGHT023).
5
+ */
6
+ import type { ValidateContext } from '@znight/compiler'
7
+ import type { MetaEntry } from '@znight/http/compiler'
8
+ import type { GatewayData, WebSocketData } from './data.ts'
9
+
10
+ import { requiredRouteGroups } from '@znight/http/compiler'
11
+ import { parseRoutePath } from '@znight/http/path'
12
+
13
+ import { compareStrings, INVALID_DECORATOR, INVALID_ROUTE, MISSING_ROUTE_DECISION } from './constants.ts'
14
+
15
+ /** Validates the gateways of the application. */
16
+ export function validateWebSocket(ctx: ValidateContext<WebSocketData>): void {
17
+ const gateways = ctx.stored
18
+ .filter((data): data is GatewayData => data.kind === 'gateway')
19
+ .sort((a, b) => compareStrings(a.token, b.token))
20
+
21
+ const byShape = new Map<string, GatewayData>()
22
+ for (const gateway of gateways) {
23
+ if (gateway.path === undefined) continue
24
+ const shape = parseRoutePath(gateway.path).shape
25
+ const first = byShape.get(shape)
26
+ if (first === undefined) {
27
+ byShape.set(shape, gateway)
28
+ continue
29
+ }
30
+ ctx.report({
31
+ code: INVALID_ROUTE,
32
+ severity: 'error',
33
+ message: `${first.displayName} (${first.path}) and ${gateway.displayName} (${gateway.path}) serve the same path`,
34
+ location: gateway.location,
35
+ hints: ['Two gateways cannot match the same handshake: change one of the paths'],
36
+ related: [{ message: `${first.displayName} serves ${first.path}`, location: first.location }],
37
+ })
38
+ }
39
+
40
+ const bases = new Set(gateways.flatMap(gateway => gateway.chain))
41
+ for (const data of ctx.stored) {
42
+ if (data.kind !== 'members' || data.abstract || bases.has(data.identity)) continue
43
+ for (const decorator of data.decorators) {
44
+ ctx.report({
45
+ code: INVALID_DECORATOR,
46
+ severity: 'error',
47
+ message: `@${decorator.name} has no effect: ${data.displayName} is not a @Gateway`,
48
+ location: decorator.location,
49
+ hints: [
50
+ `Add @Gateway('/path') to ${data.displayName}, or extend it from a @Gateway class`,
51
+ 'Base classes holding shared handlers should be abstract',
52
+ ],
53
+ })
54
+ }
55
+ }
56
+
57
+ const applied: MetaEntry[] = []
58
+ for (const gateway of gateways) {
59
+ applied.push(...gateway.meta)
60
+ for (const handler of gateway.handlers) applied.push(...handler.meta)
61
+ }
62
+ const required = requiredRouteGroups(ctx, applied)
63
+ if (required.length === 0) return
64
+ for (const gateway of gateways) {
65
+ const decided = new Set(gateway.meta.map(entry => entry.group))
66
+ for (const group of required) {
67
+ if (decided.has(group.group)) continue
68
+ const definition = group.decorators[0] as { readonly definition?: GatewayData['location'] } | undefined
69
+ ctx.report({
70
+ code: MISSING_ROUTE_DECISION,
71
+ severity: 'error',
72
+ message: `Gateway ${gateway.displayName} (${gateway.path ?? '?'}) has no "${group.group}" decision`,
73
+ location: gateway.location,
74
+ hints: [`Add ${group.suggestion} to ${gateway.displayName}: the handshake is an HTTP request`],
75
+ ...(definition?.definition === undefined
76
+ ? {}
77
+ : { related: [{ message: `The "${group.group}" group is required`, location: definition.definition }] }),
78
+ })
79
+ }
80
+ }
81
+ }