@znight/websocket 0.0.0 → 0.6.1

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