@fishenv/http 0.0.0-stage → 0.0.3
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/dist/contract/builder.d.ts +87 -0
- package/dist/contract/builder.js +89 -0
- package/dist/contract/index.d.ts +4 -0
- package/dist/contract/index.js +2 -0
- package/dist/contract/types.d.ts +47 -0
- package/dist/contract/types.js +0 -0
- package/dist/contract/ws.d.ts +178 -0
- package/dist/contract/ws.js +164 -0
- package/dist/core/compose.d.ts +22 -0
- package/dist/core/compose.js +87 -0
- package/dist/core/cors.d.ts +38 -0
- package/dist/core/cors.js +96 -0
- package/dist/core/error.d.ts +73 -0
- package/dist/core/error.js +142 -0
- package/dist/core/index.d.ts +20 -0
- package/dist/core/index.js +23 -0
- package/dist/core/matcher.d.ts +17 -0
- package/dist/core/matcher.js +110 -0
- package/dist/core/request.d.ts +28 -0
- package/dist/core/request.js +106 -0
- package/dist/core/route-builder.d.ts +58 -0
- package/dist/core/route-builder.js +122 -0
- package/dist/core/router.d.ts +162 -0
- package/dist/core/router.js +327 -0
- package/dist/core/transport.d.ts +39 -0
- package/dist/core/transport.js +68 -0
- package/dist/core/types.d.ts +86 -0
- package/dist/core/types.js +0 -0
- package/dist/core/upgrade.d.ts +85 -0
- package/dist/core/upgrade.js +110 -0
- package/dist/core/utils/headers.d.ts +1 -0
- package/dist/core/utils/headers.js +2 -0
- package/dist/core/utils/url.d.ts +27 -0
- package/dist/core/utils/url.js +130 -0
- package/dist/core/validation.d.ts +13 -0
- package/dist/core/validation.js +24 -0
- package/dist/sse/connection.d.ts +30 -0
- package/dist/sse/connection.js +104 -0
- package/dist/sse/encode.d.ts +10 -0
- package/dist/sse/encode.js +27 -0
- package/dist/sse/hub.d.ts +33 -0
- package/dist/sse/hub.js +115 -0
- package/dist/sse/index.d.ts +38 -0
- package/dist/sse/index.js +40 -0
- package/dist/sse/response.d.ts +10 -0
- package/dist/sse/response.js +50 -0
- package/dist/sse/route-builder.d.ts +44 -0
- package/dist/sse/route-builder.js +65 -0
- package/dist/sse/router.d.ts +11 -0
- package/dist/sse/router.js +16 -0
- package/dist/sse/types.d.ts +43 -0
- package/dist/sse/types.js +4 -0
- package/dist/static/index.d.ts +27 -0
- package/dist/static/index.js +25 -0
- package/dist/static/negotiate.d.ts +23 -0
- package/dist/static/negotiate.js +85 -0
- package/dist/static/options.d.ts +44 -0
- package/dist/static/options.js +190 -0
- package/dist/static/resolve.d.ts +56 -0
- package/dist/static/resolve.js +113 -0
- package/dist/static/response.d.ts +53 -0
- package/dist/static/response.js +146 -0
- package/dist/static/router.d.ts +59 -0
- package/dist/static/router.js +217 -0
- package/dist/static/rules.d.ts +21 -0
- package/dist/static/rules.js +49 -0
- package/dist/static/types.d.ts +76 -0
- package/dist/static/types.js +0 -0
- package/dist/ws/channels/channel.d.ts +175 -0
- package/dist/ws/channels/channel.js +125 -0
- package/dist/ws/channels/handler.d.ts +33 -0
- package/dist/ws/channels/handler.js +486 -0
- package/dist/ws/channels/protocol.d.ts +96 -0
- package/dist/ws/channels/protocol.js +94 -0
- package/dist/ws/channels/target.d.ts +36 -0
- package/dist/ws/channels/target.js +56 -0
- package/dist/ws/connection.d.ts +56 -0
- package/dist/ws/connection.js +172 -0
- package/dist/ws/helpers.d.ts +17 -0
- package/dist/ws/helpers.js +48 -0
- package/dist/ws/hub.d.ts +172 -0
- package/dist/ws/hub.js +489 -0
- package/dist/ws/index.d.ts +65 -0
- package/dist/ws/index.js +66 -0
- package/dist/ws/lifecycle.d.ts +21 -0
- package/dist/ws/lifecycle.js +97 -0
- package/dist/ws/limits.d.ts +32 -0
- package/dist/ws/limits.js +67 -0
- package/dist/ws/message.d.ts +19 -0
- package/dist/ws/message.js +58 -0
- package/dist/ws/origin.d.ts +40 -0
- package/dist/ws/origin.js +152 -0
- package/dist/ws/presence.d.ts +96 -0
- package/dist/ws/presence.js +420 -0
- package/dist/ws/route-builder.d.ts +136 -0
- package/dist/ws/route-builder.js +265 -0
- package/dist/ws/router.d.ts +21 -0
- package/dist/ws/router.js +36 -0
- package/dist/ws/tickets.d.ts +82 -0
- package/dist/ws/tickets.js +161 -0
- package/dist/ws/typed.d.ts +54 -0
- package/dist/ws/typed.js +104 -0
- package/dist/ws/types.d.ts +69 -0
- package/dist/ws/types.js +70 -0
- package/package.json +54 -4
- package/README.md +0 -3
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `channels()` — the opt-in protocol handler. A per-socket state machine
|
|
3
|
+
* that multiplexes authorized channels over one socket.
|
|
4
|
+
*
|
|
5
|
+
* Guarantees:
|
|
6
|
+
* - frames of one socket are processed **sequentially** (join → event →
|
|
7
|
+
* leave order holds); different sockets run concurrently
|
|
8
|
+
* - nothing reaches a handler before the channel is joined and the payload
|
|
9
|
+
* validated against the contract
|
|
10
|
+
* - repeated protocol violations close the socket with `1008`
|
|
11
|
+
*/
|
|
12
|
+
import { WsHub } from "../hub.ts";
|
|
13
|
+
import type { WsProtocolHandler } from "../route-builder.ts";
|
|
14
|
+
import type { AnyChannelImpl } from "./channel.ts";
|
|
15
|
+
export interface ChannelsOptions {
|
|
16
|
+
/** Default 32. Exceeding it fails the join with `429 too_many_channels`. */
|
|
17
|
+
maxChannelsPerSocket?: number;
|
|
18
|
+
/** Protocol violations tolerated before closing with `1008`. Default 10. */
|
|
19
|
+
maxViolations?: number;
|
|
20
|
+
/** Frames queued behind a slow handler before closing with `1013`. Default 64. */
|
|
21
|
+
maxPendingFrames?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Hub that backs channel membership — pass an instance or a DI token to
|
|
24
|
+
* reach channels from server code via `hub.channel(name)`. A private hub is
|
|
25
|
+
* used otherwise.
|
|
26
|
+
*/
|
|
27
|
+
hub?: WsHub | (abstract new (...args: any[]) => WsHub);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the protocol handler. Prefer `.channels([...])` on a ws route,
|
|
31
|
+
* which also type-checks completeness; this form plugs into `.handleWith()`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function channels(impls: readonly AnyChannelImpl[], options?: ChannelsOptions): WsProtocolHandler<Record<string, unknown>, Record<string, unknown>>;
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `channels()` — the opt-in protocol handler. A per-socket state machine
|
|
3
|
+
* that multiplexes authorized channels over one socket.
|
|
4
|
+
*
|
|
5
|
+
* Guarantees:
|
|
6
|
+
* - frames of one socket are processed **sequentially** (join → event →
|
|
7
|
+
* leave order holds); different sockets run concurrently
|
|
8
|
+
* - nothing reaches a handler before the channel is joined and the payload
|
|
9
|
+
* validated against the contract
|
|
10
|
+
* - repeated protocol violations close the socket with `1008`
|
|
11
|
+
*/
|
|
12
|
+
import * as v from "valibot";
|
|
13
|
+
import { HttpError, ValidationError, validatePathParams, } from "../../core/index.js";
|
|
14
|
+
import { WsHub } from "../hub.js";
|
|
15
|
+
import { TokenBucket } from "../limits.js";
|
|
16
|
+
import { checkOutbound } from "../typed.js";
|
|
17
|
+
import { WsCloseCode, WsCloseError } from "../types.js";
|
|
18
|
+
import { CHANNELS_PROTOCOL, encodeFrame, eventFrame, FrameError, parseClientFrame, } from "./protocol.js";
|
|
19
|
+
import { channelRoom } from "./target.js";
|
|
20
|
+
const DEFAULTS = {
|
|
21
|
+
maxChannelsPerSocket: 32,
|
|
22
|
+
maxViolations: 10,
|
|
23
|
+
maxPendingFrames: 64,
|
|
24
|
+
};
|
|
25
|
+
/** Errors for rejected frames, mapped onto the HTTP error vocabulary. */
|
|
26
|
+
class ChannelError extends HttpError {
|
|
27
|
+
#code;
|
|
28
|
+
/** Counts towards `maxViolations`. */
|
|
29
|
+
violation;
|
|
30
|
+
constructor(status, code, message, violation = true) {
|
|
31
|
+
super(status, message);
|
|
32
|
+
this.#code = code;
|
|
33
|
+
this.violation = violation;
|
|
34
|
+
}
|
|
35
|
+
get code() {
|
|
36
|
+
return this.#code;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build the protocol handler. Prefer `.channels([...])` on a ws route,
|
|
41
|
+
* which also type-checks completeness; this form plugs into `.handleWith()`.
|
|
42
|
+
*/
|
|
43
|
+
export function channels(impls, options = {}) {
|
|
44
|
+
const defs = impls.map((impl) => impl._definition);
|
|
45
|
+
const opts = { ...DEFAULTS, ...options };
|
|
46
|
+
const env = { development: false, logger: console };
|
|
47
|
+
let privateHub;
|
|
48
|
+
const resolveHub = (container) => {
|
|
49
|
+
const hub = options.hub;
|
|
50
|
+
if (hub instanceof WsHub)
|
|
51
|
+
return hub;
|
|
52
|
+
if (hub != null)
|
|
53
|
+
return container.get(hub);
|
|
54
|
+
privateHub ??= new WsHub({
|
|
55
|
+
name: `fishenv.channels.${crypto.randomUUID()}`,
|
|
56
|
+
});
|
|
57
|
+
return privateHub;
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
protocols: [CHANNELS_PROTOCOL],
|
|
61
|
+
onBuild(buildEnv, route) {
|
|
62
|
+
env.development = buildEnv.development;
|
|
63
|
+
env.logger = buildEnv.logger;
|
|
64
|
+
validateImpls(defs, route);
|
|
65
|
+
},
|
|
66
|
+
setup(args) {
|
|
67
|
+
const session = new ChannelSession(args, defs, resolveHub(args.container), opts, env);
|
|
68
|
+
args.socket.onMessage((message) => session.enqueue(message.raw));
|
|
69
|
+
args.socket.onClose(() => session.disconnect());
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Startup checks — the runtime twin of the compile-time completeness types. */
|
|
74
|
+
function validateImpls(defs, route) {
|
|
75
|
+
const where = `[fishenv.ws] ${route.path}`;
|
|
76
|
+
const names = new Set();
|
|
77
|
+
for (const def of defs) {
|
|
78
|
+
const name = def.contract.name;
|
|
79
|
+
if (names.has(name))
|
|
80
|
+
throw new Error(`${where}: channel "${name}" is implemented twice`);
|
|
81
|
+
names.add(name);
|
|
82
|
+
if (def.join == null && !def.isPublic) {
|
|
83
|
+
throw new Error(`${where}: channel "${name}" has no join authorizer; add .join(fn) or .public()`);
|
|
84
|
+
}
|
|
85
|
+
for (const event of def.contract._definition.clientEvents.keys()) {
|
|
86
|
+
if (!def.events.has(event) && def.any == null) {
|
|
87
|
+
throw new Error(`${where}: channel "${name}" is missing .on("${event}")`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const contract = route.contract;
|
|
92
|
+
if (contract == null)
|
|
93
|
+
return;
|
|
94
|
+
if (contract.kind !== "channels") {
|
|
95
|
+
throw new Error(`${where}: .channels() requires a c.ws().channels() contract`);
|
|
96
|
+
}
|
|
97
|
+
for (const expected of contract.channels) {
|
|
98
|
+
const def = defs.find((d) => d.contract.name === expected.name);
|
|
99
|
+
if (def == null) {
|
|
100
|
+
throw new Error(`${where}: contract channel "${expected.name}" is not implemented`);
|
|
101
|
+
}
|
|
102
|
+
if (def.contract !== expected) {
|
|
103
|
+
throw new Error(`${where}: channel "${expected.name}" implements a different contract object than the socket contract lists`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const def of defs) {
|
|
107
|
+
if (!contract.channels.some((c) => c.name === def.contract.name)) {
|
|
108
|
+
throw new Error(`${where}: channel "${def.contract.name}" is not part of the socket contract`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
class ChannelSession {
|
|
113
|
+
#args;
|
|
114
|
+
#socket;
|
|
115
|
+
#defs;
|
|
116
|
+
#hub;
|
|
117
|
+
#opts;
|
|
118
|
+
#env;
|
|
119
|
+
#joined = new Map();
|
|
120
|
+
#presenceBuckets = new Map();
|
|
121
|
+
#chain = Promise.resolve();
|
|
122
|
+
#pending = 0;
|
|
123
|
+
#violations = 0;
|
|
124
|
+
#disconnected = false;
|
|
125
|
+
constructor(args, defs, hub, opts, env) {
|
|
126
|
+
this.#args = args;
|
|
127
|
+
this.#socket = args.socket;
|
|
128
|
+
this.#defs = defs;
|
|
129
|
+
this.#hub = hub;
|
|
130
|
+
this.#opts = opts;
|
|
131
|
+
this.#env = env;
|
|
132
|
+
}
|
|
133
|
+
// ── Scheduling ──────────────────────────────────────────────────────
|
|
134
|
+
enqueue(raw) {
|
|
135
|
+
if (this.#pending >= this.#opts.maxPendingFrames) {
|
|
136
|
+
this.#socket.close(WsCloseCode.TryAgainLater, "Too many pending frames");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.#schedule(() => this.#process(raw));
|
|
140
|
+
}
|
|
141
|
+
#schedule(task) {
|
|
142
|
+
this.#pending++;
|
|
143
|
+
this.#chain = this.#chain
|
|
144
|
+
.then(task)
|
|
145
|
+
.catch((err) => this.#env.logger.error("[fishenv.ws] channel task failed:", err))
|
|
146
|
+
.finally(() => {
|
|
147
|
+
this.#pending--;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// ── Frame processing ────────────────────────────────────────────────
|
|
151
|
+
async #process(raw) {
|
|
152
|
+
if (this.#socket.closed)
|
|
153
|
+
return;
|
|
154
|
+
let frame;
|
|
155
|
+
try {
|
|
156
|
+
if (typeof raw !== "string")
|
|
157
|
+
throw new FrameError("Binary frames are not supported");
|
|
158
|
+
let value;
|
|
159
|
+
try {
|
|
160
|
+
value = JSON.parse(raw);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
throw new FrameError("Invalid JSON");
|
|
164
|
+
}
|
|
165
|
+
frame = parseClientFrame(value);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (!(err instanceof FrameError))
|
|
169
|
+
throw err;
|
|
170
|
+
this.#fail(err.ref, err.ch, new ChannelError(400, "bad_request", err.message));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
switch (frame.t) {
|
|
175
|
+
case "join":
|
|
176
|
+
return await this.#join(frame);
|
|
177
|
+
case "event":
|
|
178
|
+
return await this.#event(frame);
|
|
179
|
+
case "leave":
|
|
180
|
+
return await this.#leave(frame);
|
|
181
|
+
case "presence_update":
|
|
182
|
+
return await this.#presenceUpdate(frame);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
this.#fail(frame.ref, frame.ch, err);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async #join(frame) {
|
|
190
|
+
if (this.#joined.has(frame.ch)) {
|
|
191
|
+
throw new ChannelError(409, "already_joined", "Channel already joined");
|
|
192
|
+
}
|
|
193
|
+
const match = this.#match(frame.ch);
|
|
194
|
+
if (match == null)
|
|
195
|
+
throw new ChannelError(404, "unknown_channel", "Unknown channel");
|
|
196
|
+
if (this.#joined.size >= this.#opts.maxChannelsPerSocket) {
|
|
197
|
+
throw new ChannelError(429, "too_many_channels", "Too many channels joined");
|
|
198
|
+
}
|
|
199
|
+
const { def, raw } = match;
|
|
200
|
+
const contract = def.contract._definition;
|
|
201
|
+
const path = validatePathParams(raw, contract.paramSchemas);
|
|
202
|
+
const data = parsePayload(contract.join.payload, frame.d);
|
|
203
|
+
const context = this.#context(def, frame.ch);
|
|
204
|
+
const args = { ...this.#base(path), data, channel: context };
|
|
205
|
+
const reply = def.join ? await def.join(args) : undefined;
|
|
206
|
+
checkOutbound(contract.join.reply, reply, this.#env.development, `join reply of "${frame.ch}"`);
|
|
207
|
+
// Resolve presence before committing, so a failing tracker aborts the join.
|
|
208
|
+
const presence = contract.presence;
|
|
209
|
+
const tracked = presence != null && def.presence != null
|
|
210
|
+
? (await def.presence(args))
|
|
211
|
+
: null;
|
|
212
|
+
if (tracked != null) {
|
|
213
|
+
checkOutbound(presence?.meta, tracked.meta, this.#env.development, `presence meta of "${frame.ch}"`);
|
|
214
|
+
}
|
|
215
|
+
if (this.#socket.closed)
|
|
216
|
+
return;
|
|
217
|
+
const room = channelRoom(frame.ch);
|
|
218
|
+
this.#hub.join(this.#socket, room);
|
|
219
|
+
this.#hub._onLeave(this.#socket, room, (reason) => this.#schedule(() => this.#serverLeave(frame.ch, "kicked", reason)));
|
|
220
|
+
this.#joined.set(frame.ch, { impl: def, name: frame.ch, path, context });
|
|
221
|
+
this.#reply(frame.ref, reply);
|
|
222
|
+
if (presence != null) {
|
|
223
|
+
const registry = presenceFor(this.#hub);
|
|
224
|
+
if (tracked != null) {
|
|
225
|
+
registry.track(room, this.#socket, tracked.key, tracked.meta);
|
|
226
|
+
}
|
|
227
|
+
this.#send(encodeFrame({
|
|
228
|
+
t: "presence_state",
|
|
229
|
+
ch: frame.ch,
|
|
230
|
+
d: registry.list(room),
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async #presenceUpdate(frame) {
|
|
235
|
+
const joined = this.#joined.get(frame.ch);
|
|
236
|
+
if (joined == null)
|
|
237
|
+
throw new ChannelError(403, "not_joined", "Channel not joined");
|
|
238
|
+
const presence = joined.impl.contract._definition.presence;
|
|
239
|
+
if (presence == null || !presence.clientUpdates) {
|
|
240
|
+
throw new ChannelError(403, "forbidden", "Presence updates are not allowed on this channel");
|
|
241
|
+
}
|
|
242
|
+
const room = channelRoom(frame.ch);
|
|
243
|
+
const registry = presenceFor(this.#hub);
|
|
244
|
+
if (registry.metaOf(room, this.#socket) === undefined) {
|
|
245
|
+
throw new ChannelError(409, "not_tracked", "This socket is not tracked in presence", false);
|
|
246
|
+
}
|
|
247
|
+
const meta = parsePayload(presence.meta, frame.d);
|
|
248
|
+
let bucket = this.#presenceBuckets.get(frame.ch);
|
|
249
|
+
if (bucket == null) {
|
|
250
|
+
bucket = new TokenBucket(1, PRESENCE_UPDATE_INTERVAL_MS);
|
|
251
|
+
this.#presenceBuckets.set(frame.ch, bucket);
|
|
252
|
+
}
|
|
253
|
+
if (!bucket.take()) {
|
|
254
|
+
throw new ChannelError(429, "rate_limited", "Presence updates are rate limited", false);
|
|
255
|
+
}
|
|
256
|
+
registry.update(room, this.#socket, meta);
|
|
257
|
+
this.#reply(frame.ref, undefined);
|
|
258
|
+
}
|
|
259
|
+
async #event(frame) {
|
|
260
|
+
const joined = this.#joined.get(frame.ch);
|
|
261
|
+
if (joined == null)
|
|
262
|
+
throw new ChannelError(403, "not_joined", "Channel not joined");
|
|
263
|
+
const { impl } = joined;
|
|
264
|
+
const declared = impl.contract._definition.clientEvents.get(frame.ev);
|
|
265
|
+
const handler = impl.events.get(frame.ev);
|
|
266
|
+
let result;
|
|
267
|
+
if (declared != null && handler != null) {
|
|
268
|
+
const data = parsePayload(declared.payload, frame.d);
|
|
269
|
+
result = await handler({
|
|
270
|
+
...this.#base(joined.path),
|
|
271
|
+
data,
|
|
272
|
+
channel: joined.context,
|
|
273
|
+
});
|
|
274
|
+
checkOutbound(declared.reply, result, this.#env.development, `reply to "${frame.ev}" on "${frame.ch}"`);
|
|
275
|
+
}
|
|
276
|
+
else if (impl.any != null) {
|
|
277
|
+
result = await impl.any({
|
|
278
|
+
...this.#base(joined.path),
|
|
279
|
+
event: frame.ev,
|
|
280
|
+
data: frame.d,
|
|
281
|
+
channel: joined.context,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
throw new ChannelError(404, "unknown_event", "Unknown event");
|
|
286
|
+
}
|
|
287
|
+
this.#reply(frame.ref, result);
|
|
288
|
+
}
|
|
289
|
+
async #leave(frame) {
|
|
290
|
+
const joined = this.#joined.get(frame.ch);
|
|
291
|
+
if (joined == null)
|
|
292
|
+
throw new ChannelError(403, "not_joined", "Channel not joined");
|
|
293
|
+
this.#forget(joined);
|
|
294
|
+
await this.#runLeave(joined, "client");
|
|
295
|
+
this.#reply(frame.ref, undefined);
|
|
296
|
+
}
|
|
297
|
+
/** Kick or `channel.leave()`: tell the client, then run the leave handler. */
|
|
298
|
+
async #serverLeave(name, reason, message) {
|
|
299
|
+
const joined = this.#joined.get(name);
|
|
300
|
+
if (joined == null)
|
|
301
|
+
return;
|
|
302
|
+
this.#forget(joined);
|
|
303
|
+
this.#send(encodeFrame({ t: "close", ch: name, reason: message }));
|
|
304
|
+
await this.#runLeave(joined, reason);
|
|
305
|
+
}
|
|
306
|
+
/** Socket closed: run every leave handler once. Hub rooms clean up themselves. */
|
|
307
|
+
disconnect() {
|
|
308
|
+
if (this.#disconnected)
|
|
309
|
+
return;
|
|
310
|
+
this.#disconnected = true;
|
|
311
|
+
this.#schedule(async () => {
|
|
312
|
+
for (const joined of [...this.#joined.values()]) {
|
|
313
|
+
this.#joined.delete(joined.name);
|
|
314
|
+
await this.#runLeave(joined, "disconnect");
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
319
|
+
#forget(joined) {
|
|
320
|
+
this.#joined.delete(joined.name);
|
|
321
|
+
this.#presenceBuckets.delete(joined.name);
|
|
322
|
+
const room = channelRoom(joined.name);
|
|
323
|
+
this.#hub.leave(this.#socket, room);
|
|
324
|
+
if (joined.impl.contract._definition.presence != null) {
|
|
325
|
+
presenceFor(this.#hub).untrack(room, this.#socket);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async #runLeave(joined, reason) {
|
|
329
|
+
if (joined.impl.leave == null)
|
|
330
|
+
return;
|
|
331
|
+
try {
|
|
332
|
+
await joined.impl.leave({ ...this.#base(joined.path), reason });
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
this.#env.logger.error(`[fishenv.ws] leave handler of "${joined.name}" failed:`, err);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
#match(name) {
|
|
339
|
+
for (const def of this.#defs) {
|
|
340
|
+
const match = def.pattern.exec(`/${name}`);
|
|
341
|
+
if (match == null)
|
|
342
|
+
continue;
|
|
343
|
+
const raw = {};
|
|
344
|
+
def.paramNames.forEach((param, i) => {
|
|
345
|
+
const value = match[i + 1];
|
|
346
|
+
if (value !== undefined)
|
|
347
|
+
raw[param] = value;
|
|
348
|
+
});
|
|
349
|
+
return { def, raw };
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
#base(path) {
|
|
354
|
+
const { ctx, req, container } = this.#args;
|
|
355
|
+
return { ctx, path, req, container, socket: this.#socket };
|
|
356
|
+
}
|
|
357
|
+
#context(def, name) {
|
|
358
|
+
const serverEvents = def.contract._definition.serverEvents;
|
|
359
|
+
const check = (event, payload) => {
|
|
360
|
+
if (!this.#env.development || serverEvents.size === 0)
|
|
361
|
+
return;
|
|
362
|
+
const schema = serverEvents.get(event);
|
|
363
|
+
if (schema == null) {
|
|
364
|
+
throw new Error(`"${event}" is not a server event of channel "${def.contract.name}"`);
|
|
365
|
+
}
|
|
366
|
+
checkOutbound(schema, payload, true, `serverEvent "${event}"`);
|
|
367
|
+
};
|
|
368
|
+
const target = () => this.#hub.channel(name);
|
|
369
|
+
const presenceDef = def.contract._definition.presence;
|
|
370
|
+
const room = channelRoom(name);
|
|
371
|
+
const presence = presenceDef == null
|
|
372
|
+
? undefined
|
|
373
|
+
: {
|
|
374
|
+
update: (meta) => {
|
|
375
|
+
checkOutbound(presenceDef.meta, meta, this.#env.development, `presence meta of "${name}"`);
|
|
376
|
+
return presenceFor(this.#hub).update(room, this.#socket, meta);
|
|
377
|
+
},
|
|
378
|
+
list: () => presenceFor(this.#hub).list(room),
|
|
379
|
+
has: (key) => presenceFor(this.#hub).has(room, key),
|
|
380
|
+
keys: () => presenceFor(this.#hub).keys(room),
|
|
381
|
+
};
|
|
382
|
+
return {
|
|
383
|
+
name,
|
|
384
|
+
presence,
|
|
385
|
+
broadcast: (event, payload) => {
|
|
386
|
+
check(event, payload);
|
|
387
|
+
return target().send(event, payload);
|
|
388
|
+
},
|
|
389
|
+
broadcastFrom: (event, payload) => {
|
|
390
|
+
check(event, payload);
|
|
391
|
+
return target().except(this.#socket).send(event, payload);
|
|
392
|
+
},
|
|
393
|
+
push: (event, payload) => {
|
|
394
|
+
check(event, payload);
|
|
395
|
+
return this.#socket.send(eventFrame(name, event, payload));
|
|
396
|
+
},
|
|
397
|
+
leave: (reason = "") => {
|
|
398
|
+
this.#schedule(() => this.#serverLeave(name, "server", reason));
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
#reply(ref, d) {
|
|
403
|
+
if (ref === undefined)
|
|
404
|
+
return;
|
|
405
|
+
this.#send(encodeFrame(d === undefined
|
|
406
|
+
? { t: "reply", ref, ok: true }
|
|
407
|
+
: { t: "reply", ref, ok: true, d }));
|
|
408
|
+
}
|
|
409
|
+
#fail(ref, ch, err) {
|
|
410
|
+
const wire = this.#toWire(err);
|
|
411
|
+
if (ref !== undefined) {
|
|
412
|
+
this.#send(encodeFrame({ t: "reply", ref, ok: false, err: wire }));
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
this.#send(encodeFrame(ch ? { t: "error", ch, err: wire } : { t: "error", err: wire }));
|
|
416
|
+
}
|
|
417
|
+
const violation = (err instanceof ChannelError && err.violation) ||
|
|
418
|
+
err instanceof ValidationError;
|
|
419
|
+
if (violation && ++this.#violations >= this.#opts.maxViolations) {
|
|
420
|
+
this.#socket.close(WsCloseCode.PolicyViolation, "Too many protocol violations");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
#toWire(err) {
|
|
424
|
+
if (err instanceof WsCloseError) {
|
|
425
|
+
// A handler asked for the whole socket to go away.
|
|
426
|
+
this.#socket.close(err.code, err.message);
|
|
427
|
+
}
|
|
428
|
+
if (err instanceof HttpError)
|
|
429
|
+
return err.toJSON(this.#env.development);
|
|
430
|
+
this.#env.logger.error("[fishenv.ws] channel handler failed:", err);
|
|
431
|
+
const wire = {
|
|
432
|
+
status: 500,
|
|
433
|
+
code: "internal",
|
|
434
|
+
message: "Internal Server Error",
|
|
435
|
+
};
|
|
436
|
+
if (this.#env.development && err instanceof Error) {
|
|
437
|
+
wire.message = err.message;
|
|
438
|
+
wire.stack = err.stack;
|
|
439
|
+
}
|
|
440
|
+
return wire;
|
|
441
|
+
}
|
|
442
|
+
#send(frame) {
|
|
443
|
+
this.#socket.send(frame);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Client presence updates allowed per channel per socket: one per second. */
|
|
447
|
+
const PRESENCE_UPDATE_INTERVAL_MS = 1_000;
|
|
448
|
+
const CHANNEL_ROOM_PREFIX = channelRoom("");
|
|
449
|
+
const fanOutInstalled = new WeakSet();
|
|
450
|
+
const LOCAL_ONLY = {
|
|
451
|
+
exceptIds: new Set(),
|
|
452
|
+
exceptKeys: new Set(),
|
|
453
|
+
onlyKeys: null,
|
|
454
|
+
};
|
|
455
|
+
/**
|
|
456
|
+
* The hub's presence registry, with presence diffs of channel topics fanned
|
|
457
|
+
* out to the channel's *local* members — every node derives the same diffs
|
|
458
|
+
* from replicated state, so nothing is re-published over the transport.
|
|
459
|
+
*/
|
|
460
|
+
function presenceFor(hub) {
|
|
461
|
+
const registry = hub._presenceRegistry();
|
|
462
|
+
if (!fanOutInstalled.has(hub)) {
|
|
463
|
+
fanOutInstalled.add(hub);
|
|
464
|
+
registry.onDiff((topic, diff) => {
|
|
465
|
+
if (!topic.startsWith(CHANNEL_ROOM_PREFIX))
|
|
466
|
+
return;
|
|
467
|
+
const ch = topic.slice(CHANNEL_ROOM_PREFIX.length);
|
|
468
|
+
hub._sendRoom(topic, encodeFrame({
|
|
469
|
+
t: "presence_diff",
|
|
470
|
+
ch,
|
|
471
|
+
joins: diff.joins,
|
|
472
|
+
leaves: diff.leaves,
|
|
473
|
+
}), LOCAL_ONLY, false);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return registry;
|
|
477
|
+
}
|
|
478
|
+
/** Validate a client payload; no schema means the payload is ignored. */
|
|
479
|
+
function parsePayload(schema, value) {
|
|
480
|
+
if (schema == null)
|
|
481
|
+
return undefined;
|
|
482
|
+
const result = v.safeParse(schema, value);
|
|
483
|
+
if (!result.success)
|
|
484
|
+
throw new ValidationError("body", result.issues);
|
|
485
|
+
return result.output;
|
|
486
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol `fishenv.channels.v1` — JSON text frames.
|
|
3
|
+
*
|
|
4
|
+
* Client → server:
|
|
5
|
+
* { t: "join", ref?, ch, d? }
|
|
6
|
+
* { t: "event", ref?, ch, ev, d? }
|
|
7
|
+
* { t: "leave", ref?, ch }
|
|
8
|
+
* { t: "presence_update", ref?, ch, d }
|
|
9
|
+
*
|
|
10
|
+
* Server → client:
|
|
11
|
+
* { t: "reply", ref, ok: true, d? } | { t: "reply", ref, ok: false, err }
|
|
12
|
+
* { t: "event", ch, ev, d } — `seq` is reserved for replay
|
|
13
|
+
* { t: "close", ch, reason } — server-initiated leave
|
|
14
|
+
* { t: "error", ch?, err } — failure of a frame without `ref`
|
|
15
|
+
* { t: "presence_state", ch, d }
|
|
16
|
+
* { t: "presence_diff", ch, joins, leaves }
|
|
17
|
+
*
|
|
18
|
+
* Parsing reads only known keys and never merges client objects into
|
|
19
|
+
* server state, so `__proto__` & co. are inert.
|
|
20
|
+
*/
|
|
21
|
+
import type { HttpErrorJSON } from "../../core/index.ts";
|
|
22
|
+
export { CHANNELS_PROTOCOL } from "../../contract/index.ts";
|
|
23
|
+
export declare const MAX_CHANNEL_NAME_LENGTH = 256;
|
|
24
|
+
export declare const MAX_EVENT_NAME_LENGTH = 128;
|
|
25
|
+
export declare const MAX_REF: number;
|
|
26
|
+
export type ClientFrame = {
|
|
27
|
+
t: "join";
|
|
28
|
+
ref?: number;
|
|
29
|
+
ch: string;
|
|
30
|
+
d?: unknown;
|
|
31
|
+
} | {
|
|
32
|
+
t: "event";
|
|
33
|
+
ref?: number;
|
|
34
|
+
ch: string;
|
|
35
|
+
ev: string;
|
|
36
|
+
d?: unknown;
|
|
37
|
+
} | {
|
|
38
|
+
t: "leave";
|
|
39
|
+
ref?: number;
|
|
40
|
+
ch: string;
|
|
41
|
+
} | {
|
|
42
|
+
t: "presence_update";
|
|
43
|
+
ref?: number;
|
|
44
|
+
ch: string;
|
|
45
|
+
d?: unknown;
|
|
46
|
+
};
|
|
47
|
+
/** Public error payload; same shape as `HttpError.toJSON()` minus nothing. */
|
|
48
|
+
export type WireError = Pick<HttpErrorJSON, "status" | "code" | "message"> & Partial<Pick<HttpErrorJSON, "details" | "stack">>;
|
|
49
|
+
export type PresenceEntries = Record<string, {
|
|
50
|
+
ref: string;
|
|
51
|
+
meta: unknown;
|
|
52
|
+
}[]>;
|
|
53
|
+
export type ServerFrame = {
|
|
54
|
+
t: "reply";
|
|
55
|
+
ref: number;
|
|
56
|
+
ok: true;
|
|
57
|
+
d?: unknown;
|
|
58
|
+
} | {
|
|
59
|
+
t: "reply";
|
|
60
|
+
ref: number;
|
|
61
|
+
ok: false;
|
|
62
|
+
err: WireError;
|
|
63
|
+
} | {
|
|
64
|
+
t: "event";
|
|
65
|
+
ch: string;
|
|
66
|
+
ev: string;
|
|
67
|
+
d: unknown;
|
|
68
|
+
seq?: number;
|
|
69
|
+
} | {
|
|
70
|
+
t: "close";
|
|
71
|
+
ch: string;
|
|
72
|
+
reason: string;
|
|
73
|
+
} | {
|
|
74
|
+
t: "error";
|
|
75
|
+
ch?: string;
|
|
76
|
+
err: WireError;
|
|
77
|
+
} | {
|
|
78
|
+
t: "presence_state";
|
|
79
|
+
ch: string;
|
|
80
|
+
d: PresenceEntries;
|
|
81
|
+
} | {
|
|
82
|
+
t: "presence_diff";
|
|
83
|
+
ch: string;
|
|
84
|
+
joins: PresenceEntries;
|
|
85
|
+
leaves: PresenceEntries;
|
|
86
|
+
};
|
|
87
|
+
/** Why a frame was rejected; `ref` is recovered when possible for the reply. */
|
|
88
|
+
export declare class FrameError extends Error {
|
|
89
|
+
readonly ref?: number;
|
|
90
|
+
readonly ch?: string;
|
|
91
|
+
constructor(message: string, ref?: number, ch?: string);
|
|
92
|
+
}
|
|
93
|
+
/** Validate an already-JSON-parsed client frame. Throws {@link FrameError}. */
|
|
94
|
+
export declare function parseClientFrame(value: unknown): ClientFrame;
|
|
95
|
+
export declare function encodeFrame(frame: ServerFrame): string;
|
|
96
|
+
export declare function eventFrame(ch: string, ev: string, d: unknown): string;
|