@znight/websocket 0.0.0 → 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 +227 -0
- 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 +102 -1
- 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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ugho STEPHAN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# @znight/websocket
|
|
2
|
+
|
|
3
|
+
WebSocket **gateways** for Znight, served by the `HttpPlugin` server on the same port. A gateway is
|
|
4
|
+
a provider class whose methods handle the connection lifecycle (`@OnConnect`, `@OnDisconnect`) and
|
|
5
|
+
inbound events (`@OnMessage`), with Standard Schema validation, replies, rooms, broadcasts,
|
|
6
|
+
backpressure, heartbeats and a graceful shutdown. Like every Znight feature, gateways are resolved
|
|
7
|
+
**at build time**: the compiler extension reads the decorators, reports invalid gateways
|
|
8
|
+
(ZNIGHT050...) and generates one `invoke` function per handler.
|
|
9
|
+
|
|
10
|
+
- The handshake **is an HTTP request**: request scope, `http:request` hook, `HttpPlugin` global
|
|
11
|
+
guards, `@UseHandshakeGuard` guards, exception mappers, `trustProxy`. The request scope lasts as
|
|
12
|
+
long as the connection: request-scoped providers filled by a guard are there for every message.
|
|
13
|
+
- One server, every adapter: `node`, `fastify`, `express` and `hono` on Node.js and Bun. The `ws`
|
|
14
|
+
transport serves `node:http` servers (Bun ships its own `ws`); Bun's native WebSocket serves
|
|
15
|
+
`honoAdapter()` on `Bun.serve()`.
|
|
16
|
+
- A tiny wire protocol for `events` gateways (JSON `{ event, data?, id? }`), and `raw` gateways for
|
|
17
|
+
anything else.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bun add @znight/core @znight/http @znight/websocket zod
|
|
23
|
+
npm i ws # Node.js only: Bun provides it
|
|
24
|
+
bun add -d @znight/compiler vite
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Application } from '@znight/core'
|
|
29
|
+
import { HttpPlugin } from '@znight/http'
|
|
30
|
+
import { WebSocketPlugin } from '@znight/websocket'
|
|
31
|
+
import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
|
|
32
|
+
|
|
33
|
+
const app = await Application.create({
|
|
34
|
+
metadata: __ZNIGHT_METADATA__,
|
|
35
|
+
plugins: [HttpPlugin.configure({ port: 3000 }), WebSocketPlugin.configure({ maxPayload: '64kb' })],
|
|
36
|
+
})
|
|
37
|
+
await app.start()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Gateways
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { z } from 'zod'
|
|
44
|
+
import {
|
|
45
|
+
Conn,
|
|
46
|
+
Gateway,
|
|
47
|
+
OnConnect,
|
|
48
|
+
OnDisconnect,
|
|
49
|
+
OnMessage,
|
|
50
|
+
Payload,
|
|
51
|
+
WsException,
|
|
52
|
+
WsServer,
|
|
53
|
+
type WsConnection,
|
|
54
|
+
} from '@znight/websocket'
|
|
55
|
+
|
|
56
|
+
export const JoinRoom = z.object({ room: z.string().regex(/^[a-z0-9-]{1,32}$/) })
|
|
57
|
+
export type JoinRoom = z.output<typeof JoinRoom>
|
|
58
|
+
export const SendMessage = z.object({ room: z.string(), text: z.string().min(1).max(2000) })
|
|
59
|
+
export type SendMessage = z.output<typeof SendMessage>
|
|
60
|
+
|
|
61
|
+
interface ChatEvents {
|
|
62
|
+
'message.new': { readonly room: string; readonly text: string }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Gateway({ path: '/chat', protocols: ['chat.v1'], rateLimit: { messages: 30, intervalMs: 1_000 } })
|
|
66
|
+
export class ChatGateway {
|
|
67
|
+
constructor(private readonly ws: WsServer) {}
|
|
68
|
+
|
|
69
|
+
@OnConnect()
|
|
70
|
+
connected(@Conn() conn: WsConnection): void {
|
|
71
|
+
conn.join('lobby')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@OnMessage('room.join')
|
|
75
|
+
join(@Conn() conn: WsConnection, @Payload(JoinRoom) input: JoinRoom): { members: number } {
|
|
76
|
+
conn.join(input.room)
|
|
77
|
+
return { members: this.ws.of(ChatGateway).members(input.room).size } // the reply
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@OnMessage('message.send')
|
|
81
|
+
send(@Conn() conn: WsConnection, @Payload(SendMessage) input: SendMessage): void {
|
|
82
|
+
if (!conn.rooms.has(input.room)) throw new WsException('FORBIDDEN', `Join "${input.room}" first`)
|
|
83
|
+
this.ws.of<ChatEvents>(ChatGateway).to(input.room).emit('message.new', input) // encoded once
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@OnDisconnect()
|
|
87
|
+
disconnected(): void {}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
| Decorator | Target | Description |
|
|
92
|
+
| --------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| `@Gateway(path?)` / `@Gateway(options)` | class | A provider handling the connections of `path` (default `/`), joined to the `HttpPlugin` prefix |
|
|
94
|
+
| `@OnConnect()` | method | Runs once the handshake succeeded, before any message. Throwing closes the connection |
|
|
95
|
+
| `@OnMessage(event?)` | method | Handles an event; without event, the default handler (unknown events, every frame of a raw gateway) |
|
|
96
|
+
| `@OnDisconnect()` | method | Runs once per connection whose `@OnConnect` completed, after its running handlers |
|
|
97
|
+
| `@Payload(schema?)` | parameter | The message `data` (`events`) or the frame (`raw`), validated by a Standard Schema |
|
|
98
|
+
| `@Conn()` | parameter | The `WsConnection` |
|
|
99
|
+
| `@Ctx()` | parameter | The `WsMessageContext` of the message |
|
|
100
|
+
| `@UseHandshakeGuard(...guards)` | class | HTTP guards (`CanActivate`) of the handshake, after the `HttpPlugin` global guards |
|
|
101
|
+
| `@UseMessageGuard(...guards)` | class, method | `WsGuard`s run before each handler (plugin, class, then method) |
|
|
102
|
+
| `createRouteDecorator` decorators | class, method | Class: handshake and messages; method: its messages (nearest wins) |
|
|
103
|
+
|
|
104
|
+
`@Gateway` options (static): `path`, `scope` (`'singleton'` or `'request'`: one instance per
|
|
105
|
+
connection, which may inject `WsConnection`), `protocol` (`'events'` or `'raw'`), `protocols`
|
|
106
|
+
(accepted subprotocols; a client offering none of them is refused with `400`), `maxPayload`
|
|
107
|
+
(`'64kb'`), `concurrency` (messages of a connection handled at once, default `1`: in order),
|
|
108
|
+
`rateLimit` (`{ messages, intervalMs, onLimit?: 'reject' | 'close' }`) and `backpressure`
|
|
109
|
+
(`{ limit?, onLimit?: 'close' | 'drop' }`).
|
|
110
|
+
|
|
111
|
+
Handlers may be `async`; their return value is the reply of a message that carried an `id`.
|
|
112
|
+
Handlers are inherited from abstract base classes. Core AOP (`@Logged`, `@Traced`, `@Intercept`)
|
|
113
|
+
applies to handlers.
|
|
114
|
+
|
|
115
|
+
## Wire protocol (`events` gateways)
|
|
116
|
+
|
|
117
|
+
| Direction | Frame | Meaning |
|
|
118
|
+
| --------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
|
119
|
+
| client → server | `{ "event": "message.send", "data": {...} }` | fire and forget |
|
|
120
|
+
| client → server | `{ "event": "room.join", "data": {...}, "id": 7 }` | a request: one reply |
|
|
121
|
+
| server → client | `{ "id": 7, "data": {...} }` / `{ "id": 7, "error": { code, message } }` | reply |
|
|
122
|
+
| server → client | `{ "event": "message.new", "data": {...} }` | server event (`emit`, broadcasts) |
|
|
123
|
+
| server → client | `{ "event": "$error", "data": { "event": ..., "code": ..., "message": ... } }` | failure of a message without `id` |
|
|
124
|
+
| client ↔ server | `{ "event": "$ping", "id": 1 }` → `{ "id": 1, "data": { "t": ... } }` | application ping (never reaches handlers) |
|
|
125
|
+
|
|
126
|
+
Error codes: `BAD_FRAME`, `UNSUPPORTED_FRAME`, `BAD_REQUEST` (validation, `details: [{ path,
|
|
127
|
+
message }]`), `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `UNPROCESSABLE`, `RATE_LIMITED`,
|
|
128
|
+
`UNKNOWN_EVENT`, `TIMEOUT`, `INTERNAL` (message hidden unless `exposeErrors`). A `WsException(code,
|
|
129
|
+
message?, details?, { close? })` answers its code; an `HttpException` thrown by a service is mapped
|
|
130
|
+
from its status. In `@OnConnect`, a `WsException` closes with its `close` code (default `1008`), an
|
|
131
|
+
`HttpException` with `4000 + status`, anything else with `1011`.
|
|
132
|
+
|
|
133
|
+
`raw` gateways have no envelope: the default handler receives each frame (`string` or
|
|
134
|
+
`Uint8Array`), `conn.send()` sends frames as they are.
|
|
135
|
+
|
|
136
|
+
## Connections, rooms and broadcasts
|
|
137
|
+
|
|
138
|
+
`WsConnection<TData, Events>`: `id`, `gateway`, `requestId`, `handshake` (the `HttpContext`),
|
|
139
|
+
`params`, `protocol`, `data` (per-connection state), `rooms`, `readyState`, `bufferedAmount`,
|
|
140
|
+
`signal`, `closeInfo` (set before `@OnDisconnect`), `emit(event, data)`, `send(frame)`,
|
|
141
|
+
`join(room)`, `leave(room)`, `to(room)` (the other members), `drained()`, `close(code?, reason?)`,
|
|
142
|
+
`terminate()`. `emit` and `send` return `'sent'`, `'queued'` or `'dropped'`.
|
|
143
|
+
|
|
144
|
+
`WsServer` (injectable): `of(GatewayClass | '/path')` returns a lazy `GatewayChannel` (`path`,
|
|
145
|
+
`size`, `connection(id)`, `connections()`, `members(room)`, `rooms()`, `to(room)`, `except(...)`,
|
|
146
|
+
`emit`, `send`) and `connections` counts every connection. A broadcast encodes its frame once;
|
|
147
|
+
each recipient applies its own backpressure policy. Rooms are per gateway and per process.
|
|
148
|
+
|
|
149
|
+
## Plugin options
|
|
150
|
+
|
|
151
|
+
`WebSocketPlugin.configure(options | (container) => options)` (name `websocket`):
|
|
152
|
+
|
|
153
|
+
| Option | Default | Description |
|
|
154
|
+
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
|
155
|
+
| `transport` | `'auto'` | `wsTransport()` (`@znight/websocket/ws`) or `bunTransport()` (`@znight/websocket/bun`) |
|
|
156
|
+
| `origins` | `'same-origin'` | `'*'`, exact origins and anchored `RegExp`s, or `(origin, ctx) => boolean`; checked first (`403`) |
|
|
157
|
+
| `maxPayload` | `'1mb'` | Largest inbound message; gateways may lower it (`1009`) |
|
|
158
|
+
| `maxConnections` | `Infinity` | Open connections per process (handshakes in flight count); above, `503` with `retry-after: 1` |
|
|
159
|
+
| `maxPendingMessages` | `32` | Inbound queue per connection before reading pauses |
|
|
160
|
+
| `backpressure` | `{ limit: '1mb', onLimit: 'close' }` | Outbound buffer per connection: close with `1013`, or `'drop'` frames |
|
|
161
|
+
| `heartbeat` | `{ intervalMs: 30_000 }` | `false` disables it; silent connections are terminated after two intervals |
|
|
162
|
+
| `handshakeTimeoutMs` | `10_000` | `408` for handshakes whose guards did not answer |
|
|
163
|
+
| `handlerTimeoutMs` | `30_000` | Per message and `@OnConnect`: `TIMEOUT`, `ctx.signal` aborted |
|
|
164
|
+
| `closeTimeoutMs` | `2_000` | Wait for the client's close frame before terminating |
|
|
165
|
+
| `messageGuards` | `[]` | Global `WsGuard`s (classes or instances) |
|
|
166
|
+
| `exceptionMappers` | `[]` | `WsExceptionMapper`s: `map(error, ctx) → WsException \| undefined` |
|
|
167
|
+
| `exposeErrors` | `false` | Send the message of unexpected errors |
|
|
168
|
+
| `shutdown` | `{ code: 1001, reason: 'Server shutting down', timeoutMs: 3_000 }` | Graceful shutdown: close frames, then terminate |
|
|
169
|
+
|
|
170
|
+
The instance exposes `server` (`WsServer`), `transport`, `gateways`, `connections`, `stats()`
|
|
171
|
+
(`{ connections, opened, closed, messagesIn, messagesOut, bytesIn, bytesOut, dropped,
|
|
172
|
+
slowConsumerCloses }`), `connect(path, options?)` and `initRoots()`.
|
|
173
|
+
|
|
174
|
+
## Testing
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { TestApp, WsHandshakeError } from '@znight/testing'
|
|
178
|
+
import { HttpPlugin } from '@znight/http'
|
|
179
|
+
import { WebSocketPlugin } from '@znight/websocket'
|
|
180
|
+
|
|
181
|
+
await using app = await TestApp.create({
|
|
182
|
+
metadata,
|
|
183
|
+
plugins: [HttpPlugin.configure({ port: 0 }), WebSocketPlugin.configure()],
|
|
184
|
+
})
|
|
185
|
+
await using alice = await app.connect('/chat', { protocols: ['chat.v1'] }) // in memory, real handshake
|
|
186
|
+
expect(await alice.request('room.join', { room: 'general' })).toEqual({ members: 1 })
|
|
187
|
+
await expect(app.connect('/chat', { origin: 'https://evil.example' })).rejects.toBeInstanceOf(WsHandshakeError)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`connectWebSocket(url, { headers?, protocols? })` of `@znight/testing` gives the same client over
|
|
191
|
+
the network. For unit tests, `@znight/websocket/testing` provides fakes:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { createTestConnection, createTestServer } from '@znight/websocket/testing'
|
|
195
|
+
|
|
196
|
+
const server = createTestServer({ paths: [[ChatGateway, '/chat']] }) // records broadcasts
|
|
197
|
+
const conn = createTestConnection({ data: { user: 'alice' }, rooms: ['general'] }) // records emitted frames
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Compiler contract
|
|
201
|
+
|
|
202
|
+
The compiler extension (`@znight/websocket/compiler`) generates `metadata.extensions.websocket`
|
|
203
|
+
(gateways sorted by token, handlers in declaration order, bases first) and imports from
|
|
204
|
+
`@znight/websocket`:
|
|
205
|
+
|
|
206
|
+
- `validatePayload(schema, value)`: Standard Schema validation of `@Payload(schema)`; returns the
|
|
207
|
+
schema output (synchronously for synchronous schemas) or throws `WsValidationException`
|
|
208
|
+
(`BAD_REQUEST`, `details: [{ path, message }]`). Stable.
|
|
209
|
+
- the types `WsConnection` and `WsMessageContext` of the generated `invoke` functions.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
['invoke']: async (g: ChatGateway, m: WsMessageContext) =>
|
|
213
|
+
g.join(m.connection as never, await validatePayload(JoinRoom, m.data)),
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Diagnostics: ZNIGHT050 (invalid gateway: options, duplicate events, reserved `$` events, several
|
|
217
|
+
`@OnConnect`, undecorated parameters, `@Payload` outside `@OnMessage`...), ZNIGHT005 (decorator
|
|
218
|
+
misuse, parameter decorators imported from another package), ZNIGHT007 (paths), ZNIGHT017
|
|
219
|
+
(`@Payload()` without schema on a structured type), ZNIGHT003 (a singleton gateway injecting
|
|
220
|
+
`WsConnection`), ZNIGHT023 (required route decisions). At boot: ZNIGHT050 (options, metadata) and
|
|
221
|
+
ZNIGHT051 (no `HttpPlugin`, no upgrade support, `ws` missing, mismatched transport).
|
|
222
|
+
|
|
223
|
+
## Limitations
|
|
224
|
+
|
|
225
|
+
- `conn.handshake` keeps the native request of the handshake.
|
|
226
|
+
- Rooms are per process: broadcast across instances through `@znight/messaging`.
|
|
227
|
+
- No HTTP/2 or HTTP/3 WebSockets, no Socket.IO protocol.
|
package/dist/bun.d.mts
ADDED
package/dist/bun.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ErrorCode, ZnightError } from "@znight/core";
|
|
2
|
+
// src/transports/bun.ts
|
|
3
|
+
/** One Bun connection; bound before `server.upgrade()`, since `open` may run inside it. */
|
|
4
|
+
var BunTransportSocket = class {
|
|
5
|
+
#events;
|
|
6
|
+
#registry;
|
|
7
|
+
#ws;
|
|
8
|
+
#closed = false;
|
|
9
|
+
constructor(events, registry) {
|
|
10
|
+
this.#events = events;
|
|
11
|
+
this.#registry = registry;
|
|
12
|
+
}
|
|
13
|
+
get bufferedAmount() {
|
|
14
|
+
return this.#ws?.getBufferedAmount() ?? 0;
|
|
15
|
+
}
|
|
16
|
+
get extensions() {
|
|
17
|
+
return this.#ws?.extensions ?? "";
|
|
18
|
+
}
|
|
19
|
+
send(frame, _binary) {
|
|
20
|
+
if (this.#ws === void 0 || this.#closed) return "dropped";
|
|
21
|
+
const result = this.#ws.send(frame);
|
|
22
|
+
if (result > 0) return "sent";
|
|
23
|
+
return result === -1 ? "queued" : "dropped";
|
|
24
|
+
}
|
|
25
|
+
ping() {
|
|
26
|
+
this.#ws?.ping();
|
|
27
|
+
}
|
|
28
|
+
pause() {
|
|
29
|
+
if (this.#ws?.pause === void 0) return false;
|
|
30
|
+
this.#ws.pause();
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
resume() {
|
|
34
|
+
this.#ws?.resume?.();
|
|
35
|
+
}
|
|
36
|
+
close(code, reason) {
|
|
37
|
+
this.#ws?.close(code, reason);
|
|
38
|
+
}
|
|
39
|
+
terminate() {
|
|
40
|
+
this.#ws?.terminate();
|
|
41
|
+
}
|
|
42
|
+
onOpen(ws) {
|
|
43
|
+
this.#ws = ws;
|
|
44
|
+
this.#registry.add(this);
|
|
45
|
+
}
|
|
46
|
+
onMessage(message) {
|
|
47
|
+
this.#events.message(message, typeof message !== "string");
|
|
48
|
+
}
|
|
49
|
+
onClose(code, reason) {
|
|
50
|
+
if (this.#closed) return;
|
|
51
|
+
this.#closed = true;
|
|
52
|
+
this.#registry.delete(this);
|
|
53
|
+
this.#events.close(code, reason);
|
|
54
|
+
}
|
|
55
|
+
onDrain() {
|
|
56
|
+
this.#events.drain();
|
|
57
|
+
}
|
|
58
|
+
onPong() {
|
|
59
|
+
this.#events.pong();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** The Bun transport: completes the `bun` upgrades of `Bun.serve()`. */
|
|
63
|
+
function bunTransport() {
|
|
64
|
+
let settings = {
|
|
65
|
+
maxPayload: 1048576,
|
|
66
|
+
backpressureLimit: 1048576,
|
|
67
|
+
heartbeatMs: 0
|
|
68
|
+
};
|
|
69
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
70
|
+
return {
|
|
71
|
+
name: "bun",
|
|
72
|
+
accepts: "bun",
|
|
73
|
+
configure(next) {
|
|
74
|
+
settings = next;
|
|
75
|
+
},
|
|
76
|
+
bunWebSocket() {
|
|
77
|
+
return {
|
|
78
|
+
maxPayloadLength: settings.maxPayload,
|
|
79
|
+
backpressureLimit: settings.backpressureLimit,
|
|
80
|
+
closeOnBackpressureLimit: false,
|
|
81
|
+
sendPings: settings.heartbeatMs > 0,
|
|
82
|
+
idleTimeout: settings.heartbeatMs > 0 ? Math.min(960, Math.ceil(settings.heartbeatMs * 2 / 1e3)) : 0
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
accept(native, init, events) {
|
|
86
|
+
if (native.kind !== "bun") throw new ZnightError(ErrorCode.WebSocketTransportUnavailable, `bunTransport() completes Bun.serve() upgrades, not ${native.kind} upgrades`, { hints: ["Use wsTransport() with the node:http adapters"] });
|
|
87
|
+
const socket = new BunTransportSocket(events, sockets);
|
|
88
|
+
const handlers = {
|
|
89
|
+
open: (ws) => socket.onOpen(ws),
|
|
90
|
+
message: (_ws, message) => socket.onMessage(message),
|
|
91
|
+
close: (_ws, code, reason) => socket.onClose(code, reason),
|
|
92
|
+
drain: () => socket.onDrain(),
|
|
93
|
+
pong: () => socket.onPong()
|
|
94
|
+
};
|
|
95
|
+
const headers = {};
|
|
96
|
+
for (const [name, value] of Object.entries(init.headers)) headers[name] = typeof value === "string" ? value : value.join(", ");
|
|
97
|
+
if (init.protocol !== "") headers["sec-websocket-protocol"] = init.protocol;
|
|
98
|
+
if (!native.server.upgrade(native.request, {
|
|
99
|
+
data: handlers,
|
|
100
|
+
headers
|
|
101
|
+
})) throw new Error("Bun.serve() refused the WebSocket upgrade");
|
|
102
|
+
return socket;
|
|
103
|
+
},
|
|
104
|
+
close() {
|
|
105
|
+
for (const socket of [...sockets]) socket.terminate();
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export { bunTransport };
|
|
111
|
+
|
|
112
|
+
//# sourceMappingURL=bun.mjs.map
|
package/dist/bun.mjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bun.mjs","names":[],"sources":["../src/transports/bun.ts"],"sourcesContent":["/**\n * The Bun transport (RFC 0003 §8.3): completes the upgrades of `Bun.serve()` (`honoAdapter()` on\n * Bun) with Bun's built-in WebSocket server. No import: it only uses the server it is given.\n *\n * ```ts\n * import { bunTransport } from '@znight/websocket/bun'\n * ```\n */\nimport type { BunSocketHandlers, BunUpgrade, MemoryUpgrade, NodeUpgrade } from '@znight/http'\nimport type {\n AcceptInit,\n SendStatus,\n SocketEvents,\n TransportSettings,\n TransportSocket,\n WebSocketTransport,\n} from '../transport.ts'\n\nimport { ErrorCode, ZnightError } from '@znight/core'\n\n/** The part of Bun's `ServerWebSocket` the transport uses. */\ninterface BunSocket {\n send(data: string | Uint8Array): number\n getBufferedAmount(): number\n readonly extensions?: string\n ping(): void\n pause?(): void\n resume?(): void\n close(code: number, reason: string): void\n terminate(): void\n}\n\n/** The part of Bun's `Server` the transport uses. */\ninterface BunServer {\n upgrade(request: Request, options: { data: BunSocketHandlers; headers?: Record<string, string> }): boolean\n}\n\n/** One Bun connection; bound before `server.upgrade()`, since `open` may run inside it. */\nclass BunTransportSocket implements TransportSocket {\n readonly #events: SocketEvents\n readonly #registry: Set<BunTransportSocket>\n #ws: BunSocket | undefined\n #closed = false\n\n constructor(events: SocketEvents, registry: Set<BunTransportSocket>) {\n this.#events = events\n this.#registry = registry\n }\n\n get bufferedAmount(): number {\n return this.#ws?.getBufferedAmount() ?? 0\n }\n\n get extensions(): string {\n return this.#ws?.extensions ?? ''\n }\n\n send(frame: string | Uint8Array, _binary: boolean): SendStatus {\n if (this.#ws === undefined || this.#closed) return 'dropped'\n const result = this.#ws.send(frame)\n if (result > 0) return 'sent'\n return result === -1 ? 'queued' : 'dropped'\n }\n\n ping(): void {\n this.#ws?.ping()\n }\n\n pause(): boolean {\n if (this.#ws?.pause === undefined) return false\n this.#ws.pause()\n return true\n }\n\n resume(): void {\n this.#ws?.resume?.()\n }\n\n close(code: number, reason: string): void {\n this.#ws?.close(code, reason)\n }\n\n terminate(): void {\n this.#ws?.terminate()\n }\n\n onOpen(ws: unknown): void {\n this.#ws = ws as BunSocket\n this.#registry.add(this)\n }\n\n onMessage(message: string | Uint8Array): void {\n this.#events.message(message, typeof message !== 'string')\n }\n\n onClose(code: number, reason: string): void {\n if (this.#closed) return\n this.#closed = true\n this.#registry.delete(this)\n this.#events.close(code, reason)\n }\n\n onDrain(): void {\n this.#events.drain()\n }\n\n onPong(): void {\n this.#events.pong()\n }\n}\n\n/** The Bun transport: completes the `bun` upgrades of `Bun.serve()`. */\nexport function bunTransport(): WebSocketTransport {\n let settings: TransportSettings = { maxPayload: 1024 * 1024, backpressureLimit: 1024 * 1024, heartbeatMs: 0 }\n const sockets = new Set<BunTransportSocket>()\n return {\n name: 'bun',\n accepts: 'bun',\n configure(next: TransportSettings): void {\n settings = next\n },\n bunWebSocket(): Readonly<Record<string, unknown>> {\n return {\n maxPayloadLength: settings.maxPayload,\n backpressureLimit: settings.backpressureLimit,\n closeOnBackpressureLimit: false,\n sendPings: settings.heartbeatMs > 0,\n idleTimeout: settings.heartbeatMs > 0 ? Math.min(960, Math.ceil((settings.heartbeatMs * 2) / 1000)) : 0,\n }\n },\n accept(native: NodeUpgrade | BunUpgrade | MemoryUpgrade, init: AcceptInit, events: SocketEvents): TransportSocket {\n if (native.kind !== 'bun') {\n throw new ZnightError(\n ErrorCode.WebSocketTransportUnavailable,\n `bunTransport() completes Bun.serve() upgrades, not ${native.kind} upgrades`,\n { hints: ['Use wsTransport() with the node:http adapters'] },\n )\n }\n const socket = new BunTransportSocket(events, sockets)\n const handlers: BunSocketHandlers = {\n open: ws => socket.onOpen(ws),\n message: (_ws, message) => socket.onMessage(message),\n close: (_ws, code, reason) => socket.onClose(code, reason),\n drain: () => socket.onDrain(),\n pong: () => socket.onPong(),\n }\n const headers: Record<string, string> = {}\n for (const [name, value] of Object.entries(init.headers)) {\n headers[name] = typeof value === 'string' ? value : value.join(', ')\n }\n if (init.protocol !== '') headers['sec-websocket-protocol'] = init.protocol\n const server = native.server as BunServer\n if (!server.upgrade(native.request, { data: handlers, headers })) {\n throw new Error('Bun.serve() refused the WebSocket upgrade')\n }\n return socket\n },\n close(): Promise<void> {\n for (const socket of [...sockets]) socket.terminate()\n return Promise.resolve()\n },\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Token } from "@znight/core";
|
|
2
|
+
import { MetaEntry } from "@znight/http/compiler";
|
|
3
|
+
import { ClassRef, CompilerExtension, Location, ValueRef } from "@znight/compiler";
|
|
4
|
+
/** A parameter of a handler, as the generated `invoke` passes it. */
|
|
5
|
+
type WsParamData = {
|
|
6
|
+
readonly kind: "payload";
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: "validated";
|
|
9
|
+
readonly schema: ValueRef;
|
|
10
|
+
} | {
|
|
11
|
+
readonly kind: "connection";
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: "context";
|
|
14
|
+
} | {
|
|
15
|
+
readonly kind: "undefined";
|
|
16
|
+
readonly optional: boolean;
|
|
17
|
+
};
|
|
18
|
+
/** `@OnConnect` or `@OnDisconnect`. */
|
|
19
|
+
interface LifecycleData {
|
|
20
|
+
readonly method: string;
|
|
21
|
+
readonly params: readonly WsParamData[];
|
|
22
|
+
readonly declaredBy: string;
|
|
23
|
+
readonly location: Location;
|
|
24
|
+
}
|
|
25
|
+
/** One `@OnMessage` handler (a method decorated several times gives one entry per event). */
|
|
26
|
+
interface HandlerData {
|
|
27
|
+
readonly method: string;
|
|
28
|
+
/** `undefined`: the default handler. */
|
|
29
|
+
readonly event: string | undefined;
|
|
30
|
+
/** Route decorators of the method. */
|
|
31
|
+
readonly meta: readonly MetaEntry[];
|
|
32
|
+
/** `@UseMessageGuard` of the method. */
|
|
33
|
+
readonly guards: readonly ClassRef[];
|
|
34
|
+
readonly params: readonly WsParamData[];
|
|
35
|
+
readonly declaredBy: string;
|
|
36
|
+
readonly location: Location;
|
|
37
|
+
}
|
|
38
|
+
/** Static options of `@Gateway`. */
|
|
39
|
+
interface GatewayOptionsData {
|
|
40
|
+
readonly protocol: "events" | "raw";
|
|
41
|
+
readonly protocols?: readonly string[];
|
|
42
|
+
readonly maxPayload?: number;
|
|
43
|
+
readonly concurrency?: number;
|
|
44
|
+
readonly rateLimit?: {
|
|
45
|
+
readonly messages: number;
|
|
46
|
+
readonly intervalMs: number;
|
|
47
|
+
readonly onLimit: "reject" | "close";
|
|
48
|
+
};
|
|
49
|
+
readonly backpressure?: {
|
|
50
|
+
readonly limit?: number;
|
|
51
|
+
readonly onLimit?: "close" | "drop";
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** A `@Gateway` class. */
|
|
55
|
+
interface GatewayData {
|
|
56
|
+
readonly kind: "gateway";
|
|
57
|
+
readonly token: Token;
|
|
58
|
+
readonly identity: string;
|
|
59
|
+
readonly displayName: string;
|
|
60
|
+
/** Normalised path; `undefined` when invalid (reported). */
|
|
61
|
+
readonly path: string | undefined;
|
|
62
|
+
readonly options: GatewayOptionsData;
|
|
63
|
+
/** Route decorators of the class. */
|
|
64
|
+
readonly meta: readonly MetaEntry[];
|
|
65
|
+
readonly handshakeGuards: readonly ClassRef[];
|
|
66
|
+
readonly messageGuards: readonly ClassRef[];
|
|
67
|
+
readonly onConnect: LifecycleData | undefined;
|
|
68
|
+
readonly onDisconnect: LifecycleData | undefined;
|
|
69
|
+
readonly handlers: readonly HandlerData[];
|
|
70
|
+
/** Identities of the class and its analysable bases (nearest first). */
|
|
71
|
+
readonly chain: readonly string[];
|
|
72
|
+
readonly location: Location;
|
|
73
|
+
}
|
|
74
|
+
/** A class carrying websocket decorators without `@Gateway` (a base class, or a mistake). */
|
|
75
|
+
interface MemberClassData {
|
|
76
|
+
readonly kind: "members";
|
|
77
|
+
readonly identity: string;
|
|
78
|
+
readonly displayName: string;
|
|
79
|
+
readonly abstract: boolean;
|
|
80
|
+
readonly decorators: readonly {
|
|
81
|
+
readonly name: string;
|
|
82
|
+
readonly location: Location;
|
|
83
|
+
}[];
|
|
84
|
+
}
|
|
85
|
+
/** Everything the extension stores. */
|
|
86
|
+
type WebSocketData = GatewayData | MemberClassData;
|
|
87
|
+
/** The `@znight/websocket` compiler extension. */
|
|
88
|
+
export declare const webSocketCompilerExtension: CompilerExtension<WebSocketData>;
|
|
89
|
+
export { type GatewayData, type GatewayOptionsData, type HandlerData, type LifecycleData, type MemberClassData, type WebSocketData, type WsParamData, webSocketCompilerExtension as default };
|
|
90
|
+
//# sourceMappingURL=compiler.d.mts.map
|