@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,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway semantics of a connection (RFC 0003 §8.4 step 3, §5.8): decoding, `$ping`, handler
|
|
3
|
+
* selection, message guards, `invoke` bounded by the handler timeout, replies and error mapping.
|
|
4
|
+
* The plugin builds one {@link GatewayDispatcher} per gateway and gives it to its connections.
|
|
5
|
+
*/
|
|
6
|
+
import type { Hooks, WebSocketMessageEvent, WebSocketMessageOutcome } from '@znight/core'
|
|
7
|
+
import type { ConnectionDriver, WsCloseInfo, WsConnectionImpl } from './connection.ts'
|
|
8
|
+
import type { WsExceptionMapper, WsGuard, WsMessageContext } from './context.ts'
|
|
9
|
+
import type { MessageId, WireError, WsCodec } from './envelope.ts'
|
|
10
|
+
import type { GatewayDefinition, WsHandlerDefinition } from './metadata.ts'
|
|
11
|
+
|
|
12
|
+
import { HttpException } from '@znight/http'
|
|
13
|
+
|
|
14
|
+
import { isFailure, jsonCodec } from './envelope.ts'
|
|
15
|
+
import { codeOfStatus, WsCloseCode, WsErrorCode, WsException, WsValidationException } from './exceptions.ts'
|
|
16
|
+
|
|
17
|
+
/** A handler with its resolved guards and effective metadata. */
|
|
18
|
+
export interface HandlerPlan {
|
|
19
|
+
readonly definition: WsHandlerDefinition
|
|
20
|
+
/** Plugin, gateway, then method guards. */
|
|
21
|
+
readonly guards: readonly WsGuard[]
|
|
22
|
+
/** Gateway meta merged with the method's (nearest wins). */
|
|
23
|
+
readonly meta: Readonly<Record<string, unknown>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a dispatcher needs from the plugin. */
|
|
27
|
+
export interface DispatcherOptions {
|
|
28
|
+
readonly definition: GatewayDefinition
|
|
29
|
+
/** Gateway path, prefix included. */
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly handlers: readonly HandlerPlan[]
|
|
32
|
+
/** The gateway instance of a connection (singleton, or the request-scoped one of its handshake). */
|
|
33
|
+
readonly instance: (conn: WsConnectionImpl) => object | Promise<object>
|
|
34
|
+
readonly handlerTimeoutMs: number
|
|
35
|
+
readonly exposeErrors: boolean
|
|
36
|
+
/** Logs an unexpected error (handler, guard, lifecycle). */
|
|
37
|
+
readonly logError: (message: string, error: unknown, conn: WsConnectionImpl) => void
|
|
38
|
+
/** Once a connection is fully closed. */
|
|
39
|
+
readonly closed: (conn: WsConnectionImpl, info: WsCloseInfo) => void
|
|
40
|
+
/** The `websocket:message` hook: run when it has wrappers, emitted when it has listeners. */
|
|
41
|
+
readonly hooks?: Hooks
|
|
42
|
+
/** Display name of the gateway (hook events). */
|
|
43
|
+
readonly name?: string
|
|
44
|
+
/** Plugin `exceptionMappers`, in order. */
|
|
45
|
+
readonly mappers?: readonly WsExceptionMapper[]
|
|
46
|
+
/** The codec of `events` gateways. Default: {@link jsonCodec}. */
|
|
47
|
+
readonly codec?: WsCodec
|
|
48
|
+
/**
|
|
49
|
+
* Development aid: called once per handler whose result was ignored (a message without `id`
|
|
50
|
+
* answered by a value). Omitted in production.
|
|
51
|
+
*/
|
|
52
|
+
readonly ignoredResult?: (handler: string, event: string | undefined) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MESSAGE_HOOK = 'websocket:message'
|
|
56
|
+
|
|
57
|
+
/** How a message ended (the `websocket:message` outcome and error). */
|
|
58
|
+
interface Settled {
|
|
59
|
+
readonly outcome: WebSocketMessageOutcome
|
|
60
|
+
readonly error?: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const OK: Settled = Object.freeze({ outcome: 'ok' })
|
|
64
|
+
|
|
65
|
+
class TimeoutError extends Error {}
|
|
66
|
+
|
|
67
|
+
/** Converts what a handler or a guard threw to a wire error. */
|
|
68
|
+
export function toWireError(error: unknown, exposeErrors: boolean): WireError {
|
|
69
|
+
if (error instanceof WsException) {
|
|
70
|
+
return {
|
|
71
|
+
code: error.code,
|
|
72
|
+
message: error.message,
|
|
73
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (error instanceof HttpException) {
|
|
77
|
+
const code = codeOfStatus(error.status)
|
|
78
|
+
const message = code === WsErrorCode.Internal && !exposeErrors ? 'Internal error' : error.message
|
|
79
|
+
return { code, message, ...(error.details === undefined ? {} : { details: error.details }) }
|
|
80
|
+
}
|
|
81
|
+
if (error instanceof TimeoutError) return { code: WsErrorCode.Timeout, message: 'The handler timed out' }
|
|
82
|
+
const message = exposeErrors && error instanceof Error ? error.message : 'Internal error'
|
|
83
|
+
return { code: WsErrorCode.Internal, message }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isForbidden(error: unknown): boolean {
|
|
87
|
+
if (error instanceof WsException)
|
|
88
|
+
return error.code === WsErrorCode.Forbidden || error.code === WsErrorCode.Unauthorized
|
|
89
|
+
return error instanceof HttpException && (error.status === 401 || error.status === 403)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isExpected(error: unknown): boolean {
|
|
93
|
+
if (error instanceof WsException || error instanceof TimeoutError) return true
|
|
94
|
+
return error instanceof HttpException && error.status < 500
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The close code of an `@OnConnect` failure (§5.8). */
|
|
98
|
+
export function connectFailureCode(error: unknown): { readonly code: number; readonly reason: string } {
|
|
99
|
+
if (error instanceof WsValidationException) return { code: 4400, reason: error.message }
|
|
100
|
+
if (error instanceof WsException) return { code: error.close ?? WsCloseCode.PolicyViolation, reason: error.message }
|
|
101
|
+
if (error instanceof HttpException) return { code: 4000 + error.status, reason: error.message }
|
|
102
|
+
return { code: WsCloseCode.InternalError, reason: 'Internal error' }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One gateway's message pipeline, shared by its connections. */
|
|
106
|
+
export class GatewayDispatcher implements ConnectionDriver {
|
|
107
|
+
readonly #options: DispatcherOptions
|
|
108
|
+
readonly #byEvent = new Map<string, HandlerPlan>()
|
|
109
|
+
readonly #fallback: HandlerPlan | undefined
|
|
110
|
+
readonly #codec: WsCodec
|
|
111
|
+
readonly #ignored = new Set<string>()
|
|
112
|
+
|
|
113
|
+
constructor(options: DispatcherOptions) {
|
|
114
|
+
this.#options = options
|
|
115
|
+
this.#codec = options.codec ?? jsonCodec
|
|
116
|
+
let fallback: HandlerPlan | undefined
|
|
117
|
+
for (const plan of options.handlers) {
|
|
118
|
+
if (plan.definition.event === undefined) fallback = plan
|
|
119
|
+
else this.#byEvent.set(plan.definition.event, plan)
|
|
120
|
+
}
|
|
121
|
+
this.#fallback = fallback
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get raw(): boolean {
|
|
125
|
+
return this.#options.definition.protocol === 'raw'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async connect(conn: WsConnectionImpl): Promise<void> {
|
|
129
|
+
const lifecycle = this.#options.definition.onConnect
|
|
130
|
+
if (lifecycle === undefined) return
|
|
131
|
+
const gateway = await this.#options.instance(conn)
|
|
132
|
+
await this.#bounded(Promise.resolve(lifecycle.invoke(gateway, conn)), undefined)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
connectFailure(error: unknown): { readonly code: number; readonly reason: string } {
|
|
136
|
+
return connectFailureCode(error)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async disconnect(conn: WsConnectionImpl): Promise<void> {
|
|
140
|
+
const lifecycle = this.#options.definition.onDisconnect
|
|
141
|
+
if (lifecycle === undefined) return
|
|
142
|
+
try {
|
|
143
|
+
await lifecycle.invoke(await this.#options.instance(conn), conn)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.#options.logError(`@OnDisconnect ${lifecycle.method} failed`, error, conn)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
closed(conn: WsConnectionImpl, info: WsCloseInfo): void {
|
|
150
|
+
this.#options.closed(conn, info)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
rateLimited(conn: WsConnectionImpl, frame: string | Uint8Array, binary: boolean): void {
|
|
154
|
+
if (this.raw) return
|
|
155
|
+
const decoded = this.#codec.decode(frame, binary)
|
|
156
|
+
const id = decoded.id
|
|
157
|
+
const event = isFailure(decoded) ? undefined : decoded.event
|
|
158
|
+
this.#fail(conn, id, event, { code: WsErrorCode.RateLimited, message: 'Too many messages' })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async dispatch(conn: WsConnectionImpl, frame: string | Uint8Array, binary: boolean): Promise<void> {
|
|
162
|
+
const receivedAt = performance.now()
|
|
163
|
+
let event: string | undefined
|
|
164
|
+
let id: MessageId | undefined
|
|
165
|
+
let data: unknown = frame
|
|
166
|
+
let failure: WireError | undefined
|
|
167
|
+
if (!this.raw) {
|
|
168
|
+
const decoded = this.#codec.decode(frame, binary)
|
|
169
|
+
if (isFailure(decoded)) {
|
|
170
|
+
id = decoded.id
|
|
171
|
+
failure = { code: decoded.error, message: decoded.message }
|
|
172
|
+
} else {
|
|
173
|
+
;({ event, id, data } = decoded)
|
|
174
|
+
if (event === '$ping') {
|
|
175
|
+
if (id !== undefined) conn.sendFrame(this.#codec.encode({ kind: 'reply', id, data: { t: Date.now() } }))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const plan =
|
|
181
|
+
failure === undefined
|
|
182
|
+
? ((event === undefined ? undefined : this.#byEvent.get(event)) ?? this.#fallback)
|
|
183
|
+
: undefined
|
|
184
|
+
const abort = new AbortController()
|
|
185
|
+
const onClose = () => abort.abort(conn.signal.reason)
|
|
186
|
+
conn.signal.addEventListener('abort', onClose, { once: true })
|
|
187
|
+
const ctx: WsMessageContext = {
|
|
188
|
+
connection: conn,
|
|
189
|
+
event,
|
|
190
|
+
id,
|
|
191
|
+
data,
|
|
192
|
+
binary,
|
|
193
|
+
bytes: typeof frame === 'string' ? frame.length : frame.byteLength,
|
|
194
|
+
receivedAt,
|
|
195
|
+
signal: abort.signal,
|
|
196
|
+
state: new Map(),
|
|
197
|
+
route: {
|
|
198
|
+
path: this.#options.path,
|
|
199
|
+
gateway: this.#options.definition.token,
|
|
200
|
+
handler: plan?.definition.method,
|
|
201
|
+
event,
|
|
202
|
+
meta: plan?.meta ?? this.#options.definition.meta,
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const hooks = this.#options.hooks
|
|
207
|
+
if (hooks === undefined || (!hooks.hasWrappers(MESSAGE_HOOK) && !hooks.hasListeners(MESSAGE_HOOK))) {
|
|
208
|
+
await this.#handle(plan, conn, ctx, failure, abort)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
const hookEvent: WebSocketMessageEvent = {
|
|
212
|
+
gateway: this.#options.name ?? String(this.#options.definition.token),
|
|
213
|
+
path: this.#options.path,
|
|
214
|
+
event,
|
|
215
|
+
handler: plan?.definition.method,
|
|
216
|
+
connectionId: conn.id,
|
|
217
|
+
requestId: conn.requestId,
|
|
218
|
+
bytes: ctx.bytes,
|
|
219
|
+
context: ctx,
|
|
220
|
+
startTime: receivedAt,
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
await hooks.run(MESSAGE_HOOK, hookEvent, async () => {
|
|
224
|
+
const settled = await this.#handle(plan, conn, ctx, failure, abort)
|
|
225
|
+
hookEvent.outcome = settled.outcome
|
|
226
|
+
if (settled.error !== undefined) hookEvent.error = settled.error
|
|
227
|
+
})
|
|
228
|
+
} catch (error) {
|
|
229
|
+
this.#options.logError('A "websocket:message" hook wrapper failed', error, conn)
|
|
230
|
+
}
|
|
231
|
+
if (hooks.hasListeners(MESSAGE_HOOK)) hooks.emit(MESSAGE_HOOK, hookEvent)
|
|
232
|
+
} finally {
|
|
233
|
+
conn.signal.removeEventListener('abort', onClose)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Guards, handler, reply and error mapping of one message. Never rejects. */
|
|
238
|
+
async #handle(
|
|
239
|
+
plan: HandlerPlan | undefined,
|
|
240
|
+
conn: WsConnectionImpl,
|
|
241
|
+
ctx: WsMessageContext,
|
|
242
|
+
failure: WireError | undefined,
|
|
243
|
+
abort: AbortController,
|
|
244
|
+
): Promise<Settled> {
|
|
245
|
+
if (failure !== undefined) {
|
|
246
|
+
this.#fail(conn, ctx.id, undefined, failure)
|
|
247
|
+
return { outcome: 'error', error: new WsException(failure.code, failure.message) }
|
|
248
|
+
}
|
|
249
|
+
if (plan === undefined) {
|
|
250
|
+
this.#fail(conn, ctx.id, ctx.event, {
|
|
251
|
+
code: WsErrorCode.UnknownEvent,
|
|
252
|
+
message: `Unknown event "${ctx.event}"`,
|
|
253
|
+
})
|
|
254
|
+
return { outcome: 'unhandled' }
|
|
255
|
+
}
|
|
256
|
+
let rejected = false
|
|
257
|
+
try {
|
|
258
|
+
const result = await this.#bounded(
|
|
259
|
+
this.#run(plan, conn, ctx, () => {
|
|
260
|
+
rejected = true
|
|
261
|
+
}),
|
|
262
|
+
abort,
|
|
263
|
+
)
|
|
264
|
+
if (ctx.id !== undefined && conn.readyState === 'open') {
|
|
265
|
+
conn.sendFrame(this.#codec.encode({ kind: 'reply', id: ctx.id, data: result }))
|
|
266
|
+
} else if (ctx.id === undefined && result !== undefined && !this.raw) {
|
|
267
|
+
this.#ignoredResult(plan.definition.method, ctx.event)
|
|
268
|
+
}
|
|
269
|
+
return OK
|
|
270
|
+
} catch (thrown) {
|
|
271
|
+
const error = await this.#map(thrown, ctx, conn)
|
|
272
|
+
if (!isExpected(error)) this.#options.logError(`Handler ${plan.definition.method} failed`, error, conn)
|
|
273
|
+
if (!this.raw) this.#fail(conn, ctx.id, ctx.event, toWireError(error, this.#options.exposeErrors))
|
|
274
|
+
if (error instanceof WsException && error.close !== undefined) conn.close(error.close, error.message)
|
|
275
|
+
let outcome: WebSocketMessageOutcome = 'error'
|
|
276
|
+
if (thrown instanceof TimeoutError) outcome = 'timeout'
|
|
277
|
+
else if (rejected && isForbidden(error)) outcome = 'rejected'
|
|
278
|
+
return { outcome, error: thrown }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#ignoredResult(method: string, event: string | undefined): void {
|
|
283
|
+
const report = this.#options.ignoredResult
|
|
284
|
+
if (report === undefined || this.#ignored.has(method)) return
|
|
285
|
+
this.#ignored.add(method)
|
|
286
|
+
report(method, event)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Runs the plugin exception mappers, in order: the first `WsException` wins. */
|
|
290
|
+
async #map(error: unknown, ctx: WsMessageContext, conn: WsConnectionImpl): Promise<unknown> {
|
|
291
|
+
const mappers = this.#options.mappers
|
|
292
|
+
if (mappers === undefined || mappers.length === 0 || error instanceof TimeoutError) return error
|
|
293
|
+
for (const mapper of mappers) {
|
|
294
|
+
try {
|
|
295
|
+
const mapped = await mapper.map(error, ctx)
|
|
296
|
+
if (mapped !== undefined) return mapped
|
|
297
|
+
} catch (failure) {
|
|
298
|
+
this.#options.logError('A WebSocket exception mapper failed', failure, conn)
|
|
299
|
+
return failure
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return error
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async #run(plan: HandlerPlan, conn: WsConnectionImpl, ctx: WsMessageContext, refused: () => void): Promise<unknown> {
|
|
306
|
+
for (const guard of plan.guards) {
|
|
307
|
+
let allowed: boolean
|
|
308
|
+
try {
|
|
309
|
+
allowed = (await guard.canHandle(ctx)) === true
|
|
310
|
+
} catch (error) {
|
|
311
|
+
refused()
|
|
312
|
+
throw error
|
|
313
|
+
}
|
|
314
|
+
if (!allowed) {
|
|
315
|
+
refused()
|
|
316
|
+
throw new WsException(WsErrorCode.Forbidden, 'Forbidden')
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const gateway = await this.#options.instance(conn)
|
|
320
|
+
return await plan.definition.invoke(gateway, ctx)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Races a task with the handler timeout; aborts `abort` on timeout. */
|
|
324
|
+
#bounded<T>(task: Promise<T>, abort: AbortController | undefined): Promise<T> {
|
|
325
|
+
const ms = this.#options.handlerTimeoutMs
|
|
326
|
+
if (!Number.isFinite(ms)) return task
|
|
327
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
328
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
329
|
+
timer = setTimeout(() => {
|
|
330
|
+
const error = new TimeoutError('The handler timed out')
|
|
331
|
+
abort?.abort(error)
|
|
332
|
+
reject(error)
|
|
333
|
+
}, ms)
|
|
334
|
+
})
|
|
335
|
+
task.catch(() => undefined)
|
|
336
|
+
return Promise.race([task, timeout]).finally(() => clearTimeout(timer))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#fail(conn: WsConnectionImpl, id: MessageId | undefined, event: string | undefined, error: WireError): void {
|
|
340
|
+
if (this.raw || conn.readyState !== 'open') return
|
|
341
|
+
conn.sendFrame(this.#codec.encode({ kind: 'error', id, event, error }))
|
|
342
|
+
}
|
|
343
|
+
}
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** The envelope of `events` gateways (RFC 0003 §5.3): decoding inbound frames, encoding outbound ones. */
|
|
2
|
+
import { WsErrorCode } from './exceptions.ts'
|
|
3
|
+
|
|
4
|
+
/** Event names: `^[A-Za-z][\w.:/-]{0,127}$`; names starting with `$` are reserved. */
|
|
5
|
+
export const EVENT_NAME: RegExp = /^[A-Za-z][\w.:/-]{0,127}$/
|
|
6
|
+
|
|
7
|
+
/** A message id: a string of at most 64 characters or a safe integer, chosen by the client. */
|
|
8
|
+
export type MessageId = string | number
|
|
9
|
+
|
|
10
|
+
/** A decoded inbound message. */
|
|
11
|
+
export interface InboundMessage {
|
|
12
|
+
readonly event: string
|
|
13
|
+
readonly data: unknown
|
|
14
|
+
/** Present when the client expects exactly one reply. */
|
|
15
|
+
readonly id: MessageId | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A frame that could not be decoded: answered with `$error` (or a reply when the id was readable). */
|
|
19
|
+
export interface InboundFailure {
|
|
20
|
+
readonly error: string
|
|
21
|
+
readonly message: string
|
|
22
|
+
readonly id: MessageId | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The wire error of a reply or of an `$error` event. */
|
|
26
|
+
export interface WireError {
|
|
27
|
+
readonly code: string
|
|
28
|
+
readonly message: string
|
|
29
|
+
readonly details?: unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether `value` is a valid message id. */
|
|
33
|
+
export function isMessageId(value: unknown): value is MessageId {
|
|
34
|
+
return (typeof value === 'string' && value.length <= 64) || Number.isSafeInteger(value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Decodes an inbound frame of an `events` gateway with the JSON codec: a binary frame is
|
|
39
|
+
* `UNSUPPORTED_FRAME`, anything but a JSON object with a string `event` is `BAD_FRAME`. Unknown
|
|
40
|
+
* fields are ignored.
|
|
41
|
+
*/
|
|
42
|
+
export function decodeFrame(frame: string | Uint8Array): InboundMessage | InboundFailure {
|
|
43
|
+
if (typeof frame !== 'string') {
|
|
44
|
+
return { error: WsErrorCode.UnsupportedFrame, message: 'Binary frames are not supported', id: undefined }
|
|
45
|
+
}
|
|
46
|
+
let value: unknown
|
|
47
|
+
try {
|
|
48
|
+
value = JSON.parse(frame)
|
|
49
|
+
} catch {
|
|
50
|
+
return { error: WsErrorCode.BadFrame, message: 'The frame is not valid JSON', id: undefined }
|
|
51
|
+
}
|
|
52
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
53
|
+
return { error: WsErrorCode.BadFrame, message: 'The frame must be a JSON object', id: undefined }
|
|
54
|
+
}
|
|
55
|
+
const record = value as Record<string, unknown>
|
|
56
|
+
const rawId = record['id']
|
|
57
|
+
const id = isMessageId(rawId) ? rawId : undefined
|
|
58
|
+
if (rawId !== undefined && id === undefined) {
|
|
59
|
+
return { error: WsErrorCode.BadFrame, message: 'Invalid message id', id: undefined }
|
|
60
|
+
}
|
|
61
|
+
const event = record['event']
|
|
62
|
+
if (typeof event !== 'string') return { error: WsErrorCode.BadFrame, message: 'Missing event name', id }
|
|
63
|
+
return { event, data: record['data'], id }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether a decoded frame is a failure. */
|
|
67
|
+
export function isFailure(decoded: InboundMessage | InboundFailure): decoded is InboundFailure {
|
|
68
|
+
return 'error' in decoded
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A success reply: `{ id, data }`, `data` omitted when `undefined`. */
|
|
72
|
+
export function encodeReply(id: MessageId, data: unknown): string {
|
|
73
|
+
return data === undefined ? JSON.stringify({ id }) : JSON.stringify({ id, data })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A failure reply: `{ id, error }`. */
|
|
77
|
+
export function encodeErrorReply(id: MessageId, error: WireError): string {
|
|
78
|
+
return JSON.stringify({ id, error: wireError(error) })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A server event: `{ event, data }`. */
|
|
82
|
+
export function encodeEvent(event: string, data: unknown): string {
|
|
83
|
+
return data === undefined ? JSON.stringify({ event }) : JSON.stringify({ event, data })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The failure of a message without id: `{ event: '$error', data: { event, code, message, details? } }`. */
|
|
87
|
+
export function encodeErrorEvent(event: string | undefined, error: WireError): string {
|
|
88
|
+
return JSON.stringify({ event: '$error', data: { ...(event === undefined ? {} : { event }), ...wireError(error) } })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function wireError(error: WireError): WireError {
|
|
92
|
+
return error.details === undefined
|
|
93
|
+
? { code: error.code, message: error.message }
|
|
94
|
+
: { code: error.code, message: error.message, details: error.details }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** An outbound frame of an `events` gateway, before encoding. */
|
|
98
|
+
export type OutboundMessage =
|
|
99
|
+
| { readonly kind: 'event'; readonly event: string; readonly data: unknown }
|
|
100
|
+
| { readonly kind: 'reply'; readonly id: MessageId; readonly data: unknown }
|
|
101
|
+
/** A failure: a reply when `id` is set, an `$error` event otherwise (`event`: the failed message's). */
|
|
102
|
+
| {
|
|
103
|
+
readonly kind: 'error'
|
|
104
|
+
readonly id: MessageId | undefined
|
|
105
|
+
readonly event: string | undefined
|
|
106
|
+
readonly error: WireError
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The codec of `events` gateways (`WebSocketPlugin` option `codec`): how frames are decoded into
|
|
111
|
+
* `{ event, data, id }` and how outbound messages are encoded. The default is {@link jsonCodec};
|
|
112
|
+
* a custom codec may carry MessagePack over binary frames, for instance.
|
|
113
|
+
*/
|
|
114
|
+
export interface WsCodec {
|
|
115
|
+
/** Decodes an inbound frame; a failure is answered with its code (`BAD_FRAME`...). */
|
|
116
|
+
decode(frame: string | Uint8Array, binary: boolean): InboundMessage | InboundFailure
|
|
117
|
+
/** Encodes an outbound message: a string is a text frame, bytes a binary frame. */
|
|
118
|
+
encode(message: OutboundMessage): string | Uint8Array
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The default codec: UTF-8 JSON text frames (RFC 0003 §5.3); binary frames are `UNSUPPORTED_FRAME`. */
|
|
122
|
+
export const jsonCodec: WsCodec = Object.freeze({
|
|
123
|
+
decode: (frame: string | Uint8Array): InboundMessage | InboundFailure => decodeFrame(frame),
|
|
124
|
+
encode(message: OutboundMessage): string {
|
|
125
|
+
switch (message.kind) {
|
|
126
|
+
case 'event':
|
|
127
|
+
return encodeEvent(message.event, message.data)
|
|
128
|
+
case 'reply':
|
|
129
|
+
return encodeReply(message.id, message.data)
|
|
130
|
+
case 'error':
|
|
131
|
+
return message.id === undefined
|
|
132
|
+
? encodeErrorEvent(message.event, message.error)
|
|
133
|
+
: encodeErrorReply(message.id, message.error)
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Exceptions of WebSocket handlers and their wire codes (RFC 0003 §5.3, §5.8). */
|
|
2
|
+
|
|
3
|
+
/** Built-in error codes of the wire protocol. */
|
|
4
|
+
export const WsErrorCode = {
|
|
5
|
+
BadFrame: 'BAD_FRAME',
|
|
6
|
+
UnsupportedFrame: 'UNSUPPORTED_FRAME',
|
|
7
|
+
BadRequest: 'BAD_REQUEST',
|
|
8
|
+
Unauthorized: 'UNAUTHORIZED',
|
|
9
|
+
Forbidden: 'FORBIDDEN',
|
|
10
|
+
NotFound: 'NOT_FOUND',
|
|
11
|
+
Conflict: 'CONFLICT',
|
|
12
|
+
Unprocessable: 'UNPROCESSABLE',
|
|
13
|
+
RateLimited: 'RATE_LIMITED',
|
|
14
|
+
UnknownEvent: 'UNKNOWN_EVENT',
|
|
15
|
+
Timeout: 'TIMEOUT',
|
|
16
|
+
Internal: 'INTERNAL',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
/** Close codes used by the runtime (RFC 0003 §5.8, normative). */
|
|
20
|
+
export const WsCloseCode = {
|
|
21
|
+
Normal: 1000,
|
|
22
|
+
GoingAway: 1001,
|
|
23
|
+
PolicyViolation: 1008,
|
|
24
|
+
MessageTooBig: 1009,
|
|
25
|
+
InternalError: 1011,
|
|
26
|
+
TryAgainLater: 1013,
|
|
27
|
+
} as const
|
|
28
|
+
|
|
29
|
+
/** Options of {@link WsException}. */
|
|
30
|
+
export interface WsExceptionOptions {
|
|
31
|
+
/** Close the connection with this code after replying. */
|
|
32
|
+
readonly close?: number
|
|
33
|
+
readonly cause?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* An error a handler or a message guard throws to answer the client: a reply with an `id`, an
|
|
38
|
+
* `$error` event otherwise, then a close when `close` is set.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* throw new WsException('FORBIDDEN', 'Join the room first')
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export class WsException extends Error {
|
|
45
|
+
readonly code: string
|
|
46
|
+
readonly details: unknown
|
|
47
|
+
readonly close: number | undefined
|
|
48
|
+
|
|
49
|
+
constructor(code: string, message?: string, details?: unknown, options: WsExceptionOptions = {}) {
|
|
50
|
+
super(message ?? code, options.cause === undefined ? undefined : { cause: options.cause })
|
|
51
|
+
this.name = 'WsException'
|
|
52
|
+
this.code = code
|
|
53
|
+
this.details = details
|
|
54
|
+
this.close = options.close
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One issue of a {@link WsValidationException}. */
|
|
59
|
+
export interface WsValidationIssue {
|
|
60
|
+
readonly path: string
|
|
61
|
+
readonly message: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A payload failed its schema: `BAD_REQUEST` with `details: [{ path, message }]`. */
|
|
65
|
+
export class WsValidationException extends WsException {
|
|
66
|
+
readonly issues: readonly WsValidationIssue[]
|
|
67
|
+
|
|
68
|
+
constructor(issues: readonly WsValidationIssue[], message = 'Invalid payload') {
|
|
69
|
+
super(WsErrorCode.BadRequest, message, issues)
|
|
70
|
+
this.name = 'WsValidationException'
|
|
71
|
+
this.issues = issues
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const BY_STATUS: Readonly<Record<number, string>> = {
|
|
76
|
+
400: WsErrorCode.BadRequest,
|
|
77
|
+
401: WsErrorCode.Unauthorized,
|
|
78
|
+
403: WsErrorCode.Forbidden,
|
|
79
|
+
404: WsErrorCode.NotFound,
|
|
80
|
+
409: WsErrorCode.Conflict,
|
|
81
|
+
422: WsErrorCode.Unprocessable,
|
|
82
|
+
429: WsErrorCode.RateLimited,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The wire code of an HTTP status thrown by a service (`HttpException`): the table of RFC 0003
|
|
87
|
+
* §5.8, `INTERNAL` for 5xx and unknown statuses.
|
|
88
|
+
*/
|
|
89
|
+
export function codeOfStatus(status: number): string {
|
|
90
|
+
return BY_STATUS[status] ?? (status >= 400 && status < 500 ? WsErrorCode.BadRequest : WsErrorCode.Internal)
|
|
91
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@znight/websocket` (RFC 0003): WebSocket gateways served by the
|
|
3
|
+
* `HttpPlugin` server. This entry holds the marker decorators, `WebSocketPlugin`, `WsServer`, the
|
|
4
|
+
* exceptions, the envelope of the wire protocol and the helpers of the generated code.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type { WsConnectOptions, WsWaitOptions } from './client.ts'
|
|
8
|
+
export type { WsExceptionMapper, WsGuard, WsMessageContext } from './context.ts'
|
|
9
|
+
export type {
|
|
10
|
+
GatewayDefinition,
|
|
11
|
+
WebSocketMetadata,
|
|
12
|
+
WsHandlerDefinition,
|
|
13
|
+
WsLifecycleDefinition,
|
|
14
|
+
WsParamDefinition,
|
|
15
|
+
} from './metadata.ts'
|
|
16
|
+
export type {
|
|
17
|
+
BackpressureOptions,
|
|
18
|
+
GatewayOptions,
|
|
19
|
+
RateLimitOptions,
|
|
20
|
+
WebSocketPluginOptions,
|
|
21
|
+
WsOrigins,
|
|
22
|
+
WsProviderRef,
|
|
23
|
+
} from './options.ts'
|
|
24
|
+
export type {
|
|
25
|
+
AcceptInit,
|
|
26
|
+
SendStatus,
|
|
27
|
+
SocketEvents,
|
|
28
|
+
TransportSettings,
|
|
29
|
+
TransportSocket,
|
|
30
|
+
WebSocketTransport,
|
|
31
|
+
} from './transport.ts'
|
|
32
|
+
|
|
33
|
+
export { WsClient, WsHandshakeError, WsRequestError } from './client.ts'
|
|
34
|
+
export {
|
|
35
|
+
WsClosedError,
|
|
36
|
+
type WsCloseInfo,
|
|
37
|
+
type WsCloseInitiator,
|
|
38
|
+
type WsConnection,
|
|
39
|
+
type WsSendResult,
|
|
40
|
+
type WsStats,
|
|
41
|
+
} from './connection.ts'
|
|
42
|
+
export {
|
|
43
|
+
Conn,
|
|
44
|
+
Ctx,
|
|
45
|
+
Gateway,
|
|
46
|
+
type GatewayClassMarker,
|
|
47
|
+
type GatewayClassOrMethodMarker,
|
|
48
|
+
type GatewayMethodMarker,
|
|
49
|
+
type GatewayParameterMarker,
|
|
50
|
+
OnConnect,
|
|
51
|
+
OnDisconnect,
|
|
52
|
+
OnMessage,
|
|
53
|
+
Payload,
|
|
54
|
+
UseHandshakeGuard,
|
|
55
|
+
UseMessageGuard,
|
|
56
|
+
} from './decorators.ts'
|
|
57
|
+
export {
|
|
58
|
+
decodeFrame,
|
|
59
|
+
EVENT_NAME,
|
|
60
|
+
encodeErrorEvent,
|
|
61
|
+
encodeErrorReply,
|
|
62
|
+
encodeEvent,
|
|
63
|
+
encodeReply,
|
|
64
|
+
type InboundFailure,
|
|
65
|
+
type InboundMessage,
|
|
66
|
+
isFailure,
|
|
67
|
+
isMessageId,
|
|
68
|
+
jsonCodec,
|
|
69
|
+
type MessageId,
|
|
70
|
+
type OutboundMessage,
|
|
71
|
+
type WireError,
|
|
72
|
+
type WsCodec,
|
|
73
|
+
} from './envelope.ts'
|
|
74
|
+
export {
|
|
75
|
+
codeOfStatus,
|
|
76
|
+
WsCloseCode,
|
|
77
|
+
WsErrorCode,
|
|
78
|
+
WsException,
|
|
79
|
+
type WsExceptionOptions,
|
|
80
|
+
WsValidationException,
|
|
81
|
+
type WsValidationIssue,
|
|
82
|
+
} from './exceptions.ts'
|
|
83
|
+
export { WebSocketPlugin, type WsGatewaySummary } from './plugin.ts'
|
|
84
|
+
export { TokenBucket } from './rate-limit.ts'
|
|
85
|
+
export { type Broadcast, type ChannelMember, type MemberRef, RoomRegistry } from './rooms.ts'
|
|
86
|
+
export { type GatewayChannel, type GatewayRef, type ServedGateway, WsServer } from './server.ts'
|
|
87
|
+
export { WsTokens } from './tokens.ts'
|
|
88
|
+
export { MemoryClient, type MemoryCloseInfo, memoryTransport } from './transports/memory.ts'
|
|
89
|
+
export { validatePayload } from './validate.ts'
|