@volcano.dev/sdk 1.2.0-nightly.22454064035.1
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/LICENSE +22 -0
- package/README.md +73 -0
- package/dist/index.cjs.js +2906 -0
- package/dist/index.esm.js +2897 -0
- package/dist/index.js +2912 -0
- package/dist/next/middleware.esm.js +180 -0
- package/dist/next/middleware.js +186 -0
- package/dist/realtime.cjs.js +1019 -0
- package/dist/realtime.esm.js +1016 -0
- package/dist/realtime.js +1025 -0
- package/package.json +89 -0
|
@@ -0,0 +1,2897 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volcano Auth SDK - Official JavaScript client for Volcano Hosting
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```javascript
|
|
6
|
+
* import { VolcanoAuth } from '@volcano.dev/sdk';
|
|
7
|
+
*
|
|
8
|
+
* // Basic usage (uses https://api.volcano.dev by default)
|
|
9
|
+
* const volcano = new VolcanoAuth({
|
|
10
|
+
* anonKey: 'your-anon-key'
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* // Or with custom API URL
|
|
14
|
+
* const volcano = new VolcanoAuth({
|
|
15
|
+
* apiUrl: 'https://api.yourapp.com',
|
|
16
|
+
* anonKey: 'your-anon-key'
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // Sign up
|
|
20
|
+
* const { user, session } = await volcano.auth.signUp({
|
|
21
|
+
* email: 'user@example.com',
|
|
22
|
+
* password: 'password123'
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* // Sign in
|
|
26
|
+
* const { user, session } = await volcano.auth.signIn({
|
|
27
|
+
* email: 'user@example.com',
|
|
28
|
+
* password: 'password123'
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* // Invoke function
|
|
32
|
+
* const result = await volcano.functions.invoke('my-function', {
|
|
33
|
+
* action: 'getData'
|
|
34
|
+
* });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Constants
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
const DEFAULT_API_URL = 'https://api.volcano.dev';
|
|
43
|
+
const DEFAULT_TIMEOUT_MS = 60000; // 60 seconds
|
|
44
|
+
const DEFAULT_UPLOAD_PART_SIZE = 25 * 1024 * 1024; // 25MB
|
|
45
|
+
const DEFAULT_SESSIONS_LIMIT = 20;
|
|
46
|
+
const STORAGE_KEY_ACCESS_TOKEN = 'volcano_access_token';
|
|
47
|
+
const STORAGE_KEY_REFRESH_TOKEN = 'volcano_refresh_token';
|
|
48
|
+
const FUNCTION_HOST_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
49
|
+
const DEFAULT_FUNCTION_NEGATIVE_RESOLVE_TTL_SECONDS = 30;
|
|
50
|
+
const GLOBAL_FUNCTION_RESOLVE_STATE_KEY = '__VOLCANO_SDK_FUNCTION_RESOLVE_STATE_V1__';
|
|
51
|
+
const DEFAULT_FUNCTION_RESOLVE_CACHE_MAX_ENTRIES = 1024;
|
|
52
|
+
const FUNCTION_RESOLVE_CACHE_PRUNE_INTERVAL_MS = 5000;
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Utility Functions
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Detect if we're running in a browser/client-side environment.
|
|
60
|
+
*/
|
|
61
|
+
function isBrowser() {
|
|
62
|
+
return typeof window !== 'undefined' && typeof window.document !== 'undefined';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Basic provider name sanitization - only alphanumeric and hyphens allowed
|
|
67
|
+
* This is NOT validation (backend validates), just prevents URL injection
|
|
68
|
+
* @param {string} provider - The provider name
|
|
69
|
+
* @throws {Error} If provider contains invalid characters
|
|
70
|
+
*/
|
|
71
|
+
function sanitizeProvider(provider) {
|
|
72
|
+
if (!provider || typeof provider !== 'string' || !/^[a-z0-9-]+$/.test(provider)) {
|
|
73
|
+
throw new Error('Provider must be a non-empty string containing only lowercase letters, numbers, and hyphens');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fetch with timeout using AbortController
|
|
79
|
+
* @param {string} url - The URL to fetch
|
|
80
|
+
* @param {RequestInit} options - Fetch options
|
|
81
|
+
* @param {number} [timeoutMs] - Timeout in milliseconds (default: 60000)
|
|
82
|
+
* @returns {Promise<Response>}
|
|
83
|
+
*/
|
|
84
|
+
async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(url, {
|
|
90
|
+
...options,
|
|
91
|
+
signal: controller.signal
|
|
92
|
+
});
|
|
93
|
+
return response;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error.name === 'AbortError') {
|
|
96
|
+
throw new Error(`Request timeout after ${timeoutMs}ms`);
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timeoutId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Safely parse JSON from response, returns empty object on failure
|
|
106
|
+
* @param {Response} response
|
|
107
|
+
* @returns {Promise<Object>}
|
|
108
|
+
*/
|
|
109
|
+
async function safeJsonParse(response) {
|
|
110
|
+
try {
|
|
111
|
+
return await response.json();
|
|
112
|
+
} catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Decode a base64url string to UTF-8 (JWT-safe, Node/browser compatible)
|
|
119
|
+
* @param {string} value
|
|
120
|
+
* @returns {string}
|
|
121
|
+
*/
|
|
122
|
+
function decodeBase64Url(value) {
|
|
123
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
124
|
+
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
|
|
125
|
+
const base64 = normalized + padding;
|
|
126
|
+
|
|
127
|
+
if (typeof atob === 'function') {
|
|
128
|
+
return atob(base64);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof Buffer !== 'undefined') {
|
|
132
|
+
return Buffer.from(base64, 'base64').toString('utf-8');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
throw new Error('No base64 decoder available');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getSharedRuntimeObject() {
|
|
139
|
+
if (typeof globalThis !== 'undefined') {
|
|
140
|
+
return globalThis;
|
|
141
|
+
}
|
|
142
|
+
if (typeof window !== 'undefined') {
|
|
143
|
+
return window;
|
|
144
|
+
}
|
|
145
|
+
if (typeof global !== 'undefined') {
|
|
146
|
+
return global;
|
|
147
|
+
}
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getSharedFunctionResolveState() {
|
|
152
|
+
const runtime = getSharedRuntimeObject();
|
|
153
|
+
if (!runtime[GLOBAL_FUNCTION_RESOLVE_STATE_KEY]) {
|
|
154
|
+
runtime[GLOBAL_FUNCTION_RESOLVE_STATE_KEY] = {
|
|
155
|
+
cache: new Map(),
|
|
156
|
+
inFlight: new Map(),
|
|
157
|
+
maxEntries: DEFAULT_FUNCTION_RESOLVE_CACHE_MAX_ENTRIES,
|
|
158
|
+
lastPruneAtMs: 0
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return runtime[GLOBAL_FUNCTION_RESOLVE_STATE_KEY];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function pruneFunctionResolveCache(state, nowMs = Date.now(), force = false) {
|
|
165
|
+
if (!force && (nowMs - state.lastPruneAtMs) < FUNCTION_RESOLVE_CACHE_PRUNE_INTERVAL_MS) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
state.lastPruneAtMs = nowMs;
|
|
169
|
+
|
|
170
|
+
for (const [key, value] of state.cache.entries()) {
|
|
171
|
+
if (!value || typeof value.expiresAt !== 'number' || value.expiresAt <= nowMs) {
|
|
172
|
+
state.cache.delete(key);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (state.cache.size <= state.maxEntries) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const sortedByExpiry = Array.from(state.cache.entries())
|
|
181
|
+
.sort((a, b) => (a[1].expiresAt || 0) - (b[1].expiresAt || 0));
|
|
182
|
+
const overflowCount = state.cache.size - state.maxEntries;
|
|
183
|
+
for (let i = 0; i < overflowCount; i += 1) {
|
|
184
|
+
state.cache.delete(sortedByExpiry[i][0]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function clearSharedFunctionResolveStateForTests() {
|
|
189
|
+
const state = getSharedFunctionResolveState();
|
|
190
|
+
state.cache.clear();
|
|
191
|
+
state.inFlight.clear();
|
|
192
|
+
state.maxEntries = DEFAULT_FUNCTION_RESOLVE_CACHE_MAX_ENTRIES;
|
|
193
|
+
state.lastPruneAtMs = 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function extractRequiredProjectIdFromToken(token) {
|
|
197
|
+
if (!token || typeof token !== 'string') {
|
|
198
|
+
throw new Error('No active session');
|
|
199
|
+
}
|
|
200
|
+
const parts = token.split('.');
|
|
201
|
+
if (parts.length !== 3) {
|
|
202
|
+
throw new Error('accessToken must be a JWT with project_id claim');
|
|
203
|
+
}
|
|
204
|
+
let payload;
|
|
205
|
+
try {
|
|
206
|
+
payload = JSON.parse(decodeBase64Url(parts[1]));
|
|
207
|
+
} catch {
|
|
208
|
+
throw new Error('accessToken must be a valid JWT with project_id claim');
|
|
209
|
+
}
|
|
210
|
+
if (!payload || typeof payload.project_id !== 'string' || payload.project_id.trim() === '') {
|
|
211
|
+
throw new Error('accessToken missing project_id claim');
|
|
212
|
+
}
|
|
213
|
+
return payload.project_id.trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isIPv4Address(hostname) {
|
|
217
|
+
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(hostname);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isIPv6Address(hostname) {
|
|
221
|
+
return hostname.includes(':');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isIPAddress(hostname) {
|
|
225
|
+
return isIPv4Address(hostname) || isIPv6Address(hostname);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sanitizeFunctionIdentifierForHost(identifier) {
|
|
229
|
+
if (!identifier || typeof identifier !== 'string') {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const trimmed = identifier.trim();
|
|
234
|
+
if (!trimmed) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// DNS host labels are case-insensitive; preserve exact behavior by requiring lowercase.
|
|
239
|
+
if (trimmed !== trimmed.toLowerCase()) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!FUNCTION_HOST_LABEL_REGEX.test(trimmed)) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return trimmed;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolveFunctionInvocationBase(apiUrl) {
|
|
251
|
+
try {
|
|
252
|
+
const parsed = new URL(apiUrl);
|
|
253
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
254
|
+
|
|
255
|
+
// Default mapping:
|
|
256
|
+
// api.volcano.dev -> functions.volcano.dev
|
|
257
|
+
// api.staging.volcano.dev -> functions.staging.volcano.dev
|
|
258
|
+
if (hostname === 'localhost' || isIPAddress(hostname)) {
|
|
259
|
+
return {
|
|
260
|
+
protocol: parsed.protocol,
|
|
261
|
+
port: parsed.port,
|
|
262
|
+
domain: 'functions.local.volcano.dev'
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!hostname.startsWith('api.')) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const suffix = hostname.slice(4);
|
|
271
|
+
if (!suffix || isIPAddress(suffix)) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
protocol: parsed.protocol,
|
|
277
|
+
port: parsed.port,
|
|
278
|
+
domain: `functions.${suffix}`
|
|
279
|
+
};
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Fetch with auth header and refresh retry on 401
|
|
287
|
+
* @param {VolcanoAuth} volcanoAuth
|
|
288
|
+
* @param {string} url
|
|
289
|
+
* @param {RequestInit} options
|
|
290
|
+
* @returns {Promise<Response>}
|
|
291
|
+
*/
|
|
292
|
+
async function fetchWithAuthRetry(volcanoAuth, url, options = {}) {
|
|
293
|
+
const doFetch = () => fetchWithTimeout(
|
|
294
|
+
url,
|
|
295
|
+
{
|
|
296
|
+
...options,
|
|
297
|
+
headers: {
|
|
298
|
+
'Authorization': `Bearer ${volcanoAuth.accessToken}`,
|
|
299
|
+
...options.headers
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
volcanoAuth.timeout
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
let response = await doFetch();
|
|
306
|
+
if (response.status === 401) {
|
|
307
|
+
const refreshed = await volcanoAuth.refreshSession();
|
|
308
|
+
if (!refreshed.error) {
|
|
309
|
+
response = await doFetch();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return response;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Create an error result object
|
|
318
|
+
* @param {string} message - Error message
|
|
319
|
+
* @param {Object} [extra] - Extra fields to include
|
|
320
|
+
* @returns {Object}
|
|
321
|
+
*/
|
|
322
|
+
function errorResult(message, extra = {}) {
|
|
323
|
+
return { data: null, error: new Error(message), ...extra };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ============================================================================
|
|
327
|
+
// VolcanoAuth Class
|
|
328
|
+
// ============================================================================
|
|
329
|
+
|
|
330
|
+
class VolcanoAuth {
|
|
331
|
+
constructor(config) {
|
|
332
|
+
if (!config.anonKey) {
|
|
333
|
+
throw new Error('anonKey is required. Get your anon key from project settings.');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// SECURITY: Throw hard error if service key is used client-side
|
|
337
|
+
if (config.anonKey.startsWith('sk-') && isBrowser()) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
'[VOLCANO SECURITY ERROR] Service keys (sk-*) cannot be used in client-side code. ' +
|
|
340
|
+
'Service keys bypass Row Level Security and expose your database to unauthorized access. ' +
|
|
341
|
+
'Use an anon key (ak-*) for browser/client-side applications. ' +
|
|
342
|
+
'Service keys should only be used in secure server-side environments. ' +
|
|
343
|
+
'See: https://docs.volcano.hosting/security/keys'
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
this.apiUrl = (config.apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); // Remove trailing slash
|
|
348
|
+
this.functionInvocationBase = resolveFunctionInvocationBase(this.apiUrl);
|
|
349
|
+
this.anonKey = config.anonKey;
|
|
350
|
+
this.timeout = config.timeout || DEFAULT_TIMEOUT_MS;
|
|
351
|
+
this._currentDatabaseName = null;
|
|
352
|
+
this.currentUser = null;
|
|
353
|
+
this._functionResolveState = getSharedFunctionResolveState();
|
|
354
|
+
|
|
355
|
+
// Server-side use: Allow passing accessToken directly (e.g., in Lambda functions)
|
|
356
|
+
if (config.accessToken) {
|
|
357
|
+
this.accessToken = config.accessToken;
|
|
358
|
+
this.refreshToken = config.refreshToken || null;
|
|
359
|
+
} else {
|
|
360
|
+
// Client-side use: Restore from localStorage if available
|
|
361
|
+
this.accessToken = this._getStorageItem(STORAGE_KEY_ACCESS_TOKEN);
|
|
362
|
+
this.refreshToken = this._getStorageItem(STORAGE_KEY_REFRESH_TOKEN);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Sub-objects for organization
|
|
366
|
+
this.auth = {
|
|
367
|
+
signUp: this.signUp.bind(this),
|
|
368
|
+
signIn: this.signIn.bind(this),
|
|
369
|
+
signOut: this.signOut.bind(this),
|
|
370
|
+
getUser: this.getUser.bind(this),
|
|
371
|
+
updateUser: this.updateUser.bind(this),
|
|
372
|
+
refreshSession: this.refreshSession.bind(this),
|
|
373
|
+
onAuthStateChange: this.onAuthStateChange.bind(this),
|
|
374
|
+
user: () => this.currentUser,
|
|
375
|
+
// Anonymous user methods
|
|
376
|
+
signUpAnonymous: this.signUpAnonymous.bind(this),
|
|
377
|
+
convertAnonymous: this.convertAnonymous.bind(this),
|
|
378
|
+
// Email confirmation methods
|
|
379
|
+
confirmEmail: this.confirmEmail.bind(this),
|
|
380
|
+
resendConfirmation: this.resendConfirmation.bind(this),
|
|
381
|
+
// Password recovery methods
|
|
382
|
+
forgotPassword: this.forgotPassword.bind(this),
|
|
383
|
+
resetPassword: this.resetPassword.bind(this),
|
|
384
|
+
// Email change methods
|
|
385
|
+
requestEmailChange: this.requestEmailChange.bind(this),
|
|
386
|
+
confirmEmailChange: this.confirmEmailChange.bind(this),
|
|
387
|
+
cancelEmailChange: this.cancelEmailChange.bind(this),
|
|
388
|
+
// OAuth methods
|
|
389
|
+
signInWithOAuth: this.signInWithOAuth.bind(this),
|
|
390
|
+
signInWithGoogle: this.signInWithGoogle.bind(this),
|
|
391
|
+
signInWithGitHub: this.signInWithGitHub.bind(this),
|
|
392
|
+
signInWithMicrosoft: this.signInWithMicrosoft.bind(this),
|
|
393
|
+
signInWithApple: this.signInWithApple.bind(this),
|
|
394
|
+
linkOAuthProvider: this.linkOAuthProvider.bind(this),
|
|
395
|
+
unlinkOAuthProvider: this.unlinkOAuthProvider.bind(this),
|
|
396
|
+
getLinkedOAuthProviders: this.getLinkedOAuthProviders.bind(this),
|
|
397
|
+
refreshOAuthToken: this.refreshOAuthToken.bind(this),
|
|
398
|
+
getOAuthProviderToken: this.getOAuthProviderToken.bind(this),
|
|
399
|
+
callOAuthAPI: this.callOAuthAPI.bind(this),
|
|
400
|
+
// Session management methods
|
|
401
|
+
getSessions: this.getSessions.bind(this),
|
|
402
|
+
deleteSession: this.deleteSession.bind(this),
|
|
403
|
+
deleteAllOtherSessions: this.deleteAllOtherSessions.bind(this)
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
this.functions = {
|
|
407
|
+
invoke: this.invokeFunction.bind(this)
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
this.storage = {
|
|
411
|
+
from: this.storageBucket.bind(this)
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ========================================================================
|
|
416
|
+
// Storage Methods
|
|
417
|
+
// ========================================================================
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Select a storage bucket to perform operations on
|
|
421
|
+
* @param {string} bucketName - The name of the bucket
|
|
422
|
+
* @returns {StorageFileApi} - Storage file API for the bucket
|
|
423
|
+
*/
|
|
424
|
+
storageBucket(bucketName) {
|
|
425
|
+
return new StorageFileApi(this, bucketName);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ========================================================================
|
|
429
|
+
// Internal Fetch Helpers
|
|
430
|
+
// ========================================================================
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Make an authenticated request with access token
|
|
434
|
+
* @private
|
|
435
|
+
*/
|
|
436
|
+
async _authFetch(path, options = {}) {
|
|
437
|
+
if (!this.accessToken) {
|
|
438
|
+
return { ok: false, status: null, error: new Error('No active session'), data: null };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return this._authFetchUrl(`${this.apiUrl}${path}`, options);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async _authFetchUrl(url, options = {}) {
|
|
445
|
+
if (!this.accessToken) {
|
|
446
|
+
return { ok: false, status: null, error: new Error('No active session'), data: null };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const response = await fetchWithTimeout(
|
|
451
|
+
url,
|
|
452
|
+
{
|
|
453
|
+
...options,
|
|
454
|
+
headers: {
|
|
455
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
456
|
+
'Content-Type': 'application/json',
|
|
457
|
+
...options.headers
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
this.timeout
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const data = await safeJsonParse(response);
|
|
464
|
+
|
|
465
|
+
if (!response.ok) {
|
|
466
|
+
// Try token refresh on 401
|
|
467
|
+
if (response.status === 401 && !options._retried) {
|
|
468
|
+
const refreshed = await this.refreshSession();
|
|
469
|
+
if (!refreshed.error) {
|
|
470
|
+
return this._authFetchUrl(url, { ...options, _retried: true });
|
|
471
|
+
}
|
|
472
|
+
return { ok: false, status: response.status, error: new Error('Session expired'), data };
|
|
473
|
+
}
|
|
474
|
+
return { ok: false, status: response.status, error: new Error(data.error || 'Request failed'), data };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return { ok: true, status: response.status, data, error: null };
|
|
478
|
+
} catch (error) {
|
|
479
|
+
return { ok: false, status: null, error: error instanceof Error ? error : new Error('Request failed'), data: null };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
_getFunctionInvokeUrl(functionIdentifier) {
|
|
484
|
+
const hostLabel = sanitizeFunctionIdentifierForHost(functionIdentifier);
|
|
485
|
+
if (!hostLabel) {
|
|
486
|
+
throw new Error('functionId must be DNS-safe: lowercase letters, numbers, hyphens, 1-63 chars');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!this.functionInvocationBase) {
|
|
490
|
+
throw new Error('apiUrl must be api.<domain> (or localhost/IP for local mode) to use DNS function invocation');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const portSegment = this.functionInvocationBase.port ? `:${this.functionInvocationBase.port}` : '';
|
|
494
|
+
return `${this.functionInvocationBase.protocol}//${hostLabel}.${this.functionInvocationBase.domain}${portSegment}/`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
_functionResolveCacheKey(functionName) {
|
|
498
|
+
const projectScope = extractRequiredProjectIdFromToken(this.accessToken);
|
|
499
|
+
const tokenScope = this.accessToken;
|
|
500
|
+
return `${this.apiUrl}|project:${projectScope}|token:${tokenScope}|${functionName}`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
_clearFunctionResolveCache(functionName) {
|
|
504
|
+
const cacheKey = this._functionResolveCacheKey(functionName);
|
|
505
|
+
this._functionResolveState.cache.delete(cacheKey);
|
|
506
|
+
this._functionResolveState.inFlight.delete(cacheKey);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async _resolveFunctionIdByName(functionName) {
|
|
510
|
+
const hostLabel = sanitizeFunctionIdentifierForHost(functionName);
|
|
511
|
+
if (!hostLabel) {
|
|
512
|
+
throw new Error('functionName must be DNS-safe: lowercase letters, numbers, hyphens, 1-63 chars');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const cacheKey = this._functionResolveCacheKey(hostLabel);
|
|
516
|
+
const now = Date.now();
|
|
517
|
+
pruneFunctionResolveCache(this._functionResolveState, now);
|
|
518
|
+
const cached = this._functionResolveState.cache.get(cacheKey);
|
|
519
|
+
if (cached && cached.expiresAt > now) {
|
|
520
|
+
if (cached.error) {
|
|
521
|
+
throw new Error(cached.error);
|
|
522
|
+
}
|
|
523
|
+
return cached.functionId;
|
|
524
|
+
}
|
|
525
|
+
if (cached) {
|
|
526
|
+
this._functionResolveState.cache.delete(cacheKey);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const inFlight = this._functionResolveState.inFlight.get(cacheKey);
|
|
530
|
+
if (inFlight) {
|
|
531
|
+
return inFlight;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const pending = (async () => {
|
|
535
|
+
const resolvePath = `/functions/resolve?name=${encodeURIComponent(hostLabel)}`;
|
|
536
|
+
const result = await this._authFetch(resolvePath, { method: 'GET' });
|
|
537
|
+
if (!result.ok) {
|
|
538
|
+
if (result.status === 404) {
|
|
539
|
+
this._functionResolveState.cache.set(cacheKey, {
|
|
540
|
+
functionId: null,
|
|
541
|
+
error: 'function not found',
|
|
542
|
+
expiresAt: Date.now() + (DEFAULT_FUNCTION_NEGATIVE_RESOLVE_TTL_SECONDS * 1000)
|
|
543
|
+
});
|
|
544
|
+
pruneFunctionResolveCache(this._functionResolveState, Date.now(), true);
|
|
545
|
+
}
|
|
546
|
+
throw result.error || new Error('Failed to resolve function');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const resolvedId = sanitizeFunctionIdentifierForHost(result.data && result.data.function_id);
|
|
550
|
+
if (!resolvedId) {
|
|
551
|
+
throw new Error('Resolve response missing valid function_id');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const ttlRaw = Number(result.data && result.data.cache_ttl_seconds);
|
|
555
|
+
if (!Number.isFinite(ttlRaw) || ttlRaw <= 0) {
|
|
556
|
+
throw new Error('Resolve response missing valid cache_ttl_seconds');
|
|
557
|
+
}
|
|
558
|
+
const ttlSeconds = ttlRaw;
|
|
559
|
+
|
|
560
|
+
this._functionResolveState.cache.set(cacheKey, {
|
|
561
|
+
functionId: resolvedId,
|
|
562
|
+
error: null,
|
|
563
|
+
expiresAt: Date.now() + (ttlSeconds * 1000)
|
|
564
|
+
});
|
|
565
|
+
pruneFunctionResolveCache(this._functionResolveState, Date.now(), true);
|
|
566
|
+
return resolvedId;
|
|
567
|
+
})();
|
|
568
|
+
|
|
569
|
+
this._functionResolveState.inFlight.set(cacheKey, pending);
|
|
570
|
+
try {
|
|
571
|
+
return await pending;
|
|
572
|
+
} finally {
|
|
573
|
+
this._functionResolveState.inFlight.delete(cacheKey);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Make a public request with anon key
|
|
579
|
+
* @private
|
|
580
|
+
*/
|
|
581
|
+
async _anonFetch(path, options = {}) {
|
|
582
|
+
try {
|
|
583
|
+
const response = await fetchWithTimeout(
|
|
584
|
+
`${this.apiUrl}${path}`,
|
|
585
|
+
{
|
|
586
|
+
...options,
|
|
587
|
+
headers: {
|
|
588
|
+
'Authorization': `Bearer ${this.anonKey}`,
|
|
589
|
+
'Content-Type': 'application/json',
|
|
590
|
+
...options.headers
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
this.timeout
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const data = await safeJsonParse(response);
|
|
597
|
+
|
|
598
|
+
if (!response.ok) {
|
|
599
|
+
return { ok: false, error: new Error(data.error || 'Request failed'), data };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return { ok: true, data, error: null };
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return { ok: false, error: error instanceof Error ? error : new Error('Request failed'), data: null };
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ========================================================================
|
|
609
|
+
// Query Builder Methods
|
|
610
|
+
// ========================================================================
|
|
611
|
+
|
|
612
|
+
from(table) {
|
|
613
|
+
return new QueryBuilder(this, table, this._currentDatabaseName);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
database(databaseName) {
|
|
617
|
+
this._currentDatabaseName = databaseName;
|
|
618
|
+
return this;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
insert(table, values) {
|
|
622
|
+
return new MutationBuilder(this, table, this._currentDatabaseName, 'insert', values);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
update(table, values) {
|
|
626
|
+
return new MutationBuilder(this, table, this._currentDatabaseName, 'update', values);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
delete(table) {
|
|
630
|
+
return new MutationBuilder(this, table, this._currentDatabaseName, 'delete', null);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ========================================================================
|
|
634
|
+
// Authentication Methods
|
|
635
|
+
// ========================================================================
|
|
636
|
+
|
|
637
|
+
async signUp({ email, password, metadata = {} }) {
|
|
638
|
+
const result = await this._anonFetch('/auth/signup', {
|
|
639
|
+
method: 'POST',
|
|
640
|
+
body: JSON.stringify({ email, password, user_metadata: metadata })
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
if (!result.ok) {
|
|
644
|
+
return { user: null, session: null, error: result.error };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
this._setSession(result.data);
|
|
648
|
+
return {
|
|
649
|
+
user: result.data.user,
|
|
650
|
+
session: {
|
|
651
|
+
access_token: result.data.access_token,
|
|
652
|
+
refresh_token: result.data.refresh_token,
|
|
653
|
+
expires_in: result.data.expires_in
|
|
654
|
+
},
|
|
655
|
+
error: null
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async signIn({ email, password }) {
|
|
660
|
+
const result = await this._anonFetch('/auth/signin', {
|
|
661
|
+
method: 'POST',
|
|
662
|
+
body: JSON.stringify({ email, password })
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
if (!result.ok) {
|
|
666
|
+
return { user: null, session: null, error: result.error };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
this._setSession(result.data);
|
|
670
|
+
return {
|
|
671
|
+
user: result.data.user,
|
|
672
|
+
session: {
|
|
673
|
+
access_token: result.data.access_token,
|
|
674
|
+
refresh_token: result.data.refresh_token,
|
|
675
|
+
expires_in: result.data.expires_in
|
|
676
|
+
},
|
|
677
|
+
error: null
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async signOut() {
|
|
682
|
+
if (this.refreshToken) {
|
|
683
|
+
try {
|
|
684
|
+
await this._anonFetch('/auth/logout', {
|
|
685
|
+
method: 'POST',
|
|
686
|
+
body: JSON.stringify({ refresh_token: this.refreshToken })
|
|
687
|
+
});
|
|
688
|
+
} catch (err) {
|
|
689
|
+
console.warn('[VolcanoAuth] Logout request failed:', err.message);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
this._clearSession();
|
|
693
|
+
return { error: null };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async getUser() {
|
|
697
|
+
const result = await this._authFetch('/auth/user');
|
|
698
|
+
|
|
699
|
+
if (!result.ok) {
|
|
700
|
+
return { user: null, error: result.error };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
this.currentUser = result.data.user;
|
|
704
|
+
return { user: result.data.user, error: null };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async updateUser({ password, metadata }) {
|
|
708
|
+
const result = await this._authFetch('/auth/user', {
|
|
709
|
+
method: 'PUT',
|
|
710
|
+
body: JSON.stringify({ password, user_metadata: metadata })
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
if (!result.ok) {
|
|
714
|
+
return { user: null, error: result.error };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
this.currentUser = result.data.user;
|
|
718
|
+
return { user: result.data.user, error: null };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async refreshSession() {
|
|
722
|
+
if (!this.refreshToken) {
|
|
723
|
+
return { session: null, error: new Error('No refresh token') };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
try {
|
|
727
|
+
const result = await this._anonFetch('/auth/refresh', {
|
|
728
|
+
method: 'POST',
|
|
729
|
+
body: JSON.stringify({ refresh_token: this.refreshToken })
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
if (!result.ok) {
|
|
733
|
+
this._clearSession();
|
|
734
|
+
return { session: null, error: result.error };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
this._setSession(result.data);
|
|
738
|
+
return {
|
|
739
|
+
session: {
|
|
740
|
+
access_token: result.data.access_token,
|
|
741
|
+
refresh_token: result.data.refresh_token,
|
|
742
|
+
expires_in: result.data.expires_in
|
|
743
|
+
},
|
|
744
|
+
error: null
|
|
745
|
+
};
|
|
746
|
+
} catch (error) {
|
|
747
|
+
this._clearSession();
|
|
748
|
+
return { session: null, error: error instanceof Error ? error : new Error('Refresh failed') };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Register a callback for auth state changes.
|
|
754
|
+
* @param {Function} callback - Called with user object (or null) on auth state change
|
|
755
|
+
* @returns {Function} Unsubscribe function
|
|
756
|
+
*/
|
|
757
|
+
onAuthStateChange(callback) {
|
|
758
|
+
if (!this._authCallbacks) {
|
|
759
|
+
this._authCallbacks = [];
|
|
760
|
+
}
|
|
761
|
+
this._authCallbacks.push(callback);
|
|
762
|
+
|
|
763
|
+
// Call immediately with current state
|
|
764
|
+
try {
|
|
765
|
+
callback(this.currentUser);
|
|
766
|
+
} catch (err) {
|
|
767
|
+
console.error('[VolcanoAuth] Error in auth state callback:', err);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return () => {
|
|
771
|
+
this._authCallbacks = this._authCallbacks.filter(cb => cb !== callback);
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ========================================================================
|
|
776
|
+
// Anonymous User Methods
|
|
777
|
+
// ========================================================================
|
|
778
|
+
|
|
779
|
+
async signUpAnonymous(metadata = {}) {
|
|
780
|
+
const result = await this._anonFetch('/auth/signup-anonymous', {
|
|
781
|
+
method: 'POST',
|
|
782
|
+
body: JSON.stringify({ user_metadata: metadata })
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
if (!result.ok) {
|
|
786
|
+
return { user: null, session: null, error: result.error };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
this._setSession(result.data);
|
|
790
|
+
return {
|
|
791
|
+
user: result.data.user,
|
|
792
|
+
session: {
|
|
793
|
+
access_token: result.data.access_token,
|
|
794
|
+
refresh_token: result.data.refresh_token,
|
|
795
|
+
expires_in: result.data.expires_in
|
|
796
|
+
},
|
|
797
|
+
error: null
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async convertAnonymous({ email, password, metadata = {} }) {
|
|
802
|
+
const result = await this._authFetch('/auth/user/convert-anonymous', {
|
|
803
|
+
method: 'POST',
|
|
804
|
+
body: JSON.stringify({ email, password, user_metadata: metadata })
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
if (!result.ok) {
|
|
808
|
+
return { user: null, error: result.error };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
this.currentUser = result.data.user;
|
|
812
|
+
return { user: result.data.user, error: null };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ========================================================================
|
|
816
|
+
// Email Confirmation Methods
|
|
817
|
+
// ========================================================================
|
|
818
|
+
|
|
819
|
+
async confirmEmail(token) {
|
|
820
|
+
const result = await this._anonFetch('/auth/confirm', {
|
|
821
|
+
method: 'POST',
|
|
822
|
+
body: JSON.stringify({ token })
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
if (!result.ok) {
|
|
826
|
+
return { message: null, error: result.error };
|
|
827
|
+
}
|
|
828
|
+
return { message: result.data.message, error: null };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async resendConfirmation(email) {
|
|
832
|
+
const result = await this._anonFetch('/auth/resend-confirmation', {
|
|
833
|
+
method: 'POST',
|
|
834
|
+
body: JSON.stringify({ email })
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
if (!result.ok) {
|
|
838
|
+
return { message: null, error: result.error };
|
|
839
|
+
}
|
|
840
|
+
return { message: result.data.message, error: null };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ========================================================================
|
|
844
|
+
// Password Recovery Methods
|
|
845
|
+
// ========================================================================
|
|
846
|
+
|
|
847
|
+
async forgotPassword(email) {
|
|
848
|
+
const result = await this._anonFetch('/auth/forgot-password', {
|
|
849
|
+
method: 'POST',
|
|
850
|
+
body: JSON.stringify({ email })
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
if (!result.ok) {
|
|
854
|
+
return { message: null, error: result.error };
|
|
855
|
+
}
|
|
856
|
+
return { message: result.data.message, error: null };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async resetPassword({ token, newPassword }) {
|
|
860
|
+
const result = await this._anonFetch('/auth/reset-password', {
|
|
861
|
+
method: 'POST',
|
|
862
|
+
body: JSON.stringify({ token, new_password: newPassword })
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
if (!result.ok) {
|
|
866
|
+
return { message: null, error: result.error };
|
|
867
|
+
}
|
|
868
|
+
return { message: result.data.message, error: null };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ========================================================================
|
|
872
|
+
// Email Change Methods
|
|
873
|
+
// ========================================================================
|
|
874
|
+
|
|
875
|
+
async requestEmailChange(newEmail) {
|
|
876
|
+
const result = await this._authFetch('/auth/user/change-email', {
|
|
877
|
+
method: 'POST',
|
|
878
|
+
body: JSON.stringify({ new_email: newEmail })
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
if (!result.ok) {
|
|
882
|
+
return { message: null, newEmail: null, error: result.error };
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
message: result.data.message,
|
|
886
|
+
newEmail: result.data.new_email,
|
|
887
|
+
emailChangeToken: result.data.email_change_token,
|
|
888
|
+
error: null
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async confirmEmailChange(emailChangeToken) {
|
|
893
|
+
const result = await this._authFetch('/auth/user/confirm-email-change', {
|
|
894
|
+
method: 'POST',
|
|
895
|
+
body: JSON.stringify({ email_change_token: emailChangeToken })
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
if (!result.ok) {
|
|
899
|
+
return { user: null, error: result.error };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
this.currentUser = result.data.user;
|
|
903
|
+
return { user: result.data.user, error: null };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async cancelEmailChange() {
|
|
907
|
+
const result = await this._authFetch('/auth/user/cancel-email-change', {
|
|
908
|
+
method: 'DELETE'
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
if (!result.ok) {
|
|
912
|
+
return { message: null, error: result.error };
|
|
913
|
+
}
|
|
914
|
+
return { message: result.data.message, error: null };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ========================================================================
|
|
918
|
+
// OAuth / SSO Authentication
|
|
919
|
+
// ========================================================================
|
|
920
|
+
|
|
921
|
+
signInWithOAuth(provider) {
|
|
922
|
+
sanitizeProvider(provider);
|
|
923
|
+
if (!isBrowser()) {
|
|
924
|
+
throw new Error('OAuth sign-in is only available in browser environment. Use server-side auth flow for SSR.');
|
|
925
|
+
}
|
|
926
|
+
window.location.href = `${this.apiUrl}/auth/oauth/${provider}/authorize?anon_key=${encodeURIComponent(this.anonKey)}`;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
signInWithGoogle() { this.signInWithOAuth('google'); }
|
|
930
|
+
signInWithGitHub() { this.signInWithOAuth('github'); }
|
|
931
|
+
signInWithMicrosoft() { this.signInWithOAuth('microsoft'); }
|
|
932
|
+
signInWithApple() { this.signInWithOAuth('apple'); }
|
|
933
|
+
|
|
934
|
+
async linkOAuthProvider(provider) {
|
|
935
|
+
sanitizeProvider(provider);
|
|
936
|
+
const result = await this._authFetch(`/auth/oauth/${provider}/link`, {
|
|
937
|
+
method: 'POST'
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
if (!result.ok) {
|
|
941
|
+
return { data: null, error: result.error };
|
|
942
|
+
}
|
|
943
|
+
return { data: result.data, error: null };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async unlinkOAuthProvider(provider) {
|
|
947
|
+
sanitizeProvider(provider);
|
|
948
|
+
const result = await this._authFetch(`/auth/oauth/${provider}/unlink`, {
|
|
949
|
+
method: 'DELETE'
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
if (!result.ok) {
|
|
953
|
+
return { error: result.error };
|
|
954
|
+
}
|
|
955
|
+
return { error: null };
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async getLinkedOAuthProviders() {
|
|
959
|
+
const result = await this._authFetch('/auth/oauth/providers');
|
|
960
|
+
|
|
961
|
+
if (!result.ok) {
|
|
962
|
+
return { providers: null, error: result.error };
|
|
963
|
+
}
|
|
964
|
+
return { providers: result.data.providers || [], error: null };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async refreshOAuthToken(provider) {
|
|
968
|
+
sanitizeProvider(provider);
|
|
969
|
+
const result = await this._authFetch(`/auth/oauth/${provider}/refresh-token`, {
|
|
970
|
+
method: 'POST'
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
if (!result.ok) {
|
|
974
|
+
return { message: null, provider: null, expiresIn: null, error: result.error };
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
message: result.data.message,
|
|
978
|
+
provider: result.data.provider,
|
|
979
|
+
expiresIn: result.data.expires_in,
|
|
980
|
+
error: null
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async getOAuthProviderToken(provider) {
|
|
985
|
+
sanitizeProvider(provider);
|
|
986
|
+
const result = await this._authFetch(`/auth/oauth/${provider}/token`);
|
|
987
|
+
|
|
988
|
+
if (!result.ok) {
|
|
989
|
+
return { message: null, provider: null, expiresIn: null, error: result.error };
|
|
990
|
+
}
|
|
991
|
+
return {
|
|
992
|
+
message: result.data.message,
|
|
993
|
+
provider: result.data.provider,
|
|
994
|
+
expiresIn: result.data.expires_in,
|
|
995
|
+
error: null
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async callOAuthAPI(provider, { endpoint, method = 'GET', body = null }) {
|
|
1000
|
+
sanitizeProvider(provider);
|
|
1001
|
+
const result = await this._authFetch(`/auth/oauth/${provider}/call-api`, {
|
|
1002
|
+
method: 'POST',
|
|
1003
|
+
body: JSON.stringify({ endpoint, method, body })
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
if (!result.ok) {
|
|
1007
|
+
return { data: null, error: result.error };
|
|
1008
|
+
}
|
|
1009
|
+
return { data: result.data.data, error: null };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// ========================================================================
|
|
1013
|
+
// Session Management (User's sessions)
|
|
1014
|
+
// ========================================================================
|
|
1015
|
+
|
|
1016
|
+
async getSessions(options = {}) {
|
|
1017
|
+
const { page = 1, limit = DEFAULT_SESSIONS_LIMIT } = options;
|
|
1018
|
+
const params = new URLSearchParams();
|
|
1019
|
+
if (page > 1) params.set('page', page.toString());
|
|
1020
|
+
if (limit !== DEFAULT_SESSIONS_LIMIT) params.set('limit', limit.toString());
|
|
1021
|
+
|
|
1022
|
+
const queryString = params.toString();
|
|
1023
|
+
const url = `/auth/user/sessions${queryString ? '?' + queryString : ''}`;
|
|
1024
|
+
const result = await this._authFetch(url);
|
|
1025
|
+
|
|
1026
|
+
if (!result.ok) {
|
|
1027
|
+
return { sessions: null, total: 0, page: 1, limit: DEFAULT_SESSIONS_LIMIT, total_pages: 0, error: result.error };
|
|
1028
|
+
}
|
|
1029
|
+
return {
|
|
1030
|
+
sessions: result.data.sessions,
|
|
1031
|
+
total: result.data.total,
|
|
1032
|
+
page: result.data.page,
|
|
1033
|
+
limit: result.data.limit,
|
|
1034
|
+
total_pages: result.data.total_pages,
|
|
1035
|
+
error: null
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async deleteSession(sessionId) {
|
|
1040
|
+
const result = await this._authFetch(`/auth/user/sessions/${encodeURIComponent(sessionId)}`, {
|
|
1041
|
+
method: 'DELETE'
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
if (!result.ok) {
|
|
1045
|
+
return { error: result.error };
|
|
1046
|
+
}
|
|
1047
|
+
return { error: null };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async deleteAllOtherSessions() {
|
|
1051
|
+
const result = await this._authFetch('/auth/user/sessions', {
|
|
1052
|
+
method: 'DELETE'
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
if (!result.ok) {
|
|
1056
|
+
return { error: result.error };
|
|
1057
|
+
}
|
|
1058
|
+
return { error: null };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ========================================================================
|
|
1062
|
+
// Function Invocation
|
|
1063
|
+
// ========================================================================
|
|
1064
|
+
|
|
1065
|
+
async invokeFunction(functionName, payload = {}) {
|
|
1066
|
+
if (!functionName || typeof functionName !== 'string') {
|
|
1067
|
+
return { data: null, error: new Error('functionName must be a non-empty string') };
|
|
1068
|
+
}
|
|
1069
|
+
if (!this.accessToken) {
|
|
1070
|
+
return { data: null, error: new Error('No active session') };
|
|
1071
|
+
}
|
|
1072
|
+
if (!this.functionInvocationBase) {
|
|
1073
|
+
return { data: null, error: new Error('apiUrl must be api.<domain> (or localhost/IP for local mode) to use DNS function invocation') };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
let resolvedFunctionId;
|
|
1077
|
+
try {
|
|
1078
|
+
resolvedFunctionId = await this._resolveFunctionIdByName(functionName.trim());
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
return { data: null, error: error instanceof Error ? error : new Error('Failed to resolve function') };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
let invokeUrl;
|
|
1084
|
+
try {
|
|
1085
|
+
invokeUrl = this._getFunctionInvokeUrl(resolvedFunctionId);
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
return { data: null, error: error instanceof Error ? error : new Error('Invalid function identifier') };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
let result = await this._authFetchUrl(invokeUrl, {
|
|
1091
|
+
method: 'POST',
|
|
1092
|
+
body: JSON.stringify(payload)
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
// Function can be deleted/recreated, making cached name->id mapping stale.
|
|
1096
|
+
// On 404, invalidate and resolve once more before failing.
|
|
1097
|
+
if (!result.ok && result.status === 404) {
|
|
1098
|
+
this._clearFunctionResolveCache(functionName.trim());
|
|
1099
|
+
try {
|
|
1100
|
+
resolvedFunctionId = await this._resolveFunctionIdByName(functionName.trim());
|
|
1101
|
+
invokeUrl = this._getFunctionInvokeUrl(resolvedFunctionId);
|
|
1102
|
+
result = await this._authFetchUrl(invokeUrl, {
|
|
1103
|
+
method: 'POST',
|
|
1104
|
+
body: JSON.stringify(payload)
|
|
1105
|
+
});
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
return { data: null, error: error instanceof Error ? error : new Error('Failed to resolve function') };
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (!result.ok) {
|
|
1112
|
+
return { data: null, error: result.error };
|
|
1113
|
+
}
|
|
1114
|
+
return { data: result.data, error: null };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ========================================================================
|
|
1118
|
+
// Session Management (Internal)
|
|
1119
|
+
// ========================================================================
|
|
1120
|
+
|
|
1121
|
+
_setSession(data) {
|
|
1122
|
+
this.accessToken = data.access_token;
|
|
1123
|
+
this.refreshToken = data.refresh_token;
|
|
1124
|
+
this.currentUser = data.user;
|
|
1125
|
+
|
|
1126
|
+
this._setStorageItem(STORAGE_KEY_ACCESS_TOKEN, this.accessToken);
|
|
1127
|
+
this._setStorageItem(STORAGE_KEY_REFRESH_TOKEN, this.refreshToken);
|
|
1128
|
+
|
|
1129
|
+
this._notifyAuthCallbacks(this.currentUser);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
_clearSession() {
|
|
1133
|
+
this.accessToken = null;
|
|
1134
|
+
this.refreshToken = null;
|
|
1135
|
+
this.currentUser = null;
|
|
1136
|
+
|
|
1137
|
+
this._removeStorageItem(STORAGE_KEY_ACCESS_TOKEN);
|
|
1138
|
+
this._removeStorageItem(STORAGE_KEY_REFRESH_TOKEN);
|
|
1139
|
+
|
|
1140
|
+
this._notifyAuthCallbacks(null);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
_notifyAuthCallbacks(user) {
|
|
1144
|
+
if (this._authCallbacks) {
|
|
1145
|
+
this._authCallbacks.forEach(cb => {
|
|
1146
|
+
try {
|
|
1147
|
+
cb(user);
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
console.error('[VolcanoAuth] Error in auth state callback:', err);
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// ========================================================================
|
|
1156
|
+
// Storage Helpers (Browser/Node.js compatible)
|
|
1157
|
+
// ========================================================================
|
|
1158
|
+
|
|
1159
|
+
_getStorageItem(key) {
|
|
1160
|
+
if (typeof localStorage !== 'undefined') {
|
|
1161
|
+
return localStorage.getItem(key);
|
|
1162
|
+
}
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
_setStorageItem(key, value) {
|
|
1167
|
+
if (typeof localStorage !== 'undefined') {
|
|
1168
|
+
localStorage.setItem(key, value);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
_removeStorageItem(key) {
|
|
1173
|
+
if (typeof localStorage !== 'undefined') {
|
|
1174
|
+
localStorage.removeItem(key);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// ========================================================================
|
|
1179
|
+
// Initialization
|
|
1180
|
+
// ========================================================================
|
|
1181
|
+
|
|
1182
|
+
async initialize() {
|
|
1183
|
+
if (this.accessToken && this.refreshToken) {
|
|
1184
|
+
const { user, error } = await this.getUser();
|
|
1185
|
+
return { user, error };
|
|
1186
|
+
}
|
|
1187
|
+
return { user: null, error: null };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* @internal Test-only helper to ensure deterministic cache behavior in unit/integration tests.
|
|
1192
|
+
*/
|
|
1193
|
+
static __resetFunctionResolveCacheForTests() {
|
|
1194
|
+
clearSharedFunctionResolveStateForTests();
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* @internal Test-only helper for asserting global resolver cache state.
|
|
1199
|
+
*/
|
|
1200
|
+
static __getFunctionResolveCacheMetricsForTests() {
|
|
1201
|
+
const state = getSharedFunctionResolveState();
|
|
1202
|
+
return {
|
|
1203
|
+
cacheSize: state.cache.size,
|
|
1204
|
+
inFlightSize: state.inFlight.size,
|
|
1205
|
+
maxEntries: state.maxEntries
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* @internal Test-only helper for forcing resolver cache limits.
|
|
1211
|
+
*/
|
|
1212
|
+
static __setFunctionResolveCacheMaxEntriesForTests(maxEntries) {
|
|
1213
|
+
const nextMax = Number(maxEntries);
|
|
1214
|
+
if (!Number.isInteger(nextMax) || nextMax < 1) {
|
|
1215
|
+
throw new Error('maxEntries must be a positive integer');
|
|
1216
|
+
}
|
|
1217
|
+
const state = getSharedFunctionResolveState();
|
|
1218
|
+
state.maxEntries = nextMax;
|
|
1219
|
+
pruneFunctionResolveCache(state, Date.now(), true);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ============================================================================
|
|
1224
|
+
// Shared Filter Mixin - Used by QueryBuilder and MutationBuilder
|
|
1225
|
+
// ============================================================================
|
|
1226
|
+
|
|
1227
|
+
const FilterMixin = {
|
|
1228
|
+
eq(column, value) {
|
|
1229
|
+
this.filters.push({ column, operator: 'eq', value });
|
|
1230
|
+
return this;
|
|
1231
|
+
},
|
|
1232
|
+
neq(column, value) {
|
|
1233
|
+
this.filters.push({ column, operator: 'neq', value });
|
|
1234
|
+
return this;
|
|
1235
|
+
},
|
|
1236
|
+
gt(column, value) {
|
|
1237
|
+
this.filters.push({ column, operator: 'gt', value });
|
|
1238
|
+
return this;
|
|
1239
|
+
},
|
|
1240
|
+
gte(column, value) {
|
|
1241
|
+
this.filters.push({ column, operator: 'gte', value });
|
|
1242
|
+
return this;
|
|
1243
|
+
},
|
|
1244
|
+
lt(column, value) {
|
|
1245
|
+
this.filters.push({ column, operator: 'lt', value });
|
|
1246
|
+
return this;
|
|
1247
|
+
},
|
|
1248
|
+
lte(column, value) {
|
|
1249
|
+
this.filters.push({ column, operator: 'lte', value });
|
|
1250
|
+
return this;
|
|
1251
|
+
},
|
|
1252
|
+
like(column, pattern) {
|
|
1253
|
+
this.filters.push({ column, operator: 'like', value: pattern });
|
|
1254
|
+
return this;
|
|
1255
|
+
},
|
|
1256
|
+
ilike(column, pattern) {
|
|
1257
|
+
this.filters.push({ column, operator: 'ilike', value: pattern });
|
|
1258
|
+
return this;
|
|
1259
|
+
},
|
|
1260
|
+
is(column, value) {
|
|
1261
|
+
this.filters.push({ column, operator: 'is', value });
|
|
1262
|
+
return this;
|
|
1263
|
+
},
|
|
1264
|
+
in(column, values) {
|
|
1265
|
+
this.filters.push({ column, operator: 'in', value: values });
|
|
1266
|
+
return this;
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
// ============================================================================
|
|
1271
|
+
// QueryBuilder - For SELECT operations
|
|
1272
|
+
// ============================================================================
|
|
1273
|
+
|
|
1274
|
+
class QueryBuilder {
|
|
1275
|
+
constructor(volcanoAuth, table, databaseName) {
|
|
1276
|
+
this.volcanoAuth = volcanoAuth;
|
|
1277
|
+
this.table = table;
|
|
1278
|
+
this.databaseName = databaseName;
|
|
1279
|
+
this.selectColumns = [];
|
|
1280
|
+
this.filters = [];
|
|
1281
|
+
this.orderClauses = [];
|
|
1282
|
+
this.limitValue = null;
|
|
1283
|
+
this.offsetValue = null;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
select(columns) {
|
|
1287
|
+
if (columns === '*') {
|
|
1288
|
+
this.selectColumns = [];
|
|
1289
|
+
} else if (Array.isArray(columns)) {
|
|
1290
|
+
this.selectColumns = columns;
|
|
1291
|
+
} else {
|
|
1292
|
+
this.selectColumns = columns.split(',').map(c => c.trim());
|
|
1293
|
+
}
|
|
1294
|
+
return this;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
order(column, options = {}) {
|
|
1298
|
+
this.orderClauses.push({
|
|
1299
|
+
column,
|
|
1300
|
+
ascending: options.ascending !== false
|
|
1301
|
+
});
|
|
1302
|
+
return this;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
limit(count) {
|
|
1306
|
+
this.limitValue = count;
|
|
1307
|
+
return this;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
offset(count) {
|
|
1311
|
+
this.offsetValue = count;
|
|
1312
|
+
return this;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async execute() {
|
|
1316
|
+
if (!this.volcanoAuth.accessToken) {
|
|
1317
|
+
return errorResult('No active session. Please sign in first.', { count: 0 });
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (!this.databaseName) {
|
|
1321
|
+
return errorResult('Database name not set. Use .database(databaseName) first.', { count: 0 });
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const requestBody = { table: this.table };
|
|
1325
|
+
if (this.selectColumns.length > 0) requestBody.select = this.selectColumns;
|
|
1326
|
+
if (this.filters.length > 0) requestBody.filters = this.filters;
|
|
1327
|
+
if (this.orderClauses.length > 0) requestBody.order = this.orderClauses;
|
|
1328
|
+
if (this.limitValue !== null) requestBody.limit = this.limitValue;
|
|
1329
|
+
if (this.offsetValue !== null) requestBody.offset = this.offsetValue;
|
|
1330
|
+
|
|
1331
|
+
try {
|
|
1332
|
+
const response = await fetchWithAuthRetry(
|
|
1333
|
+
this.volcanoAuth,
|
|
1334
|
+
`${this.volcanoAuth.apiUrl}/databases/${encodeURIComponent(this.databaseName)}/query/select`,
|
|
1335
|
+
{
|
|
1336
|
+
method: 'POST',
|
|
1337
|
+
headers: {
|
|
1338
|
+
'Content-Type': 'application/json'
|
|
1339
|
+
},
|
|
1340
|
+
body: JSON.stringify(requestBody)
|
|
1341
|
+
}
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1344
|
+
const result = await safeJsonParse(response);
|
|
1345
|
+
|
|
1346
|
+
if (!response.ok) {
|
|
1347
|
+
return errorResult(result.error || 'Query failed', { count: 0 });
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
return { data: result.data, error: null, count: result.count || result.data.length };
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
return { data: null, error: error instanceof Error ? error : new Error('Query failed'), count: 0 };
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
then(resolve, reject) {
|
|
1357
|
+
return this.execute().then(resolve, reject);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
Object.assign(QueryBuilder.prototype, FilterMixin);
|
|
1362
|
+
|
|
1363
|
+
// ============================================================================
|
|
1364
|
+
// MutationBuilder - Unified builder for INSERT, UPDATE, DELETE
|
|
1365
|
+
// ============================================================================
|
|
1366
|
+
|
|
1367
|
+
class MutationBuilder {
|
|
1368
|
+
constructor(volcanoAuth, table, databaseName, operation, values) {
|
|
1369
|
+
this.volcanoAuth = volcanoAuth;
|
|
1370
|
+
this.table = table;
|
|
1371
|
+
this.databaseName = databaseName;
|
|
1372
|
+
this.operation = operation;
|
|
1373
|
+
this.values = values;
|
|
1374
|
+
this.filters = [];
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async execute() {
|
|
1378
|
+
if (!this.volcanoAuth.accessToken) {
|
|
1379
|
+
return errorResult('No active session. Please sign in first.');
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (!this.databaseName) {
|
|
1383
|
+
return errorResult('Database name not set. Use .database(databaseName) first.');
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const requestBody = { table: this.table };
|
|
1387
|
+
if (this.values) requestBody.values = this.values;
|
|
1388
|
+
if (this.filters.length > 0) requestBody.filters = this.filters;
|
|
1389
|
+
|
|
1390
|
+
try {
|
|
1391
|
+
const response = await fetchWithAuthRetry(
|
|
1392
|
+
this.volcanoAuth,
|
|
1393
|
+
`${this.volcanoAuth.apiUrl}/databases/${encodeURIComponent(this.databaseName)}/query/${encodeURIComponent(this.operation)}`,
|
|
1394
|
+
{
|
|
1395
|
+
method: 'POST',
|
|
1396
|
+
headers: {
|
|
1397
|
+
'Content-Type': 'application/json'
|
|
1398
|
+
},
|
|
1399
|
+
body: JSON.stringify(requestBody)
|
|
1400
|
+
}
|
|
1401
|
+
);
|
|
1402
|
+
|
|
1403
|
+
const result = await safeJsonParse(response);
|
|
1404
|
+
|
|
1405
|
+
if (!response.ok) {
|
|
1406
|
+
return errorResult(result.error || `${this.operation} failed`);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
return { data: result.data, error: null };
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
return { data: null, error: error instanceof Error ? error : new Error(`${this.operation} failed`) };
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
then(resolve, reject) {
|
|
1416
|
+
return this.execute().then(resolve, reject);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
Object.assign(MutationBuilder.prototype, FilterMixin);
|
|
1421
|
+
|
|
1422
|
+
// ============================================================================
|
|
1423
|
+
// StorageFileApi - For storage operations on a specific bucket
|
|
1424
|
+
// ============================================================================
|
|
1425
|
+
|
|
1426
|
+
class StorageFileApi {
|
|
1427
|
+
constructor(volcanoAuth, bucketName) {
|
|
1428
|
+
this.volcanoAuth = volcanoAuth;
|
|
1429
|
+
this.bucketName = bucketName;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Check if user is authenticated
|
|
1434
|
+
* @private
|
|
1435
|
+
*/
|
|
1436
|
+
_checkAuth() {
|
|
1437
|
+
if (!this.volcanoAuth.accessToken) {
|
|
1438
|
+
return errorResult('No active session. Please sign in first.');
|
|
1439
|
+
}
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* Build a storage URL for the given path
|
|
1445
|
+
* @private
|
|
1446
|
+
*/
|
|
1447
|
+
_buildUrl(path) {
|
|
1448
|
+
return `${this.volcanoAuth.apiUrl}/storage/${encodeURIComponent(this.bucketName)}/${this._encodePath(path)}`;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Encode a storage path for use in URLs
|
|
1453
|
+
* @private
|
|
1454
|
+
*/
|
|
1455
|
+
_encodePath(path) {
|
|
1456
|
+
return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Make an authenticated storage request
|
|
1461
|
+
* @private
|
|
1462
|
+
*/
|
|
1463
|
+
async _storageRequest(url, options = {}) {
|
|
1464
|
+
try {
|
|
1465
|
+
const response = await fetchWithAuthRetry(this.volcanoAuth, url, options);
|
|
1466
|
+
|
|
1467
|
+
// For blob responses (downloads), handle separately
|
|
1468
|
+
if (options.responseType === 'blob') {
|
|
1469
|
+
if (!response.ok) {
|
|
1470
|
+
const errorData = await safeJsonParse(response);
|
|
1471
|
+
return { data: null, error: new Error(errorData.error || 'Request failed') };
|
|
1472
|
+
}
|
|
1473
|
+
const blob = await response.blob();
|
|
1474
|
+
return { data: blob, error: null };
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const data = await safeJsonParse(response);
|
|
1478
|
+
|
|
1479
|
+
if (!response.ok) {
|
|
1480
|
+
return { data: null, error: new Error(data.error || 'Request failed') };
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
return { data, error: null };
|
|
1484
|
+
} catch (error) {
|
|
1485
|
+
return { data: null, error: error instanceof Error ? error : new Error('Request failed') };
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Upload a file to the bucket
|
|
1491
|
+
*/
|
|
1492
|
+
async upload(path, fileBody, options = {}) {
|
|
1493
|
+
const authError = this._checkAuth();
|
|
1494
|
+
if (authError) return authError;
|
|
1495
|
+
|
|
1496
|
+
try {
|
|
1497
|
+
const formData = new FormData();
|
|
1498
|
+
let file;
|
|
1499
|
+
|
|
1500
|
+
if (fileBody instanceof File) {
|
|
1501
|
+
file = fileBody;
|
|
1502
|
+
} else if (fileBody instanceof Blob) {
|
|
1503
|
+
const contentType = options.contentType || 'application/octet-stream';
|
|
1504
|
+
file = new File([fileBody], path.split('/').pop() || 'file', { type: contentType });
|
|
1505
|
+
} else if (fileBody instanceof ArrayBuffer) {
|
|
1506
|
+
const contentType = options.contentType || 'application/octet-stream';
|
|
1507
|
+
file = new File([fileBody], path.split('/').pop() || 'file', { type: contentType });
|
|
1508
|
+
} else {
|
|
1509
|
+
return errorResult('Invalid file body type. Expected File, Blob, or ArrayBuffer.');
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
formData.append('file', file);
|
|
1513
|
+
|
|
1514
|
+
const response = await fetchWithAuthRetry(this.volcanoAuth, this._buildUrl(path), {
|
|
1515
|
+
method: 'POST',
|
|
1516
|
+
body: formData
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
const data = await safeJsonParse(response);
|
|
1520
|
+
|
|
1521
|
+
if (!response.ok) {
|
|
1522
|
+
return errorResult(data.error || 'Upload failed');
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
return { data, error: null };
|
|
1526
|
+
} catch (error) {
|
|
1527
|
+
return { data: null, error: error instanceof Error ? error : new Error('Upload failed') };
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Download a file from the bucket
|
|
1533
|
+
*/
|
|
1534
|
+
async download(path, options = {}) {
|
|
1535
|
+
const authError = this._checkAuth();
|
|
1536
|
+
if (authError) return authError;
|
|
1537
|
+
|
|
1538
|
+
const headers = {};
|
|
1539
|
+
if (options.range) {
|
|
1540
|
+
headers['Range'] = options.range;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
return this._storageRequest(this._buildUrl(path), {
|
|
1544
|
+
method: 'GET',
|
|
1545
|
+
headers,
|
|
1546
|
+
responseType: 'blob'
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* List files in the bucket
|
|
1552
|
+
*/
|
|
1553
|
+
async list(prefix = '', options = {}) {
|
|
1554
|
+
const authError = this._checkAuth();
|
|
1555
|
+
if (authError) return { ...authError, nextCursor: null };
|
|
1556
|
+
|
|
1557
|
+
const params = new URLSearchParams();
|
|
1558
|
+
if (prefix) params.set('prefix', prefix);
|
|
1559
|
+
if (options.limit) params.set('limit', String(options.limit));
|
|
1560
|
+
if (options.cursor) params.set('cursor', options.cursor);
|
|
1561
|
+
|
|
1562
|
+
const queryString = params.toString();
|
|
1563
|
+
const url = `${this.volcanoAuth.apiUrl}/storage/${encodeURIComponent(this.bucketName)}${queryString ? '?' + queryString : ''}`;
|
|
1564
|
+
|
|
1565
|
+
const result = await this._storageRequest(url, {
|
|
1566
|
+
method: 'GET',
|
|
1567
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
if (result.error) {
|
|
1571
|
+
return { data: null, error: result.error, nextCursor: null };
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
return {
|
|
1575
|
+
data: result.data.objects || [],
|
|
1576
|
+
error: null,
|
|
1577
|
+
nextCursor: result.data.next_cursor || null
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* Delete one or more files from the bucket
|
|
1583
|
+
*/
|
|
1584
|
+
async remove(paths) {
|
|
1585
|
+
const authError = this._checkAuth();
|
|
1586
|
+
if (authError) return authError;
|
|
1587
|
+
|
|
1588
|
+
const pathList = Array.isArray(paths) ? paths : [paths];
|
|
1589
|
+
const errors = [];
|
|
1590
|
+
const deleted = [];
|
|
1591
|
+
|
|
1592
|
+
for (const path of pathList) {
|
|
1593
|
+
const result = await this._storageRequest(this._buildUrl(path), {
|
|
1594
|
+
method: 'DELETE'
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
if (result.error) {
|
|
1598
|
+
errors.push({ path, error: result.error.message });
|
|
1599
|
+
} else {
|
|
1600
|
+
deleted.push(path);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
if (errors.length > 0) {
|
|
1605
|
+
return {
|
|
1606
|
+
data: { deleted },
|
|
1607
|
+
error: new Error(`Failed to delete ${errors.length} file(s): ${errors.map(e => e.path).join(', ')}`)
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
return { data: { deleted }, error: null };
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Move/rename a file within the bucket
|
|
1616
|
+
*/
|
|
1617
|
+
async move(fromPath, toPath) {
|
|
1618
|
+
const authError = this._checkAuth();
|
|
1619
|
+
if (authError) return authError;
|
|
1620
|
+
|
|
1621
|
+
return this._storageRequest(
|
|
1622
|
+
`${this.volcanoAuth.apiUrl}/storage/${encodeURIComponent(this.bucketName)}/move`,
|
|
1623
|
+
{
|
|
1624
|
+
method: 'POST',
|
|
1625
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1626
|
+
body: JSON.stringify({ from: fromPath, to: toPath })
|
|
1627
|
+
}
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* Copy a file within the bucket
|
|
1633
|
+
*/
|
|
1634
|
+
async copy(fromPath, toPath) {
|
|
1635
|
+
const authError = this._checkAuth();
|
|
1636
|
+
if (authError) return authError;
|
|
1637
|
+
|
|
1638
|
+
return this._storageRequest(
|
|
1639
|
+
`${this.volcanoAuth.apiUrl}/storage/${encodeURIComponent(this.bucketName)}/copy`,
|
|
1640
|
+
{
|
|
1641
|
+
method: 'POST',
|
|
1642
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1643
|
+
body: JSON.stringify({ from: fromPath, to: toPath })
|
|
1644
|
+
}
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Get the public URL for a file (only works for files with is_public=true)
|
|
1650
|
+
*/
|
|
1651
|
+
getPublicUrl(path) {
|
|
1652
|
+
try {
|
|
1653
|
+
const parts = this.volcanoAuth.anonKey.split('.');
|
|
1654
|
+
if (parts.length !== 3) {
|
|
1655
|
+
return errorResult('Invalid anon key format');
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const payload = JSON.parse(decodeBase64Url(parts[1]));
|
|
1659
|
+
const projectId = payload.project_id;
|
|
1660
|
+
|
|
1661
|
+
if (!projectId) {
|
|
1662
|
+
return errorResult('Project ID not found in anon key');
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const encodedPath = this._encodePath(path);
|
|
1666
|
+
const publicUrl = `${this.volcanoAuth.apiUrl}/public/${projectId}/${encodeURIComponent(this.bucketName)}/${encodedPath}`;
|
|
1667
|
+
return { data: { publicUrl }, error: null };
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
return errorResult('Failed to parse anon key: ' + (error instanceof Error ? error.message : 'Unknown error'));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* Update the visibility (public/private) of a file
|
|
1675
|
+
*/
|
|
1676
|
+
async updateVisibility(path, isPublic) {
|
|
1677
|
+
const authError = this._checkAuth();
|
|
1678
|
+
if (authError) return authError;
|
|
1679
|
+
|
|
1680
|
+
return this._storageRequest(
|
|
1681
|
+
`${this._buildUrl(path)}/visibility`,
|
|
1682
|
+
{
|
|
1683
|
+
method: 'PATCH',
|
|
1684
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1685
|
+
body: JSON.stringify({ is_public: isPublic })
|
|
1686
|
+
}
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// ========================================================================
|
|
1691
|
+
// Resumable Upload Methods
|
|
1692
|
+
// ========================================================================
|
|
1693
|
+
|
|
1694
|
+
async createUploadSession(path, options) {
|
|
1695
|
+
const authError = this._checkAuth();
|
|
1696
|
+
if (authError) return authError;
|
|
1697
|
+
|
|
1698
|
+
if (!options || !options.totalSize) {
|
|
1699
|
+
return errorResult('totalSize is required');
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
return this._storageRequest(this._buildUrl(path), {
|
|
1703
|
+
method: 'POST',
|
|
1704
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1705
|
+
body: JSON.stringify({
|
|
1706
|
+
filename: path.split('/').pop() || path,
|
|
1707
|
+
content_type: options.contentType || 'application/octet-stream',
|
|
1708
|
+
total_size: options.totalSize,
|
|
1709
|
+
part_size: options.partSize
|
|
1710
|
+
})
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
async uploadPart(path, sessionId, partNumber, partData) {
|
|
1715
|
+
const authError = this._checkAuth();
|
|
1716
|
+
if (authError) return authError;
|
|
1717
|
+
|
|
1718
|
+
return this._storageRequest(this._buildUrl(path), {
|
|
1719
|
+
method: 'PUT',
|
|
1720
|
+
headers: {
|
|
1721
|
+
'Content-Type': 'application/octet-stream',
|
|
1722
|
+
'X-Upload-Session': sessionId,
|
|
1723
|
+
'X-Part-Number': String(partNumber)
|
|
1724
|
+
},
|
|
1725
|
+
body: partData
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
async completeUploadSession(path, sessionId) {
|
|
1730
|
+
const authError = this._checkAuth();
|
|
1731
|
+
if (authError) return authError;
|
|
1732
|
+
|
|
1733
|
+
return this._storageRequest(this._buildUrl(path), {
|
|
1734
|
+
method: 'POST',
|
|
1735
|
+
headers: {
|
|
1736
|
+
'Content-Type': 'application/json',
|
|
1737
|
+
'X-Upload-Session': sessionId,
|
|
1738
|
+
'X-Upload-Complete': 'true'
|
|
1739
|
+
},
|
|
1740
|
+
body: JSON.stringify({})
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
async getUploadSession(path, sessionId) {
|
|
1745
|
+
const authError = this._checkAuth();
|
|
1746
|
+
if (authError) return authError;
|
|
1747
|
+
|
|
1748
|
+
return this._storageRequest(this._buildUrl(path), {
|
|
1749
|
+
method: 'GET',
|
|
1750
|
+
headers: { 'X-Upload-Session': sessionId }
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
async abortUploadSession(path, sessionId) {
|
|
1755
|
+
const authError = this._checkAuth();
|
|
1756
|
+
if (authError) return { error: authError.error };
|
|
1757
|
+
|
|
1758
|
+
const result = await this._storageRequest(this._buildUrl(path), {
|
|
1759
|
+
method: 'DELETE',
|
|
1760
|
+
headers: { 'X-Upload-Session': sessionId }
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
return { error: result.error };
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* Upload a large file using resumable upload with automatic chunking
|
|
1768
|
+
*/
|
|
1769
|
+
async uploadResumable(path, fileBody, options = {}) {
|
|
1770
|
+
const authError = this._checkAuth();
|
|
1771
|
+
if (authError) return authError;
|
|
1772
|
+
|
|
1773
|
+
const totalSize = fileBody.size;
|
|
1774
|
+
const contentType = options.contentType || (fileBody instanceof File ? fileBody.type : 'application/octet-stream') || 'application/octet-stream';
|
|
1775
|
+
const partSize = options.partSize || DEFAULT_UPLOAD_PART_SIZE;
|
|
1776
|
+
const onProgress = options.onProgress;
|
|
1777
|
+
|
|
1778
|
+
try {
|
|
1779
|
+
const { data: session, error: sessionError } = await this.createUploadSession(path, {
|
|
1780
|
+
totalSize,
|
|
1781
|
+
contentType,
|
|
1782
|
+
partSize
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
if (sessionError) {
|
|
1786
|
+
return { data: null, error: sessionError };
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const sessionId = session.session_id;
|
|
1790
|
+
const totalParts = session.total_parts;
|
|
1791
|
+
const actualPartSize = session.part_size;
|
|
1792
|
+
|
|
1793
|
+
let uploaded = 0;
|
|
1794
|
+
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
|
1795
|
+
const start = (partNumber - 1) * actualPartSize;
|
|
1796
|
+
const end = Math.min(start + actualPartSize, totalSize);
|
|
1797
|
+
const partData = fileBody.slice(start, end);
|
|
1798
|
+
|
|
1799
|
+
const { error: partError } = await this.uploadPart(path, sessionId, partNumber, partData);
|
|
1800
|
+
|
|
1801
|
+
if (partError) {
|
|
1802
|
+
const { error: abortError } = await this.abortUploadSession(path, sessionId);
|
|
1803
|
+
if (abortError) {
|
|
1804
|
+
console.warn(`[Storage] Failed to abort upload session ${sessionId}:`, abortError.message);
|
|
1805
|
+
}
|
|
1806
|
+
return { data: null, error: partError };
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
uploaded = end;
|
|
1810
|
+
if (onProgress) {
|
|
1811
|
+
onProgress(uploaded, totalSize);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
return this.completeUploadSession(path, sessionId);
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
return { data: null, error: error instanceof Error ? error : new Error('Resumable upload failed') };
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// ============================================================================
|
|
1823
|
+
// Realtime Import Note
|
|
1824
|
+
// ============================================================================
|
|
1825
|
+
|
|
1826
|
+
// Realtime is available via separate import: import { VolcanoRealtime } from '@volcano.dev/sdk/realtime'
|
|
1827
|
+
// This improves tree-shaking - centrifuge (~5.5MB) is only loaded when realtime is used
|
|
1828
|
+
//
|
|
1829
|
+
// To use realtime:
|
|
1830
|
+
// 1. Install centrifuge: npm install centrifuge
|
|
1831
|
+
// 2. Import directly: import { VolcanoRealtime } from '@volcano.dev/sdk/realtime'
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* Lazy-load the realtime module
|
|
1835
|
+
* @returns {Promise<{VolcanoRealtime: any, RealtimeChannel: any}>}
|
|
1836
|
+
*/
|
|
1837
|
+
async function loadRealtime() {
|
|
1838
|
+
const module = await Promise.resolve().then(function () { return realtime; });
|
|
1839
|
+
return {
|
|
1840
|
+
VolcanoRealtime: module.VolcanoRealtime,
|
|
1841
|
+
RealtimeChannel: module.RealtimeChannel
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// ============================================================================
|
|
1846
|
+
// Exports
|
|
1847
|
+
// ============================================================================
|
|
1848
|
+
|
|
1849
|
+
// Browser global exports
|
|
1850
|
+
if (typeof window !== 'undefined') {
|
|
1851
|
+
window.VolcanoAuth = VolcanoAuth;
|
|
1852
|
+
window.QueryBuilder = QueryBuilder;
|
|
1853
|
+
window.StorageFileApi = StorageFileApi;
|
|
1854
|
+
window.isBrowser = isBrowser;
|
|
1855
|
+
window.loadRealtime = loadRealtime;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// CommonJS exports
|
|
1859
|
+
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
|
|
1860
|
+
module.exports = VolcanoAuth;
|
|
1861
|
+
module.exports.VolcanoAuth = VolcanoAuth;
|
|
1862
|
+
module.exports.default = VolcanoAuth;
|
|
1863
|
+
module.exports.QueryBuilder = QueryBuilder;
|
|
1864
|
+
module.exports.StorageFileApi = StorageFileApi;
|
|
1865
|
+
module.exports.isBrowser = isBrowser;
|
|
1866
|
+
module.exports.loadRealtime = loadRealtime;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// AMD exports
|
|
1870
|
+
if (typeof define === 'function' && define.amd) {
|
|
1871
|
+
define([], function() {
|
|
1872
|
+
return VolcanoAuth;
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* Volcano Realtime SDK - WebSocket client for real-time messaging
|
|
1878
|
+
*
|
|
1879
|
+
* This module provides real-time capabilities including:
|
|
1880
|
+
* - Broadcast: Pub/sub messaging between clients
|
|
1881
|
+
* - Presence: Track online users and their state
|
|
1882
|
+
* - Postgres Changes: Subscribe to database INSERT/UPDATE/DELETE events
|
|
1883
|
+
*
|
|
1884
|
+
* @example
|
|
1885
|
+
* ```javascript
|
|
1886
|
+
* import { VolcanoRealtime } from '@volcano.dev/sdk/realtime';
|
|
1887
|
+
*
|
|
1888
|
+
* const realtime = new VolcanoRealtime({
|
|
1889
|
+
* apiUrl: 'https://api.yourapp.com',
|
|
1890
|
+
* anonKey: 'your-anon-key',
|
|
1891
|
+
* accessToken: 'your-access-token'
|
|
1892
|
+
* });
|
|
1893
|
+
*
|
|
1894
|
+
* // Connect to realtime server
|
|
1895
|
+
* await realtime.connect();
|
|
1896
|
+
*
|
|
1897
|
+
* // Subscribe to a broadcast channel
|
|
1898
|
+
* const channel = realtime.channel('chat-room');
|
|
1899
|
+
* channel.on('message', (payload) => console.log('New message:', payload));
|
|
1900
|
+
* await channel.subscribe();
|
|
1901
|
+
*
|
|
1902
|
+
* // Send a message
|
|
1903
|
+
* channel.send({ text: 'Hello, world!' });
|
|
1904
|
+
*
|
|
1905
|
+
* // Subscribe to database changes
|
|
1906
|
+
* const dbChannel = realtime.channel('public:messages');
|
|
1907
|
+
* dbChannel.onPostgresChanges('*', 'public', 'messages', (payload) => {
|
|
1908
|
+
* console.log('Database change:', payload);
|
|
1909
|
+
* });
|
|
1910
|
+
* await dbChannel.subscribe();
|
|
1911
|
+
*
|
|
1912
|
+
* // Track presence
|
|
1913
|
+
* const presenceChannel = realtime.channel('lobby', { type: 'presence' });
|
|
1914
|
+
* presenceChannel.onPresenceSync((state) => {
|
|
1915
|
+
* console.log('Online users:', Object.keys(state));
|
|
1916
|
+
* });
|
|
1917
|
+
* await presenceChannel.subscribe();
|
|
1918
|
+
* presenceChannel.track({ status: 'online' });
|
|
1919
|
+
* ```
|
|
1920
|
+
*/
|
|
1921
|
+
|
|
1922
|
+
// Centrifuge client - dynamically imported
|
|
1923
|
+
let Centrifuge = null;
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* Dynamically imports the Centrifuge client
|
|
1927
|
+
*/
|
|
1928
|
+
async function loadCentrifuge() {
|
|
1929
|
+
if (Centrifuge) return Centrifuge;
|
|
1930
|
+
|
|
1931
|
+
try {
|
|
1932
|
+
// Try ES module import
|
|
1933
|
+
const module = await import('centrifuge');
|
|
1934
|
+
Centrifuge = module.Centrifuge || module.default;
|
|
1935
|
+
return Centrifuge;
|
|
1936
|
+
} catch {
|
|
1937
|
+
throw new Error(
|
|
1938
|
+
'Centrifuge client not found. Please install it: npm install centrifuge'
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Load WebSocket for Node.js environments
|
|
1944
|
+
let WebSocketImpl = null;
|
|
1945
|
+
async function loadWebSocket() {
|
|
1946
|
+
if (WebSocketImpl) return WebSocketImpl;
|
|
1947
|
+
|
|
1948
|
+
// Check if we're in a browser environment
|
|
1949
|
+
if (typeof window !== 'undefined' && window.WebSocket) {
|
|
1950
|
+
WebSocketImpl = window.WebSocket;
|
|
1951
|
+
return WebSocketImpl;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// Node.js environment - try to load ws package
|
|
1955
|
+
try {
|
|
1956
|
+
const ws = await import('ws');
|
|
1957
|
+
WebSocketImpl = ws.default || ws.WebSocket || ws;
|
|
1958
|
+
return WebSocketImpl;
|
|
1959
|
+
} catch {
|
|
1960
|
+
throw new Error(
|
|
1961
|
+
'WebSocket implementation not found. In Node.js, please install: npm install ws'
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* VolcanoRealtime - Main realtime client
|
|
1968
|
+
*
|
|
1969
|
+
* Channel names use simple format: type:name (e.g., "broadcast:chat")
|
|
1970
|
+
* The server automatically handles project isolation - clients never
|
|
1971
|
+
* need to know about project IDs.
|
|
1972
|
+
*
|
|
1973
|
+
* Authentication options:
|
|
1974
|
+
* 1. User token: anonKey (required) + accessToken (user JWT)
|
|
1975
|
+
* 2. Service key: anonKey (optional) + accessToken (service role key)
|
|
1976
|
+
*/
|
|
1977
|
+
class VolcanoRealtime {
|
|
1978
|
+
/**
|
|
1979
|
+
* Create a new VolcanoRealtime client
|
|
1980
|
+
* @param {Object} config - Configuration options
|
|
1981
|
+
* @param {string} config.apiUrl - Volcano API URL
|
|
1982
|
+
* @param {string} [config.anonKey] - Anon key (required for user tokens, optional for service keys)
|
|
1983
|
+
* @param {string} config.accessToken - Access token (user JWT) or service role key (sk-...)
|
|
1984
|
+
* @param {Function} [config.getToken] - Function to get/refresh token
|
|
1985
|
+
* @param {Object} [config.volcanoClient] - VolcanoAuth client for auto-fetching lightweight notifications
|
|
1986
|
+
* @param {string} [config.databaseName] - Database name for auto-fetch queries
|
|
1987
|
+
* @param {Object} [config.fetchConfig] - Configuration for auto-fetch behavior
|
|
1988
|
+
*/
|
|
1989
|
+
constructor(config) {
|
|
1990
|
+
if (!config.apiUrl) throw new Error('apiUrl is required');
|
|
1991
|
+
// anonKey is optional for service role keys (they contain project ID)
|
|
1992
|
+
// But we need either anonKey or accessToken
|
|
1993
|
+
if (config.anonKey === undefined) throw new Error('anonKey is required');
|
|
1994
|
+
|
|
1995
|
+
this.apiUrl = config.apiUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
1996
|
+
this.anonKey = config.anonKey || ''; // Allow empty string for service keys
|
|
1997
|
+
this.accessToken = config.accessToken;
|
|
1998
|
+
this.getToken = config.getToken;
|
|
1999
|
+
|
|
2000
|
+
this._client = null;
|
|
2001
|
+
this._channels = new Map();
|
|
2002
|
+
this._connected = false;
|
|
2003
|
+
this._connectionPromise = null;
|
|
2004
|
+
|
|
2005
|
+
// Callbacks
|
|
2006
|
+
this._onConnect = [];
|
|
2007
|
+
this._onDisconnect = [];
|
|
2008
|
+
this._onError = [];
|
|
2009
|
+
|
|
2010
|
+
// Auto-fetch support (Phase 3)
|
|
2011
|
+
this._volcanoClient = config.volcanoClient || null;
|
|
2012
|
+
this._fetchConfig = {
|
|
2013
|
+
batchWindowMs: config.fetchConfig?.batchWindowMs || 20,
|
|
2014
|
+
maxBatchSize: config.fetchConfig?.maxBatchSize || 50,
|
|
2015
|
+
enabled: config.fetchConfig?.enabled !== false,
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
// Database name for auto-fetch queries (optional)
|
|
2019
|
+
this._databaseName = config.databaseName || null;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Set the VolcanoAuth client for auto-fetching
|
|
2024
|
+
* @param {Object} volcanoClient - VolcanoAuth client instance
|
|
2025
|
+
*/
|
|
2026
|
+
setVolcanoClient(volcanoClient) {
|
|
2027
|
+
this._volcanoClient = volcanoClient;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* Get the configured VolcanoAuth client
|
|
2032
|
+
* @returns {Object|null} The VolcanoAuth client or null
|
|
2033
|
+
*/
|
|
2034
|
+
getVolcanoClient() {
|
|
2035
|
+
return this._volcanoClient;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* Get the fetch configuration
|
|
2040
|
+
* @returns {Object} The fetch configuration
|
|
2041
|
+
*/
|
|
2042
|
+
getFetchConfig() {
|
|
2043
|
+
return { ...this._fetchConfig };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* Set the database name for auto-fetch queries
|
|
2048
|
+
* @param {string} databaseName
|
|
2049
|
+
*/
|
|
2050
|
+
setDatabaseName(databaseName) {
|
|
2051
|
+
this._databaseName = databaseName;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Get the configured database name
|
|
2056
|
+
* @returns {string|null}
|
|
2057
|
+
*/
|
|
2058
|
+
getDatabaseName() {
|
|
2059
|
+
return this._databaseName;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* Get the WebSocket URL for realtime connections
|
|
2065
|
+
*/
|
|
2066
|
+
get wsUrl() {
|
|
2067
|
+
const url = new URL(this.apiUrl);
|
|
2068
|
+
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
2069
|
+
return `${protocol}//${url.host}/realtime/v1/websocket`;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* Connect to the realtime server
|
|
2074
|
+
*/
|
|
2075
|
+
async connect() {
|
|
2076
|
+
if (this._connected) return;
|
|
2077
|
+
if (this._connectionPromise) return this._connectionPromise;
|
|
2078
|
+
|
|
2079
|
+
this._connectionPromise = this._doConnect();
|
|
2080
|
+
try {
|
|
2081
|
+
await this._connectionPromise;
|
|
2082
|
+
} finally {
|
|
2083
|
+
this._connectionPromise = null;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
async _doConnect() {
|
|
2088
|
+
const CentrifugeClient = await loadCentrifuge();
|
|
2089
|
+
const WebSocket = await loadWebSocket();
|
|
2090
|
+
|
|
2091
|
+
const wsUrl = `${this.wsUrl}?apikey=${encodeURIComponent(this.anonKey)}`;
|
|
2092
|
+
|
|
2093
|
+
this._client = new CentrifugeClient(wsUrl, {
|
|
2094
|
+
token: this.accessToken,
|
|
2095
|
+
getToken: this.getToken ? async () => {
|
|
2096
|
+
const token = await this.getToken();
|
|
2097
|
+
this.accessToken = token;
|
|
2098
|
+
return token;
|
|
2099
|
+
} : undefined,
|
|
2100
|
+
debug: false,
|
|
2101
|
+
websocket: WebSocket,
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
// Set up event handlers (store references for cleanup)
|
|
2105
|
+
this._clientHandlers = {
|
|
2106
|
+
connected: (ctx) => {
|
|
2107
|
+
this._connected = true;
|
|
2108
|
+
this._onConnect.forEach(cb => cb(ctx));
|
|
2109
|
+
},
|
|
2110
|
+
disconnected: (ctx) => {
|
|
2111
|
+
this._connected = false;
|
|
2112
|
+
this._onDisconnect.forEach(cb => cb(ctx));
|
|
2113
|
+
},
|
|
2114
|
+
error: (ctx) => {
|
|
2115
|
+
this._onError.forEach(cb => cb(ctx));
|
|
2116
|
+
},
|
|
2117
|
+
publication: (ctx) => {
|
|
2118
|
+
this._handleServerPublication(ctx);
|
|
2119
|
+
},
|
|
2120
|
+
join: (ctx) => {
|
|
2121
|
+
this._handleServerJoin(ctx);
|
|
2122
|
+
},
|
|
2123
|
+
leave: (ctx) => {
|
|
2124
|
+
this._handleServerLeave(ctx);
|
|
2125
|
+
},
|
|
2126
|
+
subscribed: (ctx) => {
|
|
2127
|
+
this._handleServerSubscribed(ctx);
|
|
2128
|
+
},
|
|
2129
|
+
};
|
|
2130
|
+
|
|
2131
|
+
this._client.on('connected', this._clientHandlers.connected);
|
|
2132
|
+
this._client.on('disconnected', this._clientHandlers.disconnected);
|
|
2133
|
+
this._client.on('error', this._clientHandlers.error);
|
|
2134
|
+
this._client.on('publication', this._clientHandlers.publication);
|
|
2135
|
+
this._client.on('join', this._clientHandlers.join);
|
|
2136
|
+
this._client.on('leave', this._clientHandlers.leave);
|
|
2137
|
+
this._client.on('subscribed', this._clientHandlers.subscribed);
|
|
2138
|
+
|
|
2139
|
+
// Connect and wait for connected event
|
|
2140
|
+
return new Promise((resolve, reject) => {
|
|
2141
|
+
const timeout = setTimeout(() => {
|
|
2142
|
+
reject(new Error('Connection timeout'));
|
|
2143
|
+
}, 10000);
|
|
2144
|
+
|
|
2145
|
+
const onConnected = () => {
|
|
2146
|
+
clearTimeout(timeout);
|
|
2147
|
+
this._client.off('connected', onConnected);
|
|
2148
|
+
this._client.off('error', onError);
|
|
2149
|
+
resolve();
|
|
2150
|
+
};
|
|
2151
|
+
|
|
2152
|
+
const onError = (ctx) => {
|
|
2153
|
+
clearTimeout(timeout);
|
|
2154
|
+
this._client.off('connected', onConnected);
|
|
2155
|
+
this._client.off('error', onError);
|
|
2156
|
+
reject(new Error(ctx.error?.message || 'Connection failed'));
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
this._client.on('connected', onConnected);
|
|
2160
|
+
this._client.on('error', onError);
|
|
2161
|
+
this._client.connect();
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* Disconnect from the realtime server
|
|
2167
|
+
*/
|
|
2168
|
+
disconnect() {
|
|
2169
|
+
// Unsubscribe all channels first to clean up their timers
|
|
2170
|
+
for (const channel of this._channels.values()) {
|
|
2171
|
+
try {
|
|
2172
|
+
channel.unsubscribe();
|
|
2173
|
+
} catch {
|
|
2174
|
+
// Ignore errors during cleanup
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
this._channels.clear();
|
|
2178
|
+
|
|
2179
|
+
if (this._client) {
|
|
2180
|
+
// Remove event handlers first to prevent memory leaks
|
|
2181
|
+
if (this._clientHandlers) {
|
|
2182
|
+
this._client.off('connected', this._clientHandlers.connected);
|
|
2183
|
+
this._client.off('disconnected', this._clientHandlers.disconnected);
|
|
2184
|
+
this._client.off('error', this._clientHandlers.error);
|
|
2185
|
+
this._client.off('publication', this._clientHandlers.publication);
|
|
2186
|
+
this._client.off('join', this._clientHandlers.join);
|
|
2187
|
+
this._client.off('leave', this._clientHandlers.leave);
|
|
2188
|
+
this._client.off('subscribed', this._clientHandlers.subscribed);
|
|
2189
|
+
this._clientHandlers = null;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// Manually trigger disconnect callbacks
|
|
2193
|
+
this._onDisconnect.forEach(cb => cb({ reason: 'manual' }));
|
|
2194
|
+
|
|
2195
|
+
// Disconnect the client
|
|
2196
|
+
this._client.disconnect();
|
|
2197
|
+
this._client = null;
|
|
2198
|
+
this._connected = false;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* Check if connected to the realtime server
|
|
2204
|
+
*/
|
|
2205
|
+
isConnected() {
|
|
2206
|
+
return this._connected;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Create or get a channel
|
|
2211
|
+
* @param {string} name - Channel name
|
|
2212
|
+
* @param {Object} [options] - Channel options
|
|
2213
|
+
* @param {string} [options.type='broadcast'] - Channel type: 'broadcast', 'presence', 'postgres'
|
|
2214
|
+
* @param {boolean} [options.autoFetch=true] - Enable auto-fetch for lightweight notifications
|
|
2215
|
+
* @param {number} [options.fetchBatchWindowMs] - Batch window for fetch requests
|
|
2216
|
+
* @param {number} [options.fetchMaxBatchSize] - Max batch size for fetch requests
|
|
2217
|
+
*/
|
|
2218
|
+
channel(name, options = {}) {
|
|
2219
|
+
const type = options.type || 'broadcast';
|
|
2220
|
+
const fullName = this._formatChannelName(name, type);
|
|
2221
|
+
|
|
2222
|
+
if (this._channels.has(fullName)) {
|
|
2223
|
+
return this._channels.get(fullName);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
const channel = new RealtimeChannel(this, fullName, type, options);
|
|
2227
|
+
this._channels.set(fullName, channel);
|
|
2228
|
+
return channel;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* Format channel name for subscription
|
|
2233
|
+
* Format: type:name
|
|
2234
|
+
*
|
|
2235
|
+
* The server automatically adds the project ID prefix based on
|
|
2236
|
+
* the authenticated connection. Clients never need to know about project IDs.
|
|
2237
|
+
*/
|
|
2238
|
+
_formatChannelName(name, type) {
|
|
2239
|
+
return `${type}:${name}`;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* Handle publications from server-side subscriptions
|
|
2244
|
+
* The server uses project-prefixed channels: "projectId:type:name"
|
|
2245
|
+
* We extract the type:name portion and route to the SDK channel
|
|
2246
|
+
*/
|
|
2247
|
+
_handleServerPublication(ctx) {
|
|
2248
|
+
const serverChannel = ctx.channel;
|
|
2249
|
+
|
|
2250
|
+
// Server channel format: projectId:type:name
|
|
2251
|
+
// We need to extract type:name to match our SDK channel
|
|
2252
|
+
const parts = serverChannel.split(':');
|
|
2253
|
+
if (parts.length < 3) {
|
|
2254
|
+
// Not a valid server channel format, ignore
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// Skip projectId, reconstruct type:name
|
|
2259
|
+
const sdkChannel = parts.slice(1).join(':');
|
|
2260
|
+
|
|
2261
|
+
// Find the SDK channel and deliver the message
|
|
2262
|
+
const channel = this._channels.get(sdkChannel);
|
|
2263
|
+
if (channel) {
|
|
2264
|
+
channel._handlePublication(ctx);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/**
|
|
2269
|
+
* Handle join events from server-side subscriptions
|
|
2270
|
+
*/
|
|
2271
|
+
_handleServerJoin(ctx) {
|
|
2272
|
+
const serverChannel = ctx.channel;
|
|
2273
|
+
const parts = serverChannel.split(':');
|
|
2274
|
+
if (parts.length < 3) return;
|
|
2275
|
+
|
|
2276
|
+
const sdkChannel = parts.slice(1).join(':');
|
|
2277
|
+
const channel = this._channels.get(sdkChannel);
|
|
2278
|
+
if (channel && channel._type === 'presence') {
|
|
2279
|
+
// Update presence state
|
|
2280
|
+
if (ctx.info) {
|
|
2281
|
+
channel._presenceState[ctx.info.client] = ctx.info;
|
|
2282
|
+
}
|
|
2283
|
+
channel._triggerPresenceSync();
|
|
2284
|
+
channel._triggerEvent('join', ctx.info);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* Handle leave events from server-side subscriptions
|
|
2290
|
+
*/
|
|
2291
|
+
_handleServerLeave(ctx) {
|
|
2292
|
+
const serverChannel = ctx.channel;
|
|
2293
|
+
const parts = serverChannel.split(':');
|
|
2294
|
+
if (parts.length < 3) return;
|
|
2295
|
+
|
|
2296
|
+
const sdkChannel = parts.slice(1).join(':');
|
|
2297
|
+
const channel = this._channels.get(sdkChannel);
|
|
2298
|
+
if (channel && channel._type === 'presence') {
|
|
2299
|
+
// Update presence state
|
|
2300
|
+
if (ctx.info) {
|
|
2301
|
+
delete channel._presenceState[ctx.info.client];
|
|
2302
|
+
}
|
|
2303
|
+
channel._triggerPresenceSync();
|
|
2304
|
+
channel._triggerEvent('leave', ctx.info);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/**
|
|
2309
|
+
* Handle subscribed events - includes initial presence state
|
|
2310
|
+
*/
|
|
2311
|
+
_handleServerSubscribed(ctx) {
|
|
2312
|
+
const serverChannel = ctx.channel;
|
|
2313
|
+
const parts = serverChannel.split(':');
|
|
2314
|
+
if (parts.length < 3) return;
|
|
2315
|
+
|
|
2316
|
+
const sdkChannel = parts.slice(1).join(':');
|
|
2317
|
+
const channel = this._channels.get(sdkChannel);
|
|
2318
|
+
|
|
2319
|
+
// For presence channels, populate initial state from subscribe response
|
|
2320
|
+
if (channel && channel._type === 'presence' && ctx.data) {
|
|
2321
|
+
// data contains initial presence information
|
|
2322
|
+
if (ctx.data.presence) {
|
|
2323
|
+
channel._presenceState = {};
|
|
2324
|
+
for (const [clientId, info] of Object.entries(ctx.data.presence)) {
|
|
2325
|
+
channel._presenceState[clientId] = info;
|
|
2326
|
+
}
|
|
2327
|
+
channel._triggerPresenceSync();
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
/**
|
|
2333
|
+
* Get the underlying Centrifuge client
|
|
2334
|
+
*/
|
|
2335
|
+
getClient() {
|
|
2336
|
+
return this._client;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* Register callback for connection events
|
|
2341
|
+
*/
|
|
2342
|
+
onConnect(callback) {
|
|
2343
|
+
this._onConnect.push(callback);
|
|
2344
|
+
return () => {
|
|
2345
|
+
this._onConnect = this._onConnect.filter(cb => cb !== callback);
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* Register callback for disconnection events
|
|
2351
|
+
*/
|
|
2352
|
+
onDisconnect(callback) {
|
|
2353
|
+
this._onDisconnect.push(callback);
|
|
2354
|
+
return () => {
|
|
2355
|
+
this._onDisconnect = this._onDisconnect.filter(cb => cb !== callback);
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
/**
|
|
2360
|
+
* Register callback for error events
|
|
2361
|
+
*/
|
|
2362
|
+
onError(callback) {
|
|
2363
|
+
this._onError.push(callback);
|
|
2364
|
+
return () => {
|
|
2365
|
+
this._onError = this._onError.filter(cb => cb !== callback);
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
/**
|
|
2370
|
+
* Remove a specific channel
|
|
2371
|
+
* @param {string} name - Channel name
|
|
2372
|
+
* @param {string} [type='broadcast'] - Channel type
|
|
2373
|
+
*/
|
|
2374
|
+
removeChannel(name, type = 'broadcast') {
|
|
2375
|
+
const fullName = this._formatChannelName(name, type);
|
|
2376
|
+
const channel = this._channels.get(fullName);
|
|
2377
|
+
if (channel) {
|
|
2378
|
+
channel.unsubscribe();
|
|
2379
|
+
this._channels.delete(fullName);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/**
|
|
2384
|
+
* Remove all channels and listeners
|
|
2385
|
+
*/
|
|
2386
|
+
removeAllChannels() {
|
|
2387
|
+
for (const channel of this._channels.values()) {
|
|
2388
|
+
channel.unsubscribe();
|
|
2389
|
+
}
|
|
2390
|
+
this._channels.clear();
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* RealtimeChannel - Represents a subscription to a realtime channel
|
|
2396
|
+
*/
|
|
2397
|
+
class RealtimeChannel {
|
|
2398
|
+
constructor(realtime, name, type, options) {
|
|
2399
|
+
this._realtime = realtime;
|
|
2400
|
+
this._name = name;
|
|
2401
|
+
this._type = type;
|
|
2402
|
+
this._options = options;
|
|
2403
|
+
this._subscription = null;
|
|
2404
|
+
this._callbacks = new Map();
|
|
2405
|
+
this._presenceState = {};
|
|
2406
|
+
|
|
2407
|
+
// Auto-fetch support (Phase 3)
|
|
2408
|
+
const parentFetchConfig = realtime.getFetchConfig();
|
|
2409
|
+
this._fetchConfig = {
|
|
2410
|
+
batchWindowMs: options.fetchBatchWindowMs || parentFetchConfig.batchWindowMs,
|
|
2411
|
+
maxBatchSize: options.fetchMaxBatchSize || parentFetchConfig.maxBatchSize,
|
|
2412
|
+
enabled: options.autoFetch !== false && parentFetchConfig.enabled,
|
|
2413
|
+
};
|
|
2414
|
+
this._pendingFetches = new Map(); // table -> { ids: Map<id, {resolve, reject}>, timer }
|
|
2415
|
+
|
|
2416
|
+
// Event handler references for cleanup
|
|
2417
|
+
this._eventHandlers = {};
|
|
2418
|
+
this._presenceTimeoutId = null;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/**
|
|
2422
|
+
* Get channel name
|
|
2423
|
+
*/
|
|
2424
|
+
get name() {
|
|
2425
|
+
return this._name;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* Subscribe to the channel
|
|
2430
|
+
*/
|
|
2431
|
+
async subscribe() {
|
|
2432
|
+
if (this._subscription) return;
|
|
2433
|
+
|
|
2434
|
+
const client = this._realtime.getClient();
|
|
2435
|
+
if (!client) {
|
|
2436
|
+
throw new Error('Not connected to realtime server');
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
this._subscription = client.newSubscription(this._name, {
|
|
2440
|
+
// Enable presence for presence channels
|
|
2441
|
+
presence: this._type === 'presence',
|
|
2442
|
+
joinLeave: this._type === 'presence',
|
|
2443
|
+
// Enable recovery for all channels
|
|
2444
|
+
recover: true,
|
|
2445
|
+
});
|
|
2446
|
+
|
|
2447
|
+
// Set up message handler (store reference for cleanup)
|
|
2448
|
+
this._eventHandlers.publication = (ctx) => {
|
|
2449
|
+
const event = ctx.data?.event || 'message';
|
|
2450
|
+
const callbacks = this._callbacks.get(event) || [];
|
|
2451
|
+
callbacks.forEach(cb => cb(ctx.data, ctx));
|
|
2452
|
+
|
|
2453
|
+
// Also trigger wildcard listeners
|
|
2454
|
+
const wildcardCallbacks = this._callbacks.get('*') || [];
|
|
2455
|
+
wildcardCallbacks.forEach(cb => cb(ctx.data, ctx));
|
|
2456
|
+
};
|
|
2457
|
+
this._subscription.on('publication', this._eventHandlers.publication);
|
|
2458
|
+
|
|
2459
|
+
// Set up presence handlers for presence channels
|
|
2460
|
+
if (this._type === 'presence') {
|
|
2461
|
+
this._eventHandlers.presence = (ctx) => {
|
|
2462
|
+
this._updatePresenceState(ctx);
|
|
2463
|
+
this._triggerPresenceSync();
|
|
2464
|
+
};
|
|
2465
|
+
this._subscription.on('presence', this._eventHandlers.presence);
|
|
2466
|
+
|
|
2467
|
+
this._eventHandlers.join = (ctx) => {
|
|
2468
|
+
this._presenceState[ctx.info.client] = ctx.info.data;
|
|
2469
|
+
this._triggerPresenceSync();
|
|
2470
|
+
this._triggerEvent('join', ctx.info);
|
|
2471
|
+
};
|
|
2472
|
+
this._subscription.on('join', this._eventHandlers.join);
|
|
2473
|
+
|
|
2474
|
+
this._eventHandlers.leave = (ctx) => {
|
|
2475
|
+
delete this._presenceState[ctx.info.client];
|
|
2476
|
+
this._triggerPresenceSync();
|
|
2477
|
+
this._triggerEvent('leave', ctx.info);
|
|
2478
|
+
};
|
|
2479
|
+
this._subscription.on('leave', this._eventHandlers.leave);
|
|
2480
|
+
|
|
2481
|
+
// After subscribing, immediately fetch current presence for late joiners
|
|
2482
|
+
// For server-side subscriptions, use client.presence() not subscription.presence()
|
|
2483
|
+
this._eventHandlers.subscribed = async () => {
|
|
2484
|
+
// Small delay to ensure subscription is fully active
|
|
2485
|
+
this._presenceTimeoutId = setTimeout(async () => {
|
|
2486
|
+
this._presenceTimeoutId = null;
|
|
2487
|
+
try {
|
|
2488
|
+
const client = this._realtime.getClient();
|
|
2489
|
+
if (client && this._subscription) {
|
|
2490
|
+
// Use client-level presence() for server-side subscriptions
|
|
2491
|
+
const presence = await client.presence(this._name);
|
|
2492
|
+
|
|
2493
|
+
// Centrifuge returns presence data in `clients` field
|
|
2494
|
+
if (presence && presence.clients) {
|
|
2495
|
+
this._presenceState = {};
|
|
2496
|
+
for (const [clientId, info] of Object.entries(presence.clients)) {
|
|
2497
|
+
this._presenceState[clientId] = info;
|
|
2498
|
+
}
|
|
2499
|
+
this._triggerPresenceSync();
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
} catch (err) {
|
|
2503
|
+
// Ignore errors - presence might not be available yet
|
|
2504
|
+
}
|
|
2505
|
+
}, 150);
|
|
2506
|
+
};
|
|
2507
|
+
this._subscription.on('subscribed', this._eventHandlers.subscribed);
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
await this._subscription.subscribe();
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
/**
|
|
2514
|
+
* Unsubscribe from the channel
|
|
2515
|
+
*/
|
|
2516
|
+
unsubscribe() {
|
|
2517
|
+
// Cancel pending presence fetch timeout
|
|
2518
|
+
if (this._presenceTimeoutId) {
|
|
2519
|
+
clearTimeout(this._presenceTimeoutId);
|
|
2520
|
+
this._presenceTimeoutId = null;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
// Clear all pending fetch timers to prevent memory leaks
|
|
2524
|
+
if (this._pendingFetches) {
|
|
2525
|
+
for (const batch of this._pendingFetches.values()) {
|
|
2526
|
+
if (batch.timer) {
|
|
2527
|
+
clearTimeout(batch.timer);
|
|
2528
|
+
}
|
|
2529
|
+
// Reject any pending promises
|
|
2530
|
+
for (const { reject } of batch.ids.values()) {
|
|
2531
|
+
reject(new Error('Channel unsubscribed'));
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
this._pendingFetches.clear();
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
if (this._subscription) {
|
|
2538
|
+
// Remove event listeners before unsubscribing
|
|
2539
|
+
for (const [event, handler] of Object.entries(this._eventHandlers)) {
|
|
2540
|
+
try {
|
|
2541
|
+
this._subscription.off(event, handler);
|
|
2542
|
+
} catch {
|
|
2543
|
+
// Ignore errors if listener already removed
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
this._eventHandlers = {};
|
|
2547
|
+
|
|
2548
|
+
this._subscription.unsubscribe();
|
|
2549
|
+
// Also remove from Centrifuge client registry to allow re-subscription
|
|
2550
|
+
const client = this._realtime.getClient();
|
|
2551
|
+
if (client) {
|
|
2552
|
+
try {
|
|
2553
|
+
client.removeSubscription(this._subscription);
|
|
2554
|
+
} catch {
|
|
2555
|
+
// Ignore errors if subscription already removed
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
this._subscription = null;
|
|
2559
|
+
}
|
|
2560
|
+
this._callbacks.clear();
|
|
2561
|
+
this._presenceState = {};
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/**
|
|
2565
|
+
* Handle publication from server-side subscription
|
|
2566
|
+
* Called by VolcanoRealtime when a message arrives on the internal channel
|
|
2567
|
+
*/
|
|
2568
|
+
_handlePublication(ctx) {
|
|
2569
|
+
const data = ctx.data;
|
|
2570
|
+
|
|
2571
|
+
// Check if this is a lightweight notification (Phase 3)
|
|
2572
|
+
if (data?.mode === 'lightweight') {
|
|
2573
|
+
this._handleLightweightNotification(data, ctx);
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
// Full payload - deliver immediately
|
|
2578
|
+
this._deliverPayload(data, ctx);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/**
|
|
2582
|
+
* Handle a lightweight notification by auto-fetching the record data
|
|
2583
|
+
* @param {Object} data - Lightweight notification data
|
|
2584
|
+
* @param {Object} ctx - Publication context
|
|
2585
|
+
*/
|
|
2586
|
+
async _handleLightweightNotification(data, ctx) {
|
|
2587
|
+
const volcanoClient = this._realtime.getVolcanoClient();
|
|
2588
|
+
|
|
2589
|
+
// DELETE notifications may include old_record, deliver immediately
|
|
2590
|
+
if (data.type === 'DELETE') {
|
|
2591
|
+
// Convert lightweight DELETE to full format for backward compatibility
|
|
2592
|
+
const oldRecord = data.old_record !== undefined
|
|
2593
|
+
? data.old_record
|
|
2594
|
+
: (data.id !== undefined ? { id: data.id } : undefined);
|
|
2595
|
+
const fullPayload = {
|
|
2596
|
+
type: data.type,
|
|
2597
|
+
schema: data.schema,
|
|
2598
|
+
table: data.table,
|
|
2599
|
+
old_record: oldRecord,
|
|
2600
|
+
id: data.id,
|
|
2601
|
+
timestamp: data.timestamp,
|
|
2602
|
+
};
|
|
2603
|
+
this._deliverPayload(fullPayload, ctx);
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// If no volcanoClient or auto-fetch disabled, deliver lightweight as-is
|
|
2608
|
+
if (!volcanoClient || !this._fetchConfig.enabled) {
|
|
2609
|
+
this._deliverPayload(data, ctx);
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
// Auto-fetch the record for INSERT/UPDATE
|
|
2614
|
+
try {
|
|
2615
|
+
const record = await this._fetchRow(data.schema, data.table, data.id);
|
|
2616
|
+
|
|
2617
|
+
// Convert to full payload format for backward compatibility
|
|
2618
|
+
const fullPayload = {
|
|
2619
|
+
type: data.type,
|
|
2620
|
+
schema: data.schema,
|
|
2621
|
+
table: data.table,
|
|
2622
|
+
record: record,
|
|
2623
|
+
timestamp: data.timestamp,
|
|
2624
|
+
};
|
|
2625
|
+
|
|
2626
|
+
this._deliverPayload(fullPayload, ctx);
|
|
2627
|
+
} catch (err) {
|
|
2628
|
+
// On fetch error, still deliver the lightweight notification
|
|
2629
|
+
// so the client knows something changed, even if we couldn't get the data
|
|
2630
|
+
console.warn(`[Realtime] Failed to fetch record for ${data.schema}.${data.table}:${data.id}:`, err.message);
|
|
2631
|
+
this._deliverPayload(data, ctx);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
/**
|
|
2636
|
+
* Fetch a row from the database, batching requests for efficiency
|
|
2637
|
+
* @param {string} schema - Schema name
|
|
2638
|
+
* @param {string} table - Table name
|
|
2639
|
+
* @param {*} id - Primary key value
|
|
2640
|
+
* @returns {Promise<Object>} The fetched record
|
|
2641
|
+
*/
|
|
2642
|
+
_fetchRow(schema, table, id) {
|
|
2643
|
+
const tableKey = `${schema}.${table}`;
|
|
2644
|
+
|
|
2645
|
+
return new Promise((resolve, reject) => {
|
|
2646
|
+
// Get or create pending batch for this table
|
|
2647
|
+
if (!this._pendingFetches.has(tableKey)) {
|
|
2648
|
+
this._pendingFetches.set(tableKey, {
|
|
2649
|
+
ids: new Map(),
|
|
2650
|
+
timer: null,
|
|
2651
|
+
schema: schema,
|
|
2652
|
+
table: table,
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
const batch = this._pendingFetches.get(tableKey);
|
|
2657
|
+
|
|
2658
|
+
// Add this ID to the batch
|
|
2659
|
+
batch.ids.set(String(id), { resolve, reject });
|
|
2660
|
+
|
|
2661
|
+
// Check if we should flush due to size
|
|
2662
|
+
if (batch.ids.size >= this._fetchConfig.maxBatchSize) {
|
|
2663
|
+
this._flushFetch(schema, table);
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// Set timer for batch window if not already set
|
|
2668
|
+
if (!batch.timer) {
|
|
2669
|
+
batch.timer = setTimeout(() => {
|
|
2670
|
+
this._flushFetch(schema, table);
|
|
2671
|
+
}, this._fetchConfig.batchWindowMs);
|
|
2672
|
+
}
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
/**
|
|
2677
|
+
* Flush pending fetch requests for a table
|
|
2678
|
+
* @param {string} schema - Schema name
|
|
2679
|
+
* @param {string} table - Table name
|
|
2680
|
+
*/
|
|
2681
|
+
async _flushFetch(schema, table) {
|
|
2682
|
+
const tableKey = `${schema}.${table}`;
|
|
2683
|
+
const batch = this._pendingFetches.get(tableKey);
|
|
2684
|
+
|
|
2685
|
+
if (!batch || batch.ids.size === 0) {
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// Clear timer and remove from pending
|
|
2690
|
+
if (batch.timer) {
|
|
2691
|
+
clearTimeout(batch.timer);
|
|
2692
|
+
}
|
|
2693
|
+
this._pendingFetches.delete(tableKey);
|
|
2694
|
+
|
|
2695
|
+
// Get all IDs to fetch
|
|
2696
|
+
const idsToFetch = Array.from(batch.ids.keys());
|
|
2697
|
+
const callbacks = new Map(batch.ids);
|
|
2698
|
+
|
|
2699
|
+
try {
|
|
2700
|
+
const volcanoClient = this._realtime.getVolcanoClient();
|
|
2701
|
+
|
|
2702
|
+
if (!volcanoClient?.from || typeof volcanoClient.from !== 'function') {
|
|
2703
|
+
throw new Error('volcanoClient.from not available');
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
const databaseName = this._realtime.getDatabaseName?.() || volcanoClient._currentDatabaseName || null;
|
|
2707
|
+
let dbClient = volcanoClient;
|
|
2708
|
+
if (databaseName) {
|
|
2709
|
+
if (typeof volcanoClient.database !== 'function') {
|
|
2710
|
+
throw new Error('volcanoClient.database not available');
|
|
2711
|
+
}
|
|
2712
|
+
dbClient = volcanoClient.database(databaseName);
|
|
2713
|
+
} else if (typeof volcanoClient.database === 'function') {
|
|
2714
|
+
throw new Error('Database name not set. Call volcanoClient.database(name) or pass databaseName to VolcanoRealtime.');
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
const tableName = schema && schema !== 'public' ? `${schema}.${table}` : table;
|
|
2718
|
+
|
|
2719
|
+
// Fetch all records in a single query using IN clause
|
|
2720
|
+
// Assumes primary key column is 'id' - this is a common convention
|
|
2721
|
+
const { data, error } = await dbClient
|
|
2722
|
+
.from(tableName)
|
|
2723
|
+
.select('*')
|
|
2724
|
+
.in('id', idsToFetch);
|
|
2725
|
+
|
|
2726
|
+
if (error) {
|
|
2727
|
+
// Reject all pending callbacks
|
|
2728
|
+
for (const cb of callbacks.values()) {
|
|
2729
|
+
cb.reject(new Error(error.message || 'Database fetch failed'));
|
|
2730
|
+
}
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
// Build a map of id -> record
|
|
2735
|
+
const recordMap = new Map();
|
|
2736
|
+
for (const record of (data || [])) {
|
|
2737
|
+
recordMap.set(String(record.id), record);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
// Resolve callbacks
|
|
2741
|
+
for (const [id, cb] of callbacks) {
|
|
2742
|
+
const record = recordMap.get(id);
|
|
2743
|
+
if (record) {
|
|
2744
|
+
cb.resolve(record);
|
|
2745
|
+
} else {
|
|
2746
|
+
// Record not found - could be RLS denial or row deleted
|
|
2747
|
+
cb.reject(new Error(`Record not found or access denied: ${table}:${id}`));
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
} catch (err) {
|
|
2751
|
+
// Reject all pending callbacks on error
|
|
2752
|
+
for (const cb of callbacks.values()) {
|
|
2753
|
+
cb.reject(err);
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/**
|
|
2759
|
+
* Deliver a payload to registered callbacks
|
|
2760
|
+
* @param {Object} data - Payload data
|
|
2761
|
+
* @param {Object} ctx - Publication context
|
|
2762
|
+
*/
|
|
2763
|
+
_deliverPayload(data, ctx) {
|
|
2764
|
+
const event = data?.event || data?.type || 'message';
|
|
2765
|
+
const callbacks = this._callbacks.get(event) || [];
|
|
2766
|
+
callbacks.forEach(cb => cb(data, ctx));
|
|
2767
|
+
|
|
2768
|
+
// Also trigger wildcard listeners
|
|
2769
|
+
const wildcardCallbacks = this._callbacks.get('*') || [];
|
|
2770
|
+
wildcardCallbacks.forEach(cb => cb(data, ctx));
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
/**
|
|
2774
|
+
* Listen for events on the channel
|
|
2775
|
+
* @param {string} event - Event name or '*' for all events
|
|
2776
|
+
* @param {Function} callback - Callback function
|
|
2777
|
+
*/
|
|
2778
|
+
on(event, callback) {
|
|
2779
|
+
if (!this._callbacks.has(event)) {
|
|
2780
|
+
this._callbacks.set(event, []);
|
|
2781
|
+
}
|
|
2782
|
+
this._callbacks.get(event).push(callback);
|
|
2783
|
+
|
|
2784
|
+
// Return unsubscribe function
|
|
2785
|
+
return () => {
|
|
2786
|
+
const callbacks = this._callbacks.get(event) || [];
|
|
2787
|
+
this._callbacks.set(event, callbacks.filter(cb => cb !== callback));
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
/**
|
|
2792
|
+
* Send a message to the channel (broadcast only)
|
|
2793
|
+
* @param {Object} data - Message data
|
|
2794
|
+
*/
|
|
2795
|
+
async send(data) {
|
|
2796
|
+
if (this._type !== 'broadcast') {
|
|
2797
|
+
throw new Error('send() is only available for broadcast channels');
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
if (!this._subscription) {
|
|
2801
|
+
throw new Error('Channel not subscribed');
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
await this._subscription.publish(data);
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
/**
|
|
2808
|
+
* Listen for database changes (postgres channels only)
|
|
2809
|
+
* @param {string} event - Event type: 'INSERT', 'UPDATE', 'DELETE', or '*'
|
|
2810
|
+
* @param {string} schema - Schema name
|
|
2811
|
+
* @param {string} table - Table name
|
|
2812
|
+
* @param {Function} callback - Callback function
|
|
2813
|
+
*/
|
|
2814
|
+
onPostgresChanges(event, schema, table, callback) {
|
|
2815
|
+
if (this._type !== 'postgres') {
|
|
2816
|
+
throw new Error('onPostgresChanges() is only available for postgres channels');
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// Filter callback to only match the requested event type
|
|
2820
|
+
return this.on('*', (data, ctx) => {
|
|
2821
|
+
if (data.schema !== schema || data.table !== table) return;
|
|
2822
|
+
if (event !== '*' && data.type !== event) return;
|
|
2823
|
+
callback(data, ctx);
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
/**
|
|
2828
|
+
* Listen for presence state sync
|
|
2829
|
+
* @param {Function} callback - Callback with presence state
|
|
2830
|
+
*/
|
|
2831
|
+
onPresenceSync(callback) {
|
|
2832
|
+
if (this._type !== 'presence') {
|
|
2833
|
+
throw new Error('onPresenceSync() is only available for presence channels');
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
return this.on('presence_sync', callback);
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
/**
|
|
2840
|
+
* Track this client's presence
|
|
2841
|
+
* @param {Object} state - Presence state data (optional, for client-side state tracking)
|
|
2842
|
+
*
|
|
2843
|
+
* Note: Presence data is automatically sent from the server based on your
|
|
2844
|
+
* user metadata (from sign-up). Custom presence data should be included
|
|
2845
|
+
* when creating the anonymous user.
|
|
2846
|
+
*/
|
|
2847
|
+
async track(state = {}) {
|
|
2848
|
+
if (this._type !== 'presence') {
|
|
2849
|
+
throw new Error('track() is only available for presence channels');
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// Store local presence state for client-side access
|
|
2853
|
+
this._myPresenceState = state;
|
|
2854
|
+
|
|
2855
|
+
// Presence is automatically managed by Centrifuge based on subscription
|
|
2856
|
+
// The connection data (from user metadata) is what other clients see
|
|
2857
|
+
// Note: Custom state is stored locally for client-side access
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
/**
|
|
2861
|
+
* Get current presence state
|
|
2862
|
+
*/
|
|
2863
|
+
getPresenceState() {
|
|
2864
|
+
return { ...this._presenceState };
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
_updatePresenceState(ctx) {
|
|
2868
|
+
this._presenceState = {};
|
|
2869
|
+
if (ctx.clients) {
|
|
2870
|
+
for (const [clientId, info] of Object.entries(ctx.clients)) {
|
|
2871
|
+
this._presenceState[clientId] = info.data;
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
_triggerPresenceSync() {
|
|
2877
|
+
this._triggerEvent('presence_sync', this._presenceState);
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
_triggerEvent(event, data) {
|
|
2881
|
+
const callbacks = this._callbacks.get(event) || [];
|
|
2882
|
+
callbacks.forEach(cb => cb(data));
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// Export for CommonJS
|
|
2887
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
2888
|
+
module.exports = { VolcanoRealtime, RealtimeChannel };
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
var realtime = /*#__PURE__*/Object.freeze({
|
|
2892
|
+
__proto__: null,
|
|
2893
|
+
RealtimeChannel: RealtimeChannel,
|
|
2894
|
+
VolcanoRealtime: VolcanoRealtime
|
|
2895
|
+
});
|
|
2896
|
+
|
|
2897
|
+
export { QueryBuilder, StorageFileApi, VolcanoAuth, VolcanoAuth as default, isBrowser, loadRealtime };
|