@fishenv/http 0.0.3 → 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.
@@ -7,6 +7,12 @@
7
7
  * leave order holds); different sockets run concurrently
8
8
  * - nothing reaches a handler before the channel is joined and the payload
9
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
10
16
  * - repeated protocol violations close the socket with `1008`
11
17
  */
12
18
  import { WsHub } from "../hub.ts";
@@ -19,6 +25,11 @@ export interface ChannelsOptions {
19
25
  maxViolations?: number;
20
26
  /** Frames queued behind a slow handler before closing with `1013`. Default 64. */
21
27
  maxPendingFrames?: number;
28
+ /**
29
+ * Channel events queued for a socket while its join handler runs. Default
30
+ * 1024. Exceeding it fails the join with `503 join_overflow`.
31
+ */
32
+ maxJoinBufferedFrames?: number;
22
33
  /**
23
34
  * Hub that backs channel membership — pass an instance or a DI token to
24
35
  * reach channels from server code via `hub.channel(name)`. A private hub is
@@ -7,6 +7,12 @@
7
7
  * leave order holds); different sockets run concurrently
8
8
  * - nothing reaches a handler before the channel is joined and the payload
9
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
10
16
  * - repeated protocol violations close the socket with `1008`
11
17
  */
12
18
  import * as v from "valibot";
@@ -21,6 +27,7 @@ const DEFAULTS = {
21
27
  maxChannelsPerSocket: 32,
22
28
  maxViolations: 10,
23
29
  maxPendingFrames: 64,
30
+ maxJoinBufferedFrames: 1024,
24
31
  };
25
32
  /** Errors for rejected frames, mapped onto the HTTP error vocabulary. */
26
33
  class ChannelError extends HttpError {
@@ -202,23 +209,43 @@ class ChannelSession {
202
209
  const data = parsePayload(contract.join.payload, frame.d);
203
210
  const context = this.#context(def, frame.ch);
204
211
  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.
212
+ const room = channelRoom(frame.ch);
208
213
  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
+ // 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;
214
233
  }
215
234
  if (this.#socket.closed)
216
235
  return;
217
- const room = channelRoom(frame.ch);
218
- this.#hub.join(this.#socket, room);
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
+ }
219
244
  this.#hub._onLeave(this.#socket, room, (reason) => this.#schedule(() => this.#serverLeave(frame.ch, "kicked", reason)));
220
245
  this.#joined.set(frame.ch, { impl: def, name: frame.ch, path, context });
221
246
  this.#reply(frame.ref, reply);
247
+ for (const queuedFrame of queued)
248
+ this.#socket.send(queuedFrame);
222
249
  if (presence != null) {
223
250
  const registry = presenceFor(this.#hub);
224
251
  if (tracked != null) {
@@ -392,7 +419,11 @@ class ChannelSession {
392
419
  },
393
420
  push: (event, payload) => {
394
421
  check(event, payload);
395
- return this.#socket.send(eventFrame(name, 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);
396
427
  },
397
428
  leave: (reason = "") => {
398
429
  this.#schedule(() => this.#serverLeave(name, "server", reason));
package/dist/ws/hub.d.ts CHANGED
@@ -116,6 +116,24 @@ export declare class WsHub {
116
116
  join(socket: WsConnection, room: string): void;
117
117
  /** Remove a socket from a room. Does *not* run leave hooks. */
118
118
  leave(socket: WsConnection, room: string): void;
119
+ /**
120
+ * @internal Make a socket a member of `room` whose frames for the room are
121
+ * queued instead of sent, until {@link _release}. Channels use this while a
122
+ * join handler runs, so nothing broadcast meanwhile is missed and nothing
123
+ * arrives before the join reply. Past `limit` queued frames the hold is
124
+ * marked overflowed (further frames are dropped). `false` when nothing is
125
+ * held: the socket is closed or already a (normal) member.
126
+ */
127
+ _hold(socket: WsConnection, room: string, limit: number): boolean;
128
+ /**
129
+ * @internal End a hold: subscribe the socket normally and return the queued
130
+ * frames, oldest first, for the caller to send. `null` when the socket is no
131
+ * longer held (it left, was kicked or closed); `"overflow"` when the queue
132
+ * overflowed, in which case the socket also leaves the room.
133
+ */
134
+ _release(socket: WsConnection, room: string): Frame[] | "overflow" | null;
135
+ /** @internal Queue `frame` if `socket` is held in `room`; `false` otherwise. */
136
+ _holdFrame(socket: WsConnection, room: string, frame: Frame): boolean;
119
137
  /** Rooms a socket is in. */
120
138
  rooms(socket: WsConnection): string[];
121
139
  /** Every room with at least one local member. */
package/dist/ws/hub.js CHANGED
@@ -114,6 +114,8 @@ export class WsHub {
114
114
  #roomsOf = new Map();
115
115
  #tracked = new Set();
116
116
  #leaveHooks = new Map();
117
+ /** Members whose room frames are queued until `_release` (pending joins). */
118
+ #held = new Map();
117
119
  #extensions = new Map();
118
120
  #bridge;
119
121
  #replicated;
@@ -161,29 +163,14 @@ export class WsHub {
161
163
  // ── Rooms ───────────────────────────────────────────────────────────
162
164
  /** Put a socket in a room (idempotent). Server-side only. */
163
165
  join(socket, room) {
164
- if (socket.closed)
165
- return;
166
- let members = this.#rooms.get(room);
167
- if (members == null) {
168
- members = new Set();
169
- this.#rooms.set(room, members);
170
- }
171
- if (members.has(socket))
172
- return;
173
- members.add(socket);
174
- let rooms = this.#roomsOf.get(socket);
175
- if (rooms == null) {
176
- rooms = new Set();
177
- this.#roomsOf.set(socket, rooms);
178
- }
179
- rooms.add(room);
180
- if (serverOf(socket) != null)
166
+ if (this.#addMember(socket, room) && serverOf(socket) != null) {
181
167
  socket.raw.subscribe(this.#topic(room));
182
- this.#track(socket);
168
+ }
183
169
  }
184
170
  /** Remove a socket from a room. Does *not* run leave hooks. */
185
171
  leave(socket, room) {
186
172
  this.#leaveHooks.get(socket)?.delete(room);
173
+ this.#unhold(socket, room);
187
174
  const members = this.#rooms.get(room);
188
175
  if (members == null || !members.delete(socket))
189
176
  return;
@@ -197,6 +184,86 @@ export class WsHub {
197
184
  socket.raw.unsubscribe(this.#topic(room));
198
185
  }
199
186
  }
187
+ /**
188
+ * @internal Make a socket a member of `room` whose frames for the room are
189
+ * queued instead of sent, until {@link _release}. Channels use this while a
190
+ * join handler runs, so nothing broadcast meanwhile is missed and nothing
191
+ * arrives before the join reply. Past `limit` queued frames the hold is
192
+ * marked overflowed (further frames are dropped). `false` when nothing is
193
+ * held: the socket is closed or already a (normal) member.
194
+ */
195
+ _hold(socket, room, limit) {
196
+ if (!this.#addMember(socket, room))
197
+ return false;
198
+ let held = this.#held.get(socket);
199
+ if (held == null) {
200
+ held = new Map();
201
+ this.#held.set(socket, held);
202
+ }
203
+ held.set(room, { frames: [], limit, overflowed: false });
204
+ return true;
205
+ }
206
+ /**
207
+ * @internal End a hold: subscribe the socket normally and return the queued
208
+ * frames, oldest first, for the caller to send. `null` when the socket is no
209
+ * longer held (it left, was kicked or closed); `"overflow"` when the queue
210
+ * overflowed, in which case the socket also leaves the room.
211
+ */
212
+ _release(socket, room) {
213
+ const held = this.#held.get(socket)?.get(room);
214
+ if (held == null)
215
+ return null;
216
+ this.#unhold(socket, room);
217
+ if (held.overflowed) {
218
+ this.leave(socket, room);
219
+ return "overflow";
220
+ }
221
+ if (serverOf(socket) != null)
222
+ socket.raw.subscribe(this.#topic(room));
223
+ return held.frames;
224
+ }
225
+ /** @internal Queue `frame` if `socket` is held in `room`; `false` otherwise. */
226
+ _holdFrame(socket, room, frame) {
227
+ const held = this.#held.get(socket)?.get(room);
228
+ if (held == null)
229
+ return false;
230
+ if (held.overflowed)
231
+ return true;
232
+ if (held.frames.length >= held.limit) {
233
+ held.overflowed = true;
234
+ held.frames = [];
235
+ }
236
+ else {
237
+ held.frames.push(frame);
238
+ }
239
+ return true;
240
+ }
241
+ /** `true` when the socket was added (not closed, not already a member). */
242
+ #addMember(socket, room) {
243
+ if (socket.closed)
244
+ return false;
245
+ let members = this.#rooms.get(room);
246
+ if (members == null) {
247
+ members = new Set();
248
+ this.#rooms.set(room, members);
249
+ }
250
+ if (members.has(socket))
251
+ return false;
252
+ members.add(socket);
253
+ let rooms = this.#roomsOf.get(socket);
254
+ if (rooms == null) {
255
+ rooms = new Set();
256
+ this.#roomsOf.set(socket, rooms);
257
+ }
258
+ rooms.add(room);
259
+ this.#track(socket);
260
+ return true;
261
+ }
262
+ #unhold(socket, room) {
263
+ const held = this.#held.get(socket);
264
+ if (held?.delete(room) && held.size === 0)
265
+ this.#held.delete(socket);
266
+ }
200
267
  /** Rooms a socket is in. */
201
268
  rooms(socket) {
202
269
  return [...(this.#roomsOf.get(socket) ?? [])];
@@ -330,6 +397,10 @@ export class WsHub {
330
397
  for (const socket of members) {
331
398
  if (socket.closed)
332
399
  continue;
400
+ if (this._holdFrame(socket, room, frame)) {
401
+ delivered++;
402
+ continue;
403
+ }
333
404
  const server = serverOf(socket);
334
405
  if (server == null) {
335
406
  if (isDelivered(socket.send(frame)))
@@ -348,7 +419,9 @@ export class WsHub {
348
419
  for (const socket of [...members]) {
349
420
  if (!this.#matches(socket, filter))
350
421
  continue;
351
- if (isDelivered(socket.send(frame)))
422
+ if (this._holdFrame(socket, room, frame))
423
+ delivered++;
424
+ else if (isDelivered(socket.send(frame)))
352
425
  delivered++;
353
426
  }
354
427
  return delivered;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fishenv/http",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Type-safe HTTP router for Bun with valibot validation, contracts and Server-Sent Events.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",