@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,27 @@
1
+ /** The token bucket of `@Gateway({ rateLimit })` (RFC 0003 §8.6), one per connection. */
2
+ export class TokenBucket {
3
+ readonly #capacity: number
4
+ readonly #perMs: number
5
+ #tokens: number
6
+ #updatedAt: number
7
+
8
+ /** `messages` per `intervalMs`, refilled continuously; full at first. */
9
+ constructor(messages: number, intervalMs: number, now: number = performance.now()) {
10
+ this.#capacity = messages
11
+ this.#perMs = messages / intervalMs
12
+ this.#tokens = messages
13
+ this.#updatedAt = now
14
+ }
15
+
16
+ /** Takes one token: `false` when the bucket is empty. */
17
+ take(now: number = performance.now()): boolean {
18
+ const elapsed = now - this.#updatedAt
19
+ if (elapsed > 0) {
20
+ this.#tokens = Math.min(this.#capacity, this.#tokens + elapsed * this.#perMs)
21
+ this.#updatedAt = now
22
+ }
23
+ if (this.#tokens < 1) return false
24
+ this.#tokens -= 1
25
+ return true
26
+ }
27
+ }
package/src/rooms.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Rooms and broadcasts of one gateway (RFC 0003 §5.4): rooms are strings scoped per gateway,
3
+ * created on first join and deleted when empty; a broadcast encodes its frame once, then sends it
4
+ * to each selected member, which applies its own backpressure policy.
5
+ */
6
+ import type { WsCodec } from './envelope.ts'
7
+
8
+ import { jsonCodec } from './envelope.ts'
9
+
10
+ /** What a room holds: a connection, seen through the operations rooms need. */
11
+ export interface ChannelMember {
12
+ readonly id: string
13
+ /** The rooms of the member, kept in sync by the channel. */
14
+ readonly roomSet: Set<string>
15
+ /** Sends an already encoded frame (`string`: text, `Uint8Array`: binary). */
16
+ sendFrame(frame: string | Uint8Array): unknown
17
+ close(code?: number, reason?: string): void
18
+ }
19
+
20
+ /** A target of `except()`: a member or its id. */
21
+ export type MemberRef<M extends ChannelMember = ChannelMember> = M | string
22
+
23
+ /** A selection of members of a gateway (RFC 0003 §5.4). */
24
+ export interface Broadcast<E extends object = Record<string, unknown>, M extends ChannelMember = ChannelMember> {
25
+ to(room: string | readonly string[]): Broadcast<E, M>
26
+ except(target: MemberRef<M> | readonly MemberRef<M>[]): Broadcast<E, M>
27
+ exceptRoom(room: string | readonly string[]): Broadcast<E, M>
28
+ emit<K extends keyof E & string>(event: K, data: E[K]): void
29
+ send(frame: string | Uint8Array): void
30
+ join(room: string): void
31
+ leave(room: string): void
32
+ close(code?: number, reason?: string): void
33
+ }
34
+
35
+ interface Selection {
36
+ /** `undefined`: every member of the gateway. */
37
+ readonly rooms: readonly string[] | undefined
38
+ readonly except: ReadonlySet<string>
39
+ readonly exceptRooms: readonly string[]
40
+ }
41
+
42
+ function list(value: string | readonly string[]): readonly string[] {
43
+ return typeof value === 'string' ? [value] : value
44
+ }
45
+
46
+ /** The members and rooms of one gateway. */
47
+ export class RoomRegistry<M extends ChannelMember = ChannelMember> {
48
+ readonly #members = new Map<string, M>()
49
+ readonly #rooms = new Map<string, Set<M>>()
50
+ /** Encodes the events of broadcasts (once per broadcast). */
51
+ readonly codec: WsCodec
52
+
53
+ constructor(codec: WsCodec = jsonCodec) {
54
+ this.codec = codec
55
+ }
56
+
57
+ /** Open members. */
58
+ get size(): number {
59
+ return this.#members.size
60
+ }
61
+
62
+ add(member: M): void {
63
+ this.#members.set(member.id, member)
64
+ }
65
+
66
+ /** Removes the member and leaves its rooms. */
67
+ remove(member: M): void {
68
+ for (const room of [...member.roomSet]) this.leave(member, room)
69
+ this.#members.delete(member.id)
70
+ }
71
+
72
+ get(id: string): M | undefined {
73
+ return this.#members.get(id)
74
+ }
75
+
76
+ members(): IterableIterator<M> {
77
+ return this.#members.values()
78
+ }
79
+
80
+ join(member: M, room: string): void {
81
+ if (!this.#members.has(member.id)) return
82
+ let set = this.#rooms.get(room)
83
+ if (set === undefined) {
84
+ set = new Set()
85
+ this.#rooms.set(room, set)
86
+ }
87
+ set.add(member)
88
+ member.roomSet.add(room)
89
+ }
90
+
91
+ leave(member: M, room: string): void {
92
+ const set = this.#rooms.get(room)
93
+ member.roomSet.delete(room)
94
+ if (set === undefined) return
95
+ set.delete(member)
96
+ if (set.size === 0) this.#rooms.delete(room)
97
+ }
98
+
99
+ /** The members of `room` (a read-only view). */
100
+ room(room: string): ReadonlySet<M> {
101
+ return this.#rooms.get(room) ?? new Set()
102
+ }
103
+
104
+ /** Room → size. */
105
+ rooms(): ReadonlyMap<string, number> {
106
+ const sizes = new Map<string, number>()
107
+ for (const [name, set] of this.#rooms) sizes.set(name, set.size)
108
+ return sizes
109
+ }
110
+
111
+ /** A broadcast over every member, or over the members of `rooms`. */
112
+ select<E extends object>(rooms?: string | readonly string[], except: readonly string[] = []): Broadcast<E, M> {
113
+ return new BroadcastImpl<E, M>(this, {
114
+ rooms: rooms === undefined ? undefined : list(rooms),
115
+ except: new Set(except),
116
+ exceptRooms: [],
117
+ })
118
+ }
119
+
120
+ /** @internal The members a selection designates, each once. */
121
+ resolve(selection: Selection): M[] {
122
+ const excluded = new Set(selection.except)
123
+ for (const room of selection.exceptRooms) for (const member of this.room(room)) excluded.add(member.id)
124
+ const result: M[] = []
125
+ const seen = new Set<string>()
126
+ const sources = selection.rooms === undefined ? [this.#members.values()] : selection.rooms.map(r => this.room(r))
127
+ for (const source of sources) {
128
+ for (const member of source) {
129
+ if (seen.has(member.id) || excluded.has(member.id)) continue
130
+ seen.add(member.id)
131
+ result.push(member)
132
+ }
133
+ }
134
+ return result
135
+ }
136
+ }
137
+
138
+ class BroadcastImpl<E extends object, M extends ChannelMember> implements Broadcast<E, M> {
139
+ readonly #registry: RoomRegistry<M>
140
+ readonly #selection: Selection
141
+
142
+ constructor(registry: RoomRegistry<M>, selection: Selection) {
143
+ this.#registry = registry
144
+ this.#selection = selection
145
+ }
146
+
147
+ to(room: string | readonly string[]): Broadcast<E, M> {
148
+ const rooms = [...(this.#selection.rooms ?? []), ...list(room)]
149
+ return new BroadcastImpl(this.#registry, { ...this.#selection, rooms })
150
+ }
151
+
152
+ except(target: MemberRef<M> | readonly MemberRef<M>[]): Broadcast<E, M> {
153
+ const refs: readonly MemberRef<M>[] = Array.isArray(target) ? target : [target as MemberRef<M>]
154
+ const except = new Set(this.#selection.except)
155
+ for (const ref of refs) except.add(typeof ref === 'string' ? ref : ref.id)
156
+ return new BroadcastImpl(this.#registry, { ...this.#selection, except })
157
+ }
158
+
159
+ exceptRoom(room: string | readonly string[]): Broadcast<E, M> {
160
+ return new BroadcastImpl(this.#registry, {
161
+ ...this.#selection,
162
+ exceptRooms: [...this.#selection.exceptRooms, ...list(room)],
163
+ })
164
+ }
165
+
166
+ emit<K extends keyof E & string>(event: K, data: E[K]): void {
167
+ this.send(this.#registry.codec.encode({ kind: 'event', event, data }))
168
+ }
169
+
170
+ send(frame: string | Uint8Array): void {
171
+ for (const member of this.#registry.resolve(this.#selection)) member.sendFrame(frame)
172
+ }
173
+
174
+ join(room: string): void {
175
+ for (const member of this.#registry.resolve(this.#selection)) this.#registry.join(member, room)
176
+ }
177
+
178
+ leave(room: string): void {
179
+ for (const member of this.#registry.resolve(this.#selection)) this.#registry.leave(member, room)
180
+ }
181
+
182
+ close(code?: number, reason?: string): void {
183
+ for (const member of this.#registry.resolve(this.#selection)) member.close(code, reason)
184
+ }
185
+ }
package/src/server.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `WsServer` and `GatewayChannel` (RFC 0003 §5.4): the injectable view of the gateways, their
3
+ * connections and rooms. `of()` is lazy: it never throws, a handle of an unknown gateway throws a
4
+ * `TypeError` when used.
5
+ */
6
+ import type { WsConnection, WsConnectionImpl } from './connection.ts'
7
+ import type { Broadcast, MemberRef, RoomRegistry } from './rooms.ts'
8
+
9
+ /** A gateway class or its declared path (`'/chat'`, without the HTTP prefix). */
10
+ export type GatewayRef = (abstract new (...args: never[]) => object) | string
11
+
12
+ /** The connections and rooms of one gateway (this process). */
13
+ export interface GatewayChannel<E extends object = Record<string, unknown>> {
14
+ /** The gateway path, prefix included. */
15
+ readonly path: string
16
+ readonly size: number
17
+ connection(id: string): WsConnection | undefined
18
+ connections(): IterableIterator<WsConnection>
19
+ members(room: string): ReadonlySet<WsConnection>
20
+ rooms(): ReadonlyMap<string, number>
21
+ to(room: string | readonly string[]): Broadcast<E>
22
+ except(target: MemberRef<WsConnectionImpl> | readonly MemberRef<WsConnectionImpl>[]): Broadcast<E>
23
+ emit<K extends keyof E & string>(event: K, data: E[K]): void
24
+ send(frame: string | Uint8Array): void
25
+ }
26
+
27
+ /** One served gateway, as registered by the plugin. */
28
+ export interface ServedGateway {
29
+ /** The gateway class. */
30
+ readonly type: abstract new (
31
+ ...args: never[]
32
+ ) => object
33
+ /** The declared path, without the prefix. */
34
+ readonly declaredPath: string
35
+ /** The path, prefix included. */
36
+ readonly path: string
37
+ readonly rooms: RoomRegistry<WsConnectionImpl>
38
+ }
39
+
40
+ /** The gateways of the application: injectable singleton provided by `WebSocketPlugin`. */
41
+ export class WsServer {
42
+ readonly #byType = new Map<unknown, ServedGateway>()
43
+ readonly #byPath = new Map<string, ServedGateway>()
44
+
45
+ /** @internal Called by the plugin at bootstrap. */
46
+ register(gateway: ServedGateway): void {
47
+ this.#byType.set(gateway.type, gateway)
48
+ this.#byPath.set(gateway.declaredPath, gateway)
49
+ }
50
+
51
+ /** @internal Called by the plugin when it serves a new application. */
52
+ reset(): void {
53
+ this.#byType.clear()
54
+ this.#byPath.clear()
55
+ }
56
+
57
+ /** Open connections of every gateway of this process. */
58
+ get connections(): number {
59
+ let total = 0
60
+ for (const gateway of this.#byType.values()) total += gateway.rooms.size
61
+ return total
62
+ }
63
+
64
+ /** A lazy handle on a gateway: its class or its declared path. */
65
+ of<E extends object = Record<string, unknown>>(gateway: GatewayRef): GatewayChannel<E> {
66
+ return new Channel<E>(() => {
67
+ const served = typeof gateway === 'string' ? this.#byPath.get(gateway) : this.#byType.get(gateway)
68
+ if (served === undefined) {
69
+ const name = typeof gateway === 'string' ? `"${gateway}"` : gateway.name
70
+ throw new TypeError(
71
+ `WsServer.of(${name}): no gateway is served for it (or WebSocketPlugin is not bootstrapped)`,
72
+ )
73
+ }
74
+ return served
75
+ })
76
+ }
77
+ }
78
+
79
+ class Channel<E extends object> implements GatewayChannel<E> {
80
+ readonly #resolve: () => ServedGateway
81
+
82
+ constructor(resolve: () => ServedGateway) {
83
+ this.#resolve = resolve
84
+ }
85
+
86
+ get path(): string {
87
+ return this.#resolve().path
88
+ }
89
+
90
+ get size(): number {
91
+ return this.#resolve().rooms.size
92
+ }
93
+
94
+ connection(id: string): WsConnection | undefined {
95
+ return this.#resolve().rooms.get(id)
96
+ }
97
+
98
+ connections(): IterableIterator<WsConnection> {
99
+ return this.#resolve().rooms.members()
100
+ }
101
+
102
+ members(room: string): ReadonlySet<WsConnection> {
103
+ return this.#resolve().rooms.room(room)
104
+ }
105
+
106
+ rooms(): ReadonlyMap<string, number> {
107
+ return this.#resolve().rooms.rooms()
108
+ }
109
+
110
+ to(room: string | readonly string[]): Broadcast<E> {
111
+ return this.#resolve().rooms.select<E>(room)
112
+ }
113
+
114
+ except(target: MemberRef<WsConnectionImpl> | readonly MemberRef<WsConnectionImpl>[]): Broadcast<E> {
115
+ return this.#resolve().rooms.select<E>().except(target)
116
+ }
117
+
118
+ emit<K extends keyof E & string>(event: K, data: E[K]): void {
119
+ this.#resolve().rooms.select<E>().emit(event, data)
120
+ }
121
+
122
+ send(frame: string | Uint8Array): void {
123
+ this.#resolve().rooms.select<E>().send(frame)
124
+ }
125
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `@znight/websocket/testing` (RFC 0003 §5.10): fakes for unit tests of gateways and services.
3
+ * `createTestConnection()` is a `WsConnection` recording what the code under test sends to it;
4
+ * `createTestServer()` is a `WsServer` recording broadcasts instead of delivering them.
5
+ *
6
+ * ```ts
7
+ * const server = createTestServer({ paths: [[ChatGateway, '/chat']] })
8
+ * const { instance } = await TestBed.create(ChatGateway, { metadata, overrides: [[WsServer, server]] })
9
+ * const conn = createTestConnection<ChatData, ChatEvents>({ data: { user: ALICE }, rooms: ['general'] })
10
+ * await instance.send(conn, { room: 'general', text: 'hi' })
11
+ * expect(server.broadcasts).toEqual([{ gateway: '/chat', rooms: ['general'], except: [], event: 'message.new', data: MESSAGE }])
12
+ * ```
13
+ */
14
+ import type { HttpContext } from '@znight/http'
15
+ import type { WsCloseInfo, WsConnection, WsSendResult } from '../connection.ts'
16
+ import type { Broadcast } from '../rooms.ts'
17
+ import type { GatewayChannel, GatewayRef } from '../server.ts'
18
+
19
+ import { WsServer } from '../server.ts'
20
+
21
+ /** One recorded broadcast (or a `close`, `join` or `leave` of a selection). */
22
+ export interface RecordedBroadcast {
23
+ /** The gateway path, or the class name when no path was given for it. */
24
+ readonly gateway: string
25
+ /** Selected rooms, `[]` for every connection of the gateway. */
26
+ readonly rooms: readonly string[]
27
+ /** Excluded connection ids. */
28
+ readonly except: readonly string[]
29
+ /** Excluded rooms (omitted when none). */
30
+ readonly exceptRooms?: readonly string[]
31
+ /** The emitted event, `undefined` for `send()` and the selection operations. */
32
+ readonly event?: string
33
+ readonly data?: unknown
34
+ /** A raw frame (`send()`). */
35
+ readonly frame?: string | Uint8Array
36
+ /** `join`, `leave` or `close` of the selection. */
37
+ readonly action?: 'join' | 'leave' | 'close'
38
+ readonly room?: string
39
+ }
40
+
41
+ /** Options of {@link createTestConnection}. */
42
+ export interface TestConnectionOptions<TData extends object> {
43
+ readonly id?: string
44
+ /** Gateway path. Default: `'/'`. */
45
+ readonly gateway?: string
46
+ readonly data?: TData
47
+ readonly rooms?: readonly string[]
48
+ readonly params?: Readonly<Record<string, string>>
49
+ readonly protocol?: string
50
+ readonly requestId?: string
51
+ /** The handshake context. Default: a minimal object with `params`, `query`, `headers` and `state`. */
52
+ readonly handshake?: Partial<HttpContext>
53
+ }
54
+
55
+ /** A fake connection: a `WsConnection` with the record of what it was sent. */
56
+ export interface TestConnection<
57
+ TData extends object = Record<string, unknown>,
58
+ E extends object = Record<string, unknown>,
59
+ > extends WsConnection<TData, E> {
60
+ /** Events emitted to this connection only (`conn.emit`), in order. */
61
+ readonly emitted: { readonly event: string; readonly data: unknown }[]
62
+ /** Raw frames sent to this connection (`conn.send`). */
63
+ readonly sent: (string | Uint8Array)[]
64
+ /** Broadcasts made through `conn.to()` (the connection itself is in `except`). */
65
+ readonly broadcasts: RecordedBroadcast[]
66
+ }
67
+
68
+ /** A fake server: a `WsServer` recording broadcasts. */
69
+ export interface TestServer extends WsServer {
70
+ readonly broadcasts: RecordedBroadcast[]
71
+ }
72
+
73
+ type Record_ = (entry: RecordedBroadcast) => void
74
+
75
+ /** A selection recording what is done with it. */
76
+ class RecordingBroadcast<E extends object> implements Broadcast<E> {
77
+ readonly #gateway: string
78
+ readonly #rooms: readonly string[]
79
+ readonly #except: readonly string[]
80
+ readonly #exceptRooms: readonly string[]
81
+ readonly #record: Record_
82
+
83
+ constructor(
84
+ gateway: string,
85
+ rooms: readonly string[],
86
+ except: readonly string[],
87
+ exceptRooms: readonly string[],
88
+ record: Record_,
89
+ ) {
90
+ this.#gateway = gateway
91
+ this.#rooms = rooms
92
+ this.#except = except
93
+ this.#exceptRooms = exceptRooms
94
+ this.#record = record
95
+ }
96
+
97
+ to(room: string | readonly string[]): Broadcast<E> {
98
+ return new RecordingBroadcast(
99
+ this.#gateway,
100
+ [...this.#rooms, ...list(room)],
101
+ this.#except,
102
+ this.#exceptRooms,
103
+ this.#record,
104
+ )
105
+ }
106
+
107
+ except(target: string | { readonly id: string } | readonly (string | { readonly id: string })[]): Broadcast<E> {
108
+ const refs = Array.isArray(target) ? target : [target]
109
+ const ids = refs.map(ref => (typeof ref === 'string' ? ref : (ref as { readonly id: string }).id))
110
+ return new RecordingBroadcast(
111
+ this.#gateway,
112
+ this.#rooms,
113
+ [...this.#except, ...ids],
114
+ this.#exceptRooms,
115
+ this.#record,
116
+ )
117
+ }
118
+
119
+ exceptRoom(room: string | readonly string[]): Broadcast<E> {
120
+ return new RecordingBroadcast(
121
+ this.#gateway,
122
+ this.#rooms,
123
+ this.#except,
124
+ [...this.#exceptRooms, ...list(room)],
125
+ this.#record,
126
+ )
127
+ }
128
+
129
+ emit<K extends keyof E & string>(event: K, data: E[K]): void {
130
+ this.#record({ ...this.#base(), event, data })
131
+ }
132
+
133
+ send(frame: string | Uint8Array): void {
134
+ this.#record({ ...this.#base(), frame })
135
+ }
136
+
137
+ join(room: string): void {
138
+ this.#record({ ...this.#base(), action: 'join', room })
139
+ }
140
+
141
+ leave(room: string): void {
142
+ this.#record({ ...this.#base(), action: 'leave', room })
143
+ }
144
+
145
+ close(): void {
146
+ this.#record({ ...this.#base(), action: 'close' })
147
+ }
148
+
149
+ #base(): RecordedBroadcast {
150
+ return {
151
+ gateway: this.#gateway,
152
+ rooms: [...this.#rooms],
153
+ except: [...this.#except],
154
+ ...(this.#exceptRooms.length === 0 ? {} : { exceptRooms: [...this.#exceptRooms] }),
155
+ }
156
+ }
157
+ }
158
+
159
+ function list(value: string | readonly string[]): readonly string[] {
160
+ return typeof value === 'string' ? [value] : value
161
+ }
162
+
163
+ /** Creates a fake connection for unit tests. */
164
+ export function createTestConnection<
165
+ TData extends object = Record<string, unknown>,
166
+ E extends object = Record<string, unknown>,
167
+ >(options: TestConnectionOptions<TData> = {}): TestConnection<TData, E> {
168
+ const id = options.id ?? crypto.randomUUID()
169
+ const gateway = options.gateway ?? '/'
170
+ const rooms = new Set(options.rooms ?? [])
171
+ const abort = new AbortController()
172
+ const params = options.params ?? {}
173
+ const handshake = {
174
+ params,
175
+ query: {},
176
+ headers: {},
177
+ state: new Map(),
178
+ requestId: options.requestId ?? 'test-request',
179
+ ...options.handshake,
180
+ } as unknown as HttpContext
181
+ let state: 'open' | 'closing' | 'closed' = 'open'
182
+ let closeInfo: WsCloseInfo | undefined
183
+ const emitted: { event: string; data: unknown }[] = []
184
+ const sent: (string | Uint8Array)[] = []
185
+ const broadcasts: RecordedBroadcast[] = []
186
+ const result = (): WsSendResult => (state === 'open' ? 'sent' : 'dropped')
187
+ const connection: TestConnection<TData, E> = {
188
+ id,
189
+ gateway,
190
+ requestId: options.requestId ?? 'test-request',
191
+ handshake,
192
+ params,
193
+ protocol: options.protocol ?? '',
194
+ data: (options.data ?? {}) as TData,
195
+ rooms,
196
+ get readyState() {
197
+ return state
198
+ },
199
+ bufferedAmount: 0,
200
+ signal: abort.signal,
201
+ get closeInfo() {
202
+ return closeInfo
203
+ },
204
+ emitted,
205
+ sent,
206
+ broadcasts,
207
+ emit(event, data) {
208
+ if (state === 'open') emitted.push({ event, data })
209
+ return result()
210
+ },
211
+ send(frame) {
212
+ if (state === 'open') sent.push(frame)
213
+ return result()
214
+ },
215
+ join(room) {
216
+ for (const name of list(room)) rooms.add(name)
217
+ },
218
+ leave(room) {
219
+ rooms.delete(room)
220
+ },
221
+ to(room) {
222
+ return new RecordingBroadcast<E>(gateway, list(room), [id], [], entry => broadcasts.push(entry))
223
+ },
224
+ drained() {
225
+ return state === 'open' ? Promise.resolve() : Promise.reject(new Error('The WebSocket connection is closed'))
226
+ },
227
+ close(code = 1000, reason = '') {
228
+ if (state !== 'open') return
229
+ state = 'closed'
230
+ closeInfo = { code, reason, initiator: 'server' }
231
+ abort.abort()
232
+ },
233
+ terminate() {
234
+ if (state !== 'open') return
235
+ state = 'closed'
236
+ closeInfo = { code: 1006, reason: '', initiator: 'server' }
237
+ abort.abort()
238
+ },
239
+ }
240
+ return connection
241
+ }
242
+
243
+ /** Options of {@link createTestServer}. */
244
+ export interface TestServerOptions {
245
+ /** Paths of gateway classes, for the `gateway` of recorded broadcasts (`[[ChatGateway, '/chat']]`). */
246
+ readonly paths?: readonly (readonly [abstract new (...args: never[]) => object, string])[]
247
+ }
248
+
249
+ /** Creates a fake `WsServer` recording broadcasts (no connection, every room empty). */
250
+ export function createTestServer(options: TestServerOptions = {}): TestServer {
251
+ const paths = new Map<unknown, string>(options.paths ?? [])
252
+ const broadcasts: RecordedBroadcast[] = []
253
+ const record: Record_ = entry => broadcasts.push(entry)
254
+ class FakeServer extends WsServer {
255
+ readonly broadcasts: RecordedBroadcast[] = broadcasts
256
+
257
+ override get connections(): number {
258
+ return 0
259
+ }
260
+
261
+ override of<E extends object = Record<string, unknown>>(gateway: GatewayRef): GatewayChannel<E> {
262
+ const path = typeof gateway === 'string' ? gateway : (paths.get(gateway) ?? gateway.name)
263
+ const all = (): RecordingBroadcast<E> => new RecordingBroadcast<E>(path, [], [], [], record)
264
+ return {
265
+ path,
266
+ size: 0,
267
+ connection: () => undefined,
268
+ connections: () => [][Symbol.iterator](),
269
+ members: () => new Set(),
270
+ rooms: () => new Map(),
271
+ to: room => all().to(room),
272
+ except: target => all().except(target as never),
273
+ emit: (event, data) => all().emit(event, data),
274
+ send: frame => all().send(frame),
275
+ }
276
+ }
277
+ }
278
+ return new FakeServer()
279
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { TypedToken } from '@znight/core'
2
+ import type { WsConnection } from './connection.ts'
3
+ import type { WebSocketPlugin } from './plugin.ts'
4
+ import type { WsServer } from './server.ts'
5
+
6
+ /**
7
+ * Tokens of the values `@znight/websocket` supplies to the container. `WsConnection` is a
8
+ * request-scoped external: the request scope of a WebSocket handshake lasts as long as its
9
+ * connection. Inject it in request-scoped providers (`@Gateway({ scope: 'request' })`) or through
10
+ * `Lazy<WsConnection>`.
11
+ */
12
+ export const WsTokens: {
13
+ /** The {@link WsServer} of the application (singleton). */
14
+ readonly Server: TypedToken<WsServer>
15
+ /** The {@link WebSocketPlugin} instance (singleton). */
16
+ readonly Plugin: TypedToken<WebSocketPlugin>
17
+ /** The {@link WsConnection} of the current connection (request scope). */
18
+ readonly Connection: TypedToken<WsConnection>
19
+ } = Object.freeze({
20
+ Server: '@znight/websocket#WsServer',
21
+ Plugin: '@znight/websocket#WebSocketPlugin',
22
+ Connection: '@znight/websocket#WsConnection',
23
+ })