@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.
Files changed (78) hide show
  1. package/dist/contract/builder.d.ts +9 -4
  2. package/dist/contract/builder.js +2 -0
  3. package/dist/contract/index.d.ts +3 -1
  4. package/dist/contract/index.js +1 -0
  5. package/dist/contract/types.d.ts +9 -1
  6. package/dist/contract/ws.d.ts +178 -0
  7. package/dist/contract/ws.js +164 -0
  8. package/dist/core/compose.js +5 -0
  9. package/dist/core/error.d.ts +21 -0
  10. package/dist/core/error.js +50 -7
  11. package/dist/core/index.d.ts +7 -3
  12. package/dist/core/index.js +5 -1
  13. package/dist/core/matcher.js +2 -1
  14. package/dist/core/route-builder.d.ts +4 -4
  15. package/dist/core/router.d.ts +16 -2
  16. package/dist/core/router.js +45 -6
  17. package/dist/core/transport.d.ts +39 -0
  18. package/dist/core/transport.js +68 -0
  19. package/dist/core/types.d.ts +22 -3
  20. package/dist/core/upgrade.d.ts +85 -0
  21. package/dist/core/upgrade.js +110 -0
  22. package/dist/core/utils/url.d.ts +4 -1
  23. package/dist/core/utils/url.js +14 -3
  24. package/dist/sse/hub.js +15 -33
  25. package/dist/sse/types.d.ts +3 -5
  26. package/dist/static/index.d.ts +27 -0
  27. package/dist/static/index.js +25 -0
  28. package/dist/static/negotiate.d.ts +23 -0
  29. package/dist/static/negotiate.js +85 -0
  30. package/dist/static/options.d.ts +44 -0
  31. package/dist/static/options.js +190 -0
  32. package/dist/static/resolve.d.ts +56 -0
  33. package/dist/static/resolve.js +113 -0
  34. package/dist/static/response.d.ts +53 -0
  35. package/dist/static/response.js +146 -0
  36. package/dist/static/router.d.ts +59 -0
  37. package/dist/static/router.js +217 -0
  38. package/dist/static/rules.d.ts +21 -0
  39. package/dist/static/rules.js +49 -0
  40. package/dist/static/types.d.ts +76 -0
  41. package/dist/static/types.js +0 -0
  42. package/dist/ws/channels/channel.d.ts +175 -0
  43. package/dist/ws/channels/channel.js +125 -0
  44. package/dist/ws/channels/handler.d.ts +44 -0
  45. package/dist/ws/channels/handler.js +517 -0
  46. package/dist/ws/channels/protocol.d.ts +96 -0
  47. package/dist/ws/channels/protocol.js +94 -0
  48. package/dist/ws/channels/target.d.ts +36 -0
  49. package/dist/ws/channels/target.js +56 -0
  50. package/dist/ws/connection.d.ts +56 -0
  51. package/dist/ws/connection.js +172 -0
  52. package/dist/ws/helpers.d.ts +17 -0
  53. package/dist/ws/helpers.js +48 -0
  54. package/dist/ws/hub.d.ts +190 -0
  55. package/dist/ws/hub.js +562 -0
  56. package/dist/ws/index.d.ts +65 -0
  57. package/dist/ws/index.js +66 -0
  58. package/dist/ws/lifecycle.d.ts +21 -0
  59. package/dist/ws/lifecycle.js +97 -0
  60. package/dist/ws/limits.d.ts +32 -0
  61. package/dist/ws/limits.js +67 -0
  62. package/dist/ws/message.d.ts +19 -0
  63. package/dist/ws/message.js +58 -0
  64. package/dist/ws/origin.d.ts +40 -0
  65. package/dist/ws/origin.js +152 -0
  66. package/dist/ws/presence.d.ts +96 -0
  67. package/dist/ws/presence.js +420 -0
  68. package/dist/ws/route-builder.d.ts +136 -0
  69. package/dist/ws/route-builder.js +265 -0
  70. package/dist/ws/router.d.ts +21 -0
  71. package/dist/ws/router.js +36 -0
  72. package/dist/ws/tickets.d.ts +82 -0
  73. package/dist/ws/tickets.js +161 -0
  74. package/dist/ws/typed.d.ts +54 -0
  75. package/dist/ws/typed.js +104 -0
  76. package/dist/ws/types.d.ts +69 -0
  77. package/dist/ws/types.js +70 -0
  78. package/package.json +14 -2
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Turns a route's `handle(fn)` into the per-socket `SocketHandlers` that core
3
+ * dispatches to. One lifecycle object is created per accepted handshake.
4
+ */
5
+ import { type Logger, type SocketHandlers } from "../core/index.ts";
6
+ import { WsConnection } from "./connection.ts";
7
+ import { type WsCleanupFn, type WsConnectionOptions } from "./types.ts";
8
+ export type WsSetupFn = (socket: WsConnection) => void | WsCleanupFn | Promise<void | WsCleanupFn>;
9
+ export interface LifecycleOptions extends WsConnectionOptions {
10
+ logger?: Pick<Logger, "error">;
11
+ /** Called after the socket opened and before setup runs; throw to reject. */
12
+ onOpen?: (socket: WsConnection) => void;
13
+ }
14
+ /**
15
+ * - Messages arriving while an async setup is still running are buffered
16
+ * (bounded) and replayed in order once it settles — Bun does not hold
17
+ * `message` back while `open` awaits.
18
+ * - Size and rate limits are enforced before anything reaches user code.
19
+ * - Any throw/rejection closes the socket via {@link closeFrameFor}.
20
+ */
21
+ export declare function createLifecycle(req: Request, protocol: string | null, setup: WsSetupFn, options?: LifecycleOptions): SocketHandlers;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Turns a route's `handle(fn)` into the per-socket `SocketHandlers` that core
3
+ * dispatches to. One lifecycle object is created per accepted handshake.
4
+ */
5
+ import { HttpError, } from "../core/index.js";
6
+ import { runCallback, WsConnection } from "./connection.js";
7
+ import { TokenBucket } from "./limits.js";
8
+ import { WsMessage } from "./message.js";
9
+ import { closeFrameFor, DEFAULT_MAX_QUEUED_MESSAGES, WsCloseCode, WsCloseError, } from "./types.js";
10
+ /**
11
+ * - Messages arriving while an async setup is still running are buffered
12
+ * (bounded) and replayed in order once it settles — Bun does not hold
13
+ * `message` back while `open` awaits.
14
+ * - Size and rate limits are enforced before anything reaches user code.
15
+ * - Any throw/rejection closes the socket via {@link closeFrameFor}.
16
+ */
17
+ export function createLifecycle(req, protocol, setup, options = {}) {
18
+ const logger = options.logger ?? console;
19
+ const maxQueued = options.maxQueuedMessages ?? DEFAULT_MAX_QUEUED_MESSAGES;
20
+ const limiter = options.rateLimit
21
+ ? new TokenBucket(options.rateLimit.messages, options.rateLimit.perMs)
22
+ : null;
23
+ let socket;
24
+ let ready = false;
25
+ const queue = [];
26
+ const fail = (err) => {
27
+ if (!(err instanceof WsCloseError || err instanceof HttpError)) {
28
+ logger.error("[fishenv.ws] handler failed:", err);
29
+ }
30
+ const frame = closeFrameFor(err, options.development);
31
+ socket?.close(frame.code, frame.reason);
32
+ };
33
+ const flush = () => {
34
+ ready = true;
35
+ while (socket != null && !socket.closed && queue.length > 0) {
36
+ socket._deliver(queue.shift());
37
+ }
38
+ queue.length = 0;
39
+ };
40
+ return {
41
+ open(ws) {
42
+ const conn = new WsConnection(ws, req, protocol, fail);
43
+ socket = conn;
44
+ let result;
45
+ try {
46
+ options.onOpen?.(conn);
47
+ result = setup(conn);
48
+ }
49
+ catch (err) {
50
+ fail(err);
51
+ return;
52
+ }
53
+ const settle = (cleanup) => {
54
+ if (typeof cleanup === "function")
55
+ conn.onClose(cleanup);
56
+ flush();
57
+ };
58
+ if (result instanceof Promise)
59
+ result.then(settle, fail);
60
+ else
61
+ settle(result);
62
+ },
63
+ message(_ws, raw) {
64
+ if (socket == null || socket.closed)
65
+ return;
66
+ const message = new WsMessage(raw);
67
+ if (options.maxMessageSize != null &&
68
+ message.size > options.maxMessageSize) {
69
+ socket.close(WsCloseCode.MessageTooBig, "Message too big");
70
+ return;
71
+ }
72
+ if (limiter != null && !limiter.take()) {
73
+ if (options.rateLimit?.onExceed === "drop")
74
+ return;
75
+ socket.close(WsCloseCode.PolicyViolation, "Rate limit exceeded");
76
+ return;
77
+ }
78
+ if (!ready) {
79
+ if (queue.length >= maxQueued) {
80
+ socket.close(WsCloseCode.PolicyViolation, "Too many queued messages");
81
+ return;
82
+ }
83
+ queue.push(message);
84
+ return;
85
+ }
86
+ socket._deliver(message);
87
+ },
88
+ close(_ws, code, reason) {
89
+ queue.length = 0;
90
+ socket?._closed(code, reason);
91
+ },
92
+ drain() {
93
+ if (socket != null)
94
+ runCallback(() => socket?._drained(), fail);
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,32 @@
1
+ /** Per-connection rate limiting and per-route connection counting. */
2
+ /**
3
+ * Classic token bucket: `capacity` tokens, refilled continuously at
4
+ * `capacity / perMs`. Allows short bursts up to `capacity`.
5
+ */
6
+ export declare class TokenBucket {
7
+ #private;
8
+ constructor(capacity: number, perMs: number, now?: () => number);
9
+ /** Consume one token; `false` if the bucket is empty. */
10
+ take(): boolean;
11
+ }
12
+ export interface WsConnectionLimit<A> {
13
+ /** Max open sockets on this route. Rejected with `503`. */
14
+ total?: number;
15
+ /** Max open sockets per key (e.g. user id). Rejected with `429`. */
16
+ perKey?: {
17
+ key: (args: A) => string;
18
+ max: number;
19
+ };
20
+ }
21
+ /**
22
+ * Open-socket counters for one route. Checked before the upgrade (cheap
23
+ * rejection) and re-checked on open, because concurrent handshakes can both
24
+ * pass the first check — counting only real opens means nothing can leak.
25
+ */
26
+ export declare class ConnectionCounter {
27
+ #private;
28
+ get total(): number;
29
+ count(key: string): number;
30
+ /** Register an open socket; returns the release fn (idempotent). */
31
+ acquire(key: string | null): () => void;
32
+ }
@@ -0,0 +1,67 @@
1
+ /** Per-connection rate limiting and per-route connection counting. */
2
+ /**
3
+ * Classic token bucket: `capacity` tokens, refilled continuously at
4
+ * `capacity / perMs`. Allows short bursts up to `capacity`.
5
+ */
6
+ export class TokenBucket {
7
+ #capacity;
8
+ #ratePerMs;
9
+ #now;
10
+ #tokens;
11
+ #last;
12
+ constructor(capacity, perMs, now = () => performance.now()) {
13
+ if (!(capacity > 0) || !(perMs > 0)) {
14
+ throw new Error("rateLimit: messages and perMs must be positive");
15
+ }
16
+ this.#capacity = capacity;
17
+ this.#ratePerMs = capacity / perMs;
18
+ this.#now = now;
19
+ this.#tokens = capacity;
20
+ this.#last = now();
21
+ }
22
+ /** Consume one token; `false` if the bucket is empty. */
23
+ take() {
24
+ const now = this.#now();
25
+ this.#tokens = Math.min(this.#capacity, this.#tokens + (now - this.#last) * this.#ratePerMs);
26
+ this.#last = now;
27
+ if (this.#tokens < 1)
28
+ return false;
29
+ this.#tokens -= 1;
30
+ return true;
31
+ }
32
+ }
33
+ /**
34
+ * Open-socket counters for one route. Checked before the upgrade (cheap
35
+ * rejection) and re-checked on open, because concurrent handshakes can both
36
+ * pass the first check — counting only real opens means nothing can leak.
37
+ */
38
+ export class ConnectionCounter {
39
+ #total = 0;
40
+ #byKey = new Map();
41
+ get total() {
42
+ return this.#total;
43
+ }
44
+ count(key) {
45
+ return this.#byKey.get(key) ?? 0;
46
+ }
47
+ /** Register an open socket; returns the release fn (idempotent). */
48
+ acquire(key) {
49
+ this.#total++;
50
+ if (key != null)
51
+ this.#byKey.set(key, this.count(key) + 1);
52
+ let released = false;
53
+ return () => {
54
+ if (released)
55
+ return;
56
+ released = true;
57
+ this.#total--;
58
+ if (key == null)
59
+ return;
60
+ const next = this.count(key) - 1;
61
+ if (next <= 0)
62
+ this.#byKey.delete(key);
63
+ else
64
+ this.#byKey.set(key, next);
65
+ };
66
+ }
67
+ }
@@ -0,0 +1,19 @@
1
+ /** A lazily decoded inbound WebSocket message. */
2
+ /**
3
+ * Bun delivers text frames as `string` and binary frames as `Buffer`; this
4
+ * wraps both without forcing a decode until the handler asks for one.
5
+ */
6
+ export declare class WsMessage {
7
+ #private;
8
+ constructor(raw: string | Uint8Array);
9
+ get isBinary(): boolean;
10
+ /** Size in bytes (UTF-8 for text frames). */
11
+ get size(): number;
12
+ /** The original value Bun delivered. */
13
+ get raw(): string | Uint8Array;
14
+ /** Text content. Binary frames must hold valid UTF-8. */
15
+ text(): string;
16
+ bytes(): Uint8Array;
17
+ /** Parse as JSON; malformed input throws a {@link WsProtocolError} (→ `1008`). */
18
+ json<T = unknown>(): T;
19
+ }
@@ -0,0 +1,58 @@
1
+ /** A lazily decoded inbound WebSocket message. */
2
+ import { WsProtocolError } from "./types.js";
3
+ const encoder = new TextEncoder();
4
+ const decoder = new TextDecoder("utf-8", { fatal: true });
5
+ /**
6
+ * Bun delivers text frames as `string` and binary frames as `Buffer`; this
7
+ * wraps both without forcing a decode until the handler asks for one.
8
+ */
9
+ export class WsMessage {
10
+ #raw;
11
+ #size;
12
+ constructor(raw) {
13
+ this.#raw = raw;
14
+ }
15
+ get isBinary() {
16
+ return typeof this.#raw !== "string";
17
+ }
18
+ /** Size in bytes (UTF-8 for text frames). */
19
+ get size() {
20
+ if (this.#size == null) {
21
+ this.#size =
22
+ typeof this.#raw === "string"
23
+ ? Buffer.byteLength(this.#raw, "utf8")
24
+ : this.#raw.byteLength;
25
+ }
26
+ return this.#size;
27
+ }
28
+ /** The original value Bun delivered. */
29
+ get raw() {
30
+ return this.#raw;
31
+ }
32
+ /** Text content. Binary frames must hold valid UTF-8. */
33
+ text() {
34
+ if (typeof this.#raw === "string")
35
+ return this.#raw;
36
+ try {
37
+ return decoder.decode(this.#raw);
38
+ }
39
+ catch {
40
+ throw new WsProtocolError("Invalid UTF-8");
41
+ }
42
+ }
43
+ bytes() {
44
+ return typeof this.#raw === "string"
45
+ ? encoder.encode(this.#raw)
46
+ : this.#raw;
47
+ }
48
+ /** Parse as JSON; malformed input throws a {@link WsProtocolError} (→ `1008`). */
49
+ json() {
50
+ const text = this.text();
51
+ try {
52
+ return JSON.parse(text);
53
+ }
54
+ catch {
55
+ throw new WsProtocolError("Invalid JSON");
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Cross-Site WebSocket Hijacking (CSWSH) protection.
3
+ *
4
+ * Browsers attach cookies to cross-origin WebSocket handshakes and CORS does
5
+ * not apply to them, so the `Origin` header is the only thing stopping a
6
+ * hostile page from opening an authenticated socket. Requests *without* an
7
+ * `Origin` (curl, native apps, servers) can't carry ambient browser
8
+ * credentials and are allowed.
9
+ */
10
+ import type { CorsOptions } from "../core/index.ts";
11
+ export type OriginPredicate = (origin: string, req: Request) => boolean | Promise<boolean>;
12
+ export type OriginSource =
13
+ /** `Origin` host equals the `Host` header. */
14
+ "same-origin"
15
+ /** Disable the check. Logged once at build. */
16
+ | "any"
17
+ /** Exact origin (`"https://app.example.com"`) or a leftmost wildcard (`"https://*.example.com"`). */
18
+ | string
19
+ /** Tested against the normalized origin; anchored automatically. */
20
+ | RegExp | OriginPredicate;
21
+ export interface OriginPolicy {
22
+ /** `true` if the handshake may proceed. */
23
+ check(req: Request): boolean | Promise<boolean>;
24
+ /** Whether the policy is `"any"` (no protection). */
25
+ readonly allowsAny: boolean;
26
+ }
27
+ /**
28
+ * Compile origin sources into a policy. An empty list means `"same-origin"`.
29
+ * Throws on malformed sources so mistakes surface at startup.
30
+ */
31
+ export declare function createOriginPolicy(sources: readonly OriginSource[]): OriginPolicy;
32
+ /**
33
+ * Origin sources inherited from a root `cors()` config. `"*"` is *not*
34
+ * inherited — a wildcard would disable CSWSH protection on cookie-authenticated
35
+ * sockets — and is reported via `wildcard` so the caller can warn.
36
+ */
37
+ export declare function originSourcesFromCors(cors: CorsOptions | null): {
38
+ sources: OriginSource[];
39
+ wildcard: boolean;
40
+ };
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Cross-Site WebSocket Hijacking (CSWSH) protection.
3
+ *
4
+ * Browsers attach cookies to cross-origin WebSocket handshakes and CORS does
5
+ * not apply to them, so the `Origin` header is the only thing stopping a
6
+ * hostile page from opening an authenticated socket. Requests *without* an
7
+ * `Origin` (curl, native apps, servers) can't carry ambient browser
8
+ * credentials and are allowed.
9
+ */
10
+ const DEFAULT_PORTS = {
11
+ "http:": "80",
12
+ "https:": "443",
13
+ "ws:": "80",
14
+ "wss:": "443",
15
+ };
16
+ /** `scheme://host[:port]`, lowercased, default ports dropped. */
17
+ function normalize(url) {
18
+ return `${url.protocol}//${url.host}`.toLowerCase();
19
+ }
20
+ function parseOrigin(raw) {
21
+ if (raw === "null")
22
+ return null;
23
+ try {
24
+ const url = new URL(raw);
25
+ // A real Origin header never has a path, query, fragment or userinfo.
26
+ if (url.username || url.password)
27
+ return null;
28
+ return url;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** Host header → `host[:port]` with any default port removed. */
35
+ function normalizeHost(host) {
36
+ const lower = host.toLowerCase().replace(/\.$/, "");
37
+ const match = /^(.*?)(?::(\d+))?$/.exec(lower);
38
+ if (match == null)
39
+ return lower;
40
+ const [, name, port] = match;
41
+ if (port === "80" || port === "443" || port == null)
42
+ return name ?? lower;
43
+ return `${name}:${port}`;
44
+ }
45
+ function sameOrigin(origin, _raw, req) {
46
+ if (origin == null)
47
+ return false;
48
+ if (origin.protocol !== "http:" && origin.protocol !== "https:")
49
+ return false;
50
+ const host = req.headers.get("host");
51
+ if (host == null)
52
+ return false;
53
+ return normalizeHost(origin.host) === normalizeHost(host);
54
+ }
55
+ function compileString(source) {
56
+ const wildcard = /^([a-z][a-z0-9+.-]*):\/\/\*\.([^/:*]+)(?::(\d+))?$/i.exec(source);
57
+ if (wildcard) {
58
+ const [, scheme, domain, port] = wildcard;
59
+ const protocol = `${scheme.toLowerCase()}:`;
60
+ const suffix = `.${domain.toLowerCase()}`;
61
+ const wantPort = port ?? DEFAULT_PORTS[protocol] ?? "";
62
+ return (origin) => {
63
+ if (origin == null || origin.protocol !== protocol)
64
+ return false;
65
+ const gotPort = origin.port || DEFAULT_PORTS[origin.protocol] || "";
66
+ if (gotPort !== wantPort)
67
+ return false;
68
+ const host = origin.hostname.toLowerCase();
69
+ // Require at least one label before the suffix: "*.example.com"
70
+ // matches "a.example.com" but not "example.com" or "evilexample.com".
71
+ return host.endsWith(suffix) && host.length > suffix.length;
72
+ };
73
+ }
74
+ if (source.includes("*")) {
75
+ throw new Error(`Invalid origin "${source}": only a leftmost "*." wildcard is supported`);
76
+ }
77
+ if (source === "null") {
78
+ return (_origin, raw) => raw === "null";
79
+ }
80
+ const parsed = parseOrigin(source);
81
+ if (parsed == null ||
82
+ parsed.pathname !== "/" ||
83
+ parsed.search ||
84
+ parsed.hash) {
85
+ throw new Error(`Invalid origin "${source}": expected "scheme://host[:port]"`);
86
+ }
87
+ const expected = normalize(parsed);
88
+ return (origin) => origin != null && normalize(origin) === expected;
89
+ }
90
+ function compileRegExp(source) {
91
+ const body = source.source;
92
+ const anchored = new RegExp(`^(?:${body})$`, source.flags.replace(/[gy]/g, ""));
93
+ return (origin, raw) => anchored.test(origin == null ? raw : normalize(origin));
94
+ }
95
+ /**
96
+ * Compile origin sources into a policy. An empty list means `"same-origin"`.
97
+ * Throws on malformed sources so mistakes surface at startup.
98
+ */
99
+ export function createOriginPolicy(sources) {
100
+ if (sources.includes("any")) {
101
+ return { check: () => true, allowsAny: true };
102
+ }
103
+ const matchers = (sources.length ? sources : ["same-origin"]).map((source) => {
104
+ if (source === "same-origin")
105
+ return sameOrigin;
106
+ if (typeof source === "string")
107
+ return compileString(source);
108
+ if (source instanceof RegExp)
109
+ return compileRegExp(source);
110
+ return (_origin, raw, req) => source(raw, req);
111
+ });
112
+ return {
113
+ allowsAny: false,
114
+ check(req) {
115
+ const raw = req.headers.get("origin");
116
+ if (raw == null)
117
+ return true; // non-browser client
118
+ const origin = parseOrigin(raw);
119
+ const pending = [];
120
+ for (const matcher of matchers) {
121
+ const result = matcher(origin, raw, req);
122
+ if (result === true)
123
+ return true;
124
+ if (result instanceof Promise)
125
+ pending.push(result);
126
+ }
127
+ if (pending.length === 0)
128
+ return false;
129
+ return Promise.all(pending).then((results) => results.includes(true));
130
+ },
131
+ };
132
+ }
133
+ /**
134
+ * Origin sources inherited from a root `cors()` config. `"*"` is *not*
135
+ * inherited — a wildcard would disable CSWSH protection on cookie-authenticated
136
+ * sockets — and is reported via `wildcard` so the caller can warn.
137
+ */
138
+ export function originSourcesFromCors(cors) {
139
+ if (cors == null)
140
+ return { sources: [], wildcard: false };
141
+ const { origin } = cors;
142
+ if (origin === "*")
143
+ return { sources: [], wildcard: true };
144
+ if (typeof origin === "function") {
145
+ return { sources: [(o) => origin(o) != null], wildcard: false };
146
+ }
147
+ const list = Array.isArray(origin) ? origin : [origin];
148
+ return {
149
+ sources: list.filter((o) => o !== "*"),
150
+ wildcard: list.includes("*"),
151
+ };
152
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Presence — *who* is in a topic (a channel, or any app-defined topic like
3
+ * `"online-users"`), keyed by identity rather than by socket, replicated
4
+ * across nodes.
5
+ *
6
+ * Model: an entry is `(key, ref, meta)` — `key` is the identity (user id),
7
+ * `ref` the connection id, `meta` app data. Three tabs = one key, three refs;
8
+ * closing one tab is not a "leave" of the user.
9
+ *
10
+ * Replication is single-writer: every entry is owned by the node holding its
11
+ * socket and only that node writes it, so state is conflict-free without a
12
+ * merge CRDT. Each node stamps its messages with a per-node clock; a gap or
13
+ * an unknown node triggers a full `sync` from the owner. Nodes heartbeat;
14
+ * a silent node is declared dead and its entries are dropped.
15
+ */
16
+ import type { WsConnection } from "./connection.ts";
17
+ import type { WsHub } from "./hub.ts";
18
+ export interface PresenceEntry<M = unknown> {
19
+ /** Connection id. */
20
+ ref: string;
21
+ meta: M;
22
+ }
23
+ /** Entries grouped by key — the wire format of state and diffs. */
24
+ export type PresenceState<M = unknown> = Record<string, PresenceEntry<M>[]>;
25
+ export interface PresenceDiff<M = unknown> {
26
+ joins: PresenceState<M>;
27
+ leaves: PresenceState<M>;
28
+ }
29
+ export interface PresenceOptions {
30
+ /** Client-facing diff coalescing window. Default 50 ms. */
31
+ batchMs?: number;
32
+ /** Heartbeat interval on a transport. Default 5 s. */
33
+ heartbeatMs?: number;
34
+ /** A node silent for longer is considered dead. Default 15 s. */
35
+ peerTimeoutMs?: number;
36
+ /** Max entries per topic on this node (protects memory). Default 10 000. */
37
+ maxEntriesPerTopic?: number;
38
+ /** Injectable clock/timers for tests. */
39
+ now?: () => number;
40
+ setTimer?: (fn: () => void, ms: number) => unknown;
41
+ clearTimer?: (handle: unknown) => void;
42
+ setInterval?: (fn: () => void, ms: number) => unknown;
43
+ clearInterval?: (handle: unknown) => void;
44
+ }
45
+ type DiffListener = (topic: string, diff: PresenceDiff) => void;
46
+ export declare class PresenceLimitError extends Error {
47
+ constructor(topic: string);
48
+ }
49
+ /**
50
+ * Per-hub presence state. Get it via `hub.presence(topic)` — you rarely
51
+ * construct this directly.
52
+ */
53
+ export declare class PresenceRegistry {
54
+ #private;
55
+ constructor(hub: WsHub, replicated: boolean, options?: PresenceOptions);
56
+ /**
57
+ * Track `socket` in `topic` under `key`. Re-tracking replaces the meta.
58
+ * Untracked automatically when the socket closes.
59
+ */
60
+ track(topic: string, socket: WsConnection, key: string, meta: unknown): void;
61
+ /** Replace the meta of an already tracked socket. No-op if untracked. */
62
+ update(topic: string, socket: WsConnection, meta: unknown): boolean;
63
+ untrack(topic: string, socket: WsConnection): void;
64
+ list(topic: string): PresenceState;
65
+ has(topic: string, key: string): boolean;
66
+ keys(topic: string): string[];
67
+ /** Tracked meta of a local socket. */
68
+ metaOf(topic: string, socket: WsConnection): unknown;
69
+ /** Topics with at least one entry anywhere. */
70
+ topics(): string[];
71
+ /** Coalesced diffs for every topic (local and remote changes). */
72
+ onDiff(fn: DiffListener): () => void;
73
+ /** Stop timers and drop remote state (e.g. on shutdown). */
74
+ close(): void;
75
+ /** Emit pending diffs now. Called automatically after `batchMs`. */
76
+ flush(): void;
77
+ }
78
+ /** A read/write view of one topic, from `hub.presence(topic)`. */
79
+ export declare class PresenceTopic<M = unknown> {
80
+ #private;
81
+ readonly topic: string;
82
+ /** @internal */
83
+ constructor(registry: PresenceRegistry, topic: string);
84
+ track(socket: WsConnection, key: string, meta: M): void;
85
+ update(socket: WsConnection, meta: M): boolean;
86
+ untrack(socket: WsConnection): void;
87
+ /** Cluster-wide: `{ [key]: { ref, meta }[] }`. */
88
+ list(): PresenceState<M>;
89
+ has(key: string): boolean;
90
+ keys(): string[];
91
+ /** Number of distinct keys (users), cluster-wide. */
92
+ count(): number;
93
+ /** Coalesced diffs of this topic. */
94
+ onDiff(fn: (diff: PresenceDiff<M>) => void): () => void;
95
+ }
96
+ export {};