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