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