@pawabase/client 0.0.0-stage → 0.1.0
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/README.md +313 -2
- package/dist/cjs/admin.d.ts +187 -0
- package/dist/cjs/admin.d.ts.map +1 -0
- package/dist/cjs/admin.js +235 -0
- package/dist/cjs/admin.js.map +1 -0
- package/dist/cjs/auth.d.ts +448 -0
- package/dist/cjs/auth.d.ts.map +1 -0
- package/dist/cjs/auth.js +1016 -0
- package/dist/cjs/auth.js.map +1 -0
- package/dist/cjs/client.d.ts +103 -0
- package/dist/cjs/client.d.ts.map +1 -0
- package/dist/cjs/client.js +144 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/data.d.ts +220 -0
- package/dist/cjs/data.d.ts.map +1 -0
- package/dist/cjs/data.js +490 -0
- package/dist/cjs/data.js.map +1 -0
- package/dist/cjs/errors.d.ts +167 -0
- package/dist/cjs/errors.d.ts.map +1 -0
- package/dist/cjs/errors.js +316 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/files.d.ts +80 -0
- package/dist/cjs/files.d.ts.map +1 -0
- package/dist/cjs/files.js +167 -0
- package/dist/cjs/files.js.map +1 -0
- package/dist/cjs/functions.d.ts +66 -0
- package/dist/cjs/functions.d.ts.map +1 -0
- package/dist/cjs/functions.js +109 -0
- package/dist/cjs/functions.js.map +1 -0
- package/dist/cjs/http.d.ts +101 -0
- package/dist/cjs/http.d.ts.map +1 -0
- package/dist/cjs/http.js +249 -0
- package/dist/cjs/http.js.map +1 -0
- package/dist/cjs/index.d.ts +22 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/manage/access.d.ts +136 -0
- package/dist/cjs/manage/access.d.ts.map +1 -0
- package/dist/cjs/manage/access.js +162 -0
- package/dist/cjs/manage/access.js.map +1 -0
- package/dist/cjs/manage/automation.d.ts +106 -0
- package/dist/cjs/manage/automation.d.ts.map +1 -0
- package/dist/cjs/manage/automation.js +144 -0
- package/dist/cjs/manage/automation.js.map +1 -0
- package/dist/cjs/manage/base.d.ts +52 -0
- package/dist/cjs/manage/base.d.ts.map +1 -0
- package/dist/cjs/manage/base.js +69 -0
- package/dist/cjs/manage/base.js.map +1 -0
- package/dist/cjs/manage/data.d.ts +100 -0
- package/dist/cjs/manage/data.d.ts.map +1 -0
- package/dist/cjs/manage/data.js +141 -0
- package/dist/cjs/manage/data.js.map +1 -0
- package/dist/cjs/manage/files.d.ts +45 -0
- package/dist/cjs/manage/files.d.ts.map +1 -0
- package/dist/cjs/manage/files.js +73 -0
- package/dist/cjs/manage/files.js.map +1 -0
- package/dist/cjs/manage/functions.d.ts +48 -0
- package/dist/cjs/manage/functions.d.ts.map +1 -0
- package/dist/cjs/manage/functions.js +61 -0
- package/dist/cjs/manage/functions.js.map +1 -0
- package/dist/cjs/manage/index.d.ts +76 -0
- package/dist/cjs/manage/index.d.ts.map +1 -0
- package/dist/cjs/manage/index.js +134 -0
- package/dist/cjs/manage/index.js.map +1 -0
- package/dist/cjs/manage/lifecycle.d.ts +128 -0
- package/dist/cjs/manage/lifecycle.d.ts.map +1 -0
- package/dist/cjs/manage/lifecycle.js +167 -0
- package/dist/cjs/manage/lifecycle.js.map +1 -0
- package/dist/cjs/manage/mail.d.ts +16 -0
- package/dist/cjs/manage/mail.d.ts.map +1 -0
- package/dist/cjs/manage/mail.js +24 -0
- package/dist/cjs/manage/mail.js.map +1 -0
- package/dist/cjs/manage/observe.d.ts +149 -0
- package/dist/cjs/manage/observe.d.ts.map +1 -0
- package/dist/cjs/manage/observe.js +168 -0
- package/dist/cjs/manage/observe.js.map +1 -0
- package/dist/cjs/manage/realtime.d.ts +52 -0
- package/dist/cjs/manage/realtime.d.ts.map +1 -0
- package/dist/cjs/manage/realtime.js +71 -0
- package/dist/cjs/manage/realtime.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/realtime.d.ts +207 -0
- package/dist/cjs/realtime.d.ts.map +1 -0
- package/dist/cjs/realtime.js +704 -0
- package/dist/cjs/realtime.js.map +1 -0
- package/dist/cjs/session-store.d.ts +16 -0
- package/dist/cjs/session-store.d.ts.map +1 -0
- package/dist/cjs/session-store.js +37 -0
- package/dist/cjs/session-store.js.map +1 -0
- package/dist/cjs/typegen.d.ts +26 -0
- package/dist/cjs/typegen.d.ts.map +1 -0
- package/dist/cjs/typegen.js +167 -0
- package/dist/cjs/typegen.js.map +1 -0
- package/dist/cjs/types.d.ts +178 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +4 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/cjs/util.d.ts +27 -0
- package/dist/cjs/util.d.ts.map +1 -0
- package/dist/cjs/util.js +115 -0
- package/dist/cjs/util.js.map +1 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.d.ts.map +1 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/version.js.map +1 -0
- package/dist/esm/admin.d.ts +187 -0
- package/dist/esm/admin.d.ts.map +1 -0
- package/dist/esm/admin.js +222 -0
- package/dist/esm/admin.js.map +1 -0
- package/dist/esm/auth.d.ts +448 -0
- package/dist/esm/auth.d.ts.map +1 -0
- package/dist/esm/auth.js +1012 -0
- package/dist/esm/auth.js.map +1 -0
- package/dist/esm/cli/gen-types.d.ts +2 -0
- package/dist/esm/cli/gen-types.d.ts.map +1 -0
- package/dist/esm/cli/gen-types.js +100 -0
- package/dist/esm/cli/gen-types.js.map +1 -0
- package/dist/esm/client.d.ts +103 -0
- package/dist/esm/client.d.ts.map +1 -0
- package/dist/esm/client.js +139 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/data.d.ts +220 -0
- package/dist/esm/data.d.ts.map +1 -0
- package/dist/esm/data.js +485 -0
- package/dist/esm/data.js.map +1 -0
- package/dist/esm/errors.d.ts +167 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +293 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/files.d.ts +80 -0
- package/dist/esm/files.d.ts.map +1 -0
- package/dist/esm/files.js +162 -0
- package/dist/esm/files.js.map +1 -0
- package/dist/esm/functions.d.ts +66 -0
- package/dist/esm/functions.d.ts.map +1 -0
- package/dist/esm/functions.js +103 -0
- package/dist/esm/functions.js.map +1 -0
- package/dist/esm/http.d.ts +101 -0
- package/dist/esm/http.d.ts.map +1 -0
- package/dist/esm/http.js +245 -0
- package/dist/esm/http.js.map +1 -0
- package/dist/esm/index.d.ts +22 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/manage/access.d.ts +136 -0
- package/dist/esm/manage/access.d.ts.map +1 -0
- package/dist/esm/manage/access.js +155 -0
- package/dist/esm/manage/access.js.map +1 -0
- package/dist/esm/manage/automation.d.ts +106 -0
- package/dist/esm/manage/automation.d.ts.map +1 -0
- package/dist/esm/manage/automation.js +134 -0
- package/dist/esm/manage/automation.js.map +1 -0
- package/dist/esm/manage/base.d.ts +52 -0
- package/dist/esm/manage/base.d.ts.map +1 -0
- package/dist/esm/manage/base.js +63 -0
- package/dist/esm/manage/base.js.map +1 -0
- package/dist/esm/manage/data.d.ts +100 -0
- package/dist/esm/manage/data.d.ts.map +1 -0
- package/dist/esm/manage/data.js +130 -0
- package/dist/esm/manage/data.js.map +1 -0
- package/dist/esm/manage/files.d.ts +45 -0
- package/dist/esm/manage/files.d.ts.map +1 -0
- package/dist/esm/manage/files.js +68 -0
- package/dist/esm/manage/files.js.map +1 -0
- package/dist/esm/manage/functions.d.ts +48 -0
- package/dist/esm/manage/functions.d.ts.map +1 -0
- package/dist/esm/manage/functions.js +57 -0
- package/dist/esm/manage/functions.js.map +1 -0
- package/dist/esm/manage/index.d.ts +76 -0
- package/dist/esm/manage/index.d.ts.map +1 -0
- package/dist/esm/manage/index.js +115 -0
- package/dist/esm/manage/index.js.map +1 -0
- package/dist/esm/manage/lifecycle.d.ts +128 -0
- package/dist/esm/manage/lifecycle.d.ts.map +1 -0
- package/dist/esm/manage/lifecycle.js +161 -0
- package/dist/esm/manage/lifecycle.js.map +1 -0
- package/dist/esm/manage/mail.d.ts +16 -0
- package/dist/esm/manage/mail.d.ts.map +1 -0
- package/dist/esm/manage/mail.js +19 -0
- package/dist/esm/manage/mail.js.map +1 -0
- package/dist/esm/manage/observe.d.ts +149 -0
- package/dist/esm/manage/observe.d.ts.map +1 -0
- package/dist/esm/manage/observe.js +161 -0
- package/dist/esm/manage/observe.js.map +1 -0
- package/dist/esm/manage/realtime.d.ts +52 -0
- package/dist/esm/manage/realtime.d.ts.map +1 -0
- package/dist/esm/manage/realtime.js +66 -0
- package/dist/esm/manage/realtime.js.map +1 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/realtime.d.ts +207 -0
- package/dist/esm/realtime.d.ts.map +1 -0
- package/dist/esm/realtime.js +699 -0
- package/dist/esm/realtime.js.map +1 -0
- package/dist/esm/session-store.d.ts +16 -0
- package/dist/esm/session-store.d.ts.map +1 -0
- package/dist/esm/session-store.js +32 -0
- package/dist/esm/session-store.js.map +1 -0
- package/dist/esm/typegen.d.ts +26 -0
- package/dist/esm/typegen.d.ts.map +1 -0
- package/dist/esm/typegen.js +163 -0
- package/dist/esm/typegen.js.map +1 -0
- package/dist/esm/types.d.ts +178 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +3 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/util.d.ts +27 -0
- package/dist/esm/util.d.ts.map +1 -0
- package/dist/esm/util.js +102 -0
- package/dist/esm/util.js.map +1 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.d.ts.map +1 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/version.js.map +1 -0
- package/package.json +84 -4
- package/src/admin.ts +311 -0
- package/src/auth.ts +1180 -0
- package/src/cli/gen-types.ts +105 -0
- package/src/client.ts +189 -0
- package/src/data.ts +576 -0
- package/src/errors.ts +330 -0
- package/src/files.ts +208 -0
- package/src/functions.ts +131 -0
- package/src/http.ts +354 -0
- package/src/index.ts +56 -0
- package/src/manage/access.ts +206 -0
- package/src/manage/automation.ts +162 -0
- package/src/manage/base.ts +83 -0
- package/src/manage/data.ts +159 -0
- package/src/manage/files.ts +79 -0
- package/src/manage/functions.ts +69 -0
- package/src/manage/index.ts +132 -0
- package/src/manage/lifecycle.ts +208 -0
- package/src/manage/mail.ts +22 -0
- package/src/manage/observe.ts +226 -0
- package/src/manage/realtime.ts +95 -0
- package/src/realtime.ts +765 -0
- package/src/session-store.ts +36 -0
- package/src/typegen.ts +192 -0
- package/src/types.ts +195 -0
- package/src/util.ts +114 -0
- package/src/version.ts +1 -0
package/src/realtime.ts
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import type { AuthClient } from "./auth.js";
|
|
2
|
+
import { ConfigError, RealtimeError } from "./errors.js";
|
|
3
|
+
import type { HttpClient } from "./http.js";
|
|
4
|
+
import type { ChannelMessage, PresenceMember } from "./types.js";
|
|
5
|
+
import { backoff, encodePath, sleep } from "./util.js";
|
|
6
|
+
|
|
7
|
+
export type RealtimeStatus = "idle" | "connecting" | "open" | "reconnecting" | "closed";
|
|
8
|
+
|
|
9
|
+
/** The slice of the WebSocket API the client uses; the browser's, Node's and `ws` all fit. */
|
|
10
|
+
export interface WebSocketLike {
|
|
11
|
+
readyState: number;
|
|
12
|
+
send(data: string): void;
|
|
13
|
+
close(code?: number, reason?: string): void;
|
|
14
|
+
onopen: ((event: unknown) => void) | null;
|
|
15
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
16
|
+
onclose: ((event: { code: number; reason?: string }) => void) | null;
|
|
17
|
+
onerror: ((event: unknown) => void) | null;
|
|
18
|
+
}
|
|
19
|
+
export type WebSocketConstructor = new (url: string) => WebSocketLike;
|
|
20
|
+
|
|
21
|
+
export interface RealtimeOptions {
|
|
22
|
+
/** A WebSocket implementation. Default: the global one (browsers, Node 22+). On older Node pass `ws`. */
|
|
23
|
+
webSocket?: WebSocketConstructor;
|
|
24
|
+
/** Seconds between pings. Default `25`. The connection is recycled after two silent intervals. */
|
|
25
|
+
heartbeatSeconds?: number;
|
|
26
|
+
/** Milliseconds to wait for the server to acknowledge a request. Default `10000`. */
|
|
27
|
+
ackTimeoutMs?: number;
|
|
28
|
+
/** Milliseconds to wait for the connection to open. Default `10000`. */
|
|
29
|
+
connectTimeoutMs?: number;
|
|
30
|
+
/** Reconnect after a drop. `false` to stay closed. */
|
|
31
|
+
reconnect?: false | { baseDelayMs?: number; maxDelayMs?: number; maxAttempts?: number };
|
|
32
|
+
/** Called with protocol-level surprises (unknown frames, invalid JSON). */
|
|
33
|
+
onProtocolError?: (detail: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ChannelOptions {
|
|
37
|
+
/** Your presence data: shown to everyone on the channel while you are subscribed. Needs presence enabled by the channel's rule. */
|
|
38
|
+
presence?: Record<string, unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* After a reconnect, fetch this many recent messages and deliver the ones you missed
|
|
41
|
+
* (de-duplicated by id). `0`/omitted: only messages sent while connected are delivered.
|
|
42
|
+
*/
|
|
43
|
+
replay?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PresenceChange<M> {
|
|
47
|
+
joins: PresenceMember<M>[];
|
|
48
|
+
leaves: PresenceMember<M>[];
|
|
49
|
+
/** Everyone present after the change. */
|
|
50
|
+
members: PresenceMember<M>[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ChannelEvents<T, M> {
|
|
54
|
+
message: ChannelMessage<T>;
|
|
55
|
+
presence: PresenceChange<M>;
|
|
56
|
+
subscribed: undefined;
|
|
57
|
+
/** The channel stopped receiving: unsubscribed, or the connection was lost (it resubscribes by itself when it can). */
|
|
58
|
+
closed: { reason: string };
|
|
59
|
+
error: RealtimeError;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Handler<V> = (value: V) => void;
|
|
63
|
+
|
|
64
|
+
interface Pending {
|
|
65
|
+
resolve: (value: Record<string, unknown>) => void;
|
|
66
|
+
reject: (error: unknown) => void;
|
|
67
|
+
timer: ReturnType<typeof setTimeout>;
|
|
68
|
+
expect: "ack" | "history";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const OPEN = 1;
|
|
72
|
+
const FATAL_CLOSE = new Set([4001]);
|
|
73
|
+
const SEEN_LIMIT = 1000;
|
|
74
|
+
|
|
75
|
+
export class RealtimeClient {
|
|
76
|
+
private socket: WebSocketLike | null = null;
|
|
77
|
+
private currentStatus: RealtimeStatus = "idle";
|
|
78
|
+
private readonly channels = new Map<string, RealtimeChannel<any, any>>();
|
|
79
|
+
private readonly pending = new Map<string, Pending>();
|
|
80
|
+
private readonly statusListeners = new Set<Handler<RealtimeStatus>>();
|
|
81
|
+
private refs = 0;
|
|
82
|
+
private attempts = 0;
|
|
83
|
+
private manual = false;
|
|
84
|
+
private connecting: Promise<void> | null = null;
|
|
85
|
+
private heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
86
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
87
|
+
private lastActivity = 0;
|
|
88
|
+
private triedRefresh = false;
|
|
89
|
+
private authSubscription: { unsubscribe: () => void } | null = null;
|
|
90
|
+
/** The server's id for this connection, once welcomed. */
|
|
91
|
+
connectionId: string | null = null;
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
private readonly http: HttpClient,
|
|
95
|
+
private readonly auth: AuthClient | null,
|
|
96
|
+
private readonly options: RealtimeOptions & { project?: string | undefined; environment?: string | undefined } = {},
|
|
97
|
+
) {}
|
|
98
|
+
|
|
99
|
+
get status(): RealtimeStatus {
|
|
100
|
+
return this.currentStatus;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onStatusChange(handler: Handler<RealtimeStatus>): () => void {
|
|
104
|
+
this.statusListeners.add(handler);
|
|
105
|
+
return () => void this.statusListeners.delete(handler);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private setStatus(status: RealtimeStatus): void {
|
|
109
|
+
if (this.currentStatus === status) return;
|
|
110
|
+
this.currentStatus = status;
|
|
111
|
+
for (const handler of [...this.statusListeners]) {
|
|
112
|
+
try {
|
|
113
|
+
handler(status);
|
|
114
|
+
} catch {
|
|
115
|
+
/* observers must not break the connection */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── channels ──────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A channel by name. Asking twice returns the same object, so listeners are shared.
|
|
124
|
+
* Channel names are authorised by the project's realtime rules.
|
|
125
|
+
*/
|
|
126
|
+
channel<T = unknown, M = Record<string, unknown>>(name: string, options: ChannelOptions = {}): RealtimeChannel<T, M> {
|
|
127
|
+
let channel = this.channels.get(name) as RealtimeChannel<T, M> | undefined;
|
|
128
|
+
if (!channel) {
|
|
129
|
+
channel = new RealtimeChannel<T, M>(this, name, options);
|
|
130
|
+
this.channels.set(name, channel);
|
|
131
|
+
} else if (options.presence || options.replay !== undefined) {
|
|
132
|
+
channel.configure(options);
|
|
133
|
+
}
|
|
134
|
+
return channel;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Forget a channel object once it is unsubscribed. */
|
|
138
|
+
forget(name: string): void {
|
|
139
|
+
this.channels.delete(name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── connection ────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
private url(token: string | null): string {
|
|
145
|
+
const base = new URL(this.http.config.baseUrl);
|
|
146
|
+
base.protocol = base.protocol === "https:" ? "wss:" : "ws:";
|
|
147
|
+
base.pathname = `${base.pathname.replace(/\/+$/, "")}/realtime/v1/socket`;
|
|
148
|
+
base.search = "";
|
|
149
|
+
base.searchParams.set("apikey", this.http.config.apiKey);
|
|
150
|
+
if (token) base.searchParams.set("token", token);
|
|
151
|
+
if (this.options.environment) base.searchParams.set("environment", this.options.environment);
|
|
152
|
+
return base.toString();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Open the connection (channels do this for you). Resolves once the server has welcomed it. */
|
|
156
|
+
connect(): Promise<void> {
|
|
157
|
+
if (this.currentStatus === "open") return Promise.resolve();
|
|
158
|
+
if (this.connecting) return this.connecting;
|
|
159
|
+
this.manual = false;
|
|
160
|
+
this.connecting = this.open().finally(() => {
|
|
161
|
+
this.connecting = null;
|
|
162
|
+
});
|
|
163
|
+
return this.connecting;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async open(): Promise<void> {
|
|
167
|
+
const Impl = this.options.webSocket ?? (globalThis as { WebSocket?: WebSocketConstructor }).WebSocket;
|
|
168
|
+
if (!Impl) {
|
|
169
|
+
throw new ConfigError(
|
|
170
|
+
"No WebSocket implementation found. Pass `realtime: { webSocket }` to createClient (for example the `ws` package on Node 20).",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
this.setStatus(this.attempts > 0 ? "reconnecting" : "connecting");
|
|
174
|
+
const token = (await this.auth?.getAccessToken().catch(() => null)) ?? null;
|
|
175
|
+
const socket = new Impl(this.url(token));
|
|
176
|
+
this.socket = socket;
|
|
177
|
+
this.lastActivity = Date.now();
|
|
178
|
+
const timeoutMs = this.options.connectTimeoutMs ?? 10_000;
|
|
179
|
+
|
|
180
|
+
await new Promise<void>((resolve, reject) => {
|
|
181
|
+
const timer = setTimeout(() => {
|
|
182
|
+
reject(new RealtimeError("connect_timeout", `The realtime connection did not open within ${timeoutMs} ms.`));
|
|
183
|
+
try {
|
|
184
|
+
socket.close(4000, "connect timeout");
|
|
185
|
+
} catch {
|
|
186
|
+
/* already closed */
|
|
187
|
+
}
|
|
188
|
+
}, timeoutMs);
|
|
189
|
+
let welcomed = false;
|
|
190
|
+
socket.onmessage = (event) => {
|
|
191
|
+
this.lastActivity = Date.now();
|
|
192
|
+
const frame = this.parse(event.data);
|
|
193
|
+
if (!frame) return;
|
|
194
|
+
if (!welcomed && frame["type"] === "welcome") {
|
|
195
|
+
welcomed = true;
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
this.connectionId = typeof frame["connection_id"] === "string" ? frame["connection_id"] : null;
|
|
198
|
+
socket.onmessage = (next) => this.onMessage(next.data);
|
|
199
|
+
this.attempts = 0;
|
|
200
|
+
this.triedRefresh = false;
|
|
201
|
+
this.setStatus("open");
|
|
202
|
+
this.startHeartbeat();
|
|
203
|
+
this.watchAuth();
|
|
204
|
+
void this.resubscribe();
|
|
205
|
+
resolve();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.onMessage(event.data);
|
|
209
|
+
};
|
|
210
|
+
socket.onerror = () => {
|
|
211
|
+
/* the close event follows with the code */
|
|
212
|
+
};
|
|
213
|
+
socket.onclose = (event) => {
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
if (!welcomed) {
|
|
216
|
+
reject(new RealtimeError(`closed_${event.code}`, event.reason || `The connection closed (${event.code}) before it opened.`));
|
|
217
|
+
}
|
|
218
|
+
this.onClose(event.code, event.reason ?? "", welcomed);
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Close the connection and stop reconnecting. Channels keep their listeners and rejoin on the next `connect()`. */
|
|
224
|
+
disconnect(): void {
|
|
225
|
+
this.manual = true;
|
|
226
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
227
|
+
this.reconnectTimer = null;
|
|
228
|
+
this.stopHeartbeat();
|
|
229
|
+
this.authSubscription?.unsubscribe();
|
|
230
|
+
this.authSubscription = null;
|
|
231
|
+
const socket = this.socket;
|
|
232
|
+
this.socket = null;
|
|
233
|
+
this.failPending(new RealtimeError("closed", "The realtime connection was closed."));
|
|
234
|
+
for (const channel of this.channels.values()) channel.markClosed("disconnected");
|
|
235
|
+
if (socket) {
|
|
236
|
+
socket.onclose = null;
|
|
237
|
+
socket.onmessage = null;
|
|
238
|
+
try {
|
|
239
|
+
socket.close(1000, "client closed");
|
|
240
|
+
} catch {
|
|
241
|
+
/* already closed */
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
this.attempts = 0;
|
|
245
|
+
this.setStatus("closed");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private onClose(code: number, reason: string, wasOpen: boolean): void {
|
|
249
|
+
this.stopHeartbeat();
|
|
250
|
+
this.socket = null;
|
|
251
|
+
this.failPending(new RealtimeError("closed", "The realtime connection closed."));
|
|
252
|
+
for (const channel of this.channels.values()) channel.markClosed(reason || `closed (${code})`);
|
|
253
|
+
if (this.manual) return;
|
|
254
|
+
if (FATAL_CLOSE.has(code)) {
|
|
255
|
+
this.setStatus("closed");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (code === 4003 && !this.triedRefresh && this.auth) {
|
|
259
|
+
// The token was refused: refresh it once, then try again.
|
|
260
|
+
this.triedRefresh = true;
|
|
261
|
+
void this.auth
|
|
262
|
+
.refreshSession()
|
|
263
|
+
.then(() => this.scheduleReconnect(0))
|
|
264
|
+
.catch(() => this.setStatus("closed"));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (code === 4003) {
|
|
268
|
+
this.setStatus("closed");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
void wasOpen;
|
|
272
|
+
this.scheduleReconnect();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private scheduleReconnect(forcedDelay?: number): void {
|
|
276
|
+
const policy = this.options.reconnect;
|
|
277
|
+
if (policy === false) {
|
|
278
|
+
this.setStatus("closed");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const maxAttempts = policy?.maxAttempts ?? Infinity;
|
|
282
|
+
if (this.attempts >= maxAttempts) {
|
|
283
|
+
this.setStatus("closed");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const delay =
|
|
287
|
+
forcedDelay ?? backoff(this.attempts, { baseDelayMs: policy?.baseDelayMs ?? 500, maxDelayMs: policy?.maxDelayMs ?? 15_000 });
|
|
288
|
+
this.attempts += 1;
|
|
289
|
+
this.setStatus("reconnecting");
|
|
290
|
+
this.reconnectTimer = setTimeout(() => {
|
|
291
|
+
this.reconnectTimer = null;
|
|
292
|
+
this.connecting = this.open()
|
|
293
|
+
.catch(() => {
|
|
294
|
+
// open() rejected without a close event reaching onClose (rare); try again.
|
|
295
|
+
if (!this.manual && !this.socket) this.scheduleReconnect();
|
|
296
|
+
})
|
|
297
|
+
.finally(() => {
|
|
298
|
+
this.connecting = null;
|
|
299
|
+
});
|
|
300
|
+
}, delay);
|
|
301
|
+
(this.reconnectTimer as { unref?: () => void }).unref?.();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private startHeartbeat(): void {
|
|
305
|
+
this.stopHeartbeat();
|
|
306
|
+
const interval = (this.options.heartbeatSeconds ?? 25) * 1000;
|
|
307
|
+
this.heartbeat = setInterval(() => {
|
|
308
|
+
const socket = this.socket;
|
|
309
|
+
if (!socket || socket.readyState !== OPEN) return;
|
|
310
|
+
if (Date.now() - this.lastActivity > interval * 2 + 1000) {
|
|
311
|
+
// Nothing, not even a pong: the link is dead without having closed.
|
|
312
|
+
try {
|
|
313
|
+
socket.close(4000, "heartbeat timeout");
|
|
314
|
+
} catch {
|
|
315
|
+
/* already closed */
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
socket.send(JSON.stringify({ type: "ping" }));
|
|
321
|
+
} catch {
|
|
322
|
+
/* the close handler will follow */
|
|
323
|
+
}
|
|
324
|
+
}, interval);
|
|
325
|
+
(this.heartbeat as { unref?: () => void }).unref?.();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private stopHeartbeat(): void {
|
|
329
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
330
|
+
this.heartbeat = null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Tell the server about a refreshed (or removed) token without reconnecting. */
|
|
334
|
+
private watchAuth(): void {
|
|
335
|
+
if (!this.auth || this.authSubscription) return;
|
|
336
|
+
this.authSubscription = this.auth.onAuthStateChange((event, session) => {
|
|
337
|
+
if (event === "INITIAL_SESSION" || this.currentStatus !== "open") return;
|
|
338
|
+
void this.request({ type: "auth", token: session?.access_token ?? null }).catch(() => undefined);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private async resubscribe(): Promise<void> {
|
|
343
|
+
for (const channel of [...this.channels.values()]) {
|
|
344
|
+
if (channel.wantsJoin) await channel.rejoin().catch(() => undefined);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── messages ──────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
private parse(raw: unknown): Record<string, unknown> | null {
|
|
351
|
+
if (typeof raw !== "string") {
|
|
352
|
+
this.options.onProtocolError?.("a binary frame arrived; Pawabase sends JSON text");
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const value: unknown = JSON.parse(raw);
|
|
357
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
358
|
+
} catch {
|
|
359
|
+
/* fall through */
|
|
360
|
+
}
|
|
361
|
+
this.options.onProtocolError?.(`an invalid frame arrived: ${raw.slice(0, 120)}`);
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private onMessage(raw: unknown): void {
|
|
366
|
+
this.lastActivity = Date.now();
|
|
367
|
+
const frame = this.parse(raw);
|
|
368
|
+
if (!frame) return;
|
|
369
|
+
switch (frame["type"]) {
|
|
370
|
+
case "ack": {
|
|
371
|
+
const pending = this.take(frame["ref"]);
|
|
372
|
+
if (!pending) return;
|
|
373
|
+
if (frame["ok"] === false) {
|
|
374
|
+
pending.reject(
|
|
375
|
+
new RealtimeError(String(frame["error"] ?? "error"), String(frame["reason"] ?? frame["error"] ?? "The request was refused.")),
|
|
376
|
+
);
|
|
377
|
+
} else if (pending.expect === "ack") pending.resolve(frame);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case "history": {
|
|
381
|
+
const pending = this.take(frame["ref"]);
|
|
382
|
+
pending?.resolve(frame);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
case "message": {
|
|
386
|
+
this.channels.get(String(frame["channel"]))?.deliver(frame as unknown as ChannelMessage);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
case "presence_state": {
|
|
390
|
+
this.channels.get(String(frame["channel"]))?.setPresence((frame["members"] as PresenceMember[]) ?? []);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
case "presence_diff": {
|
|
394
|
+
this.channels
|
|
395
|
+
.get(String(frame["channel"]))
|
|
396
|
+
?.applyPresence((frame["joins"] as PresenceMember[]) ?? [], (frame["leaves"] as PresenceMember[]) ?? []);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
case "pong":
|
|
400
|
+
case "welcome":
|
|
401
|
+
return;
|
|
402
|
+
case "error":
|
|
403
|
+
this.options.onProtocolError?.(`the server reported: ${String(frame["error"])}`);
|
|
404
|
+
return;
|
|
405
|
+
default:
|
|
406
|
+
this.options.onProtocolError?.(`an unknown frame arrived: ${String(frame["type"])}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private take(ref: unknown): Pending | undefined {
|
|
411
|
+
if (typeof ref !== "string") return undefined;
|
|
412
|
+
const pending = this.pending.get(ref);
|
|
413
|
+
if (!pending) return undefined;
|
|
414
|
+
clearTimeout(pending.timer);
|
|
415
|
+
this.pending.delete(ref);
|
|
416
|
+
return pending;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private failPending(error: unknown): void {
|
|
420
|
+
for (const [ref, pending] of this.pending) {
|
|
421
|
+
clearTimeout(pending.timer);
|
|
422
|
+
pending.reject(error);
|
|
423
|
+
this.pending.delete(ref);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Send a frame and wait for its acknowledgement (or, for `history`, its answer). */
|
|
428
|
+
request(frame: Record<string, unknown>, expect: "ack" | "history" = "ack"): Promise<Record<string, unknown>> {
|
|
429
|
+
const socket = this.socket;
|
|
430
|
+
if (!socket || socket.readyState !== OPEN) {
|
|
431
|
+
return Promise.reject(new RealtimeError("not_connected", "The realtime connection is not open."));
|
|
432
|
+
}
|
|
433
|
+
const ref = String(++this.refs);
|
|
434
|
+
return new Promise((resolve, reject) => {
|
|
435
|
+
const timeoutMs = this.options.ackTimeoutMs ?? 10_000;
|
|
436
|
+
const timer = setTimeout(() => {
|
|
437
|
+
this.pending.delete(ref);
|
|
438
|
+
reject(new RealtimeError("ack_timeout", `The server did not answer within ${timeoutMs} ms.`));
|
|
439
|
+
}, timeoutMs);
|
|
440
|
+
this.pending.set(ref, { resolve, reject, timer, expect });
|
|
441
|
+
try {
|
|
442
|
+
socket.send(JSON.stringify({ ...frame, ref }));
|
|
443
|
+
} catch (error) {
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
this.pending.delete(ref);
|
|
446
|
+
reject(new RealtimeError("send_failed", "Could not send on the realtime connection.", { cause: error }));
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── HTTP counterparts (no socket needed) ──────────────────────────────
|
|
452
|
+
|
|
453
|
+
/** Publish from anywhere, without a socket. The channel's `publish` rule applies. */
|
|
454
|
+
publish(channel: string, event: string, payload?: unknown): Promise<{ id: string; channel: string; event: string }> {
|
|
455
|
+
return this.http.request({ method: "POST", path: "/realtime/v1/publish", body: { channel, event, payload } });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Listen to channels over Server-Sent Events: one-way, over plain HTTP, with no socket to manage. Iterate the frames
|
|
460
|
+
* (`welcome`, `message`, `presence_diff`) until you `break` or abort the `signal`. Publish with {@link publish}. At most 20 channels
|
|
461
|
+
* per stream; the signed-in user, if any, is who is listening.
|
|
462
|
+
*
|
|
463
|
+
* ```ts
|
|
464
|
+
* for await (const frame of pawabase.realtime.stream(["chat:lobby"])) if (frame.type === "message") show(frame.payload);
|
|
465
|
+
* ```
|
|
466
|
+
*/
|
|
467
|
+
async *stream(channels: string | string[], options: { signal?: AbortSignal } = {}): AsyncGenerator<Record<string, any>> {
|
|
468
|
+
const names = Array.isArray(channels) ? channels : [channels];
|
|
469
|
+
if (names.length === 0) throw new ConfigError("Pass at least one channel to listen to.");
|
|
470
|
+
const token = (await this.auth?.getAccessToken().catch(() => null)) ?? null;
|
|
471
|
+
const response = await this.http.send({
|
|
472
|
+
path: "/realtime/v1/sse",
|
|
473
|
+
query: { channels: names.join(","), ...(token ? { token } : {}) },
|
|
474
|
+
headers: { accept: "text/event-stream" },
|
|
475
|
+
retry: false,
|
|
476
|
+
timeoutMs: 0, // a stream is meant to stay open
|
|
477
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
478
|
+
});
|
|
479
|
+
if (!response.body) return;
|
|
480
|
+
const reader = response.body.getReader();
|
|
481
|
+
const decoder = new TextDecoder();
|
|
482
|
+
let buffer = "";
|
|
483
|
+
let data: string[] = [];
|
|
484
|
+
const flush = (): Record<string, any> | null => {
|
|
485
|
+
const text = data.join("\n");
|
|
486
|
+
data = [];
|
|
487
|
+
if (!text) return null;
|
|
488
|
+
try {
|
|
489
|
+
const frame: unknown = JSON.parse(text);
|
|
490
|
+
return frame && typeof frame === "object" && !Array.isArray(frame) ? (frame as Record<string, any>) : null;
|
|
491
|
+
} catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
try {
|
|
496
|
+
for (;;) {
|
|
497
|
+
const { done, value } = await reader.read();
|
|
498
|
+
if (done) break;
|
|
499
|
+
buffer += decoder.decode(value, { stream: true });
|
|
500
|
+
let newline = buffer.indexOf("\n");
|
|
501
|
+
while (newline >= 0) {
|
|
502
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
503
|
+
buffer = buffer.slice(newline + 1);
|
|
504
|
+
if (line === "") {
|
|
505
|
+
const frame = flush();
|
|
506
|
+
if (frame) yield frame;
|
|
507
|
+
} else if (line.startsWith("data:")) {
|
|
508
|
+
data.push(line.slice(5).replace(/^ /, ""));
|
|
509
|
+
}
|
|
510
|
+
newline = buffer.indexOf("\n");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const last = flush();
|
|
514
|
+
if (last) yield last;
|
|
515
|
+
} finally {
|
|
516
|
+
await reader.cancel().catch(() => undefined);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Who is on a channel right now. */
|
|
521
|
+
async presence<M = Record<string, unknown>>(channel: string): Promise<PresenceMember<M>[]> {
|
|
522
|
+
return (
|
|
523
|
+
await this.http.request<{ members: PresenceMember<M>[] }>({ path: `/realtime/v1/channels/${encodePath(channel)}/presence` })
|
|
524
|
+
).members;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Recent messages on a channel, oldest first. */
|
|
528
|
+
async history<T = unknown>(channel: string, limit = 50): Promise<ChannelMessage<T>[]> {
|
|
529
|
+
return (
|
|
530
|
+
await this.http.request<{ messages: ChannelMessage<T>[] }>({
|
|
531
|
+
path: `/realtime/v1/channels/${encodePath(channel)}/history`,
|
|
532
|
+
query: { limit },
|
|
533
|
+
})
|
|
534
|
+
).messages;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Wait until the socket is really open, or `ms` pass: after a drop the status can lag the
|
|
539
|
+
* socket for a moment, and a reconnect takes a few. Connects first when idle or closed.
|
|
540
|
+
*/
|
|
541
|
+
async whenOpen(ms = 5000): Promise<void> {
|
|
542
|
+
const deadline = Date.now() + ms;
|
|
543
|
+
for (;;) {
|
|
544
|
+
if (this.currentStatus === "open" && this.socket?.readyState === OPEN) return;
|
|
545
|
+
if (Date.now() > deadline) throw new RealtimeError("not_connected", "The realtime connection did not open in time.");
|
|
546
|
+
if (this.currentStatus === "idle" || (this.currentStatus === "closed" && !this.manual)) await this.connect().catch(() => undefined);
|
|
547
|
+
else await sleep(20);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** One named channel: receive messages and presence, publish, and see who is here. */
|
|
553
|
+
export class RealtimeChannel<T = unknown, M = Record<string, unknown>> {
|
|
554
|
+
private readonly handlers = new Map<string, Set<Handler<any>>>();
|
|
555
|
+
private options: ChannelOptions;
|
|
556
|
+
private joined = false;
|
|
557
|
+
private joining: Promise<void> | null = null;
|
|
558
|
+
private wanted = false;
|
|
559
|
+
private readonly members = new Map<string, PresenceMember<M>>();
|
|
560
|
+
private readonly seen = new Set<string>();
|
|
561
|
+
private readonly order: string[] = [];
|
|
562
|
+
private readonly queue: ChannelMessage<T>[] = [];
|
|
563
|
+
private readonly waiters: Array<(value: IteratorResult<ChannelMessage<T>>) => void> = [];
|
|
564
|
+
private ended = false;
|
|
565
|
+
|
|
566
|
+
constructor(
|
|
567
|
+
private readonly client: RealtimeClient,
|
|
568
|
+
readonly name: string,
|
|
569
|
+
options: ChannelOptions,
|
|
570
|
+
) {
|
|
571
|
+
this.options = { ...options };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** @internal */
|
|
575
|
+
configure(options: ChannelOptions): void {
|
|
576
|
+
this.options = { ...this.options, ...options };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** True while the channel is subscribed on the open connection. */
|
|
580
|
+
get subscribed(): boolean {
|
|
581
|
+
return this.joined;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** @internal Whether the channel should rejoin after a reconnect. */
|
|
585
|
+
get wantsJoin(): boolean {
|
|
586
|
+
return this.wanted;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Listen to `message`, `presence`, `subscribed`, `closed` or `error`. Returns a function that stops listening. */
|
|
590
|
+
on<K extends keyof ChannelEvents<T, M>>(event: K, handler: Handler<ChannelEvents<T, M>[K]>): () => void {
|
|
591
|
+
let set = this.handlers.get(event);
|
|
592
|
+
if (!set) this.handlers.set(event, (set = new Set()));
|
|
593
|
+
set.add(handler);
|
|
594
|
+
return () => void set.delete(handler);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Listen to messages with one event name only. */
|
|
598
|
+
onEvent(event: string, handler: Handler<ChannelMessage<T>>): () => void {
|
|
599
|
+
return this.on("message", (message) => {
|
|
600
|
+
if (message.event === event) handler(message);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private emit<K extends keyof ChannelEvents<T, M>>(event: K, value: ChannelEvents<T, M>[K]): void {
|
|
605
|
+
for (const handler of [...(this.handlers.get(event) ?? [])]) {
|
|
606
|
+
try {
|
|
607
|
+
handler(value);
|
|
608
|
+
} catch {
|
|
609
|
+
/* a listener must not break delivery */
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Start receiving. Connects first if needed. Rejects with a {@link RealtimeError} when the
|
|
616
|
+
* channel's rule refuses (`code: "forbidden"`). Safe to call twice.
|
|
617
|
+
*/
|
|
618
|
+
async subscribe(): Promise<this> {
|
|
619
|
+
this.ended = false;
|
|
620
|
+
if (this.joined) return this;
|
|
621
|
+
if (!this.joining) {
|
|
622
|
+
this.joining = (async () => {
|
|
623
|
+
await this.client.connect();
|
|
624
|
+
await this.join(false);
|
|
625
|
+
// Only a channel that joined is rejoined after a reconnect: the client rejoins
|
|
626
|
+
// on its own at welcome, and must not race this first join.
|
|
627
|
+
this.wanted = true;
|
|
628
|
+
})().finally(() => {
|
|
629
|
+
this.joining = null;
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
await this.joining;
|
|
633
|
+
return this;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** @internal Rejoin after a reconnect. */
|
|
637
|
+
async rejoin(): Promise<void> {
|
|
638
|
+
await this.join(true);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private async join(rejoining: boolean): Promise<void> {
|
|
642
|
+
try {
|
|
643
|
+
await this.client.request({
|
|
644
|
+
type: "subscribe",
|
|
645
|
+
channel: this.name,
|
|
646
|
+
...(this.options.presence ? { presence: this.options.presence } : {}),
|
|
647
|
+
});
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (error instanceof RealtimeError) this.emit("error", error);
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
this.joined = true;
|
|
653
|
+
this.emit("subscribed", undefined);
|
|
654
|
+
if (rejoining && (this.options.replay ?? 0) > 0) {
|
|
655
|
+
const history = await this.history(this.options.replay).catch(() => [] as ChannelMessage<T>[]);
|
|
656
|
+
for (const message of history) this.deliver(message);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Stop receiving. Pending iterators end. */
|
|
661
|
+
async unsubscribe(): Promise<void> {
|
|
662
|
+
this.wanted = false;
|
|
663
|
+
if (this.joined) {
|
|
664
|
+
await this.client.request({ type: "unsubscribe", channel: this.name }).catch(() => undefined);
|
|
665
|
+
}
|
|
666
|
+
this.joined = false;
|
|
667
|
+
this.members.clear();
|
|
668
|
+
this.end("unsubscribed");
|
|
669
|
+
this.client.forget(this.name);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Publish to the channel over the socket. Resolves with the message id once the server accepted it. */
|
|
673
|
+
async send(event: string, payload?: T): Promise<{ id: string }> {
|
|
674
|
+
await this.client.whenOpen();
|
|
675
|
+
const ack = await this.client.request({ type: "publish", channel: this.name, event, payload });
|
|
676
|
+
return { id: String(ack["id"] ?? "") };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Set the presence data others see for you. Subscribe first. */
|
|
680
|
+
async track(meta: Record<string, unknown>): Promise<void> {
|
|
681
|
+
this.options = { ...this.options, presence: meta };
|
|
682
|
+
await this.client.whenOpen();
|
|
683
|
+
await this.client.request({ type: "presence", channel: this.name, meta });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Recent messages, oldest first. */
|
|
687
|
+
async history(limit = 50): Promise<ChannelMessage<T>[]> {
|
|
688
|
+
await this.client.whenOpen();
|
|
689
|
+
const reply = await this.client.request({ type: "history", channel: this.name, limit }, "history");
|
|
690
|
+
return (reply["messages"] as ChannelMessage<T>[] | undefined) ?? [];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Who is on the channel now, as last told by the server. Empty unless presence is enabled for it. */
|
|
694
|
+
presenceState(): PresenceMember<M>[] {
|
|
695
|
+
return [...this.members.values()];
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── delivery (called by the client) ───────────────────────────────────
|
|
699
|
+
|
|
700
|
+
/** @internal */
|
|
701
|
+
deliver(message: ChannelMessage): void {
|
|
702
|
+
if (message.id) {
|
|
703
|
+
if (this.seen.has(message.id)) return;
|
|
704
|
+
this.seen.add(message.id);
|
|
705
|
+
this.order.push(message.id);
|
|
706
|
+
if (this.order.length > SEEN_LIMIT) this.seen.delete(this.order.shift() as string);
|
|
707
|
+
}
|
|
708
|
+
const typed = message as ChannelMessage<T>;
|
|
709
|
+
this.emit("message", typed);
|
|
710
|
+
const waiter = this.waiters.shift();
|
|
711
|
+
if (waiter) waiter({ value: typed, done: false });
|
|
712
|
+
else {
|
|
713
|
+
this.queue.push(typed);
|
|
714
|
+
if (this.queue.length > 1000) this.queue.shift();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** @internal */
|
|
719
|
+
setPresence(list: PresenceMember[]): void {
|
|
720
|
+
this.members.clear();
|
|
721
|
+
for (const member of list as PresenceMember<M>[]) this.members.set(member.presence_ref, member);
|
|
722
|
+
this.emit("presence", { joins: [...this.members.values()], leaves: [], members: this.presenceState() });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** @internal */
|
|
726
|
+
applyPresence(joins: PresenceMember[], leaves: PresenceMember[]): void {
|
|
727
|
+
for (const member of leaves as PresenceMember<M>[]) this.members.delete(member.presence_ref);
|
|
728
|
+
for (const member of joins as PresenceMember<M>[]) this.members.set(member.presence_ref, member);
|
|
729
|
+
this.emit("presence", {
|
|
730
|
+
joins: joins as PresenceMember<M>[],
|
|
731
|
+
leaves: leaves as PresenceMember<M>[],
|
|
732
|
+
members: this.presenceState(),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** @internal The connection dropped; the channel will rejoin by itself if it can. */
|
|
737
|
+
markClosed(reason: string): void {
|
|
738
|
+
if (!this.joined) return;
|
|
739
|
+
this.joined = false;
|
|
740
|
+
this.members.clear();
|
|
741
|
+
this.emit("closed", { reason });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private end(reason: string): void {
|
|
745
|
+
this.ended = true;
|
|
746
|
+
this.emit("closed", { reason });
|
|
747
|
+
for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Iterate messages: `for await (const message of channel) { … }`. Ends on `unsubscribe()`. */
|
|
751
|
+
[Symbol.asyncIterator](): AsyncIterator<ChannelMessage<T>> {
|
|
752
|
+
return {
|
|
753
|
+
next: () => {
|
|
754
|
+
const ready = this.queue.shift();
|
|
755
|
+
if (ready) return Promise.resolve({ value: ready, done: false });
|
|
756
|
+
if (this.ended) return Promise.resolve({ value: undefined, done: true });
|
|
757
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
758
|
+
},
|
|
759
|
+
return: async () => {
|
|
760
|
+
await this.unsubscribe();
|
|
761
|
+
return { value: undefined, done: true };
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
}
|