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