@bunbase-ae/js 1.0.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/package.json +19 -0
- package/src/admin.ts +912 -0
- package/src/auth.ts +338 -0
- package/src/client.ts +61 -0
- package/src/collection.ts +185 -0
- package/src/http.ts +217 -0
- package/src/index.ts +76 -0
- package/src/realtime.ts +374 -0
- package/src/storage.ts +206 -0
- package/src/types.ts +266 -0
package/src/http.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// HTTP client — handles auth headers, 401 interception, and token refresh.
|
|
2
|
+
//
|
|
3
|
+
// Token refresh strategy:
|
|
4
|
+
// - On 401: call /auth/refresh once. If multiple requests fail concurrently,
|
|
5
|
+
// they all wait on the same in-flight refresh promise (not N parallel calls).
|
|
6
|
+
// - After a successful refresh: retry the original request with new token.
|
|
7
|
+
// - After a failed refresh: clear tokens, re-throw so the caller can redirect
|
|
8
|
+
// to login.
|
|
9
|
+
|
|
10
|
+
import { BunBaseError } from "./types";
|
|
11
|
+
|
|
12
|
+
interface TokenStore {
|
|
13
|
+
accessToken: string | null;
|
|
14
|
+
refreshToken: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TokenChangeListener = (tokens: TokenStore) => void;
|
|
18
|
+
|
|
19
|
+
export class HttpClient {
|
|
20
|
+
private tokens: TokenStore = { accessToken: null, refreshToken: null };
|
|
21
|
+
private refreshPromise: Promise<void> | null = null;
|
|
22
|
+
private tokenListeners: TokenChangeListener[] = [];
|
|
23
|
+
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
private readonly apiKey?: string;
|
|
26
|
+
private adminSecret: string | null;
|
|
27
|
+
|
|
28
|
+
constructor(baseUrl: string, apiKey?: string, adminSecret?: string) {
|
|
29
|
+
this.baseUrl = baseUrl;
|
|
30
|
+
this.apiKey = apiKey;
|
|
31
|
+
this.adminSecret = adminSecret ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setAdminSecret(secret: string | null): void {
|
|
35
|
+
this.adminSecret = secret;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Token management ───────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
setTokens(accessToken: string, refreshToken: string): void {
|
|
41
|
+
this.tokens = { accessToken, refreshToken };
|
|
42
|
+
this.notifyTokenListeners();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
clearTokens(): void {
|
|
46
|
+
this.tokens = { accessToken: null, refreshToken: null };
|
|
47
|
+
this.notifyTokenListeners();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getAccessToken(): string | null {
|
|
51
|
+
return this.tokens.accessToken;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getRefreshToken(): string | null {
|
|
55
|
+
return this.tokens.refreshToken;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onTokenChange(listener: TokenChangeListener): () => void {
|
|
59
|
+
this.tokenListeners.push(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
this.tokenListeners = this.tokenListeners.filter((l) => l !== listener);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private notifyTokenListeners(): void {
|
|
66
|
+
for (const l of this.tokenListeners) l(this.tokens);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Returns the auth headers this client would attach to a request.
|
|
70
|
+
// Used by StorageClient for XHR-based uploads with progress tracking.
|
|
71
|
+
getAuthHeaders(): Record<string, string> {
|
|
72
|
+
if (this.adminSecret) return { Authorization: `Bearer ${this.adminSecret}` };
|
|
73
|
+
if (this.apiKey) return { "X-Api-Key": this.apiKey };
|
|
74
|
+
if (this.tokens.accessToken) return { Authorization: `Bearer ${this.tokens.accessToken}` };
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Request ───────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
async request<T>(
|
|
81
|
+
method: string,
|
|
82
|
+
path: string,
|
|
83
|
+
options: {
|
|
84
|
+
body?: unknown;
|
|
85
|
+
formData?: FormData;
|
|
86
|
+
query?: Record<string, string>;
|
|
87
|
+
skipAuth?: boolean;
|
|
88
|
+
} = {},
|
|
89
|
+
): Promise<T> {
|
|
90
|
+
const res = await this.send(method, path, options);
|
|
91
|
+
|
|
92
|
+
// Static credentials (adminSecret, apiKey) don't use refresh tokens — skip the 401 retry flow.
|
|
93
|
+
if (
|
|
94
|
+
res.status === 401 &&
|
|
95
|
+
!options.skipAuth &&
|
|
96
|
+
!this.adminSecret &&
|
|
97
|
+
!this.apiKey &&
|
|
98
|
+
this.tokens.refreshToken
|
|
99
|
+
) {
|
|
100
|
+
try {
|
|
101
|
+
await this.doRefresh();
|
|
102
|
+
} catch {
|
|
103
|
+
// Refresh failed — tokens are already cleared in doRefresh.
|
|
104
|
+
throw new BunBaseError("Session expired. Please log in again.", 401, null);
|
|
105
|
+
}
|
|
106
|
+
const retried = await this.send(method, path, options);
|
|
107
|
+
return this.parse<T>(retried);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return this.parse<T>(res);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Returns the raw Response without parsing — for file downloads / blobs.
|
|
114
|
+
async requestRaw(method: string, path: string): Promise<Response> {
|
|
115
|
+
const res = await this.send(method, path, {});
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
let message = res.statusText;
|
|
118
|
+
try {
|
|
119
|
+
const data = (await res.clone().json()) as { error?: string };
|
|
120
|
+
if (data.error) message = data.error;
|
|
121
|
+
} catch {
|
|
122
|
+
// ignore
|
|
123
|
+
}
|
|
124
|
+
throw new Error(message);
|
|
125
|
+
}
|
|
126
|
+
return res;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
private async send(
|
|
132
|
+
method: string,
|
|
133
|
+
path: string,
|
|
134
|
+
options: {
|
|
135
|
+
body?: unknown;
|
|
136
|
+
formData?: FormData;
|
|
137
|
+
query?: Record<string, string>;
|
|
138
|
+
skipAuth?: boolean;
|
|
139
|
+
},
|
|
140
|
+
): Promise<Response> {
|
|
141
|
+
let url = `${this.baseUrl}${path}`;
|
|
142
|
+
if (options.query && Object.keys(options.query).length > 0) {
|
|
143
|
+
url += `?${new URLSearchParams(options.query).toString()}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const headers: Record<string, string> = {};
|
|
147
|
+
|
|
148
|
+
if (!options.skipAuth) {
|
|
149
|
+
if (this.adminSecret) {
|
|
150
|
+
headers.Authorization = `Bearer ${this.adminSecret}`;
|
|
151
|
+
} else if (this.apiKey) {
|
|
152
|
+
headers["X-Api-Key"] = this.apiKey;
|
|
153
|
+
} else if (this.tokens.accessToken) {
|
|
154
|
+
headers.Authorization = `Bearer ${this.tokens.accessToken}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let body: string | FormData | null = null;
|
|
159
|
+
if (options.formData) {
|
|
160
|
+
body = options.formData;
|
|
161
|
+
// Do not set Content-Type — let the browser/runtime set the multipart boundary.
|
|
162
|
+
} else if (options.body !== undefined) {
|
|
163
|
+
headers["Content-Type"] = "application/json";
|
|
164
|
+
body = JSON.stringify(options.body);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return fetch(url, { method, headers, body });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private async parse<T>(res: Response): Promise<T> {
|
|
171
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
172
|
+
const isJson = contentType.includes("application/json");
|
|
173
|
+
const data = isJson ? await res.json() : await res.text();
|
|
174
|
+
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
const body =
|
|
177
|
+
typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
|
178
|
+
const message =
|
|
179
|
+
"error" in body ? String(body.error) : `Request failed with status ${res.status}`;
|
|
180
|
+
const code = "code" in body ? String(body.code) : undefined;
|
|
181
|
+
const field = "field" in body ? String(body.field) : undefined;
|
|
182
|
+
throw new BunBaseError(message, res.status, data, code, field);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return data as T;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Deduplicated token refresh — multiple concurrent callers share one in-flight request.
|
|
189
|
+
private doRefresh(): Promise<void> {
|
|
190
|
+
if (this.refreshPromise) return this.refreshPromise;
|
|
191
|
+
this.refreshPromise = this.executeRefresh().finally(() => {
|
|
192
|
+
this.refreshPromise = null;
|
|
193
|
+
});
|
|
194
|
+
return this.refreshPromise;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async executeRefresh(): Promise<void> {
|
|
198
|
+
const refreshToken = this.tokens.refreshToken;
|
|
199
|
+
if (!refreshToken) {
|
|
200
|
+
this.clearTokens();
|
|
201
|
+
throw new Error("No refresh token available.");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const res = await this.send("POST", "/api/v1/auth/refresh", {
|
|
205
|
+
body: { refresh_token: refreshToken },
|
|
206
|
+
skipAuth: true,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
this.clearTokens();
|
|
211
|
+
throw new Error("Token refresh failed.");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const data = (await res.json()) as { access_token: string; refresh_token: string };
|
|
215
|
+
this.setTokens(data.access_token, data.refresh_token);
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type AccessRule,
|
|
3
|
+
type AdminApiKey,
|
|
4
|
+
AdminClient,
|
|
5
|
+
type AdminFieldRule,
|
|
6
|
+
type AdminListResult,
|
|
7
|
+
type AdminRecord,
|
|
8
|
+
type AdminSession,
|
|
9
|
+
type AdminStoredFile,
|
|
10
|
+
type AdminUser,
|
|
11
|
+
type BackupFile,
|
|
12
|
+
type BucketAccessRule,
|
|
13
|
+
type CollectionIntrospection,
|
|
14
|
+
type CollectionMeta,
|
|
15
|
+
type CollectionRules,
|
|
16
|
+
type CollectionStats,
|
|
17
|
+
type ColumnInfo,
|
|
18
|
+
type ConfigResponse,
|
|
19
|
+
type CreateRelationParams,
|
|
20
|
+
type EmailTemplate,
|
|
21
|
+
type HealthResponse,
|
|
22
|
+
type ImpersonationResult,
|
|
23
|
+
type IndexInfo,
|
|
24
|
+
type MigrationStatus,
|
|
25
|
+
type MinuteBucket,
|
|
26
|
+
type Relation,
|
|
27
|
+
type RelationOnDelete,
|
|
28
|
+
type RelationType,
|
|
29
|
+
type ServerSettings,
|
|
30
|
+
type SettingsAuditEntry,
|
|
31
|
+
type StatsResponse,
|
|
32
|
+
type StorageBucket,
|
|
33
|
+
type TemplateName,
|
|
34
|
+
type UpdateUserParams,
|
|
35
|
+
type WebhookLogRow,
|
|
36
|
+
} from "./admin";
|
|
37
|
+
export { AuthClient, type AuthSnapshot } from "./auth";
|
|
38
|
+
export { BunBaseClient } from "./client";
|
|
39
|
+
export { CollectionClient } from "./collection";
|
|
40
|
+
export { RealtimeClient, type SubscribeOptions } from "./realtime";
|
|
41
|
+
export { type SignedUploadResult, StorageClient, type UploadOptions } from "./storage";
|
|
42
|
+
export {
|
|
43
|
+
type AggregateFunction,
|
|
44
|
+
type AggregateResult,
|
|
45
|
+
type ApiKey,
|
|
46
|
+
type AuthResult,
|
|
47
|
+
type AuthUser,
|
|
48
|
+
type BatchCreate,
|
|
49
|
+
type BatchDelete,
|
|
50
|
+
type BatchOperation,
|
|
51
|
+
type BatchResult,
|
|
52
|
+
type BatchResultCreate,
|
|
53
|
+
type BatchResultDelete,
|
|
54
|
+
type BatchResultUpdate,
|
|
55
|
+
type BatchUpdate,
|
|
56
|
+
type BunBaseClientOptions,
|
|
57
|
+
BunBaseError,
|
|
58
|
+
type BunBaseRecord,
|
|
59
|
+
type FieldFilter,
|
|
60
|
+
type FieldRule,
|
|
61
|
+
type FileRecord,
|
|
62
|
+
type Filter,
|
|
63
|
+
type FilterOperator,
|
|
64
|
+
type FilterValue,
|
|
65
|
+
type GetQuery,
|
|
66
|
+
type ListQuery,
|
|
67
|
+
type ListResult,
|
|
68
|
+
type LoginResult,
|
|
69
|
+
type RealtimeCallback,
|
|
70
|
+
type RealtimeEvent,
|
|
71
|
+
type RealtimeEventType,
|
|
72
|
+
type TotpChallenge,
|
|
73
|
+
type TotpSetup,
|
|
74
|
+
type UnsubscribeFn,
|
|
75
|
+
type WithExpand,
|
|
76
|
+
} from "./types";
|
package/src/realtime.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// Realtime client — WebSocket subscriptions with auto-reconnect.
|
|
2
|
+
//
|
|
3
|
+
// Connection lifecycle:
|
|
4
|
+
// 1. connect() opens the WebSocket lazily on first subscribe.
|
|
5
|
+
// 2. After open: send auth token if available, then re-send all pending subs.
|
|
6
|
+
// 3. On message: dispatch change events to per-channel callbacks.
|
|
7
|
+
// 4. On close (unexpected): reconnect with exponential backoff (max 30s).
|
|
8
|
+
// 5. On explicit disconnect(): close cleanly, clear pending reconnects.
|
|
9
|
+
//
|
|
10
|
+
// Channel format:
|
|
11
|
+
// "collection:{name}" — all changes to a collection
|
|
12
|
+
// "collection:{name}:mine" — changes scoped to the authenticated user (server resolves)
|
|
13
|
+
// "record:{collection}:{id}" — changes to a specific record
|
|
14
|
+
// "records:{collection}" — multi-record: pass ids in the subscribe options
|
|
15
|
+
|
|
16
|
+
import type { HttpClient } from "./http";
|
|
17
|
+
import type { BunBaseRecord, RealtimeCallback, RealtimeEvent, UnsubscribeFn } from "./types";
|
|
18
|
+
|
|
19
|
+
const INITIAL_RECONNECT_MS = 500;
|
|
20
|
+
const MAX_RECONNECT_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
// Internal message shapes from the server.
|
|
23
|
+
interface ServerChangeMessage<T = BunBaseRecord> {
|
|
24
|
+
type: "change";
|
|
25
|
+
channel: string;
|
|
26
|
+
event: "create" | "update" | "delete";
|
|
27
|
+
record: T & BunBaseRecord;
|
|
28
|
+
collection: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ServerSubscriptionsMessage {
|
|
32
|
+
type: "subscriptions";
|
|
33
|
+
channels: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ServerPresenceMessage {
|
|
37
|
+
type: "presence";
|
|
38
|
+
channel: string;
|
|
39
|
+
users: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ServerAuthMessage {
|
|
43
|
+
type: "auth";
|
|
44
|
+
event: "session_revoked" | "password_changed" | "account_deleted" | "sessions_purged";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ServerMessage<T = BunBaseRecord> =
|
|
48
|
+
| ServerChangeMessage<T>
|
|
49
|
+
| ServerSubscriptionsMessage
|
|
50
|
+
| ServerPresenceMessage
|
|
51
|
+
| ServerAuthMessage
|
|
52
|
+
| { type: string };
|
|
53
|
+
|
|
54
|
+
// Options accepted by subscribe().
|
|
55
|
+
export interface SubscribeOptions {
|
|
56
|
+
// Only deliver events of these types. Omit for all types.
|
|
57
|
+
events?: ("create" | "update" | "delete")[];
|
|
58
|
+
// Only deliver events where the record matches all key=value pairs.
|
|
59
|
+
// Applies to create/update events; delete events always pass.
|
|
60
|
+
filter?: Record<string, unknown>;
|
|
61
|
+
// For "records:{collection}" channels: list of record IDs to subscribe to.
|
|
62
|
+
ids?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Internal subscription state — tracks what was sent to the server so
|
|
66
|
+
// re-subscribe on reconnect sends the same options.
|
|
67
|
+
interface ChannelState {
|
|
68
|
+
callbacks: Set<RealtimeCallback>;
|
|
69
|
+
options: SubscribeOptions;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class RealtimeClient {
|
|
73
|
+
private ws: WebSocket | null = null;
|
|
74
|
+
// Map from channel key → { callbacks, options }
|
|
75
|
+
private channels = new Map<string, ChannelState>();
|
|
76
|
+
private connected = false;
|
|
77
|
+
private intentionalClose = false;
|
|
78
|
+
private reconnectDelay = INITIAL_RECONNECT_MS;
|
|
79
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
80
|
+
private connectListeners = new Set<() => void>();
|
|
81
|
+
private pongListeners = new Set<() => void>();
|
|
82
|
+
private subscriptionsListeners = new Set<(channels: string[]) => void>();
|
|
83
|
+
private presenceListeners = new Set<(channel: string, users: string[]) => void>();
|
|
84
|
+
|
|
85
|
+
// Called when the server pushes an auth event indicating session state change.
|
|
86
|
+
// Receives null to indicate the session is no longer valid.
|
|
87
|
+
onAuthChange?: (user: null) => void;
|
|
88
|
+
|
|
89
|
+
// Called when a presence response arrives: (channel, userIds[]).
|
|
90
|
+
onPresence?: (channel: string, users: string[]) => void;
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
private wsUrl: string,
|
|
94
|
+
private readonly http: HttpClient,
|
|
95
|
+
) {
|
|
96
|
+
// Re-authenticate over an open WebSocket whenever tokens change.
|
|
97
|
+
// Covers: login while connected, silent token refresh, logout.
|
|
98
|
+
// On logout (accessToken = null) no explicit unauth is needed — the
|
|
99
|
+
// server rejects further subscription events for the revoked session.
|
|
100
|
+
this.http.onTokenChange(({ accessToken }) => {
|
|
101
|
+
if (this.connected && accessToken) {
|
|
102
|
+
this.send({ type: "auth", token: accessToken });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setWsUrl(url: string): void {
|
|
108
|
+
this.wsUrl = url;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
subscribe<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
114
|
+
channel: string,
|
|
115
|
+
callback: RealtimeCallback<T>,
|
|
116
|
+
options: SubscribeOptions = {},
|
|
117
|
+
): UnsubscribeFn {
|
|
118
|
+
const existing = this.channels.get(channel);
|
|
119
|
+
|
|
120
|
+
if (!existing) {
|
|
121
|
+
this.channels.set(channel, {
|
|
122
|
+
callbacks: new Set(),
|
|
123
|
+
options,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Wrap the typed callback so the internal map holds a consistent RealtimeCallback type.
|
|
128
|
+
const wrapped: RealtimeCallback = ({ event: evtType, collection, record }) => {
|
|
129
|
+
callback({ event: evtType, collection, record: record as T & BunBaseRecord });
|
|
130
|
+
};
|
|
131
|
+
this.channels.get(channel)?.callbacks.add(wrapped);
|
|
132
|
+
|
|
133
|
+
// Send subscribe message if already connected.
|
|
134
|
+
if (this.connected) {
|
|
135
|
+
this.sendSubscribe(channel, this.channels.get(channel)?.options ?? {});
|
|
136
|
+
} else {
|
|
137
|
+
this.connect();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return () => this.unsubscribe(channel, wrapped);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Register a callback that fires once when the WebSocket connection opens
|
|
144
|
+
// (or immediately if already connected). Returns an unsubscribe function.
|
|
145
|
+
onConnect(callback: () => void): () => void {
|
|
146
|
+
if (this.connected) {
|
|
147
|
+
callback();
|
|
148
|
+
return () => {};
|
|
149
|
+
}
|
|
150
|
+
this.connectListeners.add(callback);
|
|
151
|
+
return () => this.connectListeners.delete(callback);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Send a ping to the server. Returns a Promise that resolves when pong is received.
|
|
155
|
+
// Useful for detecting stale connections on mobile.
|
|
156
|
+
ping(timeoutMs = 5000): Promise<void> {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
if (!this.connected) {
|
|
159
|
+
reject(new Error("Not connected"));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
this.pongListeners.delete(onPong);
|
|
164
|
+
reject(new Error("Ping timeout"));
|
|
165
|
+
}, timeoutMs);
|
|
166
|
+
const onPong = () => {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
resolve();
|
|
169
|
+
};
|
|
170
|
+
this.pongListeners.add(onPong);
|
|
171
|
+
this.send({ type: "ping" });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Request the list of active subscriptions from the server.
|
|
176
|
+
// Resolves with the resolved topic names — useful for reconnect state recovery.
|
|
177
|
+
getSubscriptions(timeoutMs = 5000): Promise<string[]> {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
if (!this.connected) {
|
|
180
|
+
reject(new Error("Not connected"));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const timer = setTimeout(() => {
|
|
184
|
+
this.subscriptionsListeners.delete(onSubs);
|
|
185
|
+
reject(new Error("Subscriptions query timeout"));
|
|
186
|
+
}, timeoutMs);
|
|
187
|
+
const onSubs = (channels: string[]) => {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
resolve(channels);
|
|
190
|
+
};
|
|
191
|
+
this.subscriptionsListeners.add(onSubs);
|
|
192
|
+
this.send({ type: "subscriptions" });
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Query which users are currently subscribed to a channel on this server worker.
|
|
197
|
+
// Response is delivered via the onPresence callback.
|
|
198
|
+
// Returns a Promise that resolves with the user ID list (using a one-shot listener).
|
|
199
|
+
presence(channel: string, timeoutMs = 5000): Promise<string[]> {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
if (!this.connected) {
|
|
202
|
+
reject(new Error("Not connected"));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
this.presenceListeners.delete(onPresence);
|
|
207
|
+
reject(new Error("Presence query timeout"));
|
|
208
|
+
}, timeoutMs);
|
|
209
|
+
const onPresence = (ch: string, users: string[]) => {
|
|
210
|
+
if (ch === channel) {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
this.presenceListeners.delete(onPresence);
|
|
213
|
+
resolve(users);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
this.presenceListeners.add(onPresence);
|
|
217
|
+
this.send({ type: "presence", channel });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
disconnect(): void {
|
|
222
|
+
this.intentionalClose = true;
|
|
223
|
+
if (this.reconnectTimer !== null) {
|
|
224
|
+
clearTimeout(this.reconnectTimer);
|
|
225
|
+
this.reconnectTimer = null;
|
|
226
|
+
}
|
|
227
|
+
this.ws?.close();
|
|
228
|
+
this.ws = null;
|
|
229
|
+
this.connected = false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
private unsubscribe(channel: string, callback: RealtimeCallback): void {
|
|
235
|
+
const state = this.channels.get(channel);
|
|
236
|
+
if (!state) return;
|
|
237
|
+
state.callbacks.delete(callback);
|
|
238
|
+
if (state.callbacks.size === 0) {
|
|
239
|
+
this.channels.delete(channel);
|
|
240
|
+
if (this.connected) this.send({ type: "unsubscribe", channel });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private sendSubscribe(channel: string, options: SubscribeOptions): void {
|
|
245
|
+
const msg: Record<string, unknown> = { type: "subscribe", channel };
|
|
246
|
+
if (options.events && options.events.length > 0) msg.events = options.events;
|
|
247
|
+
if (options.filter && Object.keys(options.filter).length > 0) msg.filter = options.filter;
|
|
248
|
+
if (options.ids && options.ids.length > 0) msg.ids = options.ids;
|
|
249
|
+
this.send(msg);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Eagerly open the WebSocket. Safe to call multiple times — no-ops if already open.
|
|
253
|
+
// Called automatically on first subscribe(); exposed publicly so BunBaseProvider
|
|
254
|
+
// can keep the connection alive for auth event delivery even with no active subscriptions.
|
|
255
|
+
connect(): void {
|
|
256
|
+
if (
|
|
257
|
+
this.ws &&
|
|
258
|
+
(this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
|
|
259
|
+
) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.intentionalClose = false;
|
|
264
|
+
this.ws = new WebSocket(`${this.wsUrl}/realtime`);
|
|
265
|
+
|
|
266
|
+
this.ws.onopen = () => {
|
|
267
|
+
this.connected = true;
|
|
268
|
+
this.reconnectDelay = INITIAL_RECONNECT_MS;
|
|
269
|
+
|
|
270
|
+
// Notify connection listeners and clear them (one-shot per connect).
|
|
271
|
+
for (const cb of this.connectListeners) cb();
|
|
272
|
+
this.connectListeners.clear();
|
|
273
|
+
|
|
274
|
+
// Authenticate if token available.
|
|
275
|
+
const token = this.http.getAccessToken();
|
|
276
|
+
if (token) this.send({ type: "auth", token });
|
|
277
|
+
|
|
278
|
+
// Re-subscribe all active channels (handles reconnect case).
|
|
279
|
+
for (const [channel, state] of this.channels) {
|
|
280
|
+
this.sendSubscribe(channel, state.options);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
this.ws.onmessage = (event: MessageEvent) => {
|
|
285
|
+
let msg: ServerMessage;
|
|
286
|
+
try {
|
|
287
|
+
msg = JSON.parse(event.data as string) as ServerMessage;
|
|
288
|
+
} catch {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (msg.type === "pong") {
|
|
293
|
+
for (const cb of this.pongListeners) cb();
|
|
294
|
+
this.pongListeners.clear();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (msg.type === "subscriptions") {
|
|
299
|
+
const { channels } = msg as ServerSubscriptionsMessage;
|
|
300
|
+
for (const cb of this.subscriptionsListeners) cb(channels);
|
|
301
|
+
this.subscriptionsListeners.clear();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (msg.type === "presence") {
|
|
306
|
+
const presenceMsg = msg as ServerPresenceMessage;
|
|
307
|
+
// Notify one-shot Promise listeners.
|
|
308
|
+
for (const cb of this.presenceListeners) cb(presenceMsg.channel, presenceMsg.users);
|
|
309
|
+
// Notify global onPresence callback if set.
|
|
310
|
+
this.onPresence?.(presenceMsg.channel, presenceMsg.users);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (msg.type === "auth") {
|
|
315
|
+
const authMsg = msg as ServerAuthMessage;
|
|
316
|
+
if (
|
|
317
|
+
authMsg.event === "session_revoked" ||
|
|
318
|
+
authMsg.event === "account_deleted" ||
|
|
319
|
+
authMsg.event === "sessions_purged"
|
|
320
|
+
) {
|
|
321
|
+
// Session is definitively invalidated — notify and close.
|
|
322
|
+
this.onAuthChange?.(null);
|
|
323
|
+
this.http.clearTokens();
|
|
324
|
+
this.disconnect();
|
|
325
|
+
} else if (authMsg.event === "password_changed") {
|
|
326
|
+
// Notify that credentials may have changed but don't force disconnect.
|
|
327
|
+
this.onAuthChange?.(null);
|
|
328
|
+
this.http.clearTokens();
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (msg.type !== "change") return;
|
|
334
|
+
|
|
335
|
+
const changeMsg = msg as ServerChangeMessage;
|
|
336
|
+
const state = this.channels.get(changeMsg.channel);
|
|
337
|
+
if (!state) return;
|
|
338
|
+
|
|
339
|
+
const realtimeEvent: RealtimeEvent = {
|
|
340
|
+
event: changeMsg.event,
|
|
341
|
+
collection: changeMsg.collection,
|
|
342
|
+
record: changeMsg.record,
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
for (const cb of state.callbacks) cb(realtimeEvent);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
this.ws.onclose = () => {
|
|
349
|
+
this.connected = false;
|
|
350
|
+
this.ws = null;
|
|
351
|
+
if (!this.intentionalClose && this.channels.size > 0) {
|
|
352
|
+
this.scheduleReconnect();
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
this.ws.onerror = () => {
|
|
357
|
+
// onclose fires after onerror, so reconnect is handled there.
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private scheduleReconnect(): void {
|
|
362
|
+
this.reconnectTimer = setTimeout(() => {
|
|
363
|
+
this.reconnectTimer = null;
|
|
364
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_MS);
|
|
365
|
+
this.connect();
|
|
366
|
+
}, this.reconnectDelay);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private send(message: object): void {
|
|
370
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
371
|
+
this.ws.send(JSON.stringify(message));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|