@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,94 @@
|
|
|
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
|
+
export { CHANNELS_PROTOCOL } from "../../contract/index.js";
|
|
22
|
+
export const MAX_CHANNEL_NAME_LENGTH = 256;
|
|
23
|
+
export const MAX_EVENT_NAME_LENGTH = 128;
|
|
24
|
+
export const MAX_REF = 2 ** 31 - 1;
|
|
25
|
+
/** Why a frame was rejected; `ref` is recovered when possible for the reply. */
|
|
26
|
+
export class FrameError extends Error {
|
|
27
|
+
ref;
|
|
28
|
+
ch;
|
|
29
|
+
constructor(message, ref, ch) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "FrameError";
|
|
32
|
+
this.ref = ref;
|
|
33
|
+
this.ch = ch;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const TYPES = new Set(["join", "event", "leave", "presence_update"]);
|
|
37
|
+
function isRecord(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function readRef(raw) {
|
|
41
|
+
const ref = raw.ref;
|
|
42
|
+
if (ref === undefined)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (typeof ref === "number" &&
|
|
45
|
+
Number.isInteger(ref) &&
|
|
46
|
+
ref >= 0 &&
|
|
47
|
+
ref <= MAX_REF) {
|
|
48
|
+
return ref;
|
|
49
|
+
}
|
|
50
|
+
throw new FrameError("Invalid ref");
|
|
51
|
+
}
|
|
52
|
+
function readName(value, max) {
|
|
53
|
+
if (typeof value !== "string" || value === "" || value.length > max) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
// No control characters (incl. NUL) in names.
|
|
57
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: that's the point
|
|
58
|
+
if (/[\u0000-\u001f\u007f]/.test(value))
|
|
59
|
+
return null;
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
/** Validate an already-JSON-parsed client frame. Throws {@link FrameError}. */
|
|
63
|
+
export function parseClientFrame(value) {
|
|
64
|
+
if (!isRecord(value))
|
|
65
|
+
throw new FrameError("Frame must be an object");
|
|
66
|
+
const ref = readRef(value);
|
|
67
|
+
const t = value.t;
|
|
68
|
+
if (typeof t !== "string" || !TYPES.has(t)) {
|
|
69
|
+
throw new FrameError("Unknown frame type", ref);
|
|
70
|
+
}
|
|
71
|
+
const ch = readName(value.ch, MAX_CHANNEL_NAME_LENGTH);
|
|
72
|
+
if (ch == null)
|
|
73
|
+
throw new FrameError("Invalid channel name", ref);
|
|
74
|
+
switch (t) {
|
|
75
|
+
case "join":
|
|
76
|
+
return { t, ref, ch, d: value.d };
|
|
77
|
+
case "leave":
|
|
78
|
+
return { t, ref, ch };
|
|
79
|
+
case "presence_update":
|
|
80
|
+
return { t, ref, ch, d: value.d };
|
|
81
|
+
default: {
|
|
82
|
+
const ev = readName(value.ev, MAX_EVENT_NAME_LENGTH);
|
|
83
|
+
if (ev == null)
|
|
84
|
+
throw new FrameError("Invalid event name", ref, ch);
|
|
85
|
+
return { t: "event", ref, ch, ev, d: value.d };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function encodeFrame(frame) {
|
|
90
|
+
return JSON.stringify(frame);
|
|
91
|
+
}
|
|
92
|
+
export function eventFrame(ch, ev, d) {
|
|
93
|
+
return encodeFrame({ t: "event", ch, ev, d });
|
|
94
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side access to a concrete channel (`rooms/42`) from anywhere —
|
|
3
|
+
* use cases, jobs, webhooks — via the hub the channels are mounted with.
|
|
4
|
+
*/
|
|
5
|
+
import type { AnyChannelContract } from "../../contract/index.ts";
|
|
6
|
+
import type { WsConnection } from "../connection.ts";
|
|
7
|
+
import type { WsHub, WsRoomTarget } from "../hub.ts";
|
|
8
|
+
import type { PresenceTopic } from "../presence.ts";
|
|
9
|
+
import type { ServerEventName, ServerEventPayload } from "./channel.ts";
|
|
10
|
+
/**
|
|
11
|
+
* Hub room backing a channel. The control-character prefix can't collide
|
|
12
|
+
* with client-supplied channel names (which reject control characters) or
|
|
13
|
+
* with ordinary server-side rooms.
|
|
14
|
+
*/
|
|
15
|
+
export declare function channelRoom(name: string): string;
|
|
16
|
+
export declare class WsChannelTarget<C = AnyChannelContract> {
|
|
17
|
+
#private;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** @internal Use `hub.channel(name)`. */
|
|
20
|
+
constructor(hub: WsHub, name: string, room?: WsRoomTarget);
|
|
21
|
+
/** Who is in this channel, cluster-wide (contracts with `.presence()`). */
|
|
22
|
+
get presence(): PresenceTopic;
|
|
23
|
+
/** Push an event to matching members on every node. Returns the local count. */
|
|
24
|
+
send<E extends ServerEventName<C>>(event: E, payload: ServerEventPayload<C, E>): number;
|
|
25
|
+
/** Only sockets registered under these hub keys (e.g. user ids). */
|
|
26
|
+
to(...keys: string[]): WsChannelTarget<C>;
|
|
27
|
+
except(...targets: (WsConnection | string)[]): WsChannelTarget<C>;
|
|
28
|
+
/**
|
|
29
|
+
* Remove matching members on every node. Their `.leave()` handlers run
|
|
30
|
+
* with reason `"kicked"` and the client receives `{ t: "close" }`.
|
|
31
|
+
*/
|
|
32
|
+
kick(reason?: string): number;
|
|
33
|
+
/** Local matching member count. */
|
|
34
|
+
count(): number;
|
|
35
|
+
sockets(): WsConnection[];
|
|
36
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side access to a concrete channel (`rooms/42`) from anywhere —
|
|
3
|
+
* use cases, jobs, webhooks — via the hub the channels are mounted with.
|
|
4
|
+
*/
|
|
5
|
+
import { eventFrame } from "./protocol.js";
|
|
6
|
+
/**
|
|
7
|
+
* Hub room backing a channel. The control-character prefix can't collide
|
|
8
|
+
* with client-supplied channel names (which reject control characters) or
|
|
9
|
+
* with ordinary server-side rooms.
|
|
10
|
+
*/
|
|
11
|
+
export function channelRoom(name) {
|
|
12
|
+
return `\u0001ch:${name}`;
|
|
13
|
+
}
|
|
14
|
+
export class WsChannelTarget {
|
|
15
|
+
name;
|
|
16
|
+
#hub;
|
|
17
|
+
#room;
|
|
18
|
+
/** @internal Use `hub.channel(name)`. */
|
|
19
|
+
constructor(hub, name, room) {
|
|
20
|
+
this.name = name;
|
|
21
|
+
this.#hub = hub;
|
|
22
|
+
this.#room = room ?? hub.to(channelRoom(name));
|
|
23
|
+
}
|
|
24
|
+
/** Who is in this channel, cluster-wide (contracts with `.presence()`). */
|
|
25
|
+
get presence() {
|
|
26
|
+
return this.#hub.presence(channelRoom(this.name));
|
|
27
|
+
}
|
|
28
|
+
/** Push an event to matching members on every node. Returns the local count. */
|
|
29
|
+
send(event, payload) {
|
|
30
|
+
return this.#room.send(eventFrame(this.name, event, payload));
|
|
31
|
+
}
|
|
32
|
+
/** Only sockets registered under these hub keys (e.g. user ids). */
|
|
33
|
+
to(...keys) {
|
|
34
|
+
return this.#derive(this.#room.only(...keys));
|
|
35
|
+
}
|
|
36
|
+
except(...targets) {
|
|
37
|
+
return this.#derive(this.#room.except(...targets));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Remove matching members on every node. Their `.leave()` handlers run
|
|
41
|
+
* with reason `"kicked"` and the client receives `{ t: "close" }`.
|
|
42
|
+
*/
|
|
43
|
+
kick(reason = "") {
|
|
44
|
+
return this.#room.kick(reason);
|
|
45
|
+
}
|
|
46
|
+
/** Local matching member count. */
|
|
47
|
+
count() {
|
|
48
|
+
return this.#room.count();
|
|
49
|
+
}
|
|
50
|
+
sockets() {
|
|
51
|
+
return this.#room.sockets();
|
|
52
|
+
}
|
|
53
|
+
#derive(room) {
|
|
54
|
+
return new WsChannelTarget(this.#hub, this.name, room);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** A single live WebSocket, usable beyond the lifetime of its route handler. */
|
|
2
|
+
import type { UpgradedSocket } from "../core/index.ts";
|
|
3
|
+
import type { WsMessage } from "./message.ts";
|
|
4
|
+
import { type WsCleanupFn, type WsSendable, type WsSendResult } from "./types.ts";
|
|
5
|
+
export type WsMessageListener = (message: WsMessage) => unknown;
|
|
6
|
+
export type WsCloseListener = (code: number, reason: string) => unknown;
|
|
7
|
+
/**
|
|
8
|
+
* Wraps Bun's `ServerWebSocket`. Every method is a no-op once the connection
|
|
9
|
+
* is closed (or closing), so a stale reference held by a service or hub can
|
|
10
|
+
* never throw.
|
|
11
|
+
*/
|
|
12
|
+
export declare class WsConnection {
|
|
13
|
+
#private;
|
|
14
|
+
/** Unique per connection; a user may hold several (one per tab/device). */
|
|
15
|
+
readonly id: string;
|
|
16
|
+
/** The upgrade request (headers, URL). */
|
|
17
|
+
readonly req: Request;
|
|
18
|
+
/** Negotiated subprotocol, if any. */
|
|
19
|
+
readonly protocol: string | null;
|
|
20
|
+
/** Free-form per-connection state for the application. */
|
|
21
|
+
data: Record<string, unknown>;
|
|
22
|
+
/** @internal Created by the ws route lifecycle. */
|
|
23
|
+
constructor(ws: UpgradedSocket, req: Request, protocol: string | null, onError: (err: unknown) => void);
|
|
24
|
+
/** `true` once `close()` was called or the peer disconnected. */
|
|
25
|
+
get closed(): boolean;
|
|
26
|
+
get remoteAddress(): string;
|
|
27
|
+
/** Bytes queued but not yet flushed to the socket. */
|
|
28
|
+
get bufferedBytes(): number;
|
|
29
|
+
/** Escape hatch to Bun's socket. Native topics used here aren't namespaced. */
|
|
30
|
+
get raw(): UpgradedSocket;
|
|
31
|
+
send(data: WsSendable): WsSendResult;
|
|
32
|
+
/** `JSON.stringify` + send. Throws on non-serializable values. */
|
|
33
|
+
sendJson(value: unknown): WsSendResult;
|
|
34
|
+
/** Graceful close. The reason is truncated to 123 bytes. */
|
|
35
|
+
close(code?: number, reason?: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* Close with `4001 Session expired` at `when` (a `Date` or epoch ms) —
|
|
38
|
+
* align it with the token/session lifetime so sockets don't outlive auth.
|
|
39
|
+
* Calling again replaces the previous deadline.
|
|
40
|
+
*/
|
|
41
|
+
expireAt(when: Date | number): void;
|
|
42
|
+
/** Returns an unsubscribe fn. */
|
|
43
|
+
onMessage(fn: WsMessageListener): () => void;
|
|
44
|
+
/** Runs exactly once, immediately if already closed. */
|
|
45
|
+
onClose(fn: WsCloseListener | WsCleanupFn): void;
|
|
46
|
+
/** Fires when a backpressured socket's buffer drains. */
|
|
47
|
+
onDrain(fn: () => void): () => void;
|
|
48
|
+
/** @internal */
|
|
49
|
+
_deliver(message: WsMessage): void;
|
|
50
|
+
/** @internal */
|
|
51
|
+
_drained(): void;
|
|
52
|
+
/** @internal Bun reported the socket closed. */
|
|
53
|
+
_closed(code: number, reason: string): void;
|
|
54
|
+
}
|
|
55
|
+
/** Run a user callback; sync throws and rejections both go to `onError`. */
|
|
56
|
+
export declare function runCallback(fn: () => unknown, onError: (err: unknown) => void): void;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** A single live WebSocket, usable beyond the lifetime of its route handler. */
|
|
2
|
+
import { truncateReason, WsCloseCode, } from "./types.js";
|
|
3
|
+
/** setTimeout's max delay; longer expiries are chained. */
|
|
4
|
+
const MAX_TIMEOUT = 2 ** 31 - 1;
|
|
5
|
+
/**
|
|
6
|
+
* Wraps Bun's `ServerWebSocket`. Every method is a no-op once the connection
|
|
7
|
+
* is closed (or closing), so a stale reference held by a service or hub can
|
|
8
|
+
* never throw.
|
|
9
|
+
*/
|
|
10
|
+
export class WsConnection {
|
|
11
|
+
/** Unique per connection; a user may hold several (one per tab/device). */
|
|
12
|
+
id = crypto.randomUUID();
|
|
13
|
+
/** The upgrade request (headers, URL). */
|
|
14
|
+
req;
|
|
15
|
+
/** Negotiated subprotocol, if any. */
|
|
16
|
+
protocol;
|
|
17
|
+
/** Free-form per-connection state for the application. */
|
|
18
|
+
data = {};
|
|
19
|
+
#ws;
|
|
20
|
+
#onError;
|
|
21
|
+
#state = "open";
|
|
22
|
+
#closeInfo = null;
|
|
23
|
+
#messageListeners = new Set();
|
|
24
|
+
#drainListeners = new Set();
|
|
25
|
+
#closeListeners = [];
|
|
26
|
+
#expiry;
|
|
27
|
+
/** @internal Created by the ws route lifecycle. */
|
|
28
|
+
constructor(ws, req, protocol, onError) {
|
|
29
|
+
this.#ws = ws;
|
|
30
|
+
this.req = req;
|
|
31
|
+
this.protocol = protocol;
|
|
32
|
+
this.#onError = onError;
|
|
33
|
+
}
|
|
34
|
+
/** `true` once `close()` was called or the peer disconnected. */
|
|
35
|
+
get closed() {
|
|
36
|
+
return this.#state !== "open";
|
|
37
|
+
}
|
|
38
|
+
get remoteAddress() {
|
|
39
|
+
return this.#ws.remoteAddress;
|
|
40
|
+
}
|
|
41
|
+
/** Bytes queued but not yet flushed to the socket. */
|
|
42
|
+
get bufferedBytes() {
|
|
43
|
+
return this.#state === "closed" ? 0 : this.#ws.getBufferedAmount();
|
|
44
|
+
}
|
|
45
|
+
/** Escape hatch to Bun's socket. Native topics used here aren't namespaced. */
|
|
46
|
+
get raw() {
|
|
47
|
+
return this.#ws;
|
|
48
|
+
}
|
|
49
|
+
send(data) {
|
|
50
|
+
if (this.closed)
|
|
51
|
+
return "closed";
|
|
52
|
+
let status;
|
|
53
|
+
try {
|
|
54
|
+
status = this.#ws.send(data);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return "closed";
|
|
58
|
+
}
|
|
59
|
+
if (status === -1)
|
|
60
|
+
return "backpressure";
|
|
61
|
+
if (status === 0)
|
|
62
|
+
return "dropped";
|
|
63
|
+
return "sent";
|
|
64
|
+
}
|
|
65
|
+
/** `JSON.stringify` + send. Throws on non-serializable values. */
|
|
66
|
+
sendJson(value) {
|
|
67
|
+
return this.send(JSON.stringify(value));
|
|
68
|
+
}
|
|
69
|
+
/** Graceful close. The reason is truncated to 123 bytes. */
|
|
70
|
+
close(code = WsCloseCode.Normal, reason = "") {
|
|
71
|
+
if (this.closed)
|
|
72
|
+
return;
|
|
73
|
+
this.#state = "closing";
|
|
74
|
+
this.#clearExpiry();
|
|
75
|
+
try {
|
|
76
|
+
this.#ws.close(code, truncateReason(reason));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Invalid code or already torn down — make sure it really goes away.
|
|
80
|
+
this.#ws.terminate();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Close with `4001 Session expired` at `when` (a `Date` or epoch ms) —
|
|
85
|
+
* align it with the token/session lifetime so sockets don't outlive auth.
|
|
86
|
+
* Calling again replaces the previous deadline.
|
|
87
|
+
*/
|
|
88
|
+
expireAt(when) {
|
|
89
|
+
if (this.closed)
|
|
90
|
+
return;
|
|
91
|
+
this.#clearExpiry();
|
|
92
|
+
const deadline = typeof when === "number" ? when : when.getTime();
|
|
93
|
+
const arm = () => {
|
|
94
|
+
const remaining = deadline - Date.now();
|
|
95
|
+
if (remaining <= 0) {
|
|
96
|
+
this.close(WsCloseCode.SessionExpired, "Session expired");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
this.#expiry = setTimeout(arm, Math.min(remaining, MAX_TIMEOUT));
|
|
100
|
+
};
|
|
101
|
+
arm();
|
|
102
|
+
}
|
|
103
|
+
/** Returns an unsubscribe fn. */
|
|
104
|
+
onMessage(fn) {
|
|
105
|
+
if (this.#state === "closed")
|
|
106
|
+
return () => { };
|
|
107
|
+
this.#messageListeners.add(fn);
|
|
108
|
+
return () => this.#messageListeners.delete(fn);
|
|
109
|
+
}
|
|
110
|
+
/** Runs exactly once, immediately if already closed. */
|
|
111
|
+
onClose(fn) {
|
|
112
|
+
if (this.#state === "closed") {
|
|
113
|
+
const info = this.#closeInfo;
|
|
114
|
+
runCallback(() => fn(info.code, info.reason), this.#onError);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.#closeListeners.push(fn);
|
|
118
|
+
}
|
|
119
|
+
/** Fires when a backpressured socket's buffer drains. */
|
|
120
|
+
onDrain(fn) {
|
|
121
|
+
if (this.#state === "closed")
|
|
122
|
+
return () => { };
|
|
123
|
+
this.#drainListeners.add(fn);
|
|
124
|
+
return () => this.#drainListeners.delete(fn);
|
|
125
|
+
}
|
|
126
|
+
// ── Lifecycle hooks (driven by the route lifecycle) ─────────────────
|
|
127
|
+
/** @internal */
|
|
128
|
+
_deliver(message) {
|
|
129
|
+
for (const listener of [...this.#messageListeners]) {
|
|
130
|
+
if (this.closed)
|
|
131
|
+
return;
|
|
132
|
+
runCallback(() => listener(message), this.#onError);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** @internal */
|
|
136
|
+
_drained() {
|
|
137
|
+
for (const listener of [...this.#drainListeners]) {
|
|
138
|
+
runCallback(listener, this.#onError);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** @internal Bun reported the socket closed. */
|
|
142
|
+
_closed(code, reason) {
|
|
143
|
+
if (this.#state === "closed")
|
|
144
|
+
return;
|
|
145
|
+
this.#state = "closed";
|
|
146
|
+
this.#closeInfo = { code, reason };
|
|
147
|
+
this.#clearExpiry();
|
|
148
|
+
this.#messageListeners.clear();
|
|
149
|
+
this.#drainListeners.clear();
|
|
150
|
+
const listeners = this.#closeListeners;
|
|
151
|
+
this.#closeListeners = [];
|
|
152
|
+
for (const fn of listeners) {
|
|
153
|
+
runCallback(() => fn(code, reason), this.#onError);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
#clearExpiry() {
|
|
157
|
+
if (this.#expiry !== undefined)
|
|
158
|
+
clearTimeout(this.#expiry);
|
|
159
|
+
this.#expiry = undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Run a user callback; sync throws and rejections both go to `onError`. */
|
|
163
|
+
export function runCallback(fn, onError) {
|
|
164
|
+
try {
|
|
165
|
+
const result = fn();
|
|
166
|
+
if (result instanceof Promise)
|
|
167
|
+
result.catch(onError);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
onError(err);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Handshake helpers usable from custom handlers and middleware. */
|
|
2
|
+
import { HttpError } from "../core/index.ts";
|
|
3
|
+
/** `426 Upgrade Required`, advertising the websocket upgrade. */
|
|
4
|
+
export declare class UpgradeRequiredError extends HttpError {
|
|
5
|
+
constructor(message?: string);
|
|
6
|
+
get code(): string;
|
|
7
|
+
toResponse(development?: boolean): Response;
|
|
8
|
+
}
|
|
9
|
+
/** RFC 6455 §4.2.1 opening-handshake checks. */
|
|
10
|
+
export declare function isWebSocketUpgrade(req: Request): boolean;
|
|
11
|
+
/** Subprotocols the client offered, in its order. */
|
|
12
|
+
export declare function requestedProtocols(req: Request): string[];
|
|
13
|
+
/**
|
|
14
|
+
* Pick the first of the server's `supported` protocols (server preference
|
|
15
|
+
* order) that the client offered; `null` if none match.
|
|
16
|
+
*/
|
|
17
|
+
export declare function negotiateProtocol(req: Request, supported: readonly string[]): string | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Handshake helpers usable from custom handlers and middleware. */
|
|
2
|
+
import { HttpError } from "../core/index.js";
|
|
3
|
+
/** `426 Upgrade Required`, advertising the websocket upgrade. */
|
|
4
|
+
export class UpgradeRequiredError extends HttpError {
|
|
5
|
+
constructor(message = "Upgrade Required") {
|
|
6
|
+
super(426, message);
|
|
7
|
+
}
|
|
8
|
+
get code() {
|
|
9
|
+
return "upgrade_required";
|
|
10
|
+
}
|
|
11
|
+
toResponse(development = false) {
|
|
12
|
+
const res = super.toResponse(development);
|
|
13
|
+
res.headers.set("Upgrade", "websocket");
|
|
14
|
+
res.headers.set("Connection", "Upgrade");
|
|
15
|
+
return res;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function hasToken(header, token) {
|
|
19
|
+
if (header == null)
|
|
20
|
+
return false;
|
|
21
|
+
return header.split(",").some((part) => part.trim().toLowerCase() === token);
|
|
22
|
+
}
|
|
23
|
+
/** RFC 6455 §4.2.1 opening-handshake checks. */
|
|
24
|
+
export function isWebSocketUpgrade(req) {
|
|
25
|
+
return (req.method === "GET" &&
|
|
26
|
+
hasToken(req.headers.get("upgrade"), "websocket") &&
|
|
27
|
+
hasToken(req.headers.get("connection"), "upgrade") &&
|
|
28
|
+
req.headers.get("sec-websocket-version") === "13" &&
|
|
29
|
+
(req.headers.get("sec-websocket-key") ?? "") !== "");
|
|
30
|
+
}
|
|
31
|
+
/** Subprotocols the client offered, in its order. */
|
|
32
|
+
export function requestedProtocols(req) {
|
|
33
|
+
const header = req.headers.get("sec-websocket-protocol");
|
|
34
|
+
if (header == null)
|
|
35
|
+
return [];
|
|
36
|
+
return header
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((p) => p.trim())
|
|
39
|
+
.filter((p) => p !== "");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Pick the first of the server's `supported` protocols (server preference
|
|
43
|
+
* order) that the client offered; `null` if none match.
|
|
44
|
+
*/
|
|
45
|
+
export function negotiateProtocol(req, supported) {
|
|
46
|
+
const offered = new Set(requestedProtocols(req));
|
|
47
|
+
return supported.find((p) => offered.has(p)) ?? null;
|
|
48
|
+
}
|
package/dist/ws/hub.d.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed registry of live sockets plus server-side rooms — the seam for
|
|
3
|
+
* targeted (non-broadcast) sends from anywhere in the application.
|
|
4
|
+
*/
|
|
5
|
+
import type { AnyChannelContract } from "../contract/index.ts";
|
|
6
|
+
import { type Transport, type TransportEnvelope } from "../core/index.ts";
|
|
7
|
+
import { WsChannelTarget } from "./channels/target.ts";
|
|
8
|
+
import type { WsConnection } from "./connection.ts";
|
|
9
|
+
import { type PresenceOptions, PresenceRegistry, PresenceTopic } from "./presence.ts";
|
|
10
|
+
/** Anything a hub can deliver: text and binary go out as-is, the rest as JSON. */
|
|
11
|
+
export type WsHubPayload = unknown;
|
|
12
|
+
/** Serializable form of a {@link RoomFilter}. */
|
|
13
|
+
export interface WireFilter {
|
|
14
|
+
exceptIds?: string[];
|
|
15
|
+
exceptKeys?: string[];
|
|
16
|
+
onlyKeys?: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Cross-process envelope for hub operations. One transport instance can be
|
|
20
|
+
* shared by every hub: messages carry the hub name.
|
|
21
|
+
*/
|
|
22
|
+
export type WsTransportMessage = TransportEnvelope & {
|
|
23
|
+
hub: string;
|
|
24
|
+
} & ({
|
|
25
|
+
kind: "send";
|
|
26
|
+
key: string | null;
|
|
27
|
+
payload: WirePayload;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "room";
|
|
30
|
+
room: string;
|
|
31
|
+
payload: WirePayload;
|
|
32
|
+
filter?: WireFilter;
|
|
33
|
+
} | {
|
|
34
|
+
kind: "close";
|
|
35
|
+
key: string;
|
|
36
|
+
code: number;
|
|
37
|
+
reason: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "kick";
|
|
40
|
+
room: string;
|
|
41
|
+
filter?: WireFilter;
|
|
42
|
+
reason: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "presence";
|
|
45
|
+
body: unknown;
|
|
46
|
+
});
|
|
47
|
+
/** A pre-encoded payload: text, or base64 for binary frames. */
|
|
48
|
+
export interface WirePayload {
|
|
49
|
+
text?: string;
|
|
50
|
+
base64?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface WsHubOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Namespaces native topics and transport messages. Defaults to the class
|
|
55
|
+
* name — set it explicitly if your build minifies class names.
|
|
56
|
+
*/
|
|
57
|
+
name?: string;
|
|
58
|
+
transport?: Transport<WsTransportMessage>;
|
|
59
|
+
/** Presence tuning (batching, heartbeats, limits). */
|
|
60
|
+
presence?: PresenceOptions;
|
|
61
|
+
}
|
|
62
|
+
type Frame = string | Uint8Array;
|
|
63
|
+
/** Which room members a send/kick applies to. */
|
|
64
|
+
export interface RoomFilter {
|
|
65
|
+
exceptIds: ReadonlySet<string>;
|
|
66
|
+
exceptKeys: ReadonlySet<string>;
|
|
67
|
+
/** `null` = every key. */
|
|
68
|
+
onlyKeys: ReadonlySet<string> | null;
|
|
69
|
+
}
|
|
70
|
+
/** A fan-out target: a room, optionally narrowed to/away from sockets or keys. */
|
|
71
|
+
export declare class WsRoomTarget {
|
|
72
|
+
#private;
|
|
73
|
+
/** @internal Use `hub.to(room)`. */
|
|
74
|
+
constructor(hub: WsHub, room: string, filter?: RoomFilter);
|
|
75
|
+
/** Exclude a socket (e.g. the sender) or every socket of a key. */
|
|
76
|
+
except(...targets: (WsConnection | string)[]): WsRoomTarget;
|
|
77
|
+
/** Only members registered under one of `keys`. */
|
|
78
|
+
only(...keys: string[]): WsRoomTarget;
|
|
79
|
+
/** Deliver to matching members on every node. Returns the local delivery count. */
|
|
80
|
+
send(data: WsHubPayload): number;
|
|
81
|
+
/**
|
|
82
|
+
* Remove matching members from the room on every node, running any leave
|
|
83
|
+
* hook registered for them (channels use this to notify the client).
|
|
84
|
+
*/
|
|
85
|
+
kick(reason?: string): number;
|
|
86
|
+
/** Local matching member count. */
|
|
87
|
+
count(): number;
|
|
88
|
+
/** Local matching members. */
|
|
89
|
+
sockets(): WsConnection[];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Sockets keyed by an application identity (user id, tenant id, device…),
|
|
93
|
+
* plus server-side **rooms** — groupings the server assigns; clients can't
|
|
94
|
+
* ask to join one. One key maps to many sockets (tabs); one socket has at
|
|
95
|
+
* most one key and any number of rooms.
|
|
96
|
+
*
|
|
97
|
+
* Subclass it to get a DI token:
|
|
98
|
+
* ```ts
|
|
99
|
+
* class ChatSockets extends WsHub {}
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export declare class WsHub {
|
|
103
|
+
#private;
|
|
104
|
+
readonly name: string;
|
|
105
|
+
constructor(options?: WsHubOptions);
|
|
106
|
+
/** Random id of this hub instance on the transport. */
|
|
107
|
+
get nodeId(): string;
|
|
108
|
+
/**
|
|
109
|
+
* Register a socket under `key`. Returns an unregister fn; also
|
|
110
|
+
* auto-removes (keys and rooms) on close. Re-adding moves the socket.
|
|
111
|
+
*/
|
|
112
|
+
add(key: string, socket: WsConnection): () => void;
|
|
113
|
+
/** The key a socket was registered under, if any. */
|
|
114
|
+
keyOf(socket: WsConnection): string | undefined;
|
|
115
|
+
/** Put a socket in a room (idempotent). Server-side only. */
|
|
116
|
+
join(socket: WsConnection, room: string): void;
|
|
117
|
+
/** Remove a socket from a room. Does *not* run leave hooks. */
|
|
118
|
+
leave(socket: WsConnection, room: string): void;
|
|
119
|
+
/** Rooms a socket is in. */
|
|
120
|
+
rooms(socket: WsConnection): string[];
|
|
121
|
+
/** Every room with at least one local member. */
|
|
122
|
+
roomNames(): string[];
|
|
123
|
+
to(room: string): WsRoomTarget;
|
|
124
|
+
/**
|
|
125
|
+
* A concrete channel of a `channels()` handler mounted with this hub.
|
|
126
|
+
* Pass the contract for typed server events: `hub.channel(room, "rooms/42")`.
|
|
127
|
+
*/
|
|
128
|
+
channel(name: string): WsChannelTarget;
|
|
129
|
+
channel<C extends AnyChannelContract>(contract: C, name: string): WsChannelTarget<C>;
|
|
130
|
+
/** Send to every socket of `key` on every node. Returns local delivery count. */
|
|
131
|
+
send(key: string, data: WsHubPayload): number;
|
|
132
|
+
sendMany(keys: Iterable<string>, data: WsHubPayload): number;
|
|
133
|
+
/** Send to every keyed socket in this hub, on every node. */
|
|
134
|
+
broadcast(data: WsHubPayload): number;
|
|
135
|
+
/**
|
|
136
|
+
* Close every socket of `key` on every node — e.g. on logout or when
|
|
137
|
+
* permissions are revoked. Returns the local count.
|
|
138
|
+
*/
|
|
139
|
+
close(key: string, code?: number, reason?: string): number;
|
|
140
|
+
/** Close every local socket and detach from the transport. */
|
|
141
|
+
closeAll(code?: number, reason?: string): void;
|
|
142
|
+
/**
|
|
143
|
+
* Presence for a topic — who is "in" it, by key, across every node.
|
|
144
|
+
* Channels with contract presence use `hub.channel(name).presence`.
|
|
145
|
+
*/
|
|
146
|
+
presence<M = unknown>(topic: string): PresenceTopic<M>;
|
|
147
|
+
/** @internal The hub's presence registry (created on first use). */
|
|
148
|
+
_presenceRegistry(): PresenceRegistry;
|
|
149
|
+
has(key: string): boolean;
|
|
150
|
+
/** Socket count for `key`, or every keyed socket when omitted. */
|
|
151
|
+
count(key?: string): number;
|
|
152
|
+
keys(): string[];
|
|
153
|
+
connections(key: string): WsConnection[];
|
|
154
|
+
/**
|
|
155
|
+
* @internal Run `hook` when `socket` is kicked from `room` (locally or by
|
|
156
|
+
* another node). The hook is responsible for calling `leave()`.
|
|
157
|
+
*/
|
|
158
|
+
_onLeave(socket: WsConnection, room: string, hook: (reason: string) => void): void;
|
|
159
|
+
/** @internal Receive transport messages of an extension (e.g. presence). */
|
|
160
|
+
_registerExtension(kind: "presence", onRemote: (body: unknown) => void): void;
|
|
161
|
+
/** @internal Publish an extension message to other nodes. */
|
|
162
|
+
_publishExtension(kind: "presence", body: unknown): void;
|
|
163
|
+
/** @internal */
|
|
164
|
+
_members(room: string, filter?: RoomFilter): WsConnection[];
|
|
165
|
+
/** @internal */
|
|
166
|
+
_sendRoom(room: string, frame: Frame, filter: RoomFilter, publish: boolean): number;
|
|
167
|
+
/** @internal */
|
|
168
|
+
_kick(room: string, filter: RoomFilter, reason: string, publish: boolean): number;
|
|
169
|
+
}
|
|
170
|
+
/** Text and binary frames go out as-is; everything else as JSON. */
|
|
171
|
+
export declare function encodeFrame(data: WsHubPayload): Frame;
|
|
172
|
+
export {};
|