@palbase/web 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/analytics-facade-DkOwkEpi.d.ts +454 -0
- package/dist/analytics-facade-t6UrFdn7.d.cts +454 -0
- package/dist/chunk-JVT65V4E.js +3384 -0
- package/dist/chunk-JVT65V4E.js.map +1 -0
- package/dist/chunk-VJXFABBW.js +94 -0
- package/dist/chunk-VJXFABBW.js.map +1 -0
- package/dist/errors-fDoNdTrJ.d.cts +35 -0
- package/dist/errors-fDoNdTrJ.d.ts +35 -0
- package/dist/index.cjs +2394 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.cjs +3403 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +49 -0
- package/dist/internal.d.ts +49 -0
- package/dist/internal.js +19 -0
- package/dist/internal.js.map +1 -0
- package/dist/next/client.cjs +3131 -0
- package/dist/next/client.cjs.map +1 -0
- package/dist/next/client.d.cts +19 -0
- package/dist/next/client.d.ts +19 -0
- package/dist/next/client.js +75 -0
- package/dist/next/client.js.map +1 -0
- package/dist/next/index.cjs +3680 -0
- package/dist/next/index.cjs.map +1 -0
- package/dist/next/index.d.cts +238 -0
- package/dist/next/index.d.ts +238 -0
- package/dist/next/index.js +301 -0
- package/dist/next/index.js.map +1 -0
- package/dist/pb-BmgkAe97.d.ts +54 -0
- package/dist/pb-Cudze7Kb.d.cts +54 -0
- package/dist/react/index.cjs +649 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +86 -0
- package/dist/react/index.d.ts +86 -0
- package/dist/react/index.js +156 -0
- package/dist/react/index.js.map +1 -0
- package/dist/storage-BPaeSG8K.d.cts +21 -0
- package/dist/storage-BPaeSG8K.d.ts +21 -0
- package/package.json +123 -0
|
@@ -0,0 +1,3680 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/next/index.ts
|
|
31
|
+
var next_exports = {};
|
|
32
|
+
__export(next_exports, {
|
|
33
|
+
SESSION_COOKIE_ATTRS: () => SESSION_COOKIE_ATTRS,
|
|
34
|
+
clearedSessionCookieNames: () => clearedSessionCookieNames,
|
|
35
|
+
decodeSessionCookies: () => decodeSessionCookies,
|
|
36
|
+
encodeSessionCookies: () => encodeSessionCookies,
|
|
37
|
+
encodeSessionCookiesDecoded: () => encodeSessionCookiesDecoded,
|
|
38
|
+
endpointRefFromApiKey: () => endpointRefFromApiKey,
|
|
39
|
+
handleAuthCallback: () => handleAuthCallback,
|
|
40
|
+
palbeMiddleware: () => palbeMiddleware,
|
|
41
|
+
pbServer: () => pbServer,
|
|
42
|
+
sessionCookieName: () => sessionCookieName
|
|
43
|
+
});
|
|
44
|
+
module.exports = __toCommonJS(next_exports);
|
|
45
|
+
|
|
46
|
+
// src/auth-wire.ts
|
|
47
|
+
function asWireAuthResult(raw) {
|
|
48
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
49
|
+
const obj = raw;
|
|
50
|
+
if (typeof obj.access_token !== "string") return null;
|
|
51
|
+
if (typeof obj.refresh_token !== "string") return null;
|
|
52
|
+
if (typeof obj.expires_in !== "number") return null;
|
|
53
|
+
const user = obj.user;
|
|
54
|
+
if (typeof user !== "object" || user === null) return null;
|
|
55
|
+
if (typeof user.id !== "string") return null;
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/api-key.ts
|
|
60
|
+
function endpointRefFromApiKey(apiKey) {
|
|
61
|
+
const parts = apiKey.split("_");
|
|
62
|
+
return parts.length >= 3 && parts[0] === "pb" ? parts[1] ?? "" : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/next/cookie-codec.ts
|
|
66
|
+
var MAX_COOKIE_VALUE = 3500;
|
|
67
|
+
var SESSION_COOKIE_ATTRS = {
|
|
68
|
+
path: "/",
|
|
69
|
+
sameSite: "lax",
|
|
70
|
+
secure: true,
|
|
71
|
+
maxAge: 2592e3
|
|
72
|
+
};
|
|
73
|
+
function sessionCookieName(endpointRef) {
|
|
74
|
+
return `palbe-session-${endpointRef}`;
|
|
75
|
+
}
|
|
76
|
+
function encodeSessionCookies(endpointRef, session) {
|
|
77
|
+
const name = sessionCookieName(endpointRef);
|
|
78
|
+
const value = encodeURIComponent(
|
|
79
|
+
JSON.stringify({ a: session.accessToken, r: session.refreshToken, e: session.expiresAt })
|
|
80
|
+
);
|
|
81
|
+
if (value.length <= MAX_COOKIE_VALUE) {
|
|
82
|
+
return { set: [{ name, value }], clear: [`${name}.0`] };
|
|
83
|
+
}
|
|
84
|
+
const chunks = [];
|
|
85
|
+
for (let start = 0; start < value.length; ) {
|
|
86
|
+
let end = Math.min(start + MAX_COOKIE_VALUE, value.length);
|
|
87
|
+
if (end < value.length) {
|
|
88
|
+
if (value[end - 1] === "%") end -= 1;
|
|
89
|
+
else if (value[end - 2] === "%") end -= 2;
|
|
90
|
+
}
|
|
91
|
+
chunks.push({ name: `${name}.${chunks.length}`, value: value.slice(start, end) });
|
|
92
|
+
start = end;
|
|
93
|
+
}
|
|
94
|
+
return { set: chunks, clear: [`${name}.${chunks.length}`] };
|
|
95
|
+
}
|
|
96
|
+
function encodeSessionCookiesDecoded(endpointRef, session) {
|
|
97
|
+
const { set, clear } = encodeSessionCookies(endpointRef, session);
|
|
98
|
+
return {
|
|
99
|
+
set: set.map(({ name, value }) => ({ name, value: decodeURIComponent(value) })),
|
|
100
|
+
clear
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function decodeSessionCookies(get, endpointRef) {
|
|
104
|
+
const base = sessionCookieName(endpointRef);
|
|
105
|
+
let encoded = get(base);
|
|
106
|
+
if (encoded === void 0) {
|
|
107
|
+
let joined = "";
|
|
108
|
+
for (let i = 0; ; i++) {
|
|
109
|
+
const part = get(`${base}.${i}`);
|
|
110
|
+
if (part === void 0) break;
|
|
111
|
+
joined += part;
|
|
112
|
+
}
|
|
113
|
+
if (joined === "") return null;
|
|
114
|
+
encoded = joined;
|
|
115
|
+
}
|
|
116
|
+
const raw = parseStoredSession(encoded);
|
|
117
|
+
if (raw !== null) return raw;
|
|
118
|
+
try {
|
|
119
|
+
return parseStoredSession(decodeURIComponent(encoded));
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function parseStoredSession(json) {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(json);
|
|
127
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
128
|
+
const obj = parsed;
|
|
129
|
+
if (typeof obj.a === "string" && typeof obj.r === "string" && typeof obj.e === "number") {
|
|
130
|
+
return { accessToken: obj.a, refreshToken: obj.r, expiresAt: obj.e };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function clearedSessionCookieNames(endpointRef, present) {
|
|
139
|
+
const base = sessionCookieName(endpointRef);
|
|
140
|
+
const names = [];
|
|
141
|
+
if (present(base)) names.push(base);
|
|
142
|
+
for (let i = 0; ; i++) {
|
|
143
|
+
const chunk = `${base}.${i}`;
|
|
144
|
+
if (!present(chunk)) break;
|
|
145
|
+
names.push(chunk);
|
|
146
|
+
}
|
|
147
|
+
return names;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ../core/dist/index.js
|
|
151
|
+
var CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
152
|
+
var PalbaseError = class extends Error {
|
|
153
|
+
code;
|
|
154
|
+
status;
|
|
155
|
+
details;
|
|
156
|
+
constructor(code, message, status, details) {
|
|
157
|
+
super(message);
|
|
158
|
+
this.name = "PalbaseError";
|
|
159
|
+
this.code = code;
|
|
160
|
+
this.status = status;
|
|
161
|
+
this.details = details;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
var PALBASE_DEFAULT_HOST = "api.palbase.studio";
|
|
165
|
+
var REF_LEN = 8;
|
|
166
|
+
var BASE62_RE = /^[0-9A-Za-z]+$/;
|
|
167
|
+
function parseProjectRef(apiKey) {
|
|
168
|
+
if (apiKey.length < 13) return null;
|
|
169
|
+
if (!apiKey.startsWith("pb_")) return null;
|
|
170
|
+
if (apiKey[11] !== "_") return null;
|
|
171
|
+
const ref = apiKey.slice(3, 11);
|
|
172
|
+
if (ref.length !== REF_LEN) return null;
|
|
173
|
+
if (!BASE62_RE.test(ref)) return null;
|
|
174
|
+
const scope = apiKey[12];
|
|
175
|
+
if (scope !== "c") return null;
|
|
176
|
+
return ref;
|
|
177
|
+
}
|
|
178
|
+
var MAX_RETRIES = 3;
|
|
179
|
+
var INITIAL_BACKOFF_MS = 200;
|
|
180
|
+
var MAX_RETRY_DELAY_MS = 1e4;
|
|
181
|
+
var HttpClient = class _HttpClient {
|
|
182
|
+
apiKey;
|
|
183
|
+
options;
|
|
184
|
+
tokenManager = null;
|
|
185
|
+
/**
|
|
186
|
+
* Admin JWT used for platform admin endpoints (/admin/*).
|
|
187
|
+
* When set, takes precedence over tokenManager access token in the
|
|
188
|
+
* Authorization header.
|
|
189
|
+
*/
|
|
190
|
+
adminToken = null;
|
|
191
|
+
interceptors = [];
|
|
192
|
+
constructor(apiKey, options) {
|
|
193
|
+
this.apiKey = apiKey;
|
|
194
|
+
this.options = options;
|
|
195
|
+
}
|
|
196
|
+
/** Set (or clear) the admin JWT used on admin endpoints. */
|
|
197
|
+
setAdminToken(token) {
|
|
198
|
+
this.adminToken = token;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Create a scoped HttpClient that adds the given extra headers to every
|
|
202
|
+
* request. The returned client shares the admin token and token manager
|
|
203
|
+
* with the parent at runtime — later changes on the parent propagate to
|
|
204
|
+
* the scope and vice versa.
|
|
205
|
+
*
|
|
206
|
+
* Typical use: tagging admin calls with `x-palbase-project: <ref>` so the
|
|
207
|
+
* gateway can route them to the correct project's data plane.
|
|
208
|
+
*/
|
|
209
|
+
withHeaders(extra) {
|
|
210
|
+
const mergedHeaders = { ...this.options?.headers ?? {}, ...extra };
|
|
211
|
+
const scoped = new _HttpClient(this.apiKey, {
|
|
212
|
+
...this.options,
|
|
213
|
+
headers: mergedHeaders
|
|
214
|
+
});
|
|
215
|
+
scoped.tokenManager = this.tokenManager;
|
|
216
|
+
Object.defineProperty(scoped, "adminToken", {
|
|
217
|
+
get: () => this.adminToken,
|
|
218
|
+
set: (v) => {
|
|
219
|
+
this.adminToken = v;
|
|
220
|
+
},
|
|
221
|
+
configurable: true
|
|
222
|
+
});
|
|
223
|
+
return scoped;
|
|
224
|
+
}
|
|
225
|
+
/** Add a request interceptor. Runs before every request. */
|
|
226
|
+
addInterceptor(interceptor) {
|
|
227
|
+
this.interceptors.push(interceptor);
|
|
228
|
+
}
|
|
229
|
+
async request(method, path, options) {
|
|
230
|
+
if (this.tokenManager?.isExpired() && this.tokenManager.getRefreshToken() && this.tokenManager.refreshFunction) {
|
|
231
|
+
try {
|
|
232
|
+
await this.tokenManager.refreshSession();
|
|
233
|
+
} catch (e) {
|
|
234
|
+
const status = e instanceof PalbaseError ? e.status : 0;
|
|
235
|
+
if (status === 400 || status === 401 || status === 403) {
|
|
236
|
+
this.tokenManager.clearSession();
|
|
237
|
+
} else {
|
|
238
|
+
throw e;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return this.executeWithRetry(method, path, options, 0);
|
|
243
|
+
}
|
|
244
|
+
getBaseUrl() {
|
|
245
|
+
if (this.options?.url) {
|
|
246
|
+
return this.options.url;
|
|
247
|
+
}
|
|
248
|
+
if (this.apiKey && parseProjectRef(this.apiKey) === null) {
|
|
249
|
+
throw new PalbaseError(
|
|
250
|
+
"invalid_api_key",
|
|
251
|
+
'Invalid API key format. Expected pb_{ref}_{scope}{random}. For dev/staging pass `url: "https://api.dev.palbase.studio"` via options.',
|
|
252
|
+
0
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return `https://${PALBASE_DEFAULT_HOST}`;
|
|
256
|
+
}
|
|
257
|
+
buildHeaders(options) {
|
|
258
|
+
const headers = {
|
|
259
|
+
"Content-Type": "application/json"
|
|
260
|
+
};
|
|
261
|
+
const effectiveKey = this.apiKey;
|
|
262
|
+
if (effectiveKey) {
|
|
263
|
+
headers["apikey"] = effectiveKey;
|
|
264
|
+
const ref = parseProjectRef(effectiveKey);
|
|
265
|
+
if (ref) {
|
|
266
|
+
headers["X-Project-Ref"] = ref;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const token = this.tokenManager?.getAccessToken();
|
|
270
|
+
if (token) {
|
|
271
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
272
|
+
}
|
|
273
|
+
if (this.adminToken) {
|
|
274
|
+
headers["Authorization"] = `Bearer ${this.adminToken}`;
|
|
275
|
+
}
|
|
276
|
+
if (this.options?.headers) {
|
|
277
|
+
Object.assign(headers, this.options.headers);
|
|
278
|
+
}
|
|
279
|
+
if (options?.headers) {
|
|
280
|
+
Object.assign(headers, options.headers);
|
|
281
|
+
}
|
|
282
|
+
return headers;
|
|
283
|
+
}
|
|
284
|
+
async executeWithRetry(method, path, options, attempt) {
|
|
285
|
+
const url = `${this.getBaseUrl()}${path}`;
|
|
286
|
+
const headers = this.buildHeaders(options);
|
|
287
|
+
for (const interceptor of this.interceptors) {
|
|
288
|
+
await interceptor({ headers, method, path });
|
|
289
|
+
}
|
|
290
|
+
const fetchOptions = {
|
|
291
|
+
method,
|
|
292
|
+
headers,
|
|
293
|
+
signal: options?.signal
|
|
294
|
+
};
|
|
295
|
+
if (options?.body !== void 0) {
|
|
296
|
+
fetchOptions.body = JSON.stringify(options.body);
|
|
297
|
+
}
|
|
298
|
+
let response;
|
|
299
|
+
try {
|
|
300
|
+
response = await fetch(url, fetchOptions);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
303
|
+
const backoff = INITIAL_BACKOFF_MS * 2 ** attempt;
|
|
304
|
+
await this.delay(backoff);
|
|
305
|
+
return this.executeWithRetry(method, path, options, attempt + 1);
|
|
306
|
+
}
|
|
307
|
+
throw new PalbaseError(
|
|
308
|
+
"network_error",
|
|
309
|
+
error instanceof Error ? error.message : "Network request failed",
|
|
310
|
+
0
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (response.status === 429) {
|
|
314
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
315
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
316
|
+
const parsed = retryAfter ? Number.parseInt(retryAfter, 10) : Number.NaN;
|
|
317
|
+
const delayMs = Number.isNaN(parsed) ? INITIAL_BACKOFF_MS * 2 ** attempt : Math.min(parsed * 1e3, MAX_RETRY_DELAY_MS);
|
|
318
|
+
await this.delay(delayMs);
|
|
319
|
+
return this.executeWithRetry(method, path, options, attempt + 1);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
let data = null;
|
|
323
|
+
let errorBody;
|
|
324
|
+
const contentType = response.headers.get("Content-Type");
|
|
325
|
+
if (method !== "HEAD" && contentType?.includes("json")) {
|
|
326
|
+
const body = await response.json();
|
|
327
|
+
if (response.ok) {
|
|
328
|
+
data = body;
|
|
329
|
+
} else {
|
|
330
|
+
errorBody = body;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!response.ok) {
|
|
334
|
+
return {
|
|
335
|
+
data: null,
|
|
336
|
+
error: new PalbaseError(
|
|
337
|
+
errorBody?.error ?? "unknown_error",
|
|
338
|
+
errorBody?.error_description ?? response.statusText,
|
|
339
|
+
response.status,
|
|
340
|
+
errorBody
|
|
341
|
+
),
|
|
342
|
+
status: response.status
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const contentRange = response.headers.get("Content-Range");
|
|
346
|
+
let count;
|
|
347
|
+
if (contentRange) {
|
|
348
|
+
const slash = contentRange.lastIndexOf("/");
|
|
349
|
+
if (slash >= 0) {
|
|
350
|
+
const totalPart = contentRange.slice(slash + 1);
|
|
351
|
+
if (totalPart !== "*") {
|
|
352
|
+
const parsed = Number.parseInt(totalPart, 10);
|
|
353
|
+
if (!Number.isNaN(parsed)) {
|
|
354
|
+
count = parsed;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
data,
|
|
361
|
+
error: null,
|
|
362
|
+
status: response.status,
|
|
363
|
+
...count !== void 0 ? { count } : {}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
delay(ms) {
|
|
367
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
var TokenManager = class {
|
|
371
|
+
session = null;
|
|
372
|
+
listeners = /* @__PURE__ */ new Set();
|
|
373
|
+
refreshPromise = null;
|
|
374
|
+
refreshing = false;
|
|
375
|
+
refreshFunction = null;
|
|
376
|
+
setSession(session) {
|
|
377
|
+
this.session = session;
|
|
378
|
+
this.notify("SESSION_SET", session);
|
|
379
|
+
}
|
|
380
|
+
getAccessToken() {
|
|
381
|
+
return this.session?.accessToken ?? null;
|
|
382
|
+
}
|
|
383
|
+
getRefreshToken() {
|
|
384
|
+
return this.session?.refreshToken ?? null;
|
|
385
|
+
}
|
|
386
|
+
clearSession() {
|
|
387
|
+
this.session = null;
|
|
388
|
+
this.notify("SESSION_CLEARED", null);
|
|
389
|
+
}
|
|
390
|
+
isExpired() {
|
|
391
|
+
if (!this.session) return true;
|
|
392
|
+
return Date.now() >= this.session.expiresAt;
|
|
393
|
+
}
|
|
394
|
+
async refreshSession() {
|
|
395
|
+
if (!this.session?.refreshToken || !this.refreshFunction) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (this.refreshPromise) {
|
|
399
|
+
return this.refreshPromise;
|
|
400
|
+
}
|
|
401
|
+
if (this.refreshing) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
this.refreshing = true;
|
|
405
|
+
this.refreshPromise = this.executeRefresh(this.session.refreshToken);
|
|
406
|
+
try {
|
|
407
|
+
await this.refreshPromise;
|
|
408
|
+
} finally {
|
|
409
|
+
this.refreshPromise = null;
|
|
410
|
+
this.refreshing = false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
onAuthStateChange(callback) {
|
|
414
|
+
this.listeners.add(callback);
|
|
415
|
+
return () => {
|
|
416
|
+
this.listeners.delete(callback);
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
async executeRefresh(refreshToken) {
|
|
420
|
+
if (!this.refreshFunction) return;
|
|
421
|
+
const newSession = await this.refreshFunction(refreshToken);
|
|
422
|
+
this.setSession(newSession);
|
|
423
|
+
}
|
|
424
|
+
notify(event, session) {
|
|
425
|
+
for (const listener of this.listeners) {
|
|
426
|
+
listener(event, session);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// src/errors.ts
|
|
432
|
+
var BackendError = class _BackendError extends Error {
|
|
433
|
+
kind;
|
|
434
|
+
code;
|
|
435
|
+
status;
|
|
436
|
+
requestId;
|
|
437
|
+
fields;
|
|
438
|
+
retryAfter;
|
|
439
|
+
data;
|
|
440
|
+
constructor(kind, params) {
|
|
441
|
+
super(params.message);
|
|
442
|
+
this.name = "BackendError";
|
|
443
|
+
this.kind = kind;
|
|
444
|
+
this.code = params.code;
|
|
445
|
+
this.status = params.status ?? 0;
|
|
446
|
+
this.requestId = params.requestId;
|
|
447
|
+
this.fields = params.fields;
|
|
448
|
+
this.retryAfter = params.retryAfter;
|
|
449
|
+
this.data = params.data;
|
|
450
|
+
}
|
|
451
|
+
static notConfigured() {
|
|
452
|
+
return new _BackendError("notConfigured", {
|
|
453
|
+
code: "not_configured",
|
|
454
|
+
message: "Palbe is not configured. Run 'palbase web link' in your project and make sure palbe.gen.ts is imported once at app startup."
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
function isFieldErrorArray(value) {
|
|
459
|
+
return Array.isArray(value) && value.length > 0 && value.every(
|
|
460
|
+
(v) => typeof v === "object" && v !== null && typeof v.field === "string" && typeof v.message === "string"
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
function pickField(value, key) {
|
|
464
|
+
if (typeof value === "object" && value !== null) {
|
|
465
|
+
return value[key];
|
|
466
|
+
}
|
|
467
|
+
return void 0;
|
|
468
|
+
}
|
|
469
|
+
function pickNumber(value, key) {
|
|
470
|
+
const n = pickField(value, key);
|
|
471
|
+
return typeof n === "number" ? n : void 0;
|
|
472
|
+
}
|
|
473
|
+
function pickString(value, key) {
|
|
474
|
+
const s = pickField(value, key);
|
|
475
|
+
return typeof s === "string" ? s : void 0;
|
|
476
|
+
}
|
|
477
|
+
function fromPalbaseError(err) {
|
|
478
|
+
const base = {
|
|
479
|
+
code: err.code,
|
|
480
|
+
message: err.message,
|
|
481
|
+
status: err.status,
|
|
482
|
+
requestId: pickString(err.details, "request_id"),
|
|
483
|
+
data: pickField(err.details, "data")
|
|
484
|
+
};
|
|
485
|
+
if (err.code === "network_error") return new BackendError("network", base);
|
|
486
|
+
if (err.status === 401) return new BackendError("unauthorized", base);
|
|
487
|
+
if (err.status === 429)
|
|
488
|
+
return new BackendError("rateLimited", {
|
|
489
|
+
...base,
|
|
490
|
+
retryAfter: pickNumber(err.details, "retry_after")
|
|
491
|
+
});
|
|
492
|
+
const nested = pickField(err.details, "details");
|
|
493
|
+
if (err.status === 400 && isFieldErrorArray(nested))
|
|
494
|
+
return new BackendError("validation", { ...base, fields: nested });
|
|
495
|
+
return new BackendError("server", base);
|
|
496
|
+
}
|
|
497
|
+
function fromEnvelope(status, body) {
|
|
498
|
+
const code = pickString(body, "error") ?? "http_error";
|
|
499
|
+
const message = pickString(body, "error_description") ?? `HTTP ${status}`;
|
|
500
|
+
const requestId = pickString(body, "request_id");
|
|
501
|
+
const details = pickField(body, "details");
|
|
502
|
+
const params = {
|
|
503
|
+
code,
|
|
504
|
+
message,
|
|
505
|
+
status,
|
|
506
|
+
requestId,
|
|
507
|
+
data: pickField(body, "data")
|
|
508
|
+
};
|
|
509
|
+
if (status === 401) return new BackendError("unauthorized", params);
|
|
510
|
+
if (status === 429)
|
|
511
|
+
return new BackendError("rateLimited", {
|
|
512
|
+
...params,
|
|
513
|
+
// Real 429 wire body has TOP-LEVEL retry_after; nested details is a fallback.
|
|
514
|
+
retryAfter: pickNumber(body, "retry_after") ?? pickNumber(details, "retry_after")
|
|
515
|
+
});
|
|
516
|
+
if (status === 400 && isFieldErrorArray(details))
|
|
517
|
+
return new BackendError("validation", { ...params, fields: details });
|
|
518
|
+
return new BackendError("server", params);
|
|
519
|
+
}
|
|
520
|
+
function isBackendError(e) {
|
|
521
|
+
return e instanceof BackendError || typeof e === "object" && e !== null && e.name === "BackendError" && typeof e.kind === "string";
|
|
522
|
+
}
|
|
523
|
+
function asPalbaseError(e) {
|
|
524
|
+
if (e instanceof PalbaseError) return e;
|
|
525
|
+
if (e instanceof Error && e.name === "PalbaseError") return e;
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
function unwrap(res) {
|
|
529
|
+
if (res.error) throw fromPalbaseError(res.error);
|
|
530
|
+
return res.data;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/request.ts
|
|
534
|
+
var MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
535
|
+
async function palbeRequest(rt, method, path, spec = {}) {
|
|
536
|
+
const headers = { ...spec.headers };
|
|
537
|
+
const callerHasKey = Object.keys(headers).some((k) => k.toLowerCase() === "idempotency-key");
|
|
538
|
+
if (MUTATING.has(method) && !callerHasKey) {
|
|
539
|
+
headers["Idempotency-Key"] = crypto.randomUUID();
|
|
540
|
+
}
|
|
541
|
+
const attempt = async () => {
|
|
542
|
+
try {
|
|
543
|
+
return await rt.http.request(method, path, {
|
|
544
|
+
body: spec.body,
|
|
545
|
+
headers,
|
|
546
|
+
signal: spec.signal
|
|
547
|
+
});
|
|
548
|
+
} catch (e) {
|
|
549
|
+
const pe = asPalbaseError(e);
|
|
550
|
+
throw pe ? fromPalbaseError(pe) : e;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
let res = await attempt();
|
|
554
|
+
if (res.error?.status === 401 && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
|
|
555
|
+
try {
|
|
556
|
+
await rt.tokenManager.refreshSession();
|
|
557
|
+
} catch (refreshErr) {
|
|
558
|
+
const pe = asPalbaseError(refreshErr);
|
|
559
|
+
const status = pe?.status ?? 0;
|
|
560
|
+
if (status === 400 || status === 401 || status === 403) {
|
|
561
|
+
rt.tokenManager.clearSession();
|
|
562
|
+
throw fromPalbaseError(res.error);
|
|
563
|
+
}
|
|
564
|
+
throw pe ? fromPalbaseError(pe) : refreshErr;
|
|
565
|
+
}
|
|
566
|
+
res = await attempt();
|
|
567
|
+
}
|
|
568
|
+
return unwrap(res);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/state.ts
|
|
572
|
+
var STATE_KEY = /* @__PURE__ */ Symbol.for("palbe.state.v1");
|
|
573
|
+
function palbeState() {
|
|
574
|
+
const g = globalThis;
|
|
575
|
+
let state = g[STATE_KEY];
|
|
576
|
+
if (!state) {
|
|
577
|
+
state = { runtime: null, registry: {}, nsCache: {}, configuredListeners: /* @__PURE__ */ new Set() };
|
|
578
|
+
g[STATE_KEY] = state;
|
|
579
|
+
}
|
|
580
|
+
return state;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/namespaces.ts
|
|
584
|
+
function isDescriptor(node) {
|
|
585
|
+
return typeof node.path === "string" && typeof node.method === "string";
|
|
586
|
+
}
|
|
587
|
+
function getRegistry() {
|
|
588
|
+
return palbeState().registry;
|
|
589
|
+
}
|
|
590
|
+
function serializeQuery(input) {
|
|
591
|
+
if (input === void 0 || input === null) return "";
|
|
592
|
+
const entries = Object.entries(input).filter(([, v]) => v !== void 0 && v !== null).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
593
|
+
if (entries.length === 0) return "";
|
|
594
|
+
const parts = entries.map(([k, v]) => {
|
|
595
|
+
if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
|
|
596
|
+
throw new BackendError("validation", {
|
|
597
|
+
code: "invalid_query_value",
|
|
598
|
+
message: `Query parameter '${k}' must be a string, number, or boolean.`
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`;
|
|
602
|
+
});
|
|
603
|
+
return `?${parts.join("&")}`;
|
|
604
|
+
}
|
|
605
|
+
async function invokeDescriptor(rt, d, args) {
|
|
606
|
+
const params = d.pathParams ?? [];
|
|
607
|
+
let path = d.path;
|
|
608
|
+
for (let i = 0; i < params.length; i++) {
|
|
609
|
+
const v = args[i];
|
|
610
|
+
if (typeof v !== "string" && typeof v !== "number") {
|
|
611
|
+
throw new BackendError("validation", {
|
|
612
|
+
code: "missing_path_param",
|
|
613
|
+
message: `${d.path}: path parameter '${params[i]}' must be a string or number`
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
path = path.replace(`{${params[i]}}`, encodeURIComponent(String(v)));
|
|
617
|
+
}
|
|
618
|
+
if (d.input === "none" && args.length > params.length + 1) {
|
|
619
|
+
throw new BackendError("validation", {
|
|
620
|
+
code: "unexpected_argument",
|
|
621
|
+
message: `${d.path}: endpoint takes no input \u2014 pass only path params and options`
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
const input = d.input === "none" ? void 0 : args[params.length];
|
|
625
|
+
const optionsSlot = d.input === "none" ? params.length : params.length + 1;
|
|
626
|
+
const options = args[optionsSlot];
|
|
627
|
+
let body;
|
|
628
|
+
if (d.input === "query") {
|
|
629
|
+
path += serializeQuery(input);
|
|
630
|
+
} else if (d.input !== "none") {
|
|
631
|
+
if (d.method === "GET") {
|
|
632
|
+
if (input !== void 0) {
|
|
633
|
+
throw new BackendError("validation", {
|
|
634
|
+
code: "unsupported_get_input",
|
|
635
|
+
message: `${d.path}: GET endpoints take no body input`
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
} else {
|
|
639
|
+
body = input;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
return await palbeRequest(rt, d.method, path, {
|
|
644
|
+
body,
|
|
645
|
+
headers: options?.headers,
|
|
646
|
+
signal: options?.signal
|
|
647
|
+
});
|
|
648
|
+
} catch (e) {
|
|
649
|
+
if (d.errors && isBackendError(e)) {
|
|
650
|
+
const lift = d.errors[e.code];
|
|
651
|
+
if (lift) throw lift(e);
|
|
652
|
+
}
|
|
653
|
+
throw e;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function materialize(node, resolveRt) {
|
|
657
|
+
if (isDescriptor(node)) {
|
|
658
|
+
return async (...args) => invokeDescriptor(resolveRt(), node, args);
|
|
659
|
+
}
|
|
660
|
+
const out = {};
|
|
661
|
+
for (const [key, child] of Object.entries(node)) {
|
|
662
|
+
out[key] = materialize(child, resolveRt);
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
function getNamespace(name) {
|
|
667
|
+
const state = palbeState();
|
|
668
|
+
const cached = state.nsCache[name];
|
|
669
|
+
if (cached !== void 0) return cached;
|
|
670
|
+
const node = state.registry[name];
|
|
671
|
+
if (node === void 0) return void 0;
|
|
672
|
+
const ns = materialize(node, getRuntime);
|
|
673
|
+
state.nsCache[name] = ns;
|
|
674
|
+
return ns;
|
|
675
|
+
}
|
|
676
|
+
function boundNamespaceAccessor(rt) {
|
|
677
|
+
let cacheRegistry = palbeState().registry;
|
|
678
|
+
let cache = {};
|
|
679
|
+
return (name) => {
|
|
680
|
+
const registry = palbeState().registry;
|
|
681
|
+
if (registry !== cacheRegistry) {
|
|
682
|
+
cacheRegistry = registry;
|
|
683
|
+
cache = {};
|
|
684
|
+
}
|
|
685
|
+
const cached = cache[name];
|
|
686
|
+
if (cached !== void 0) return cached;
|
|
687
|
+
const node = registry[name];
|
|
688
|
+
if (node === void 0) return void 0;
|
|
689
|
+
const ns = materialize(node, () => rt);
|
|
690
|
+
cache[name] = ns;
|
|
691
|
+
return ns;
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ../modules/auth/dist/index.js
|
|
696
|
+
function validatePathParam(name, value) {
|
|
697
|
+
if (value.includes("/") || value.includes("..") || value.includes("%")) {
|
|
698
|
+
throw new Error(`Invalid ${name}: must not contain path separators`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
var TOKEN_BUFFER_MS = 5 * 60 * 1e3;
|
|
702
|
+
var DeviceClient = class {
|
|
703
|
+
httpClient;
|
|
704
|
+
cachedToken = null;
|
|
705
|
+
refreshTimer = null;
|
|
706
|
+
constructor(httpClient) {
|
|
707
|
+
this.httpClient = httpClient;
|
|
708
|
+
}
|
|
709
|
+
/** Generate a device attestation challenge. */
|
|
710
|
+
async generateChallenge() {
|
|
711
|
+
return this.httpClient.request("POST", "/auth/devices/challenge");
|
|
712
|
+
}
|
|
713
|
+
/** Attest an Android device with a Play Integrity verdict token. */
|
|
714
|
+
async attestAndroid(params) {
|
|
715
|
+
return this.httpClient.request("POST", "/auth/devices/attest/android", {
|
|
716
|
+
body: params
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
/** Attest an iOS device with App Attest attestation data. */
|
|
720
|
+
async attestiOS(params) {
|
|
721
|
+
return this.httpClient.request("POST", "/auth/devices/attest/ios", {
|
|
722
|
+
body: params
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
/** Bind a verified device with a public key for request signing. */
|
|
726
|
+
async bind(params) {
|
|
727
|
+
return this.httpClient.request("POST", "/auth/devices/bind", {
|
|
728
|
+
body: params
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
/** List all devices for the current user. */
|
|
732
|
+
async list() {
|
|
733
|
+
return this.httpClient.request("GET", "/auth/devices");
|
|
734
|
+
}
|
|
735
|
+
/** Delete a device by ID. */
|
|
736
|
+
async delete(deviceId) {
|
|
737
|
+
validatePathParam("deviceId", deviceId);
|
|
738
|
+
return this.httpClient.request("DELETE", `/auth/devices/${deviceId}`);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Verify a request signature from a device (server-only).
|
|
742
|
+
* Should NOT be exposed in client SDK.
|
|
743
|
+
*/
|
|
744
|
+
async verifyRequestSignature(deviceId, params) {
|
|
745
|
+
validatePathParam("deviceId", deviceId);
|
|
746
|
+
return this.httpClient.request(
|
|
747
|
+
"POST",
|
|
748
|
+
`/auth/devices/${deviceId}/verify`,
|
|
749
|
+
{ body: params }
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
/** Get the cached App Check token, or null if not available / expired. */
|
|
753
|
+
getToken() {
|
|
754
|
+
if (!this.cachedToken) return null;
|
|
755
|
+
if (Date.now() >= this.cachedToken.expiresAt) return null;
|
|
756
|
+
return this.cachedToken.token;
|
|
757
|
+
}
|
|
758
|
+
/** Whether App Check is active (token cached and not expired). */
|
|
759
|
+
get isActive() {
|
|
760
|
+
return this.getToken() !== null;
|
|
761
|
+
}
|
|
762
|
+
/** Set a cached App Check token manually (e.g. after attest flow). */
|
|
763
|
+
setCachedToken(token, expiresInMs) {
|
|
764
|
+
this.cachedToken = {
|
|
765
|
+
token,
|
|
766
|
+
expiresAt: Date.now() + expiresInMs
|
|
767
|
+
};
|
|
768
|
+
this.scheduleRefresh(expiresInMs);
|
|
769
|
+
}
|
|
770
|
+
/** Clean up timers and cached state. */
|
|
771
|
+
dispose() {
|
|
772
|
+
if (this.refreshTimer) {
|
|
773
|
+
clearTimeout(this.refreshTimer);
|
|
774
|
+
this.refreshTimer = null;
|
|
775
|
+
}
|
|
776
|
+
this.cachedToken = null;
|
|
777
|
+
}
|
|
778
|
+
scheduleRefresh(ttlMs) {
|
|
779
|
+
if (this.refreshTimer) {
|
|
780
|
+
clearTimeout(this.refreshTimer);
|
|
781
|
+
}
|
|
782
|
+
const refreshIn = Math.max(ttlMs - TOKEN_BUFFER_MS, 0);
|
|
783
|
+
this.refreshTimer = setTimeout(() => {
|
|
784
|
+
this.cachedToken = null;
|
|
785
|
+
}, refreshIn);
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
var PROVIDER_RE = /^[a-zA-Z0-9_-]+$/;
|
|
789
|
+
function validatePathParam2(name, value) {
|
|
790
|
+
if (value.includes("/") || value.includes("..") || value.includes("%")) {
|
|
791
|
+
throw new Error(`Invalid ${name}: must not contain path separators`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function toSession(result) {
|
|
795
|
+
return {
|
|
796
|
+
accessToken: result.access_token,
|
|
797
|
+
refreshToken: result.refresh_token,
|
|
798
|
+
expiresAt: Date.now() + result.expires_in * 1e3
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function toUser(result) {
|
|
802
|
+
return {
|
|
803
|
+
id: result.user.id,
|
|
804
|
+
email: result.user.email,
|
|
805
|
+
emailVerified: result.user.email_verified,
|
|
806
|
+
createdAt: result.user.created_at,
|
|
807
|
+
updatedAt: result.user.created_at
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
var AuthClient = class {
|
|
811
|
+
httpClient;
|
|
812
|
+
tokenManager;
|
|
813
|
+
apiKey;
|
|
814
|
+
baseUrl;
|
|
815
|
+
currentSession = null;
|
|
816
|
+
hasSession = false;
|
|
817
|
+
_mfa = null;
|
|
818
|
+
/** Device attestation */
|
|
819
|
+
device;
|
|
820
|
+
constructor(httpClient, tokenManager, apiKey, baseUrl, _options) {
|
|
821
|
+
this.httpClient = httpClient;
|
|
822
|
+
this.tokenManager = tokenManager;
|
|
823
|
+
this.apiKey = apiKey ?? "";
|
|
824
|
+
this.baseUrl = baseUrl ?? "";
|
|
825
|
+
this.device = new DeviceClient(httpClient);
|
|
826
|
+
this.tokenManager.onAuthStateChange((event, session) => {
|
|
827
|
+
if (event === "SESSION_SET") {
|
|
828
|
+
this.currentSession = session;
|
|
829
|
+
} else {
|
|
830
|
+
this.currentSession = null;
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
// ── Core Auth ───────────────────────────────────────────
|
|
835
|
+
async signUp(credentials) {
|
|
836
|
+
const response = await this.httpClient.request("POST", "/auth/signup", {
|
|
837
|
+
body: credentials
|
|
838
|
+
});
|
|
839
|
+
if (response.data) {
|
|
840
|
+
const session = toSession(response.data);
|
|
841
|
+
const user = toUser(response.data);
|
|
842
|
+
this.setSessionAndWireRefresh(session);
|
|
843
|
+
return { data: { user, session }, error: null, status: response.status };
|
|
844
|
+
}
|
|
845
|
+
return { data: null, error: response.error, status: response.status };
|
|
846
|
+
}
|
|
847
|
+
async signIn(credentials) {
|
|
848
|
+
const response = await this.httpClient.request("POST", "/auth/login", {
|
|
849
|
+
body: credentials
|
|
850
|
+
});
|
|
851
|
+
if (response.data) {
|
|
852
|
+
const session = toSession(response.data);
|
|
853
|
+
const user = toUser(response.data);
|
|
854
|
+
this.setSessionAndWireRefresh(session);
|
|
855
|
+
return { data: { user, session }, error: null, status: response.status };
|
|
856
|
+
}
|
|
857
|
+
return { data: null, error: response.error, status: response.status };
|
|
858
|
+
}
|
|
859
|
+
async signOut() {
|
|
860
|
+
const response = await this.httpClient.request("POST", "/auth/logout");
|
|
861
|
+
this.tokenManager.clearSession();
|
|
862
|
+
this.hasSession = false;
|
|
863
|
+
return response;
|
|
864
|
+
}
|
|
865
|
+
// ── Email Verification ──────────────────────────────────
|
|
866
|
+
async verifyEmail(params) {
|
|
867
|
+
return this.httpClient.request("POST", "/auth/verify-email", {
|
|
868
|
+
body: params
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
async resendVerification(email) {
|
|
872
|
+
return this.httpClient.request(
|
|
873
|
+
"POST",
|
|
874
|
+
"/auth/resend-verification",
|
|
875
|
+
{ body: { email } }
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
// ── Password ────────────────────────────────────────────
|
|
879
|
+
async requestPasswordReset(params) {
|
|
880
|
+
return this.httpClient.request("POST", "/auth/password/reset", {
|
|
881
|
+
body: params
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
async confirmPasswordReset(params) {
|
|
885
|
+
return this.httpClient.request("POST", "/auth/password/reset/confirm", {
|
|
886
|
+
body: params
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
async changePassword(params) {
|
|
890
|
+
return this.httpClient.request("POST", "/auth/password/change", {
|
|
891
|
+
body: params
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
// ── Token ───────────────────────────────────────────────
|
|
895
|
+
async refresh() {
|
|
896
|
+
const refreshToken = this.tokenManager.getRefreshToken();
|
|
897
|
+
if (!refreshToken) {
|
|
898
|
+
return {
|
|
899
|
+
data: null,
|
|
900
|
+
error: new PalbaseError("no_refresh_token", "No refresh token available", 0),
|
|
901
|
+
status: 0
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
const response = await this.httpClient.request("POST", "/auth/token/refresh", {
|
|
905
|
+
body: { refresh_token: refreshToken }
|
|
906
|
+
});
|
|
907
|
+
if (response.data) {
|
|
908
|
+
const session = {
|
|
909
|
+
accessToken: response.data.access_token,
|
|
910
|
+
refreshToken: response.data.refresh_token,
|
|
911
|
+
expiresAt: Date.now() + response.data.expires_in * 1e3
|
|
912
|
+
};
|
|
913
|
+
this.tokenManager.setSession(session);
|
|
914
|
+
}
|
|
915
|
+
return response;
|
|
916
|
+
}
|
|
917
|
+
getAccessToken() {
|
|
918
|
+
return this.tokenManager.getAccessToken();
|
|
919
|
+
}
|
|
920
|
+
onTokenChange(callback) {
|
|
921
|
+
return this.tokenManager.onAuthStateChange((event, session) => {
|
|
922
|
+
if (event === "SESSION_SET" && session) {
|
|
923
|
+
callback({ accessToken: session.accessToken, refreshToken: session.refreshToken });
|
|
924
|
+
} else {
|
|
925
|
+
callback({ accessToken: null, refreshToken: null });
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
setTokens(accessToken, refreshToken, expiresIn = 3600) {
|
|
930
|
+
const session = {
|
|
931
|
+
accessToken,
|
|
932
|
+
refreshToken,
|
|
933
|
+
// Default 1h when the caller has no TTL; corrected on next refresh anyway.
|
|
934
|
+
expiresAt: Date.now() + expiresIn * 1e3
|
|
935
|
+
};
|
|
936
|
+
this.setSessionAndWireRefresh(session);
|
|
937
|
+
}
|
|
938
|
+
// ── Sessions ────────────────────────────────────────────
|
|
939
|
+
async listSessions() {
|
|
940
|
+
const response = await this.httpClient.request(
|
|
941
|
+
"GET",
|
|
942
|
+
"/auth/sessions"
|
|
943
|
+
);
|
|
944
|
+
if (response.data) {
|
|
945
|
+
return { data: response.data.sessions, error: null, status: response.status };
|
|
946
|
+
}
|
|
947
|
+
return { data: null, error: response.error, status: response.status };
|
|
948
|
+
}
|
|
949
|
+
async revokeSession(sessionId) {
|
|
950
|
+
validatePathParam2("sessionId", sessionId);
|
|
951
|
+
return this.httpClient.request("DELETE", `/auth/sessions/${sessionId}`);
|
|
952
|
+
}
|
|
953
|
+
async revokeAllSessions() {
|
|
954
|
+
return this.httpClient.request("DELETE", "/auth/sessions");
|
|
955
|
+
}
|
|
956
|
+
// ── MFA ─────────────────────────────────────────────────
|
|
957
|
+
get mfa() {
|
|
958
|
+
this._mfa ??= this.buildMfa();
|
|
959
|
+
return this._mfa;
|
|
960
|
+
}
|
|
961
|
+
buildMfa() {
|
|
962
|
+
return {
|
|
963
|
+
enroll: (params) => this.httpClient.request("POST", "/auth/mfa/enroll", { body: params }),
|
|
964
|
+
verifyEnrollment: (code) => this.httpClient.request("POST", "/auth/mfa/verify", { body: { code } }),
|
|
965
|
+
challenge: (params) => this.httpClient.request("POST", "/auth/mfa/challenge", { body: params }),
|
|
966
|
+
recovery: (params) => this.httpClient.request("POST", "/auth/mfa/recovery", { body: params }),
|
|
967
|
+
listFactors: () => this.httpClient.request("GET", "/auth/mfa/factors"),
|
|
968
|
+
removeFactor: (factorId, currentPassword) => {
|
|
969
|
+
validatePathParam2("factorId", factorId);
|
|
970
|
+
return this.httpClient.request("DELETE", `/auth/mfa/factors/${factorId}`, {
|
|
971
|
+
body: { current_password: currentPassword }
|
|
972
|
+
});
|
|
973
|
+
},
|
|
974
|
+
regenerateRecoveryCodes: () => this.httpClient.request("POST", "/auth/mfa/recovery-codes/regenerate"),
|
|
975
|
+
emailEnroll: () => this.httpClient.request("POST", "/auth/mfa/email/enroll"),
|
|
976
|
+
emailChallenge: (params) => this.httpClient.request("POST", "/auth/mfa/email/challenge", { body: params }),
|
|
977
|
+
emailVerify: (params) => this.httpClient.request("POST", "/auth/mfa/email/verify", { body: params })
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
// ── OAuth / Social ──────────────────────────────────────
|
|
981
|
+
async getOAuthURL(options) {
|
|
982
|
+
if (!PROVIDER_RE.test(options.provider)) {
|
|
983
|
+
return {
|
|
984
|
+
data: null,
|
|
985
|
+
error: new PalbaseError("invalid_provider", "Invalid OAuth provider name", 0),
|
|
986
|
+
status: 0
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
const params = new URLSearchParams();
|
|
990
|
+
if (options.redirectTo) {
|
|
991
|
+
params.set("redirect_uri", options.redirectTo);
|
|
992
|
+
}
|
|
993
|
+
const query = params.toString();
|
|
994
|
+
const path = `/auth/oauth/${options.provider}/authorize${query ? `?${query}` : ""}`;
|
|
995
|
+
return this.httpClient.request("GET", path);
|
|
996
|
+
}
|
|
997
|
+
async signInWithCredential(params) {
|
|
998
|
+
const response = await this.httpClient.request("POST", "/auth/oauth/credential", {
|
|
999
|
+
body: params
|
|
1000
|
+
});
|
|
1001
|
+
if (response.data?.access_token) {
|
|
1002
|
+
const session = toSession(response.data);
|
|
1003
|
+
const user = toUser(response.data);
|
|
1004
|
+
this.setSessionAndWireRefresh(session);
|
|
1005
|
+
return { data: { user, session }, error: null, status: response.status };
|
|
1006
|
+
}
|
|
1007
|
+
return { data: null, error: response.error, status: response.status };
|
|
1008
|
+
}
|
|
1009
|
+
// ── Identities ──────────────────────────────────────────
|
|
1010
|
+
async listIdentities() {
|
|
1011
|
+
return this.httpClient.request("GET", "/auth/identities");
|
|
1012
|
+
}
|
|
1013
|
+
async linkIdentity(params) {
|
|
1014
|
+
return this.httpClient.request("POST", "/auth/identities/link", {
|
|
1015
|
+
body: params
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
async unlinkIdentity(identityId) {
|
|
1019
|
+
validatePathParam2("identityId", identityId);
|
|
1020
|
+
return this.httpClient.request(
|
|
1021
|
+
"DELETE",
|
|
1022
|
+
`/auth/identities/${identityId}`
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
// ── Magic Link ──────────────────────────────────────────
|
|
1026
|
+
async requestMagicLink(params) {
|
|
1027
|
+
return this.httpClient.request("POST", "/auth/magic-link", {
|
|
1028
|
+
body: params
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
async verifyMagicLink(params) {
|
|
1032
|
+
const response = await this.httpClient.request("POST", "/auth/magic-link/verify", {
|
|
1033
|
+
body: params
|
|
1034
|
+
});
|
|
1035
|
+
if (response.data?.access_token) {
|
|
1036
|
+
const session = toSession(response.data);
|
|
1037
|
+
const user = toUser(response.data);
|
|
1038
|
+
this.setSessionAndWireRefresh(session);
|
|
1039
|
+
return { data: { user, session }, error: null, status: response.status };
|
|
1040
|
+
}
|
|
1041
|
+
return { data: null, error: response.error, status: response.status };
|
|
1042
|
+
}
|
|
1043
|
+
// ── Trusted Devices ─────────────────────────────────────
|
|
1044
|
+
async listTrustedDevices() {
|
|
1045
|
+
return this.httpClient.request(
|
|
1046
|
+
"GET",
|
|
1047
|
+
"/auth/trusted-devices"
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
async registerTrustedDevice(params) {
|
|
1051
|
+
return this.httpClient.request(
|
|
1052
|
+
"POST",
|
|
1053
|
+
"/auth/trusted-devices",
|
|
1054
|
+
{ body: params }
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
async revokeTrustedDevice(deviceId) {
|
|
1058
|
+
validatePathParam2("deviceId", deviceId);
|
|
1059
|
+
return this.httpClient.request(
|
|
1060
|
+
"DELETE",
|
|
1061
|
+
`/auth/trusted-devices/${deviceId}`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
// ── Session State ───────────────────────────────────────
|
|
1065
|
+
getSession() {
|
|
1066
|
+
return { data: this.currentSession, error: null };
|
|
1067
|
+
}
|
|
1068
|
+
onAuthStateChange(callback) {
|
|
1069
|
+
const unsubscribe = this.tokenManager.onAuthStateChange((event, session) => {
|
|
1070
|
+
let authEvent;
|
|
1071
|
+
if (event === "SESSION_SET") {
|
|
1072
|
+
authEvent = this.hasSession ? "TOKEN_REFRESHED" : "SIGNED_IN";
|
|
1073
|
+
this.hasSession = true;
|
|
1074
|
+
} else {
|
|
1075
|
+
authEvent = "SIGNED_OUT";
|
|
1076
|
+
this.hasSession = false;
|
|
1077
|
+
}
|
|
1078
|
+
callback(authEvent, session);
|
|
1079
|
+
});
|
|
1080
|
+
return { data: { subscription: { unsubscribe } } };
|
|
1081
|
+
}
|
|
1082
|
+
// ── Server-only ─────────────────────────────────────────
|
|
1083
|
+
/**
|
|
1084
|
+
* Verify a user's JWT token by calling GET /auth/user with their token.
|
|
1085
|
+
* Server-only — should NOT be exposed in client SDK.
|
|
1086
|
+
*/
|
|
1087
|
+
async verifyUserToken(jwt) {
|
|
1088
|
+
const url = `${this.baseUrl}/auth/user`;
|
|
1089
|
+
let response;
|
|
1090
|
+
try {
|
|
1091
|
+
response = await fetch(url, {
|
|
1092
|
+
method: "GET",
|
|
1093
|
+
headers: {
|
|
1094
|
+
apikey: this.apiKey,
|
|
1095
|
+
Authorization: `Bearer ${jwt}`,
|
|
1096
|
+
"Content-Type": "application/json"
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
throw new PalbaseError(
|
|
1101
|
+
"network_error",
|
|
1102
|
+
error instanceof Error ? error.message : "Network request failed",
|
|
1103
|
+
0
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
const contentType = response.headers.get("Content-Type");
|
|
1107
|
+
if (response.ok && contentType?.includes("application/json")) {
|
|
1108
|
+
const data = await response.json();
|
|
1109
|
+
return { data, error: null, status: response.status };
|
|
1110
|
+
}
|
|
1111
|
+
let errorBody;
|
|
1112
|
+
if (contentType?.includes("application/json")) {
|
|
1113
|
+
errorBody = await response.json();
|
|
1114
|
+
}
|
|
1115
|
+
return {
|
|
1116
|
+
data: null,
|
|
1117
|
+
error: new PalbaseError(
|
|
1118
|
+
errorBody?.error ?? "invalid_token",
|
|
1119
|
+
errorBody?.error_description ?? "Token verification failed",
|
|
1120
|
+
response.status,
|
|
1121
|
+
errorBody
|
|
1122
|
+
),
|
|
1123
|
+
status: response.status
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
// ── Private ─────────────────────────────────────────────
|
|
1127
|
+
setSessionAndWireRefresh(session) {
|
|
1128
|
+
this.tokenManager.setSession(session);
|
|
1129
|
+
this.hasSession = true;
|
|
1130
|
+
this.tokenManager.refreshFunction = async (refreshToken) => {
|
|
1131
|
+
const response = await this.httpClient.request("POST", "/auth/token/refresh", {
|
|
1132
|
+
body: { refresh_token: refreshToken }
|
|
1133
|
+
});
|
|
1134
|
+
if (response.error || !response.data) {
|
|
1135
|
+
throw response.error ?? new Error("Failed to refresh token");
|
|
1136
|
+
}
|
|
1137
|
+
return {
|
|
1138
|
+
accessToken: response.data.access_token,
|
|
1139
|
+
refreshToken: response.data.refresh_token,
|
|
1140
|
+
expiresAt: Date.now() + response.data.expires_in * 1e3
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
// src/analytics-facade.ts
|
|
1147
|
+
var EVENT_NAME_RE = /^[a-zA-Z$][a-zA-Z0-9_.:$-]{0,63}$/;
|
|
1148
|
+
var FLUSH_AT = 20;
|
|
1149
|
+
var FLUSH_INTERVAL_MS = 1e4;
|
|
1150
|
+
var MAX_BATCH_SIZE = 100;
|
|
1151
|
+
var warnedInvalidEventName = false;
|
|
1152
|
+
function warnInvalidEventName(name) {
|
|
1153
|
+
if (warnedInvalidEventName) return;
|
|
1154
|
+
warnedInvalidEventName = true;
|
|
1155
|
+
console.warn(
|
|
1156
|
+
`palbe analytics: dropping event "${name}" \u2014 names must match ^[a-zA-Z$][a-zA-Z0-9_.:$-]{0,63}$ (warned once per process)`
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
function defaultStringStore() {
|
|
1160
|
+
if (typeof document !== "undefined" && typeof localStorage !== "undefined") {
|
|
1161
|
+
return {
|
|
1162
|
+
get: (key) => {
|
|
1163
|
+
try {
|
|
1164
|
+
return localStorage.getItem(key);
|
|
1165
|
+
} catch {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
set: (key, value) => {
|
|
1170
|
+
try {
|
|
1171
|
+
localStorage.setItem(key, value);
|
|
1172
|
+
} catch {
|
|
1173
|
+
}
|
|
1174
|
+
},
|
|
1175
|
+
remove: (key) => {
|
|
1176
|
+
try {
|
|
1177
|
+
localStorage.removeItem(key);
|
|
1178
|
+
} catch {
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
const mem = /* @__PURE__ */ new Map();
|
|
1184
|
+
return {
|
|
1185
|
+
get: (key) => mem.get(key) ?? null,
|
|
1186
|
+
set: (key, value) => {
|
|
1187
|
+
mem.set(key, value);
|
|
1188
|
+
},
|
|
1189
|
+
remove: (key) => {
|
|
1190
|
+
mem.delete(key);
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
var AnalyticsState = class {
|
|
1195
|
+
identifiedUserId = null;
|
|
1196
|
+
/** Set by PalbeAnalytics on construction — delegates auth events to it. */
|
|
1197
|
+
facadeAuthHandler = null;
|
|
1198
|
+
anonId = null;
|
|
1199
|
+
store = defaultStringStore();
|
|
1200
|
+
idKey;
|
|
1201
|
+
optOutKey;
|
|
1202
|
+
constructor(rt) {
|
|
1203
|
+
const ref = endpointRefFromApiKey(rt.config.apiKey);
|
|
1204
|
+
this.idKey = ref ? `palbe.analytics.id.${ref}` : "palbe.analytics.id";
|
|
1205
|
+
this.optOutKey = ref ? `palbe.analytics.optout.${ref}` : "palbe.analytics.optout";
|
|
1206
|
+
rt.auth.onAuthEvent((event) => {
|
|
1207
|
+
if (this.facadeAuthHandler) {
|
|
1208
|
+
this.facadeAuthHandler(event);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (event.type === "signedIn") {
|
|
1212
|
+
if (!this.isOptedOut()) this.identifiedUserId = event.user.id;
|
|
1213
|
+
} else if (event.type === "signedOut" && event.reason === "userInitiated") {
|
|
1214
|
+
this.identifiedUserId = null;
|
|
1215
|
+
this.rotateAnonymousId();
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
/** User id when identified, else the stable anon id. NEVER empty —
|
|
1220
|
+
* this is what `identify()` links, so stamping it on every request lets
|
|
1221
|
+
* the trace pipeline stitch pre-login activity to the user. */
|
|
1222
|
+
distinctId() {
|
|
1223
|
+
return this.identifiedUserId ?? this.anonymousId();
|
|
1224
|
+
}
|
|
1225
|
+
anonymousId() {
|
|
1226
|
+
if (this.anonId) return this.anonId;
|
|
1227
|
+
const stored = this.store.get(this.idKey);
|
|
1228
|
+
if (stored) {
|
|
1229
|
+
this.anonId = stored;
|
|
1230
|
+
return stored;
|
|
1231
|
+
}
|
|
1232
|
+
return this.rotateAnonymousId();
|
|
1233
|
+
}
|
|
1234
|
+
rotateAnonymousId() {
|
|
1235
|
+
const fresh = crypto.randomUUID();
|
|
1236
|
+
this.store.set(this.idKey, fresh);
|
|
1237
|
+
this.anonId = fresh;
|
|
1238
|
+
return fresh;
|
|
1239
|
+
}
|
|
1240
|
+
isOptedOut() {
|
|
1241
|
+
return this.store.get(this.optOutKey) === "true";
|
|
1242
|
+
}
|
|
1243
|
+
setOptOut(on) {
|
|
1244
|
+
if (on) this.store.set(this.optOutKey, "true");
|
|
1245
|
+
else this.store.remove(this.optOutKey);
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
var PalbeAnalytics = class {
|
|
1249
|
+
constructor(rt, state) {
|
|
1250
|
+
this.rt = rt;
|
|
1251
|
+
this.state = state;
|
|
1252
|
+
this.state.facadeAuthHandler = (event) => {
|
|
1253
|
+
this.handleAuthEvent(event);
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
rt;
|
|
1257
|
+
state;
|
|
1258
|
+
buffer = [];
|
|
1259
|
+
flushTimer = null;
|
|
1260
|
+
/** Browser → buffer + size/timer flush. Server (no document) → immediate
|
|
1261
|
+
* per-call POST, zero timers (nothing leaks into RSC/route handlers). */
|
|
1262
|
+
browser = typeof document !== "undefined";
|
|
1263
|
+
/** Record an event. Invalid names are dropped (one warn per process). */
|
|
1264
|
+
capture(event, properties) {
|
|
1265
|
+
if (this.state.isOptedOut()) return;
|
|
1266
|
+
if (!EVENT_NAME_RE.test(event)) {
|
|
1267
|
+
warnInvalidEventName(event);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
this.ingest(event, properties);
|
|
1271
|
+
}
|
|
1272
|
+
/** Record a screen view — server-canonical `$screen` + `screen_name`.
|
|
1273
|
+
* Validates non-empty only (the live wire at /v1/analytics/screen requires
|
|
1274
|
+
* non-empty screen_name; the event-name regex does NOT apply here). */
|
|
1275
|
+
screen(name, properties) {
|
|
1276
|
+
if (this.state.isOptedOut()) return;
|
|
1277
|
+
if (name.trim() === "") {
|
|
1278
|
+
console.warn("palbe analytics: dropping screen() \u2014 name must be non-empty");
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
this.ingest("$screen", { ...properties, screen_name: name });
|
|
1282
|
+
}
|
|
1283
|
+
/** Link the current anon id to `userId` and adopt it as the distinct id.
|
|
1284
|
+
* Immediate POST (not buffered). Re-identifying a DIFFERENT user
|
|
1285
|
+
* auto-resets first (iOS K10) so one anon id never links two users. */
|
|
1286
|
+
identify(userId, traits) {
|
|
1287
|
+
if (this.state.isOptedOut()) return;
|
|
1288
|
+
const existing = this.state.identifiedUserId;
|
|
1289
|
+
if (existing && existing !== userId) {
|
|
1290
|
+
console.warn(
|
|
1291
|
+
`palbe analytics: re-identify changes the user from ${existing} to ${userId} \u2014 auto-resetting (call reset() first if this is intentional)`
|
|
1292
|
+
);
|
|
1293
|
+
this.reset();
|
|
1294
|
+
}
|
|
1295
|
+
const ts = Date.now();
|
|
1296
|
+
const body = {
|
|
1297
|
+
distinct_id: userId,
|
|
1298
|
+
anonymous_id: this.state.anonymousId(),
|
|
1299
|
+
timestamp: ts,
|
|
1300
|
+
sent_at: ts
|
|
1301
|
+
};
|
|
1302
|
+
if (traits) body.traits = traits;
|
|
1303
|
+
this.state.identifiedUserId = userId;
|
|
1304
|
+
void this.post("/v1/analytics/identify", body);
|
|
1305
|
+
}
|
|
1306
|
+
/** Merge distinct id `from` into `to` (identity stitching). Immediate POST. */
|
|
1307
|
+
alias(from, to) {
|
|
1308
|
+
if (this.state.isOptedOut()) return;
|
|
1309
|
+
const ts = Date.now();
|
|
1310
|
+
void this.post("/v1/analytics/alias", { from, to, timestamp: ts, sent_at: ts });
|
|
1311
|
+
}
|
|
1312
|
+
/** Drop the buffer, clear the identified user and rotate the anon id
|
|
1313
|
+
* (sign-out hygiene). Callers wanting pending events delivered flush first
|
|
1314
|
+
* — the auth binding does. */
|
|
1315
|
+
reset() {
|
|
1316
|
+
this.buffer.length = 0;
|
|
1317
|
+
this.cancelTimer();
|
|
1318
|
+
this.state.identifiedUserId = null;
|
|
1319
|
+
this.state.rotateAnonymousId();
|
|
1320
|
+
}
|
|
1321
|
+
/** Persisted GDPR opt-out. Opting out drops anything pending so nothing
|
|
1322
|
+
* already-buffered leaks after the user said stop. */
|
|
1323
|
+
setOptOut(on) {
|
|
1324
|
+
this.state.setOptOut(on);
|
|
1325
|
+
if (on) {
|
|
1326
|
+
this.buffer.length = 0;
|
|
1327
|
+
this.cancelTimer();
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
/** Drain the buffer to /v1/analytics/batch (≤100 per request, sequential).
|
|
1331
|
+
* Resolves when delivery finished; NEVER rejects — failed slices are
|
|
1332
|
+
* dropped with one warn per flush. 207 partial-reject is also warned once
|
|
1333
|
+
* (fire-and-forget: no retry for already-delivered batches). */
|
|
1334
|
+
async flush() {
|
|
1335
|
+
this.cancelTimer();
|
|
1336
|
+
if (this.buffer.length === 0) return;
|
|
1337
|
+
const sentAt = Date.now();
|
|
1338
|
+
let warned = false;
|
|
1339
|
+
while (this.buffer.length > 0) {
|
|
1340
|
+
const slice = this.buffer.splice(0, MAX_BATCH_SIZE);
|
|
1341
|
+
try {
|
|
1342
|
+
const result = await palbeRequest(
|
|
1343
|
+
this.rt,
|
|
1344
|
+
"POST",
|
|
1345
|
+
"/v1/analytics/batch",
|
|
1346
|
+
{ body: { events: slice.map((e) => ({ ...e, sent_at: sentAt })) } }
|
|
1347
|
+
);
|
|
1348
|
+
if (Array.isArray(result?.rejected) && result.rejected.length > 0) {
|
|
1349
|
+
console.warn(
|
|
1350
|
+
`palbe analytics: batch partial-reject \u2014 ${result.rejected.length} event(s) rejected by server`
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
if (!warned) {
|
|
1355
|
+
warned = true;
|
|
1356
|
+
console.warn("palbe analytics: batch flush failed \u2014 events dropped", err);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
// ── internals ──────────────────────────────────────────
|
|
1362
|
+
ingest(event, properties) {
|
|
1363
|
+
const wire = {
|
|
1364
|
+
event,
|
|
1365
|
+
distinct_id: this.state.distinctId(),
|
|
1366
|
+
// stamped at capture time
|
|
1367
|
+
timestamp: Date.now()
|
|
1368
|
+
};
|
|
1369
|
+
if (properties) wire.properties = properties;
|
|
1370
|
+
if (!this.browser) {
|
|
1371
|
+
void this.post("/v1/analytics/capture", { ...wire, sent_at: wire.timestamp });
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
this.buffer.push(wire);
|
|
1375
|
+
if (this.buffer.length >= FLUSH_AT) void this.flush();
|
|
1376
|
+
else this.startTimer();
|
|
1377
|
+
}
|
|
1378
|
+
/** iOS PalBackend auth-binding parity: signedIn → flush + identify;
|
|
1379
|
+
* userInitiated signedOut → flush + reset; sessionExpired → flush only. */
|
|
1380
|
+
handleAuthEvent(event) {
|
|
1381
|
+
if (event.type === "signedIn") {
|
|
1382
|
+
void this.flush().then(() => {
|
|
1383
|
+
this.identify(event.user.id);
|
|
1384
|
+
});
|
|
1385
|
+
} else if (event.type === "signedOut") {
|
|
1386
|
+
if (event.reason === "userInitiated") {
|
|
1387
|
+
void this.flush().then(() => {
|
|
1388
|
+
this.reset();
|
|
1389
|
+
});
|
|
1390
|
+
} else {
|
|
1391
|
+
void this.flush();
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
startTimer() {
|
|
1396
|
+
if (this.flushTimer !== null) return;
|
|
1397
|
+
this.flushTimer = setTimeout(() => {
|
|
1398
|
+
this.flushTimer = null;
|
|
1399
|
+
void this.flush();
|
|
1400
|
+
}, FLUSH_INTERVAL_MS);
|
|
1401
|
+
}
|
|
1402
|
+
cancelTimer() {
|
|
1403
|
+
if (this.flushTimer !== null) {
|
|
1404
|
+
clearTimeout(this.flushTimer);
|
|
1405
|
+
this.flushTimer = null;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
async post(path, body) {
|
|
1409
|
+
try {
|
|
1410
|
+
await palbeRequest(this.rt, "POST", path, { body });
|
|
1411
|
+
} catch (err) {
|
|
1412
|
+
console.warn(`palbe analytics: POST ${path} failed`, err);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
// src/auth-facade.ts
|
|
1418
|
+
function mapWireUser(raw) {
|
|
1419
|
+
return {
|
|
1420
|
+
id: raw.id,
|
|
1421
|
+
email: raw.email ?? null,
|
|
1422
|
+
emailVerified: raw.email_verified,
|
|
1423
|
+
createdAt: raw.created_at
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
function mapClientUser(u) {
|
|
1427
|
+
return {
|
|
1428
|
+
id: u.id,
|
|
1429
|
+
email: u.email || null,
|
|
1430
|
+
emailVerified: u.emailVerified,
|
|
1431
|
+
createdAt: u.createdAt
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
function usersEqual(a, b) {
|
|
1435
|
+
return b !== null && a.id === b.id && a.email === b.email && a.emailVerified === b.emailVerified && a.createdAt === b.createdAt;
|
|
1436
|
+
}
|
|
1437
|
+
var PalbeAuth = class {
|
|
1438
|
+
constructor(rt) {
|
|
1439
|
+
this.rt = rt;
|
|
1440
|
+
rt.authClient.onAuthStateChange((event, _session) => {
|
|
1441
|
+
if (event === "SIGNED_OUT") {
|
|
1442
|
+
if (!this.signedInState) return;
|
|
1443
|
+
this.signedInState = false;
|
|
1444
|
+
this.cachedUser = null;
|
|
1445
|
+
const reason = this.signingOut ? "userInitiated" : "sessionExpired";
|
|
1446
|
+
this.emitState({ status: "signedOut" });
|
|
1447
|
+
this.emitEvent({ type: "signedOut", reason });
|
|
1448
|
+
} else if (event === "TOKEN_REFRESHED") {
|
|
1449
|
+
if (this.signingIn) return;
|
|
1450
|
+
this.emitEvent({ type: "tokenRefreshed" });
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
this.signedInState = rt.tokenManager.getRefreshToken() !== null;
|
|
1454
|
+
}
|
|
1455
|
+
rt;
|
|
1456
|
+
cachedUser = null;
|
|
1457
|
+
signingOut = false;
|
|
1458
|
+
signingIn = false;
|
|
1459
|
+
// suppresses AuthClient's TOKEN_REFRESHED during re-signIn
|
|
1460
|
+
signedInState = false;
|
|
1461
|
+
// dedupes AuthClient's repeated SIGNED_OUT events
|
|
1462
|
+
stateListeners = /* @__PURE__ */ new Set();
|
|
1463
|
+
eventListeners = /* @__PURE__ */ new Set();
|
|
1464
|
+
userListeners = /* @__PURE__ */ new Set();
|
|
1465
|
+
// ── state ──────────────────────────────────────────────
|
|
1466
|
+
get currentUser() {
|
|
1467
|
+
return this.cachedUser;
|
|
1468
|
+
}
|
|
1469
|
+
get isSignedIn() {
|
|
1470
|
+
return this.rt.tokenManager.getRefreshToken() !== null;
|
|
1471
|
+
}
|
|
1472
|
+
// ── core flows ─────────────────────────────────────────
|
|
1473
|
+
async signUp(params) {
|
|
1474
|
+
this.signingIn = true;
|
|
1475
|
+
try {
|
|
1476
|
+
const data = unwrap(await this.rt.authClient.signUp(params));
|
|
1477
|
+
return this.adopt(data);
|
|
1478
|
+
} finally {
|
|
1479
|
+
this.signingIn = false;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
async signIn(params) {
|
|
1483
|
+
this.signingIn = true;
|
|
1484
|
+
try {
|
|
1485
|
+
const data = unwrap(await this.rt.authClient.signIn(params));
|
|
1486
|
+
return this.adopt(data);
|
|
1487
|
+
} finally {
|
|
1488
|
+
this.signingIn = false;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
async signOut() {
|
|
1492
|
+
this.signingOut = true;
|
|
1493
|
+
try {
|
|
1494
|
+
await this.rt.authClient.signOut();
|
|
1495
|
+
} catch {
|
|
1496
|
+
} finally {
|
|
1497
|
+
this.rt.tokenManager.clearSession();
|
|
1498
|
+
this.signingOut = false;
|
|
1499
|
+
this.rt.storage.clear();
|
|
1500
|
+
this.cachedUser = null;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
async getUser() {
|
|
1504
|
+
const raw = await palbeRequest(this.rt, "GET", "/auth/user");
|
|
1505
|
+
return mapWireUser(raw);
|
|
1506
|
+
}
|
|
1507
|
+
async refreshUser() {
|
|
1508
|
+
const user = await this.getUser();
|
|
1509
|
+
const changed = !usersEqual(user, this.cachedUser);
|
|
1510
|
+
this.cachedUser = user;
|
|
1511
|
+
if (changed) for (const cb of this.userListeners) this.safeInvoke(() => cb(user));
|
|
1512
|
+
return user;
|
|
1513
|
+
}
|
|
1514
|
+
// ── listeners ──────────────────────────────────────────
|
|
1515
|
+
/**
|
|
1516
|
+
* Subscribe to signed-in/signed-out state. Fires immediately with the
|
|
1517
|
+
* current snapshot (iOS parity). NOTE: a restored session (page reload) has
|
|
1518
|
+
* no cached user yet, so the immediate snapshot reports signedOut even when
|
|
1519
|
+
* `isSignedIn` is true — call `refreshUser()` on boot to populate the user
|
|
1520
|
+
* and rely on `isSignedIn` for the session truth.
|
|
1521
|
+
*/
|
|
1522
|
+
onAuthStateChange(callback) {
|
|
1523
|
+
this.stateListeners.add(callback);
|
|
1524
|
+
this.safeInvoke(() => callback(this.snapshotState()));
|
|
1525
|
+
return () => this.stateListeners.delete(callback);
|
|
1526
|
+
}
|
|
1527
|
+
onAuthEvent(callback) {
|
|
1528
|
+
this.eventListeners.add(callback);
|
|
1529
|
+
return () => this.eventListeners.delete(callback);
|
|
1530
|
+
}
|
|
1531
|
+
/** Subscribe to user-profile changes. Replays the cached user on subscribe (iOS parity). */
|
|
1532
|
+
onUserChange(callback) {
|
|
1533
|
+
this.userListeners.add(callback);
|
|
1534
|
+
const u = this.cachedUser;
|
|
1535
|
+
if (u) this.safeInvoke(() => callback(u));
|
|
1536
|
+
return () => this.userListeners.delete(callback);
|
|
1537
|
+
}
|
|
1538
|
+
// ── internals ──────────────────────────────────────────
|
|
1539
|
+
snapshotState() {
|
|
1540
|
+
return this.cachedUser && this.isSignedIn ? { status: "signedIn", user: this.cachedUser } : { status: "signedOut" };
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Listener exception isolation: a throwing consumer callback must never
|
|
1544
|
+
* break delivery to other listeners nor propagate into the emit caller
|
|
1545
|
+
* (tokenManager.clearSession callers, pb.call paths, signOut, ...).
|
|
1546
|
+
*/
|
|
1547
|
+
safeInvoke(fn) {
|
|
1548
|
+
try {
|
|
1549
|
+
fn();
|
|
1550
|
+
} catch {
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
emitState(state) {
|
|
1554
|
+
for (const cb of this.stateListeners) this.safeInvoke(() => cb(state));
|
|
1555
|
+
}
|
|
1556
|
+
emitEvent(event) {
|
|
1557
|
+
for (const cb of this.eventListeners) this.safeInvoke(() => cb(event));
|
|
1558
|
+
}
|
|
1559
|
+
/** Cache the user + announce sign-in. Session was already set by AuthClient. */
|
|
1560
|
+
adopt(data) {
|
|
1561
|
+
const user = mapClientUser(data.user);
|
|
1562
|
+
this.cachedUser = user;
|
|
1563
|
+
this.signedInState = true;
|
|
1564
|
+
this.emitState({ status: "signedIn", user });
|
|
1565
|
+
this.emitEvent({ type: "signedIn", user });
|
|
1566
|
+
return { user, session: data.session };
|
|
1567
|
+
}
|
|
1568
|
+
/** Adopt a raw palauth AuthResult wire shape: wire tokens, cache user, announce. */
|
|
1569
|
+
adoptWire(raw) {
|
|
1570
|
+
this.rt.authClient.setTokens(raw.access_token, raw.refresh_token, raw.expires_in);
|
|
1571
|
+
const user = mapWireUser(raw.user);
|
|
1572
|
+
this.cachedUser = user;
|
|
1573
|
+
this.signedInState = true;
|
|
1574
|
+
this.emitState({ status: "signedIn", user });
|
|
1575
|
+
this.emitEvent({ type: "signedIn", user });
|
|
1576
|
+
const session = {
|
|
1577
|
+
accessToken: raw.access_token,
|
|
1578
|
+
refreshToken: raw.refresh_token,
|
|
1579
|
+
expiresAt: Date.now() + raw.expires_in * 1e3
|
|
1580
|
+
};
|
|
1581
|
+
return { user, session };
|
|
1582
|
+
}
|
|
1583
|
+
/** Run a sign-in flow under the signingIn flag to suppress spurious tokenRefreshed. */
|
|
1584
|
+
async withSigningIn(fn) {
|
|
1585
|
+
this.signingIn = true;
|
|
1586
|
+
try {
|
|
1587
|
+
return await fn();
|
|
1588
|
+
} finally {
|
|
1589
|
+
this.signingIn = false;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Defensively decode a 200 union body (AuthResult | mfa-required) and
|
|
1594
|
+
* complete the flow. The wire contract promises one of the two shapes, but
|
|
1595
|
+
* a literal-`null` (or otherwise malformed) JSON body must surface as
|
|
1596
|
+
* BackendError('decode') — never a raw TypeError from probing a non-object.
|
|
1597
|
+
* The mfa branch validates its fields: `mfa_token` is required; the factor
|
|
1598
|
+
* list (named `factors` by magic-link verify, `mfa_factors` by the OAuth
|
|
1599
|
+
* callback — extraction-verified against palauth) tolerates a missing or
|
|
1600
|
+
* malformed value as []. The signedIn branch validates the FULL AuthResult
|
|
1601
|
+
* shape (asWireAuthResult) — an incomplete body falls through to decode,
|
|
1602
|
+
* never half-adopts.
|
|
1603
|
+
*/
|
|
1604
|
+
completeAuthUnion(raw, factorsKey, context) {
|
|
1605
|
+
if (typeof raw === "object" && raw !== null) {
|
|
1606
|
+
const obj = raw;
|
|
1607
|
+
if (obj.mfa_required === true) {
|
|
1608
|
+
if (typeof obj.mfa_token === "string") {
|
|
1609
|
+
const list = obj[factorsKey];
|
|
1610
|
+
const factors = Array.isArray(list) ? list.filter((f) => typeof f === "string") : [];
|
|
1611
|
+
return { status: "mfaRequired", mfaToken: obj.mfa_token, factors };
|
|
1612
|
+
}
|
|
1613
|
+
} else {
|
|
1614
|
+
const wire = asWireAuthResult(raw);
|
|
1615
|
+
if (wire) return { status: "signedIn", ...this.adoptWire(wire) };
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
throw this.decodeError(context);
|
|
1619
|
+
}
|
|
1620
|
+
decodeError(context) {
|
|
1621
|
+
return new BackendError("decode", {
|
|
1622
|
+
code: "decode_error",
|
|
1623
|
+
message: `Unexpected ${context} response`
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
// ── additional auth flows ──────────────────────────────
|
|
1627
|
+
async signInWithOTP(params) {
|
|
1628
|
+
await palbeRequest(this.rt, "POST", "/auth/otp", {
|
|
1629
|
+
body: { phone: params.phone, channel: "sms" }
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
async verifyOTP(params) {
|
|
1633
|
+
return this.withSigningIn(async () => {
|
|
1634
|
+
const raw = await palbeRequest(this.rt, "POST", "/auth/otp/verify", {
|
|
1635
|
+
body: params
|
|
1636
|
+
});
|
|
1637
|
+
const wire = asWireAuthResult(raw);
|
|
1638
|
+
if (!wire) throw this.decodeError("OTP verify");
|
|
1639
|
+
return this.adoptWire(wire);
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
async resetPassword(email) {
|
|
1643
|
+
unwrap(await this.rt.authClient.requestPasswordReset({ email }));
|
|
1644
|
+
}
|
|
1645
|
+
async confirmPasswordReset(params) {
|
|
1646
|
+
unwrap(
|
|
1647
|
+
await this.rt.authClient.confirmPasswordReset({
|
|
1648
|
+
token: params.token,
|
|
1649
|
+
new_password: params.newPassword
|
|
1650
|
+
})
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
async updatePassword(params) {
|
|
1654
|
+
unwrap(
|
|
1655
|
+
await this.rt.authClient.changePassword({
|
|
1656
|
+
current_password: params.currentPassword,
|
|
1657
|
+
new_password: params.newPassword
|
|
1658
|
+
})
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
async verifyEmail(params) {
|
|
1662
|
+
unwrap(await this.rt.authClient.verifyEmail(params));
|
|
1663
|
+
}
|
|
1664
|
+
async resendVerification(email) {
|
|
1665
|
+
unwrap(await this.rt.authClient.resendVerification(email));
|
|
1666
|
+
}
|
|
1667
|
+
async signInWithMagicLink(email) {
|
|
1668
|
+
unwrap(await this.rt.authClient.requestMagicLink({ email }));
|
|
1669
|
+
}
|
|
1670
|
+
async verifyMagicLink(token) {
|
|
1671
|
+
return this.withSigningIn(async () => {
|
|
1672
|
+
const raw = await palbeRequest(this.rt, "POST", "/auth/magic-link/verify", {
|
|
1673
|
+
body: { token }
|
|
1674
|
+
});
|
|
1675
|
+
return this.completeAuthUnion(raw, "factors", "magic-link verify");
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
async signInWithCredential(params) {
|
|
1679
|
+
return this.withSigningIn(async () => {
|
|
1680
|
+
const data = unwrap(await this.rt.authClient.signInWithCredential(params));
|
|
1681
|
+
return this.adopt(data);
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
async signInWithOAuth(params) {
|
|
1685
|
+
const { redirect = true, ...opts } = params;
|
|
1686
|
+
const { url } = unwrap(await this.rt.authClient.getOAuthURL(opts));
|
|
1687
|
+
if (redirect && typeof window !== "undefined" && typeof window.location !== "undefined") {
|
|
1688
|
+
window.location.assign(url);
|
|
1689
|
+
}
|
|
1690
|
+
return { url };
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Complete the OAuth redirect flow: trade the provider's `code` + `state`
|
|
1694
|
+
* (from the app's redirect_uri query) for a session. PKCE verifier + state
|
|
1695
|
+
* live server-side in palauth — the client only relays the two values.
|
|
1696
|
+
*/
|
|
1697
|
+
async exchangeCodeForSession(params) {
|
|
1698
|
+
return this.withSigningIn(async () => {
|
|
1699
|
+
const path = `/auth/oauth/${encodeURIComponent(params.provider)}/callback?code=${encodeURIComponent(params.code)}&state=${encodeURIComponent(params.state)}`;
|
|
1700
|
+
const raw = await palbeRequest(this.rt, "GET", path);
|
|
1701
|
+
return this.completeAuthUnion(raw, "mfa_factors", "OAuth code-exchange");
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
/** Config-as-code gate for the zero-arg provider sugar. */
|
|
1705
|
+
requireProvider(name, label) {
|
|
1706
|
+
if (!this.rt.config.oauth?.[name]?.enabled) {
|
|
1707
|
+
throw new BackendError("validation", {
|
|
1708
|
+
code: `${name}_not_configured`,
|
|
1709
|
+
message: `${label} sign-in is not configured: enable the ${label} provider for this project, then regenerate palbe.gen.ts.`
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
async signInWithGoogle(opts) {
|
|
1714
|
+
this.requireProvider("google", "Google");
|
|
1715
|
+
return this.signInWithOAuth({ provider: "google", ...opts });
|
|
1716
|
+
}
|
|
1717
|
+
async signInWithApple(opts) {
|
|
1718
|
+
this.requireProvider("apple", "Apple");
|
|
1719
|
+
return this.signInWithOAuth({ provider: "apple", ...opts });
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1723
|
+
// ../modules/flags/dist/index.js
|
|
1724
|
+
var FLAG_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
1725
|
+
var FlagsClient = class {
|
|
1726
|
+
httpClient;
|
|
1727
|
+
getCurrentUserId;
|
|
1728
|
+
/**
|
|
1729
|
+
* @param httpClient transport.
|
|
1730
|
+
* @param getCurrentUserId optional getter resolving the signed-in user's id
|
|
1731
|
+
* for {@link setOverride}. When omitted (or it returns no id), `setOverride`
|
|
1732
|
+
* errors and the caller must use {@link asService} for cross-user writes.
|
|
1733
|
+
*/
|
|
1734
|
+
constructor(httpClient, getCurrentUserId) {
|
|
1735
|
+
this.httpClient = httpClient;
|
|
1736
|
+
this.getCurrentUserId = getCurrentUserId;
|
|
1737
|
+
}
|
|
1738
|
+
async isEnabled(flagName, context) {
|
|
1739
|
+
if (!FLAG_NAME_RE.test(flagName)) {
|
|
1740
|
+
throw new Error(
|
|
1741
|
+
`Invalid flag name: "${flagName}". Flag names must match ${FLAG_NAME_RE.source}`
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
const params = this.buildContextParams(context);
|
|
1745
|
+
const query = params.toString();
|
|
1746
|
+
const path = `/v1/flags/${flagName}/enabled${query ? `?${query}` : ""}`;
|
|
1747
|
+
return this.httpClient.request("GET", path);
|
|
1748
|
+
}
|
|
1749
|
+
async getVariant(flagName, context) {
|
|
1750
|
+
if (!FLAG_NAME_RE.test(flagName)) {
|
|
1751
|
+
throw new Error(
|
|
1752
|
+
`Invalid flag name: "${flagName}". Flag names must match ${FLAG_NAME_RE.source}`
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
const params = this.buildContextParams(context);
|
|
1756
|
+
const query = params.toString();
|
|
1757
|
+
const path = `/v1/flags/${flagName}/variant${query ? `?${query}` : ""}`;
|
|
1758
|
+
return this.httpClient.request("GET", path);
|
|
1759
|
+
}
|
|
1760
|
+
async getAll(context) {
|
|
1761
|
+
const params = this.buildContextParams(context);
|
|
1762
|
+
const query = params.toString();
|
|
1763
|
+
const path = `/v1/flags${query ? `?${query}` : ""}`;
|
|
1764
|
+
return this.httpClient.request("GET", path);
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Cold-start read: the full merged flag set for the current auth identity.
|
|
1768
|
+
* With a Bearer token the backend returns the user-merged view; without one
|
|
1769
|
+
* it returns project (system) defaults.
|
|
1770
|
+
*/
|
|
1771
|
+
snapshot() {
|
|
1772
|
+
return this.httpClient.request("GET", "/v1/user-flags/snapshot");
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Incremental read of version-ordered ops since `sinceVersion`.
|
|
1776
|
+
*
|
|
1777
|
+
* Sends `If-None-Match: <sinceVersion>` so an unchanged server can answer
|
|
1778
|
+
* `304 Not Modified` cheaply. The 304 surfaces as a `PalbaseResponse` with
|
|
1779
|
+
* `status === 304` (the core http client maps non-2xx to an error/empty body);
|
|
1780
|
+
* callers treat that as "no change".
|
|
1781
|
+
*/
|
|
1782
|
+
delta(sinceVersion) {
|
|
1783
|
+
return this.httpClient.request(
|
|
1784
|
+
"GET",
|
|
1785
|
+
`/v1/user-flags/delta?since=${encodeURIComponent(sinceVersion)}`,
|
|
1786
|
+
{ headers: { "If-None-Match": sinceVersion } }
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Set (or replace) a single feature-flag override for the CURRENT USER.
|
|
1791
|
+
*
|
|
1792
|
+
* Resolves the signed-in user from the `getCurrentUserId` getter supplied at
|
|
1793
|
+
* construction and issues `PUT /v1/user-flags/users/{uid}/{key}` with body
|
|
1794
|
+
* `{ value }`. No userId argument — the override is bound to the current
|
|
1795
|
+
* user, so no admin power is required.
|
|
1796
|
+
*
|
|
1797
|
+
* Errors when there is no signed-in user (anonymous, or no getter supplied);
|
|
1798
|
+
* use {@link asService}`.setOverrideForUser(userId, key, value)` to write a
|
|
1799
|
+
* flag override for an arbitrary (cross-user) target.
|
|
1800
|
+
*/
|
|
1801
|
+
setOverride(key, value) {
|
|
1802
|
+
const userId = this.getCurrentUserId?.();
|
|
1803
|
+
if (!userId) {
|
|
1804
|
+
throw new Error(
|
|
1805
|
+
"setOverride requires a signed-in user; use FlagsClient.asService().setOverrideForUser(userId, key, value) for cross-user writes"
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
return this.httpClient.request(
|
|
1809
|
+
"PUT",
|
|
1810
|
+
`/v1/user-flags/users/${encodeURIComponent(userId)}/${encodeURIComponent(key)}`,
|
|
1811
|
+
{ body: { value } }
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Return the cross-user admin write surface ({@link FlagsServiceClient}).
|
|
1816
|
+
*
|
|
1817
|
+
* Use sparingly and explicitly — the default {@link setOverride} path is
|
|
1818
|
+
* bound to the current user; `asService()` is how you write a flag override
|
|
1819
|
+
* for an ARBITRARY user. Mirrors `Database.asService()`. The returned client
|
|
1820
|
+
* shares this client's transport (the privileged service_role key).
|
|
1821
|
+
*/
|
|
1822
|
+
asService() {
|
|
1823
|
+
const http = this.httpClient;
|
|
1824
|
+
return {
|
|
1825
|
+
setOverrideForUser(userId, key, value) {
|
|
1826
|
+
return http.request(
|
|
1827
|
+
"PUT",
|
|
1828
|
+
`/v1/user-flags/users/${encodeURIComponent(userId)}/${encodeURIComponent(key)}`,
|
|
1829
|
+
{ body: { value } }
|
|
1830
|
+
);
|
|
1831
|
+
},
|
|
1832
|
+
setOverridesForUser(userId, values) {
|
|
1833
|
+
return http.request(
|
|
1834
|
+
"PUT",
|
|
1835
|
+
`/v1/user-flags/users/${encodeURIComponent(userId)}`,
|
|
1836
|
+
{ body: { values } }
|
|
1837
|
+
);
|
|
1838
|
+
},
|
|
1839
|
+
clearOverrideForUser(userId, key) {
|
|
1840
|
+
return http.request(
|
|
1841
|
+
"DELETE",
|
|
1842
|
+
`/v1/user-flags/users/${encodeURIComponent(userId)}/${encodeURIComponent(key)}`
|
|
1843
|
+
);
|
|
1844
|
+
},
|
|
1845
|
+
clearAllOverridesForUser(userId) {
|
|
1846
|
+
return http.request(
|
|
1847
|
+
"DELETE",
|
|
1848
|
+
`/v1/user-flags/users/${encodeURIComponent(userId)}`
|
|
1849
|
+
);
|
|
1850
|
+
},
|
|
1851
|
+
batchSetOverrides(operations) {
|
|
1852
|
+
return http.request("POST", "/v1/user-flags/batch", {
|
|
1853
|
+
body: {
|
|
1854
|
+
operations: operations.map((op) => ({
|
|
1855
|
+
user_id: op.userId,
|
|
1856
|
+
values: op.values
|
|
1857
|
+
}))
|
|
1858
|
+
}
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
buildContextParams(context) {
|
|
1864
|
+
const params = new URLSearchParams();
|
|
1865
|
+
if (context?.userId) {
|
|
1866
|
+
params.set("userId", context.userId);
|
|
1867
|
+
}
|
|
1868
|
+
if (context?.properties) {
|
|
1869
|
+
params.set("properties", JSON.stringify(context.properties));
|
|
1870
|
+
}
|
|
1871
|
+
return params;
|
|
1872
|
+
}
|
|
1873
|
+
};
|
|
1874
|
+
var DEFAULT_POLL_MS = 3e4;
|
|
1875
|
+
var DEFAULT_STORAGE_KEY = "palbase.flags.snapshot";
|
|
1876
|
+
var EMPTY_STATE = {
|
|
1877
|
+
values: {},
|
|
1878
|
+
sources: {},
|
|
1879
|
+
syncVersion: null
|
|
1880
|
+
};
|
|
1881
|
+
var FlagsPool = class {
|
|
1882
|
+
transport;
|
|
1883
|
+
pollIntervalMs;
|
|
1884
|
+
storageKey;
|
|
1885
|
+
persist;
|
|
1886
|
+
env;
|
|
1887
|
+
state = EMPTY_STATE;
|
|
1888
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1889
|
+
timer = null;
|
|
1890
|
+
polling = false;
|
|
1891
|
+
started = false;
|
|
1892
|
+
destroyed = false;
|
|
1893
|
+
readyPromise;
|
|
1894
|
+
resolveReady = null;
|
|
1895
|
+
firstLoadDone = false;
|
|
1896
|
+
authUnsub = null;
|
|
1897
|
+
visibilityUnsub = null;
|
|
1898
|
+
lastAuthHasSession = null;
|
|
1899
|
+
/** Cached frozen view for `getSnapshot` identity stability (React). */
|
|
1900
|
+
frozenView = Object.freeze({});
|
|
1901
|
+
viewDirty = true;
|
|
1902
|
+
constructor(transport, options = {}) {
|
|
1903
|
+
this.transport = transport;
|
|
1904
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_MS;
|
|
1905
|
+
this.storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
1906
|
+
this.persist = options.persist ?? true;
|
|
1907
|
+
this.env = options.env ?? defaultEnv();
|
|
1908
|
+
this.readyPromise = new Promise((resolve) => {
|
|
1909
|
+
this.resolveReady = resolve;
|
|
1910
|
+
});
|
|
1911
|
+
this.hydrateFromStorage();
|
|
1912
|
+
if (options.auth) {
|
|
1913
|
+
this.bindAuth(options.auth);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
// ---- public surface -----------------------------------------------------
|
|
1917
|
+
/** Start cold-start fetch + polling + visibility binding. Idempotent. */
|
|
1918
|
+
start() {
|
|
1919
|
+
if (this.started || this.destroyed) {
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
this.started = true;
|
|
1923
|
+
void this.refresh();
|
|
1924
|
+
this.startPolling();
|
|
1925
|
+
this.bindVisibility();
|
|
1926
|
+
}
|
|
1927
|
+
/** Resolves once the first snapshot (or persisted hydrate) is available. */
|
|
1928
|
+
ready() {
|
|
1929
|
+
if (this.firstLoadDone) {
|
|
1930
|
+
return Promise.resolve();
|
|
1931
|
+
}
|
|
1932
|
+
if (!this.started) {
|
|
1933
|
+
this.start();
|
|
1934
|
+
}
|
|
1935
|
+
return this.readyPromise;
|
|
1936
|
+
}
|
|
1937
|
+
/** Returns the cached value for `key`, or `fallback` when absent. */
|
|
1938
|
+
get(key, fallback) {
|
|
1939
|
+
const value = this.state.values[key];
|
|
1940
|
+
return value === void 0 ? fallback : value;
|
|
1941
|
+
}
|
|
1942
|
+
/** Boolean convenience: cached value strictly equals `true`, else `fallback`. */
|
|
1943
|
+
isEnabled(key, fallback = false) {
|
|
1944
|
+
const value = this.state.values[key];
|
|
1945
|
+
if (value === void 0) {
|
|
1946
|
+
return fallback;
|
|
1947
|
+
}
|
|
1948
|
+
return value === true;
|
|
1949
|
+
}
|
|
1950
|
+
/** Returns the cached value's source (`system`/`user`) when known. */
|
|
1951
|
+
getSource(key) {
|
|
1952
|
+
return this.state.sources[key];
|
|
1953
|
+
}
|
|
1954
|
+
/** Returns a frozen snapshot of all cached values (stable identity). */
|
|
1955
|
+
all() {
|
|
1956
|
+
if (this.viewDirty) {
|
|
1957
|
+
this.frozenView = Object.freeze({ ...this.state.values });
|
|
1958
|
+
this.viewDirty = false;
|
|
1959
|
+
}
|
|
1960
|
+
return this.frozenView;
|
|
1961
|
+
}
|
|
1962
|
+
/** Current opaque sync version (null before first load). */
|
|
1963
|
+
get syncVersion() {
|
|
1964
|
+
return this.state.syncVersion;
|
|
1965
|
+
}
|
|
1966
|
+
/** Subscribe to any change in the cached set. Returns unsubscribe. */
|
|
1967
|
+
onChange(listener) {
|
|
1968
|
+
this.listeners.add(listener);
|
|
1969
|
+
return () => {
|
|
1970
|
+
this.listeners.delete(listener);
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* `useSyncExternalStore`-compatible subscribe. Auto-starts the pool on first
|
|
1975
|
+
* subscription so a React component mount triggers cold-start.
|
|
1976
|
+
*/
|
|
1977
|
+
subscribe = (listener) => {
|
|
1978
|
+
if (!this.started) {
|
|
1979
|
+
this.start();
|
|
1980
|
+
}
|
|
1981
|
+
return this.onChange(listener);
|
|
1982
|
+
};
|
|
1983
|
+
/** Force an immediate re-snapshot (used on auth change / manual refresh). */
|
|
1984
|
+
async refresh() {
|
|
1985
|
+
if (this.destroyed) {
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
const res = await this.transport.snapshot();
|
|
1989
|
+
if (res.error || res.data == null) {
|
|
1990
|
+
this.markFirstLoadDone();
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
this.applySnapshot(res.data);
|
|
1994
|
+
this.markFirstLoadDone();
|
|
1995
|
+
}
|
|
1996
|
+
/** Stop timers and detach all auth/visibility listeners. */
|
|
1997
|
+
destroy() {
|
|
1998
|
+
this.destroyed = true;
|
|
1999
|
+
this.stopPolling();
|
|
2000
|
+
this.authUnsub?.();
|
|
2001
|
+
this.authUnsub = null;
|
|
2002
|
+
this.visibilityUnsub?.();
|
|
2003
|
+
this.visibilityUnsub = null;
|
|
2004
|
+
this.listeners.clear();
|
|
2005
|
+
}
|
|
2006
|
+
// ---- internals ----------------------------------------------------------
|
|
2007
|
+
async poll() {
|
|
2008
|
+
if (this.polling || this.destroyed) {
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
const since = this.state.syncVersion;
|
|
2012
|
+
if (since == null) {
|
|
2013
|
+
await this.refresh();
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
this.polling = true;
|
|
2017
|
+
try {
|
|
2018
|
+
const res = await this.transport.delta(since);
|
|
2019
|
+
if (res.status === 304) {
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (res.error || res.data == null) {
|
|
2023
|
+
if (res.status === 409 || res.status === 410) {
|
|
2024
|
+
await this.refresh();
|
|
2025
|
+
}
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
this.applyDelta(res.data.ops, res.data.sync_version);
|
|
2029
|
+
} finally {
|
|
2030
|
+
this.polling = false;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
applySnapshot(snapshot) {
|
|
2034
|
+
this.state = {
|
|
2035
|
+
values: { ...snapshot.values },
|
|
2036
|
+
sources: { ...snapshot.sources ?? {} },
|
|
2037
|
+
syncVersion: snapshot.sync_version
|
|
2038
|
+
};
|
|
2039
|
+
this.viewDirty = true;
|
|
2040
|
+
this.persistState();
|
|
2041
|
+
this.notify();
|
|
2042
|
+
}
|
|
2043
|
+
applyDelta(ops, nextVersion) {
|
|
2044
|
+
const values = { ...this.state.values };
|
|
2045
|
+
const sources = { ...this.state.sources };
|
|
2046
|
+
let changed = false;
|
|
2047
|
+
for (const raw of ops) {
|
|
2048
|
+
if (!isDeltaOp(raw)) {
|
|
2049
|
+
continue;
|
|
2050
|
+
}
|
|
2051
|
+
if (raw.op === "delete") {
|
|
2052
|
+
if (raw.key in values) {
|
|
2053
|
+
delete values[raw.key];
|
|
2054
|
+
delete sources[raw.key];
|
|
2055
|
+
changed = true;
|
|
2056
|
+
}
|
|
2057
|
+
} else {
|
|
2058
|
+
values[raw.key] = raw.value;
|
|
2059
|
+
if (raw.source) {
|
|
2060
|
+
sources[raw.key] = raw.source;
|
|
2061
|
+
}
|
|
2062
|
+
changed = true;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
this.state = { values, sources, syncVersion: nextVersion };
|
|
2066
|
+
if (changed) {
|
|
2067
|
+
this.viewDirty = true;
|
|
2068
|
+
this.persistState();
|
|
2069
|
+
this.notify();
|
|
2070
|
+
} else {
|
|
2071
|
+
this.persistState();
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
startPolling() {
|
|
2075
|
+
if (this.pollIntervalMs <= 0 || this.timer != null) {
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
this.timer = this.env.setInterval(() => {
|
|
2079
|
+
void this.poll();
|
|
2080
|
+
}, this.pollIntervalMs);
|
|
2081
|
+
}
|
|
2082
|
+
stopPolling() {
|
|
2083
|
+
if (this.timer != null) {
|
|
2084
|
+
this.env.clearInterval(this.timer);
|
|
2085
|
+
this.timer = null;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
bindVisibility() {
|
|
2089
|
+
const vis = this.env.visibility;
|
|
2090
|
+
if (!vis) {
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
this.visibilityUnsub = vis.onVisibilityChange(() => {
|
|
2094
|
+
if (vis.isHidden()) {
|
|
2095
|
+
this.stopPolling();
|
|
2096
|
+
} else {
|
|
2097
|
+
void this.poll();
|
|
2098
|
+
this.startPolling();
|
|
2099
|
+
}
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
bindAuth(auth) {
|
|
2103
|
+
const handle = auth.onAuthStateChange((_event, session) => {
|
|
2104
|
+
const hasSession = session != null;
|
|
2105
|
+
if (this.lastAuthHasSession === null) {
|
|
2106
|
+
this.lastAuthHasSession = hasSession;
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
if (hasSession !== this.lastAuthHasSession) {
|
|
2110
|
+
this.lastAuthHasSession = hasSession;
|
|
2111
|
+
if (this.started) {
|
|
2112
|
+
void this.refresh();
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
});
|
|
2116
|
+
this.authUnsub = () => handle.data.subscription.unsubscribe();
|
|
2117
|
+
}
|
|
2118
|
+
notify() {
|
|
2119
|
+
for (const listener of this.listeners) {
|
|
2120
|
+
listener();
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
markFirstLoadDone() {
|
|
2124
|
+
if (!this.firstLoadDone) {
|
|
2125
|
+
this.firstLoadDone = true;
|
|
2126
|
+
this.resolveReady?.();
|
|
2127
|
+
this.resolveReady = null;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
hydrateFromStorage() {
|
|
2131
|
+
if (!this.persist) {
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
const store = this.env.storage;
|
|
2135
|
+
if (!store) {
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
const raw = store.getItem(this.storageKey);
|
|
2139
|
+
if (!raw) {
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
const parsed = parsePersisted(raw);
|
|
2143
|
+
if (!parsed) {
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
this.state = parsed;
|
|
2147
|
+
this.viewDirty = true;
|
|
2148
|
+
this.markFirstLoadDone();
|
|
2149
|
+
}
|
|
2150
|
+
persistState() {
|
|
2151
|
+
if (!this.persist) {
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
const store = this.env.storage;
|
|
2155
|
+
if (!store || this.state.syncVersion == null) {
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
try {
|
|
2159
|
+
store.setItem(this.storageKey, JSON.stringify(this.state));
|
|
2160
|
+
} catch {
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
};
|
|
2164
|
+
function isDeltaOp(value) {
|
|
2165
|
+
if (typeof value !== "object" || value === null) {
|
|
2166
|
+
return false;
|
|
2167
|
+
}
|
|
2168
|
+
const rec = value;
|
|
2169
|
+
if (typeof rec.key !== "string") {
|
|
2170
|
+
return false;
|
|
2171
|
+
}
|
|
2172
|
+
if (rec.op === "delete") {
|
|
2173
|
+
return true;
|
|
2174
|
+
}
|
|
2175
|
+
return rec.op === "put" && "value" in rec;
|
|
2176
|
+
}
|
|
2177
|
+
function parsePersisted(raw) {
|
|
2178
|
+
let parsed;
|
|
2179
|
+
try {
|
|
2180
|
+
parsed = JSON.parse(raw);
|
|
2181
|
+
} catch {
|
|
2182
|
+
return null;
|
|
2183
|
+
}
|
|
2184
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
2185
|
+
return null;
|
|
2186
|
+
}
|
|
2187
|
+
const rec = parsed;
|
|
2188
|
+
if (typeof rec.syncVersion !== "string") {
|
|
2189
|
+
return null;
|
|
2190
|
+
}
|
|
2191
|
+
if (typeof rec.values !== "object" || rec.values === null) {
|
|
2192
|
+
return null;
|
|
2193
|
+
}
|
|
2194
|
+
const sources = typeof rec.sources === "object" && rec.sources !== null ? rec.sources : {};
|
|
2195
|
+
return {
|
|
2196
|
+
values: rec.values,
|
|
2197
|
+
sources,
|
|
2198
|
+
syncVersion: rec.syncVersion
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
function defaultEnv() {
|
|
2202
|
+
const storage = resolveStorage();
|
|
2203
|
+
const visibility = resolveVisibility();
|
|
2204
|
+
return {
|
|
2205
|
+
now: () => Date.now(),
|
|
2206
|
+
setInterval: (handler, ms) => setInterval(handler, ms),
|
|
2207
|
+
clearInterval: (handle) => clearInterval(handle),
|
|
2208
|
+
storage,
|
|
2209
|
+
visibility
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
function resolveStorage() {
|
|
2213
|
+
try {
|
|
2214
|
+
if (typeof localStorage === "undefined") {
|
|
2215
|
+
return null;
|
|
2216
|
+
}
|
|
2217
|
+
const probe = "__palbase_flags_probe__";
|
|
2218
|
+
localStorage.setItem(probe, "1");
|
|
2219
|
+
localStorage.removeItem(probe);
|
|
2220
|
+
return localStorage;
|
|
2221
|
+
} catch {
|
|
2222
|
+
return null;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
function resolveVisibility() {
|
|
2226
|
+
if (typeof document === "undefined") {
|
|
2227
|
+
return null;
|
|
2228
|
+
}
|
|
2229
|
+
const doc = document;
|
|
2230
|
+
return {
|
|
2231
|
+
isHidden: () => doc.visibilityState === "hidden",
|
|
2232
|
+
onVisibilityChange: (cb) => {
|
|
2233
|
+
doc.addEventListener("visibilitychange", cb);
|
|
2234
|
+
return () => doc.removeEventListener("visibilitychange", cb);
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
// src/flags-facade.ts
|
|
2240
|
+
function palbeAuthAdapter(auth) {
|
|
2241
|
+
return {
|
|
2242
|
+
onAuthStateChange(callback) {
|
|
2243
|
+
const unsubscribe = auth.onAuthStateChange(() => {
|
|
2244
|
+
const signedIn = auth.isSignedIn;
|
|
2245
|
+
callback(signedIn ? "SIGNED_IN" : "SIGNED_OUT", signedIn ? { signedIn } : null);
|
|
2246
|
+
});
|
|
2247
|
+
return { data: { subscription: { unsubscribe } } };
|
|
2248
|
+
}
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
function serverPoolEnv() {
|
|
2252
|
+
return {
|
|
2253
|
+
now: () => Date.now(),
|
|
2254
|
+
setInterval: () => {
|
|
2255
|
+
throw new Error("palbe flags: polling is disabled server-side");
|
|
2256
|
+
},
|
|
2257
|
+
clearInterval: () => {
|
|
2258
|
+
},
|
|
2259
|
+
storage: null,
|
|
2260
|
+
visibility: null
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
function sameFlagValue(a, b) {
|
|
2264
|
+
if (Object.is(a, b)) return true;
|
|
2265
|
+
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
|
2266
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
2267
|
+
}
|
|
2268
|
+
return false;
|
|
2269
|
+
}
|
|
2270
|
+
var PalbeFlags = class {
|
|
2271
|
+
transport;
|
|
2272
|
+
pool;
|
|
2273
|
+
constructor(rt) {
|
|
2274
|
+
this.transport = new FlagsClient(rt.http);
|
|
2275
|
+
const browser = typeof document !== "undefined";
|
|
2276
|
+
const ref = endpointRefFromApiKey(rt.config.apiKey);
|
|
2277
|
+
const options = {
|
|
2278
|
+
auth: palbeAuthAdapter(rt.auth),
|
|
2279
|
+
...ref ? { storageKey: `palbe.flags.${ref}` } : {},
|
|
2280
|
+
...browser ? {} : { pollIntervalMs: 0, env: serverPoolEnv() }
|
|
2281
|
+
};
|
|
2282
|
+
this.pool = new FlagsPool(this.transport, options);
|
|
2283
|
+
if (browser) this.pool.start();
|
|
2284
|
+
}
|
|
2285
|
+
/** Resolves once the first snapshot (or persisted hydrate) is available. Auto-starts the pool. */
|
|
2286
|
+
ready() {
|
|
2287
|
+
return this.pool.ready();
|
|
2288
|
+
}
|
|
2289
|
+
/** Force an immediate re-snapshot. */
|
|
2290
|
+
refresh() {
|
|
2291
|
+
return this.pool.refresh();
|
|
2292
|
+
}
|
|
2293
|
+
/** Frozen snapshot of all cached values (identity-stable until a change). */
|
|
2294
|
+
all() {
|
|
2295
|
+
return this.pool.all();
|
|
2296
|
+
}
|
|
2297
|
+
/** Raw cached value for `key`, or `undefined` when not in the cache. */
|
|
2298
|
+
get(key) {
|
|
2299
|
+
return this.pool.all()[key];
|
|
2300
|
+
}
|
|
2301
|
+
/** `true` only when the cached value is strictly `true`; `fallback` when the key is absent. */
|
|
2302
|
+
isEnabled(key, fallback = false) {
|
|
2303
|
+
return this.pool.isEnabled(key, fallback);
|
|
2304
|
+
}
|
|
2305
|
+
/** Alias of {@link isEnabled} (iOS parity). */
|
|
2306
|
+
bool(key, fallback = false) {
|
|
2307
|
+
return this.isEnabled(key, fallback);
|
|
2308
|
+
}
|
|
2309
|
+
/** Cached value when it is a string, else `fallback`. */
|
|
2310
|
+
getString(key, fallback) {
|
|
2311
|
+
const value = this.pool.all()[key];
|
|
2312
|
+
return typeof value === "string" ? value : fallback;
|
|
2313
|
+
}
|
|
2314
|
+
/** Cached value when it is an integer number, else `fallback`. */
|
|
2315
|
+
getInt(key, fallback) {
|
|
2316
|
+
const value = this.pool.all()[key];
|
|
2317
|
+
return typeof value === "number" && Number.isInteger(value) ? value : fallback;
|
|
2318
|
+
}
|
|
2319
|
+
/** Cached value when it is a number (integers included), else `fallback`. */
|
|
2320
|
+
getDouble(key, fallback) {
|
|
2321
|
+
const value = this.pool.all()[key];
|
|
2322
|
+
return typeof value === "number" ? value : fallback;
|
|
2323
|
+
}
|
|
2324
|
+
/**
|
|
2325
|
+
* Resolve the multivariate variant for `key` — the variant name, or `null`
|
|
2326
|
+
* when the flag has no variant (or the read fails).
|
|
2327
|
+
*
|
|
2328
|
+
* Wire failures (network errors, 404, etc.) resolve to `null`.
|
|
2329
|
+
* Invalid flag names throw `BackendError('validation', { code: 'invalid_flag_name' })`.
|
|
2330
|
+
*
|
|
2331
|
+
* DEVIATION from iOS sync-cache parity: the platform does not propagate
|
|
2332
|
+
* variant metadata into the user-flags snapshot/delta cache, so this is an
|
|
2333
|
+
* ASYNC transport read (`GET /v1/flags/{key}/variant`), not a cache lookup.
|
|
2334
|
+
*/
|
|
2335
|
+
async getVariant(key) {
|
|
2336
|
+
let res;
|
|
2337
|
+
try {
|
|
2338
|
+
res = await this.transport.getVariant(key);
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
throw new BackendError("validation", {
|
|
2341
|
+
code: "invalid_flag_name",
|
|
2342
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
return res.data?.name ?? null;
|
|
2346
|
+
}
|
|
2347
|
+
/** Subscribe to any change in the cached flag set. */
|
|
2348
|
+
onChange(callback) {
|
|
2349
|
+
return this.pool.onChange(callback);
|
|
2350
|
+
}
|
|
2351
|
+
/**
|
|
2352
|
+
* Observe ONE key: fires only when that key's value actually changes
|
|
2353
|
+
* (per {@link sameFlagValue} — structural compare for objects), with the
|
|
2354
|
+
* new value (`undefined` = deleted). P5 React-hook substrate.
|
|
2355
|
+
*/
|
|
2356
|
+
subscribeKey(key, callback) {
|
|
2357
|
+
let last = this.pool.all()[key];
|
|
2358
|
+
return this.pool.onChange(() => {
|
|
2359
|
+
const next = this.pool.all()[key];
|
|
2360
|
+
if (sameFlagValue(last, next)) return;
|
|
2361
|
+
last = next;
|
|
2362
|
+
callback(next);
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Async iteration over flag changes: yields the new {@link all} view on
|
|
2367
|
+
* every pool change notification. The listener is detached when the
|
|
2368
|
+
* consumer `break`s/`return`s/`throw`s — including while a `next()` is
|
|
2369
|
+
* still pending (it resolves `{ done: true }` instead of hanging, which a
|
|
2370
|
+
* plain async-generator `finally` would not guarantee).
|
|
2371
|
+
*/
|
|
2372
|
+
changes() {
|
|
2373
|
+
const queue = [];
|
|
2374
|
+
const pending = [];
|
|
2375
|
+
let finished = false;
|
|
2376
|
+
const unsubscribe = this.pool.onChange(() => {
|
|
2377
|
+
const snapshot = this.pool.all();
|
|
2378
|
+
const resolve = pending.shift();
|
|
2379
|
+
if (resolve) resolve({ value: snapshot, done: false });
|
|
2380
|
+
else queue.push(snapshot);
|
|
2381
|
+
});
|
|
2382
|
+
const finish = () => {
|
|
2383
|
+
if (finished) return;
|
|
2384
|
+
finished = true;
|
|
2385
|
+
unsubscribe();
|
|
2386
|
+
while (pending.length > 0) pending.shift()?.({ value: void 0, done: true });
|
|
2387
|
+
};
|
|
2388
|
+
return {
|
|
2389
|
+
next: () => {
|
|
2390
|
+
if (finished) return Promise.resolve({ value: void 0, done: true });
|
|
2391
|
+
const head = queue.shift();
|
|
2392
|
+
if (head !== void 0) return Promise.resolve({ value: head, done: false });
|
|
2393
|
+
return new Promise((resolve) => {
|
|
2394
|
+
pending.push(resolve);
|
|
2395
|
+
});
|
|
2396
|
+
},
|
|
2397
|
+
return: () => {
|
|
2398
|
+
finish();
|
|
2399
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
2400
|
+
},
|
|
2401
|
+
throw: (error) => {
|
|
2402
|
+
finish();
|
|
2403
|
+
return Promise.reject(error);
|
|
2404
|
+
},
|
|
2405
|
+
[Symbol.asyncIterator]() {
|
|
2406
|
+
return this;
|
|
2407
|
+
}
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
/** Stop polling and detach all listeners (auth + visibility + subscribers). */
|
|
2411
|
+
destroy() {
|
|
2412
|
+
this.pool.destroy();
|
|
2413
|
+
}
|
|
2414
|
+
};
|
|
2415
|
+
|
|
2416
|
+
// src/realtime/anon-token.ts
|
|
2417
|
+
var REFRESH_SKEW_MS = 6e4;
|
|
2418
|
+
var AnonTokenProvider = class {
|
|
2419
|
+
rt;
|
|
2420
|
+
cached = null;
|
|
2421
|
+
inFlight = null;
|
|
2422
|
+
constructor(rt) {
|
|
2423
|
+
this.rt = rt;
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* Return a valid anonymous access token, minting (or re-minting) one when
|
|
2427
|
+
* the cache is empty or within the skew of expiry. Concurrent callers
|
|
2428
|
+
* collapse onto a single in-flight mint.
|
|
2429
|
+
*/
|
|
2430
|
+
token() {
|
|
2431
|
+
if (this.cached && Date.now() < this.cached.expiresAt - REFRESH_SKEW_MS) {
|
|
2432
|
+
return Promise.resolve(this.cached.accessToken);
|
|
2433
|
+
}
|
|
2434
|
+
if (this.inFlight) return this.inFlight;
|
|
2435
|
+
const mint = this.mint().then(
|
|
2436
|
+
(token) => {
|
|
2437
|
+
this.cached = token;
|
|
2438
|
+
this.inFlight = null;
|
|
2439
|
+
return token.accessToken;
|
|
2440
|
+
},
|
|
2441
|
+
(e) => {
|
|
2442
|
+
this.inFlight = null;
|
|
2443
|
+
throw e;
|
|
2444
|
+
}
|
|
2445
|
+
);
|
|
2446
|
+
this.inFlight = mint;
|
|
2447
|
+
return mint;
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Raw fetch, deliberately NOT through HttpClient/palbeRequest: the mint
|
|
2451
|
+
* must carry the project apikey and NEVER a Bearer (iOS keeps
|
|
2452
|
+
* `/auth/anonymous` on the unauthenticated-path list), and HttpClient's
|
|
2453
|
+
* pre-flight would try to refresh a stale session before an anon mint —
|
|
2454
|
+
* exactly the entanglement this provider exists to avoid.
|
|
2455
|
+
*/
|
|
2456
|
+
async mint() {
|
|
2457
|
+
const base = this.rt.config.url.replace(/\/+$/, "");
|
|
2458
|
+
let response;
|
|
2459
|
+
try {
|
|
2460
|
+
response = await fetch(`${base}/auth/anonymous`, {
|
|
2461
|
+
method: "POST",
|
|
2462
|
+
headers: { apikey: this.rt.config.apiKey }
|
|
2463
|
+
});
|
|
2464
|
+
} catch (e) {
|
|
2465
|
+
throw new BackendError("network", {
|
|
2466
|
+
code: "network_error",
|
|
2467
|
+
message: e instanceof Error ? e.message : "Network request failed"
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
const body = await response.json().catch(() => null);
|
|
2471
|
+
if (!response.ok) throw fromEnvelope(response.status, body);
|
|
2472
|
+
const obj = typeof body === "object" && body !== null ? body : {};
|
|
2473
|
+
const accessToken = obj.access_token;
|
|
2474
|
+
const expiresIn = obj.expires_in;
|
|
2475
|
+
if (typeof accessToken !== "string" || accessToken === "" || typeof expiresIn !== "number") {
|
|
2476
|
+
throw new BackendError("decode", {
|
|
2477
|
+
code: "decode_error",
|
|
2478
|
+
message: "POST /auth/anonymous returned an unexpected shape (expected {access_token, expires_in})."
|
|
2479
|
+
});
|
|
2480
|
+
}
|
|
2481
|
+
return { accessToken, expiresAt: Date.now() + expiresIn * 1e3 };
|
|
2482
|
+
}
|
|
2483
|
+
};
|
|
2484
|
+
function realtimeTokenProvider(rt, anon) {
|
|
2485
|
+
return async () => {
|
|
2486
|
+
const bearer = rt.tokenManager.getAccessToken();
|
|
2487
|
+
if (bearer && !rt.tokenManager.isExpired()) return bearer;
|
|
2488
|
+
try {
|
|
2489
|
+
return await anon.token();
|
|
2490
|
+
} catch {
|
|
2491
|
+
return rt.config.apiKey;
|
|
2492
|
+
}
|
|
2493
|
+
};
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// src/realtime/frames.ts
|
|
2497
|
+
var REALTIME_PREFIX = "realtime:";
|
|
2498
|
+
function encodeJoin(ref, topic, accessToken) {
|
|
2499
|
+
const r = String(ref);
|
|
2500
|
+
return JSON.stringify([
|
|
2501
|
+
r,
|
|
2502
|
+
r,
|
|
2503
|
+
REALTIME_PREFIX + topic,
|
|
2504
|
+
"phx_join",
|
|
2505
|
+
{
|
|
2506
|
+
access_token: accessToken,
|
|
2507
|
+
config: {
|
|
2508
|
+
broadcast: { self: false },
|
|
2509
|
+
presence: { key: "" },
|
|
2510
|
+
private: false,
|
|
2511
|
+
postgres_changes: []
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
]);
|
|
2515
|
+
}
|
|
2516
|
+
function encodeLeave(ref, topic) {
|
|
2517
|
+
return JSON.stringify([null, String(ref), REALTIME_PREFIX + topic, "phx_leave", {}]);
|
|
2518
|
+
}
|
|
2519
|
+
function encodeHeartbeat(ref) {
|
|
2520
|
+
return JSON.stringify([null, String(ref), "phoenix", "heartbeat", {}]);
|
|
2521
|
+
}
|
|
2522
|
+
function encodeBroadcast(joinRef, ref, topic, event, payload) {
|
|
2523
|
+
return JSON.stringify([
|
|
2524
|
+
joinRef,
|
|
2525
|
+
String(ref),
|
|
2526
|
+
REALTIME_PREFIX + topic,
|
|
2527
|
+
"broadcast",
|
|
2528
|
+
{ type: "broadcast", event, payload }
|
|
2529
|
+
]);
|
|
2530
|
+
}
|
|
2531
|
+
function decodeFrame(text) {
|
|
2532
|
+
let parsed;
|
|
2533
|
+
try {
|
|
2534
|
+
parsed = JSON.parse(text);
|
|
2535
|
+
} catch {
|
|
2536
|
+
return null;
|
|
2537
|
+
}
|
|
2538
|
+
if (!Array.isArray(parsed)) return null;
|
|
2539
|
+
const [joinRef, ref, topic, event, payload] = parsed;
|
|
2540
|
+
if (typeof topic !== "string" || typeof event !== "string") return null;
|
|
2541
|
+
return { joinRef, ref, topic, event, payload };
|
|
2542
|
+
}
|
|
2543
|
+
function asObject(value) {
|
|
2544
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
2545
|
+
return value;
|
|
2546
|
+
}
|
|
2547
|
+
function unwrapBroadcast(frame) {
|
|
2548
|
+
if (frame.event !== "broadcast") return null;
|
|
2549
|
+
const body = asObject(frame.payload);
|
|
2550
|
+
if (!body) return null;
|
|
2551
|
+
const event = typeof body.event === "string" ? body.event : "";
|
|
2552
|
+
const payload = asObject(body.payload) ?? {};
|
|
2553
|
+
return { topic: stripRealtimePrefix(frame.topic), event, payload };
|
|
2554
|
+
}
|
|
2555
|
+
function stripRealtimePrefix(topic) {
|
|
2556
|
+
return topic.startsWith(REALTIME_PREFIX) ? topic.slice(REALTIME_PREFIX.length) : topic;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// src/realtime/connection.ts
|
|
2560
|
+
var WS_OPEN = 1;
|
|
2561
|
+
var DEFAULT_HEARTBEAT_MS = 25e3;
|
|
2562
|
+
var DEFAULT_MAX_BACKOFF_SECONDS = 30;
|
|
2563
|
+
var MAX_CHANNEL_REJOIN_ATTEMPTS = 3;
|
|
2564
|
+
function websocketUrl(base, token) {
|
|
2565
|
+
let s = base;
|
|
2566
|
+
if (s.startsWith("https://")) {
|
|
2567
|
+
s = `wss://${s.slice("https://".length)}`;
|
|
2568
|
+
} else if (s.startsWith("http://")) {
|
|
2569
|
+
s = `ws://${s.slice("http://".length)}`;
|
|
2570
|
+
}
|
|
2571
|
+
while (s.endsWith("/")) s = s.slice(0, -1);
|
|
2572
|
+
return `${s}/realtime/v1/websocket?apikey=${encodeURIComponent(token)}&vsn=2.0.0`;
|
|
2573
|
+
}
|
|
2574
|
+
function backoffDelaySeconds(attempt, refCounter, cap) {
|
|
2575
|
+
const base = Math.min(2 ** attempt, cap);
|
|
2576
|
+
const jitter = refCounter % 1e3 / 1e3;
|
|
2577
|
+
return base + jitter;
|
|
2578
|
+
}
|
|
2579
|
+
var RealtimeSocket = class {
|
|
2580
|
+
options;
|
|
2581
|
+
handlers = null;
|
|
2582
|
+
ws = null;
|
|
2583
|
+
// Membership intent (bare sub-topics): re-joined on every (re)connect. A
|
|
2584
|
+
// topic stays in this set from joinTopic() until leaveTopic().
|
|
2585
|
+
joinedTopics = /* @__PURE__ */ new Set();
|
|
2586
|
+
// topic → join_ref of the LIVE join on the current socket. Phoenix drops
|
|
2587
|
+
// channel messages whose join_ref doesn't match, so broadcasts carry this.
|
|
2588
|
+
// Cleared on disconnect (the next socket gets fresh joins/refs).
|
|
2589
|
+
joinRefs = /* @__PURE__ */ new Map();
|
|
2590
|
+
// Broadcasts pushed before the topic's join is live — flushed right after it.
|
|
2591
|
+
pendingBroadcasts = [];
|
|
2592
|
+
// Per-topic rejoin attempt counter for the token-expiry channel-death guard.
|
|
2593
|
+
// Bumped on every channel rejoin; reset to 0 when the topic delivers a live
|
|
2594
|
+
// broadcast (proof the channel recovered). At MAX_CHANNEL_REJOIN_ATTEMPTS the
|
|
2595
|
+
// channel is given up on (onChannelError) instead of looping forever.
|
|
2596
|
+
channelRejoinAttempts = /* @__PURE__ */ new Map();
|
|
2597
|
+
// Pending per-topic rejoin backoff timers — separate from the socket-level
|
|
2598
|
+
// reconnectTimer so a channel backoff never clobbers socket reconnection.
|
|
2599
|
+
channelRejoinTimers = /* @__PURE__ */ new Map();
|
|
2600
|
+
// Monotonic Phoenix message ref. Every outbound frame carries a unique ref.
|
|
2601
|
+
refCounter = 0;
|
|
2602
|
+
connected = false;
|
|
2603
|
+
started = false;
|
|
2604
|
+
stopped = false;
|
|
2605
|
+
// Reset ONLY on a genuine open, so a never-opening socket walks
|
|
2606
|
+
// attempt 0,1,2,… → delays 1,2,4,…,cap instead of hammering (iOS parity).
|
|
2607
|
+
reconnectAttempt = 0;
|
|
2608
|
+
// Invalidates async continuations (token awaits) from a superseded connect.
|
|
2609
|
+
connectEpoch = 0;
|
|
2610
|
+
heartbeatTimer = null;
|
|
2611
|
+
reconnectTimer = null;
|
|
2612
|
+
constructor(options) {
|
|
2613
|
+
this.options = options;
|
|
2614
|
+
}
|
|
2615
|
+
// MARK: lifecycle
|
|
2616
|
+
/** Install handlers and open the socket. Idempotent while started. */
|
|
2617
|
+
start(handlers) {
|
|
2618
|
+
this.handlers = handlers;
|
|
2619
|
+
if (this.started) return;
|
|
2620
|
+
this.started = true;
|
|
2621
|
+
this.stopped = false;
|
|
2622
|
+
void this.connect();
|
|
2623
|
+
}
|
|
2624
|
+
/** Tear down the socket, stop reconnecting, emit `idle`. Idempotent. */
|
|
2625
|
+
stop() {
|
|
2626
|
+
this.stopped = true;
|
|
2627
|
+
this.started = false;
|
|
2628
|
+
this.connected = false;
|
|
2629
|
+
this.connectEpoch += 1;
|
|
2630
|
+
this.stopHeartbeat();
|
|
2631
|
+
if (this.reconnectTimer !== null) {
|
|
2632
|
+
clearTimeout(this.reconnectTimer);
|
|
2633
|
+
this.reconnectTimer = null;
|
|
2634
|
+
}
|
|
2635
|
+
this.clearAllChannelRejoins();
|
|
2636
|
+
const ws = this.ws;
|
|
2637
|
+
this.ws = null;
|
|
2638
|
+
this.joinRefs.clear();
|
|
2639
|
+
ws?.close(1e3);
|
|
2640
|
+
this.handlers?.onStateChange("idle");
|
|
2641
|
+
}
|
|
2642
|
+
// MARK: topic membership
|
|
2643
|
+
/**
|
|
2644
|
+
* Join a topic (bare sub-topic, no "realtime:" prefix). Idempotent. Sent
|
|
2645
|
+
* immediately when the socket is up, else on the next (re)connect.
|
|
2646
|
+
*/
|
|
2647
|
+
joinTopic(topic) {
|
|
2648
|
+
if (this.joinedTopics.has(topic)) return;
|
|
2649
|
+
this.joinedTopics.add(topic);
|
|
2650
|
+
if (this.connected) void this.sendJoin(topic);
|
|
2651
|
+
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Leave a topic: sends `phx_leave` when connected and always drops it from
|
|
2654
|
+
* the re-join set (a future reconnect won't restore it). Pending broadcasts
|
|
2655
|
+
* for the topic are discarded.
|
|
2656
|
+
*/
|
|
2657
|
+
leaveTopic(topic) {
|
|
2658
|
+
if (!this.joinedTopics.delete(topic)) return;
|
|
2659
|
+
this.joinRefs.delete(topic);
|
|
2660
|
+
this.pendingBroadcasts = this.pendingBroadcasts.filter((p) => p.topic !== topic);
|
|
2661
|
+
this.clearChannelRejoin(topic);
|
|
2662
|
+
if (this.isOpen()) this.send(encodeLeave(this.nextRef(), topic));
|
|
2663
|
+
}
|
|
2664
|
+
/**
|
|
2665
|
+
* Push a broadcast onto a topic (web addition — iOS is receive-only). Sent
|
|
2666
|
+
* synchronously when the topic's join is live on the current socket;
|
|
2667
|
+
* otherwise queued and flushed right after the join goes out.
|
|
2668
|
+
*/
|
|
2669
|
+
sendBroadcast(topic, event, payload) {
|
|
2670
|
+
const joinRef = this.joinRefs.get(topic);
|
|
2671
|
+
if (joinRef !== void 0 && this.isOpen()) {
|
|
2672
|
+
this.send(encodeBroadcast(joinRef, this.nextRef(), topic, event, payload));
|
|
2673
|
+
} else {
|
|
2674
|
+
this.pendingBroadcasts.push({ topic, event, payload });
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
// MARK: connect / reconnect
|
|
2678
|
+
async connect() {
|
|
2679
|
+
if (this.stopped) return;
|
|
2680
|
+
const epoch = ++this.connectEpoch;
|
|
2681
|
+
let token;
|
|
2682
|
+
try {
|
|
2683
|
+
token = await this.options.tokenProvider();
|
|
2684
|
+
} catch {
|
|
2685
|
+
this.scheduleReconnect();
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
if (this.stopped || epoch !== this.connectEpoch) return;
|
|
2689
|
+
const Ctor = this.options.webSocket ?? globalThis.WebSocket;
|
|
2690
|
+
if (!Ctor) {
|
|
2691
|
+
this.scheduleReconnect();
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
const ws = new Ctor(websocketUrl(this.options.baseUrl, token));
|
|
2695
|
+
this.ws = ws;
|
|
2696
|
+
ws.onopen = () => {
|
|
2697
|
+
if (ws === this.ws) void this.handleOpen();
|
|
2698
|
+
};
|
|
2699
|
+
ws.onmessage = (event) => {
|
|
2700
|
+
if (ws === this.ws) this.handleMessage(event.data);
|
|
2701
|
+
};
|
|
2702
|
+
ws.onclose = () => {
|
|
2703
|
+
if (ws === this.ws) this.handleClose();
|
|
2704
|
+
};
|
|
2705
|
+
ws.onerror = () => {
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
/**
|
|
2709
|
+
* The browser `open` event proves the HTTP-101 handshake completed (the
|
|
2710
|
+
* analogue of iOS promoteToConnected): flip connected, announce, then
|
|
2711
|
+
* (re)join every topic with a fresh token + fresh refs. Backoff is NOT
|
|
2712
|
+
* reset here — it resets on the first inbound frame (iOS parity), so a
|
|
2713
|
+
* server that opens but immediately closes still grows the backoff.
|
|
2714
|
+
*/
|
|
2715
|
+
async handleOpen() {
|
|
2716
|
+
if (this.stopped) return;
|
|
2717
|
+
this.connected = true;
|
|
2718
|
+
this.handlers?.onStateChange("connected");
|
|
2719
|
+
this.handlers?.onReconnect();
|
|
2720
|
+
this.startHeartbeat();
|
|
2721
|
+
for (const topic of this.joinedTopics) {
|
|
2722
|
+
await this.sendJoin(topic);
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
handleClose() {
|
|
2726
|
+
if (this.stopped) return;
|
|
2727
|
+
this.ws = null;
|
|
2728
|
+
this.joinRefs.clear();
|
|
2729
|
+
this.clearAllChannelRejoins();
|
|
2730
|
+
this.stopHeartbeat();
|
|
2731
|
+
this.scheduleReconnect();
|
|
2732
|
+
}
|
|
2733
|
+
scheduleReconnect() {
|
|
2734
|
+
if (this.stopped || !this.started) return;
|
|
2735
|
+
this.connected = false;
|
|
2736
|
+
this.handlers?.onStateChange("reconnecting");
|
|
2737
|
+
const attempt = this.reconnectAttempt;
|
|
2738
|
+
this.reconnectAttempt += 1;
|
|
2739
|
+
const cap = this.options.maxBackoffSeconds ?? DEFAULT_MAX_BACKOFF_SECONDS;
|
|
2740
|
+
const delaySeconds = backoffDelaySeconds(attempt, this.refCounter, cap);
|
|
2741
|
+
this.reconnectTimer = setTimeout(() => {
|
|
2742
|
+
this.reconnectTimer = null;
|
|
2743
|
+
void this.connect();
|
|
2744
|
+
}, delaySeconds * 1e3);
|
|
2745
|
+
}
|
|
2746
|
+
// MARK: frames out
|
|
2747
|
+
/**
|
|
2748
|
+
* Join carries a freshly-resolved token (iOS parity — `sendJoin` awaits the
|
|
2749
|
+
* provider), so a Bearer rotated while offline rides the next rejoin.
|
|
2750
|
+
*/
|
|
2751
|
+
async sendJoin(topic) {
|
|
2752
|
+
const token = await this.options.tokenProvider();
|
|
2753
|
+
if (!this.isOpen() || !this.joinedTopics.has(topic)) return;
|
|
2754
|
+
const ref = this.nextRef();
|
|
2755
|
+
this.joinRefs.set(topic, String(ref));
|
|
2756
|
+
this.send(encodeJoin(ref, topic, token));
|
|
2757
|
+
this.flushPending(topic);
|
|
2758
|
+
}
|
|
2759
|
+
flushPending(topic) {
|
|
2760
|
+
const ready = this.pendingBroadcasts.filter((p) => p.topic === topic);
|
|
2761
|
+
if (ready.length === 0) return;
|
|
2762
|
+
this.pendingBroadcasts = this.pendingBroadcasts.filter((p) => p.topic !== topic);
|
|
2763
|
+
for (const p of ready) this.sendBroadcast(p.topic, p.event, p.payload);
|
|
2764
|
+
}
|
|
2765
|
+
startHeartbeat() {
|
|
2766
|
+
this.stopHeartbeat();
|
|
2767
|
+
const interval = this.options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS;
|
|
2768
|
+
this.heartbeatTimer = setInterval(() => {
|
|
2769
|
+
if (this.isOpen()) this.send(encodeHeartbeat(this.nextRef()));
|
|
2770
|
+
}, interval);
|
|
2771
|
+
}
|
|
2772
|
+
stopHeartbeat() {
|
|
2773
|
+
if (this.heartbeatTimer !== null) {
|
|
2774
|
+
clearInterval(this.heartbeatTimer);
|
|
2775
|
+
this.heartbeatTimer = null;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
/** Drop-if-not-open: a send racing a disconnect is recovered by the rejoin-all on reconnect. */
|
|
2779
|
+
send(text) {
|
|
2780
|
+
if (this.ws && this.ws.readyState === WS_OPEN) this.ws.send(text);
|
|
2781
|
+
}
|
|
2782
|
+
// MARK: frames in
|
|
2783
|
+
handleMessage(data) {
|
|
2784
|
+
if (typeof data !== "string") return;
|
|
2785
|
+
const frame = decodeFrame(data);
|
|
2786
|
+
if (!frame) return;
|
|
2787
|
+
this.reconnectAttempt = 0;
|
|
2788
|
+
if (frame.event === "phx_close" || frame.event === "phx_error") {
|
|
2789
|
+
this.handleChannelClose(frame);
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
const inbound = unwrapBroadcast(frame);
|
|
2793
|
+
if (inbound) {
|
|
2794
|
+
this.channelRejoinAttempts.delete(inbound.topic);
|
|
2795
|
+
this.handlers?.onInbound(inbound);
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
/**
|
|
2799
|
+
* Recover (or give up on) a single channel the server closed for token
|
|
2800
|
+
* reasons. Ignored unless the close targets a topic we still intend to be
|
|
2801
|
+
* joined AND its join_ref matches the LIVE join — so a stale close from a
|
|
2802
|
+
* superseded join, or a close arriving after our OWN phx_leave (the topic is
|
|
2803
|
+
* already dropped from joinedTopics/joinRefs), does NOT trigger a rejoin.
|
|
2804
|
+
*/
|
|
2805
|
+
handleChannelClose(frame) {
|
|
2806
|
+
const topic = stripRealtimePrefix(frame.topic);
|
|
2807
|
+
if (!this.joinedTopics.has(topic)) return;
|
|
2808
|
+
if (this.joinRefs.get(topic) !== frame.joinRef) return;
|
|
2809
|
+
this.joinRefs.delete(topic);
|
|
2810
|
+
const attempt = this.channelRejoinAttempts.get(topic) ?? 0;
|
|
2811
|
+
if (attempt >= MAX_CHANNEL_REJOIN_ATTEMPTS) {
|
|
2812
|
+
this.joinedTopics.delete(topic);
|
|
2813
|
+
this.channelRejoinAttempts.delete(topic);
|
|
2814
|
+
this.handlers?.onChannelError(topic);
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
this.channelRejoinAttempts.set(topic, attempt + 1);
|
|
2818
|
+
if (attempt === 0) {
|
|
2819
|
+
void this.sendJoin(topic);
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
const cap = this.options.maxBackoffSeconds ?? DEFAULT_MAX_BACKOFF_SECONDS;
|
|
2823
|
+
const delayMs = backoffDelaySeconds(attempt, this.refCounter, cap) * 1e3;
|
|
2824
|
+
const existing = this.channelRejoinTimers.get(topic);
|
|
2825
|
+
if (existing !== void 0) clearTimeout(existing);
|
|
2826
|
+
const timer = setTimeout(() => {
|
|
2827
|
+
this.channelRejoinTimers.delete(topic);
|
|
2828
|
+
if (this.joinedTopics.has(topic)) void this.sendJoin(topic);
|
|
2829
|
+
}, delayMs);
|
|
2830
|
+
this.channelRejoinTimers.set(topic, timer);
|
|
2831
|
+
}
|
|
2832
|
+
// MARK: helpers
|
|
2833
|
+
/** Drop a single topic's token-expiry rejoin state (cancel its pending timer). */
|
|
2834
|
+
clearChannelRejoin(topic) {
|
|
2835
|
+
const timer = this.channelRejoinTimers.get(topic);
|
|
2836
|
+
if (timer !== void 0) {
|
|
2837
|
+
clearTimeout(timer);
|
|
2838
|
+
this.channelRejoinTimers.delete(topic);
|
|
2839
|
+
}
|
|
2840
|
+
this.channelRejoinAttempts.delete(topic);
|
|
2841
|
+
}
|
|
2842
|
+
/** Drop all per-channel rejoin state (socket teardown / full reconnect). */
|
|
2843
|
+
clearAllChannelRejoins() {
|
|
2844
|
+
for (const timer of this.channelRejoinTimers.values()) clearTimeout(timer);
|
|
2845
|
+
this.channelRejoinTimers.clear();
|
|
2846
|
+
this.channelRejoinAttempts.clear();
|
|
2847
|
+
}
|
|
2848
|
+
isOpen() {
|
|
2849
|
+
return this.connected && this.ws !== null && this.ws.readyState === WS_OPEN;
|
|
2850
|
+
}
|
|
2851
|
+
nextRef() {
|
|
2852
|
+
this.refCounter += 1;
|
|
2853
|
+
return this.refCounter;
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
|
|
2857
|
+
// src/realtime/facade.ts
|
|
2858
|
+
var StatusStore = class {
|
|
2859
|
+
currentState = "idle";
|
|
2860
|
+
lastEvent = null;
|
|
2861
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2862
|
+
get state() {
|
|
2863
|
+
return this.currentState;
|
|
2864
|
+
}
|
|
2865
|
+
get lastEventAt() {
|
|
2866
|
+
return this.lastEvent;
|
|
2867
|
+
}
|
|
2868
|
+
onChange(callback) {
|
|
2869
|
+
this.listeners.add(callback);
|
|
2870
|
+
return () => {
|
|
2871
|
+
this.listeners.delete(callback);
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
/** No-op on equal state (iOS setState guard) — listeners see transitions only. */
|
|
2875
|
+
setState(state) {
|
|
2876
|
+
if (state === this.currentState) return;
|
|
2877
|
+
this.currentState = state;
|
|
2878
|
+
this.emit();
|
|
2879
|
+
}
|
|
2880
|
+
recordEvent(at) {
|
|
2881
|
+
this.lastEvent = at;
|
|
2882
|
+
this.emit();
|
|
2883
|
+
}
|
|
2884
|
+
emit() {
|
|
2885
|
+
const snapshot = {
|
|
2886
|
+
state: this.currentState,
|
|
2887
|
+
lastEventAt: this.lastEvent
|
|
2888
|
+
};
|
|
2889
|
+
for (const listener of this.listeners) listener(snapshot);
|
|
2890
|
+
}
|
|
2891
|
+
};
|
|
2892
|
+
var RealtimeChannel = class {
|
|
2893
|
+
/** The app-defined channel name (bare sub-topic, no "realtime:" prefix). */
|
|
2894
|
+
name;
|
|
2895
|
+
owner;
|
|
2896
|
+
/** @internal — obtain channels via `pb.realtime.channel(name)`. */
|
|
2897
|
+
constructor(name, owner) {
|
|
2898
|
+
this.name = name;
|
|
2899
|
+
this.owner = owner;
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* Subscribe `handler` to `event` on this channel. Fire-and-forget: the
|
|
2903
|
+
* join rides the shared socket asynchronously. Hold the returned
|
|
2904
|
+
* subscription and `cancel()` it to stop.
|
|
2905
|
+
*/
|
|
2906
|
+
on(event, handler) {
|
|
2907
|
+
return this.owner.subscribe(this.name, event, handler);
|
|
2908
|
+
}
|
|
2909
|
+
/**
|
|
2910
|
+
* Broadcast `payload` to the channel's other subscribers (web addition —
|
|
2911
|
+
* iOS is receive-only). Joins the channel if it isn't already; queued
|
|
2912
|
+
* until the join is live on the socket.
|
|
2913
|
+
*/
|
|
2914
|
+
send(event, payload = {}) {
|
|
2915
|
+
this.owner.send(this.name, event, payload);
|
|
2916
|
+
}
|
|
2917
|
+
};
|
|
2918
|
+
var PalbeRealtime = class {
|
|
2919
|
+
rt;
|
|
2920
|
+
socket = null;
|
|
2921
|
+
channels = /* @__PURE__ */ new Map();
|
|
2922
|
+
statusStore = new StatusStore();
|
|
2923
|
+
// Per-(topic, event) handlers — an event may have multiple handlers
|
|
2924
|
+
// (multiple .on calls); entry identity is the unsubscribe token.
|
|
2925
|
+
handlers = /* @__PURE__ */ new Set();
|
|
2926
|
+
// Joins are refcounted per topic so the socket only leaves a topic when
|
|
2927
|
+
// its last handler cancels (iOS RealtimeClient parity).
|
|
2928
|
+
topicRefcount = /* @__PURE__ */ new Map();
|
|
2929
|
+
constructor(rt) {
|
|
2930
|
+
this.rt = rt;
|
|
2931
|
+
}
|
|
2932
|
+
/**
|
|
2933
|
+
* Get the handle for an app-defined channel (e.g. "room:42"). The same
|
|
2934
|
+
* name returns the SAME instance. Throws a guided error on server-side
|
|
2935
|
+
* hosts (SSR/RSC/Node) — realtime is client-only.
|
|
2936
|
+
*/
|
|
2937
|
+
channel(name) {
|
|
2938
|
+
if (typeof document === "undefined" && typeof window === "undefined") {
|
|
2939
|
+
throw new BackendError("validation", {
|
|
2940
|
+
code: "realtime_unavailable",
|
|
2941
|
+
message: "pb.realtime requires a browser environment: document and window are not defined (server/SSR/RSC). Subscribe from browser code instead \u2014 e.g. a client component or useEffect."
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
if (typeof WebSocket === "undefined") {
|
|
2945
|
+
throw new BackendError("validation", {
|
|
2946
|
+
code: "realtime_unavailable",
|
|
2947
|
+
message: "pb.realtime is client-only: this environment has no WebSocket. Subscribe from browser code instead \u2014 e.g. a client component or useEffect."
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
let channel = this.channels.get(name);
|
|
2951
|
+
if (!channel) {
|
|
2952
|
+
channel = new RealtimeChannel(name, this);
|
|
2953
|
+
this.channels.set(name, channel);
|
|
2954
|
+
}
|
|
2955
|
+
return channel;
|
|
2956
|
+
}
|
|
2957
|
+
/**
|
|
2958
|
+
* Tear down the shared socket and clear all channel state. Called by
|
|
2959
|
+
* `__configure` and `__reset` when the runtime is replaced, so old sockets
|
|
2960
|
+
* are not left open and heartbeat timers do not leak.
|
|
2961
|
+
*/
|
|
2962
|
+
destroy() {
|
|
2963
|
+
this.socket?.stop();
|
|
2964
|
+
this.socket = null;
|
|
2965
|
+
this.channels.clear();
|
|
2966
|
+
}
|
|
2967
|
+
/** The observable connection status. Safe to read anywhere (reports `idle` until a socket exists). */
|
|
2968
|
+
get status() {
|
|
2969
|
+
return this.statusStore;
|
|
2970
|
+
}
|
|
2971
|
+
/** @internal */
|
|
2972
|
+
subscribe(topic, event, handler) {
|
|
2973
|
+
const socket = this.ensureSocket();
|
|
2974
|
+
const entry = { topic, event, handler };
|
|
2975
|
+
this.handlers.add(entry);
|
|
2976
|
+
const count = (this.topicRefcount.get(topic) ?? 0) + 1;
|
|
2977
|
+
this.topicRefcount.set(topic, count);
|
|
2978
|
+
if (count === 1) socket.joinTopic(topic);
|
|
2979
|
+
let cancelled = false;
|
|
2980
|
+
return {
|
|
2981
|
+
cancel: () => {
|
|
2982
|
+
if (cancelled) return;
|
|
2983
|
+
cancelled = true;
|
|
2984
|
+
this.handlers.delete(entry);
|
|
2985
|
+
const next = (this.topicRefcount.get(topic) ?? 1) - 1;
|
|
2986
|
+
if (next <= 0) {
|
|
2987
|
+
this.topicRefcount.delete(topic);
|
|
2988
|
+
socket.leaveTopic(topic);
|
|
2989
|
+
} else {
|
|
2990
|
+
this.topicRefcount.set(topic, next);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
/** @internal */
|
|
2996
|
+
send(topic, event, payload) {
|
|
2997
|
+
const socket = this.ensureSocket();
|
|
2998
|
+
socket.joinTopic(topic);
|
|
2999
|
+
socket.sendBroadcast(topic, event, payload);
|
|
3000
|
+
}
|
|
3001
|
+
/** Lazily build + start the shared socket on first subscription/send. */
|
|
3002
|
+
ensureSocket() {
|
|
3003
|
+
if (!this.socket) {
|
|
3004
|
+
const anon = new AnonTokenProvider(this.rt);
|
|
3005
|
+
this.socket = new RealtimeSocket({
|
|
3006
|
+
baseUrl: this.rt.config.url,
|
|
3007
|
+
tokenProvider: realtimeTokenProvider(this.rt, anon)
|
|
3008
|
+
});
|
|
3009
|
+
this.socket.start({
|
|
3010
|
+
onInbound: (inbound) => this.route(inbound),
|
|
3011
|
+
// Rejoin-all already happens inside the socket; nothing to refresh
|
|
3012
|
+
// here (no managed flags-sync on web yet — pb.flags polls).
|
|
3013
|
+
onReconnect: () => {
|
|
3014
|
+
},
|
|
3015
|
+
onStateChange: (state) => this.statusStore.setState(state),
|
|
3016
|
+
onChannelError: (topic) => this.handleChannelError(topic)
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
return this.socket;
|
|
3020
|
+
}
|
|
3021
|
+
route(inbound) {
|
|
3022
|
+
this.statusStore.recordEvent(/* @__PURE__ */ new Date());
|
|
3023
|
+
for (const entry of this.handlers) {
|
|
3024
|
+
if (entry.topic === inbound.topic && entry.event === inbound.event) {
|
|
3025
|
+
entry.handler(inbound.payload);
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
/**
|
|
3030
|
+
* A channel died for good (token-expiry rejoin exhausted N attempts). Drop its
|
|
3031
|
+
* handlers + refcount so the app stops expecting events on it, and flip the
|
|
3032
|
+
* status observable to `'error'` (the React `useChannel` hook reports this).
|
|
3033
|
+
* A later `.on()` for the same topic re-joins from scratch (refcount 1).
|
|
3034
|
+
*/
|
|
3035
|
+
handleChannelError(topic) {
|
|
3036
|
+
for (const entry of this.handlers) {
|
|
3037
|
+
if (entry.topic === topic) this.handlers.delete(entry);
|
|
3038
|
+
}
|
|
3039
|
+
this.topicRefcount.delete(topic);
|
|
3040
|
+
this.statusStore.setState("error");
|
|
3041
|
+
}
|
|
3042
|
+
};
|
|
3043
|
+
|
|
3044
|
+
// src/storage.ts
|
|
3045
|
+
function memorySessionStorage() {
|
|
3046
|
+
let current = null;
|
|
3047
|
+
return {
|
|
3048
|
+
load: () => current,
|
|
3049
|
+
save: (s) => {
|
|
3050
|
+
current = s;
|
|
3051
|
+
},
|
|
3052
|
+
clear: () => {
|
|
3053
|
+
current = null;
|
|
3054
|
+
}
|
|
3055
|
+
};
|
|
3056
|
+
}
|
|
3057
|
+
var DEFAULT_KEY = "palbe.session";
|
|
3058
|
+
function localStorageSessionStorage(key = DEFAULT_KEY) {
|
|
3059
|
+
return {
|
|
3060
|
+
load: () => {
|
|
3061
|
+
try {
|
|
3062
|
+
const raw = localStorage.getItem(key);
|
|
3063
|
+
if (!raw) return null;
|
|
3064
|
+
const parsed = JSON.parse(raw);
|
|
3065
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
3066
|
+
const obj = parsed;
|
|
3067
|
+
if (typeof obj.refreshToken === "string") {
|
|
3068
|
+
if (typeof obj.accessToken === "string" && typeof obj.expiresAt === "number") {
|
|
3069
|
+
return {
|
|
3070
|
+
refreshToken: obj.refreshToken,
|
|
3071
|
+
accessToken: obj.accessToken,
|
|
3072
|
+
expiresAt: obj.expiresAt
|
|
3073
|
+
};
|
|
3074
|
+
}
|
|
3075
|
+
return { refreshToken: obj.refreshToken };
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
return null;
|
|
3079
|
+
} catch {
|
|
3080
|
+
return null;
|
|
3081
|
+
}
|
|
3082
|
+
},
|
|
3083
|
+
save: (s) => {
|
|
3084
|
+
try {
|
|
3085
|
+
localStorage.setItem(key, JSON.stringify(s));
|
|
3086
|
+
} catch {
|
|
3087
|
+
}
|
|
3088
|
+
},
|
|
3089
|
+
clear: () => {
|
|
3090
|
+
try {
|
|
3091
|
+
localStorage.removeItem(key);
|
|
3092
|
+
} catch {
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
function defaultSessionStorage(key) {
|
|
3098
|
+
if (typeof localStorage !== "undefined") return localStorageSessionStorage(key);
|
|
3099
|
+
return memorySessionStorage();
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
// src/version.ts
|
|
3103
|
+
var VERSION = "1.0.0";
|
|
3104
|
+
|
|
3105
|
+
// src/runtime.ts
|
|
3106
|
+
function buildRuntime(config) {
|
|
3107
|
+
const http = new HttpClient(config.apiKey, {
|
|
3108
|
+
url: config.url,
|
|
3109
|
+
headers: { "X-Client-Info": `palbe-web/${VERSION}`, ...config.headers }
|
|
3110
|
+
});
|
|
3111
|
+
const tokenManager = new TokenManager();
|
|
3112
|
+
http.tokenManager = tokenManager;
|
|
3113
|
+
const authClient = new AuthClient(http, tokenManager);
|
|
3114
|
+
const ref = endpointRefFromApiKey(config.apiKey);
|
|
3115
|
+
const storage = config.storage ?? defaultSessionStorage(ref ? `palbe.session.${ref}` : void 0);
|
|
3116
|
+
const persisted = storage.load();
|
|
3117
|
+
if (persisted) {
|
|
3118
|
+
const { accessToken, expiresAt } = persisted;
|
|
3119
|
+
if (accessToken && expiresAt && expiresAt > Date.now()) {
|
|
3120
|
+
authClient.setTokens(
|
|
3121
|
+
accessToken,
|
|
3122
|
+
persisted.refreshToken,
|
|
3123
|
+
Math.floor((expiresAt - Date.now()) / 1e3)
|
|
3124
|
+
);
|
|
3125
|
+
} else {
|
|
3126
|
+
authClient.setTokens("", persisted.refreshToken);
|
|
3127
|
+
tokenManager.setSession({
|
|
3128
|
+
accessToken: "",
|
|
3129
|
+
refreshToken: persisted.refreshToken,
|
|
3130
|
+
expiresAt: 0
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
authClient.onTokenChange(({ refreshToken }) => {
|
|
3135
|
+
if (refreshToken) {
|
|
3136
|
+
const session = authClient.getSession().data;
|
|
3137
|
+
if (session) {
|
|
3138
|
+
storage.save({
|
|
3139
|
+
refreshToken,
|
|
3140
|
+
accessToken: session.accessToken,
|
|
3141
|
+
expiresAt: session.expiresAt
|
|
3142
|
+
});
|
|
3143
|
+
} else {
|
|
3144
|
+
storage.save({ refreshToken });
|
|
3145
|
+
}
|
|
3146
|
+
} else {
|
|
3147
|
+
storage.clear();
|
|
3148
|
+
}
|
|
3149
|
+
});
|
|
3150
|
+
let auth;
|
|
3151
|
+
let flags;
|
|
3152
|
+
let realtime;
|
|
3153
|
+
let analytics;
|
|
3154
|
+
const rt = {
|
|
3155
|
+
config,
|
|
3156
|
+
http,
|
|
3157
|
+
tokenManager,
|
|
3158
|
+
authClient,
|
|
3159
|
+
storage,
|
|
3160
|
+
get auth() {
|
|
3161
|
+
if (!auth) auth = new PalbeAuth(rt);
|
|
3162
|
+
return auth;
|
|
3163
|
+
},
|
|
3164
|
+
// Same lazy pattern as `auth`: the pool is constructed (and, in the
|
|
3165
|
+
// browser, started) only when `pb.flags` is actually touched — a pbServer
|
|
3166
|
+
// request that never reads flags pays nothing.
|
|
3167
|
+
get flags() {
|
|
3168
|
+
if (!flags) flags = new PalbeFlags(rt);
|
|
3169
|
+
return flags;
|
|
3170
|
+
},
|
|
3171
|
+
// Lazy like `auth`/`flags` — but constructing PalbeRealtime is itself
|
|
3172
|
+
// inert: the WebSocket only opens on the first channel subscription/send.
|
|
3173
|
+
get realtime() {
|
|
3174
|
+
if (!realtime) realtime = new PalbeRealtime(rt);
|
|
3175
|
+
return realtime;
|
|
3176
|
+
},
|
|
3177
|
+
destroyRealtime() {
|
|
3178
|
+
realtime?.destroy();
|
|
3179
|
+
realtime = void 0;
|
|
3180
|
+
},
|
|
3181
|
+
// The buffering facade is lazy; its identity state is NOT (below).
|
|
3182
|
+
get analytics() {
|
|
3183
|
+
if (!analytics) analytics = new PalbeAnalytics(rt, analyticsState);
|
|
3184
|
+
return analytics;
|
|
3185
|
+
}
|
|
3186
|
+
};
|
|
3187
|
+
const analyticsState = new AnalyticsState(rt);
|
|
3188
|
+
http.addInterceptor((request) => {
|
|
3189
|
+
const hasOwn = Object.keys(request.headers).some((k) => k.toLowerCase() === "x-distinct-id");
|
|
3190
|
+
if (!hasOwn) request.headers["X-Distinct-Id"] = analyticsState.distinctId();
|
|
3191
|
+
});
|
|
3192
|
+
return rt;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
// src/call.ts
|
|
3196
|
+
async function callEndpoint(resolveRt, name, input, options) {
|
|
3197
|
+
if (!name || name === "/") {
|
|
3198
|
+
throw new BackendError("validation", {
|
|
3199
|
+
code: "invalid_endpoint_name",
|
|
3200
|
+
message: 'Endpoint name must be a non-empty path like "todos/create"'
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
const rt = resolveRt();
|
|
3204
|
+
const path = name.startsWith("/") ? name : `/${name}`;
|
|
3205
|
+
return palbeRequest(rt, "POST", path, {
|
|
3206
|
+
body: input,
|
|
3207
|
+
headers: options?.headers,
|
|
3208
|
+
signal: options?.signal
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
// src/upload.ts
|
|
3213
|
+
function abortError() {
|
|
3214
|
+
return new BackendError("network", { code: "aborted", message: "Upload aborted" });
|
|
3215
|
+
}
|
|
3216
|
+
function checkConstraints(options) {
|
|
3217
|
+
const c = options.constraints;
|
|
3218
|
+
if (!c) return;
|
|
3219
|
+
if (c.maxSize !== void 0 && options.file.size > c.maxSize) {
|
|
3220
|
+
const message = `File size ${options.file.size} exceeds max ${c.maxSize} bytes`;
|
|
3221
|
+
throw new BackendError("validation", {
|
|
3222
|
+
code: "file_too_large",
|
|
3223
|
+
message,
|
|
3224
|
+
fields: [{ field: "file", message }]
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
const effType = options.contentType ?? options.file.type;
|
|
3228
|
+
if (c.allowedTypes && c.allowedTypes.length > 0 && !c.allowedTypes.includes(effType)) {
|
|
3229
|
+
const message = `File type '${effType}' is not allowed`;
|
|
3230
|
+
throw new BackendError("validation", {
|
|
3231
|
+
code: "file_type_not_allowed",
|
|
3232
|
+
message,
|
|
3233
|
+
fields: [{ field: "file", message }]
|
|
3234
|
+
});
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
async function buildHeaders(rt, extra) {
|
|
3238
|
+
if (rt.tokenManager.isExpired() && rt.tokenManager.getRefreshToken() && rt.tokenManager.refreshFunction) {
|
|
3239
|
+
try {
|
|
3240
|
+
await rt.tokenManager.refreshSession();
|
|
3241
|
+
} catch (e) {
|
|
3242
|
+
const pe = asPalbaseError(e);
|
|
3243
|
+
const status = pe?.status ?? 0;
|
|
3244
|
+
if (status === 400 || status === 401 || status === 403) {
|
|
3245
|
+
rt.tokenManager.clearSession();
|
|
3246
|
+
} else {
|
|
3247
|
+
throw pe ? fromPalbaseError(pe) : e;
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
const headers = {
|
|
3252
|
+
apikey: rt.config.apiKey,
|
|
3253
|
+
"X-Client-Info": `palbe-web/${VERSION}`,
|
|
3254
|
+
...rt.config.headers
|
|
3255
|
+
};
|
|
3256
|
+
const token = rt.tokenManager.getAccessToken();
|
|
3257
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
3258
|
+
const callerHasKey = Object.keys(extra ?? {}).some((k) => k.toLowerCase() === "idempotency-key");
|
|
3259
|
+
if (!callerHasKey) headers["Idempotency-Key"] = crypto.randomUUID();
|
|
3260
|
+
return { ...headers, ...extra };
|
|
3261
|
+
}
|
|
3262
|
+
function buildForm(options) {
|
|
3263
|
+
const form = new FormData();
|
|
3264
|
+
for (const [k, v] of Object.entries(options.fields ?? {})) form.append(k, v);
|
|
3265
|
+
const file = options.contentType && options.file.type !== options.contentType ? new Blob([options.file], { type: options.contentType }) : options.file;
|
|
3266
|
+
const filename = options.filename ?? (typeof File !== "undefined" && options.file instanceof File ? options.file.name : "file");
|
|
3267
|
+
form.append("file", file, filename);
|
|
3268
|
+
return form;
|
|
3269
|
+
}
|
|
3270
|
+
function decode(status, text) {
|
|
3271
|
+
let body;
|
|
3272
|
+
try {
|
|
3273
|
+
body = text === "" ? null : JSON.parse(text);
|
|
3274
|
+
} catch {
|
|
3275
|
+
if (status >= 200 && status < 300) {
|
|
3276
|
+
throw new BackendError("decode", {
|
|
3277
|
+
code: "decode_error",
|
|
3278
|
+
message: "Invalid JSON in response",
|
|
3279
|
+
status
|
|
3280
|
+
});
|
|
3281
|
+
}
|
|
3282
|
+
body = text;
|
|
3283
|
+
}
|
|
3284
|
+
if (status < 200 || status >= 300) throw fromEnvelope(status, body);
|
|
3285
|
+
return body;
|
|
3286
|
+
}
|
|
3287
|
+
function uploadViaXHR(url, form, headers, options) {
|
|
3288
|
+
return new Promise((resolve, reject) => {
|
|
3289
|
+
if (options.signal?.aborted) {
|
|
3290
|
+
reject(abortError());
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
const xhr = new XMLHttpRequest();
|
|
3294
|
+
xhr.open("POST", url);
|
|
3295
|
+
for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
|
|
3296
|
+
xhr.upload.onprogress = (e) => {
|
|
3297
|
+
if (!e.lengthComputable) return;
|
|
3298
|
+
options.onProgress?.({ sent: e.loaded, total: e.total });
|
|
3299
|
+
};
|
|
3300
|
+
const onAbort = () => xhr.abort();
|
|
3301
|
+
const cleanup = () => options.signal?.removeEventListener("abort", onAbort);
|
|
3302
|
+
xhr.onload = () => {
|
|
3303
|
+
cleanup();
|
|
3304
|
+
try {
|
|
3305
|
+
resolve(decode(xhr.status, xhr.responseText));
|
|
3306
|
+
} catch (err) {
|
|
3307
|
+
reject(err);
|
|
3308
|
+
}
|
|
3309
|
+
};
|
|
3310
|
+
xhr.onerror = () => {
|
|
3311
|
+
cleanup();
|
|
3312
|
+
reject(new BackendError("network", { code: "network_error", message: "Upload failed" }));
|
|
3313
|
+
};
|
|
3314
|
+
xhr.onabort = () => {
|
|
3315
|
+
cleanup();
|
|
3316
|
+
reject(abortError());
|
|
3317
|
+
};
|
|
3318
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
3319
|
+
xhr.send(form);
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3322
|
+
async function uploadViaFetch(url, form, headers, options) {
|
|
3323
|
+
let res;
|
|
3324
|
+
try {
|
|
3325
|
+
res = await fetch(url, { method: "POST", body: form, headers, signal: options.signal });
|
|
3326
|
+
} catch (e) {
|
|
3327
|
+
if (e instanceof Error && e.name === "AbortError") throw abortError();
|
|
3328
|
+
throw new BackendError("network", {
|
|
3329
|
+
code: "network_error",
|
|
3330
|
+
message: e instanceof Error ? e.message : "Upload failed"
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
3333
|
+
return decode(res.status, await res.text());
|
|
3334
|
+
}
|
|
3335
|
+
async function uploadEndpoint(resolveRt, name, options) {
|
|
3336
|
+
const rt = resolveRt();
|
|
3337
|
+
checkConstraints(options);
|
|
3338
|
+
const path = name.startsWith("/") ? name : `/${name}`;
|
|
3339
|
+
const url = `${rt.config.url}${path}`;
|
|
3340
|
+
const headers = await buildHeaders(rt, options.headers);
|
|
3341
|
+
const form = buildForm(options);
|
|
3342
|
+
const useXHR = options.onProgress !== void 0 && typeof XMLHttpRequest !== "undefined";
|
|
3343
|
+
return useXHR ? uploadViaXHR(url, form, headers, options) : uploadViaFetch(url, form, headers, options);
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
// src/pb.ts
|
|
3347
|
+
function createClientProxy(resolveRt, nsAccessor) {
|
|
3348
|
+
const base = {
|
|
3349
|
+
call(name, input, options) {
|
|
3350
|
+
return callEndpoint(resolveRt, name, input, options);
|
|
3351
|
+
},
|
|
3352
|
+
upload(name, options) {
|
|
3353
|
+
return uploadEndpoint(resolveRt, name, options);
|
|
3354
|
+
},
|
|
3355
|
+
get auth() {
|
|
3356
|
+
return resolveRt().auth;
|
|
3357
|
+
},
|
|
3358
|
+
get flags() {
|
|
3359
|
+
return resolveRt().flags;
|
|
3360
|
+
},
|
|
3361
|
+
get realtime() {
|
|
3362
|
+
return resolveRt().realtime;
|
|
3363
|
+
},
|
|
3364
|
+
get analytics() {
|
|
3365
|
+
return resolveRt().analytics;
|
|
3366
|
+
}
|
|
3367
|
+
};
|
|
3368
|
+
return new Proxy(base, {
|
|
3369
|
+
get(target, prop, receiver) {
|
|
3370
|
+
if (prop in target) return Reflect.get(target, prop, receiver);
|
|
3371
|
+
if (prop === "then") return void 0;
|
|
3372
|
+
if (typeof prop === "string") {
|
|
3373
|
+
const ns = nsAccessor(prop);
|
|
3374
|
+
if (ns !== void 0) return ns;
|
|
3375
|
+
}
|
|
3376
|
+
return void 0;
|
|
3377
|
+
},
|
|
3378
|
+
has(target, prop) {
|
|
3379
|
+
if (prop in target) return true;
|
|
3380
|
+
return typeof prop === "string" && prop !== "then" && getRegistry()[prop] !== void 0;
|
|
3381
|
+
}
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
3384
|
+
var pb = createClientProxy(getRuntime, getNamespace);
|
|
3385
|
+
function createBoundClient(rt) {
|
|
3386
|
+
return createClientProxy(() => rt, boundNamespaceAccessor(rt));
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// src/internal.ts
|
|
3390
|
+
function getRuntime() {
|
|
3391
|
+
const rt = palbeState().runtime;
|
|
3392
|
+
if (!rt) throw BackendError.notConfigured();
|
|
3393
|
+
return rt;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// src/next/shared.ts
|
|
3397
|
+
function requireGlobalConfig(hint) {
|
|
3398
|
+
try {
|
|
3399
|
+
return getRuntime().config;
|
|
3400
|
+
} catch {
|
|
3401
|
+
throw new BackendError("notConfigured", {
|
|
3402
|
+
code: "not_configured",
|
|
3403
|
+
message: `Palbe is not configured in this module graph. Run 'palbase web link' and ${hint}`
|
|
3404
|
+
});
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
var nextServerModule;
|
|
3408
|
+
async function importNextServer(caller) {
|
|
3409
|
+
nextServerModule ??= import("next/server");
|
|
3410
|
+
try {
|
|
3411
|
+
return await nextServerModule;
|
|
3412
|
+
} catch (cause) {
|
|
3413
|
+
nextServerModule = void 0;
|
|
3414
|
+
const detail = cause instanceof Error && cause.message ? ` (${cause.message})` : "";
|
|
3415
|
+
throw new BackendError("validation", {
|
|
3416
|
+
code: "next_required",
|
|
3417
|
+
message: `${caller}() requires Next.js \u2014 'next/server' could not be resolved${detail}.`
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
// src/next/callback.ts
|
|
3423
|
+
function safeNextPath(next) {
|
|
3424
|
+
if (!next?.startsWith("/")) return null;
|
|
3425
|
+
if (next[1] === "/" || next[1] === "\\") return null;
|
|
3426
|
+
return next;
|
|
3427
|
+
}
|
|
3428
|
+
function wireErrorCode(raw) {
|
|
3429
|
+
if (typeof raw === "object" && raw !== null) {
|
|
3430
|
+
const code = raw.error;
|
|
3431
|
+
if (typeof code === "string" && code !== "") return code;
|
|
3432
|
+
}
|
|
3433
|
+
return "oauth_exchange_failed";
|
|
3434
|
+
}
|
|
3435
|
+
function handleAuthCallback(opts) {
|
|
3436
|
+
return async (request) => {
|
|
3437
|
+
const config = requireGlobalConfig(
|
|
3438
|
+
"import the generated palbe.gen.ts once in app/layout.tsx \u2014 route handlers share the server module graph with the root layout."
|
|
3439
|
+
);
|
|
3440
|
+
const { NextResponse } = await importNextServer("handleAuthCallback");
|
|
3441
|
+
const fallback = opts?.defaultNext ?? "/";
|
|
3442
|
+
const redirect = (path, query) => {
|
|
3443
|
+
const url = new URL(path, request.nextUrl);
|
|
3444
|
+
for (const [key, value] of Object.entries(query ?? {})) url.searchParams.set(key, value);
|
|
3445
|
+
const response2 = NextResponse.redirect(url);
|
|
3446
|
+
response2.headers.set("cache-control", "private, no-store");
|
|
3447
|
+
return response2;
|
|
3448
|
+
};
|
|
3449
|
+
const params = request.nextUrl.searchParams;
|
|
3450
|
+
const providerError = params.get("error");
|
|
3451
|
+
if (providerError) {
|
|
3452
|
+
const query = { auth_error: providerError };
|
|
3453
|
+
const description = params.get("error_description");
|
|
3454
|
+
if (description) query.auth_error_description = description;
|
|
3455
|
+
return redirect(fallback, query);
|
|
3456
|
+
}
|
|
3457
|
+
const code = params.get("code");
|
|
3458
|
+
const state = params.get("state");
|
|
3459
|
+
const provider = params.get("provider");
|
|
3460
|
+
if (!code || !state || !provider) {
|
|
3461
|
+
return redirect(fallback, { auth_error: "invalid_callback" });
|
|
3462
|
+
}
|
|
3463
|
+
let exchange;
|
|
3464
|
+
try {
|
|
3465
|
+
exchange = await fetch(
|
|
3466
|
+
`${config.url}/auth/oauth/${encodeURIComponent(provider)}/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
|
3467
|
+
{ headers: { apikey: config.apiKey } }
|
|
3468
|
+
);
|
|
3469
|
+
} catch {
|
|
3470
|
+
return redirect(fallback, { auth_error: "oauth_exchange_failed" });
|
|
3471
|
+
}
|
|
3472
|
+
let raw = null;
|
|
3473
|
+
try {
|
|
3474
|
+
raw = await exchange.json();
|
|
3475
|
+
} catch {
|
|
3476
|
+
}
|
|
3477
|
+
if (!exchange.ok) return redirect(fallback, { auth_error: wireErrorCode(raw) });
|
|
3478
|
+
if (typeof raw === "object" && raw !== null) {
|
|
3479
|
+
const obj = raw;
|
|
3480
|
+
if (obj.mfa_required === true && typeof obj.mfa_token === "string") {
|
|
3481
|
+
const factors = Array.isArray(obj.mfa_factors) ? obj.mfa_factors.filter((f) => typeof f === "string") : [];
|
|
3482
|
+
return redirect(fallback, { mfa_token: obj.mfa_token, mfa_factors: factors.join(",") });
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
const wire = asWireAuthResult(raw);
|
|
3486
|
+
if (!wire) return redirect(fallback, { auth_error: "oauth_exchange_failed" });
|
|
3487
|
+
const response = redirect(safeNextPath(params.get("next")) ?? fallback);
|
|
3488
|
+
const write = encodeSessionCookiesDecoded(endpointRefFromApiKey(config.apiKey), {
|
|
3489
|
+
accessToken: wire.access_token,
|
|
3490
|
+
refreshToken: wire.refresh_token,
|
|
3491
|
+
expiresAt: Date.now() + wire.expires_in * 1e3
|
|
3492
|
+
});
|
|
3493
|
+
for (const { name, value } of write.set) {
|
|
3494
|
+
response.cookies.set(name, value, SESSION_COOKIE_ATTRS);
|
|
3495
|
+
}
|
|
3496
|
+
for (const name of write.clear) {
|
|
3497
|
+
response.cookies.set(name, "", { ...SESSION_COOKIE_ATTRS, maxAge: 0 });
|
|
3498
|
+
}
|
|
3499
|
+
return response;
|
|
3500
|
+
};
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
// src/next/middleware.ts
|
|
3504
|
+
var DEFAULT_REFRESH_MARGIN_MS = 6e4;
|
|
3505
|
+
var inflightRefreshes = /* @__PURE__ */ new Map();
|
|
3506
|
+
function refreshSingleFlight(config, refreshToken) {
|
|
3507
|
+
const existing = inflightRefreshes.get(refreshToken);
|
|
3508
|
+
if (existing) return existing;
|
|
3509
|
+
const pending = refresh(config, refreshToken).finally(() => {
|
|
3510
|
+
inflightRefreshes.delete(refreshToken);
|
|
3511
|
+
});
|
|
3512
|
+
inflightRefreshes.set(refreshToken, pending);
|
|
3513
|
+
return pending;
|
|
3514
|
+
}
|
|
3515
|
+
async function refresh(config, refreshToken) {
|
|
3516
|
+
try {
|
|
3517
|
+
const res = await fetch(`${config.url}/auth/token/refresh`, {
|
|
3518
|
+
method: "POST",
|
|
3519
|
+
headers: { apikey: config.apiKey, "content-type": "application/json" },
|
|
3520
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
3521
|
+
});
|
|
3522
|
+
if (res.status === 400 || res.status === 401 || res.status === 403) {
|
|
3523
|
+
return { kind: "terminal" };
|
|
3524
|
+
}
|
|
3525
|
+
if (!res.ok) return { kind: "transient" };
|
|
3526
|
+
const raw = await res.json();
|
|
3527
|
+
if (typeof raw === "object" && raw !== null) {
|
|
3528
|
+
const obj = raw;
|
|
3529
|
+
if (typeof obj.access_token === "string" && typeof obj.refresh_token === "string" && typeof obj.expires_in === "number") {
|
|
3530
|
+
return {
|
|
3531
|
+
kind: "rotated",
|
|
3532
|
+
session: {
|
|
3533
|
+
accessToken: obj.access_token,
|
|
3534
|
+
refreshToken: obj.refresh_token,
|
|
3535
|
+
expiresAt: Date.now() + obj.expires_in * 1e3
|
|
3536
|
+
}
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
return { kind: "transient" };
|
|
3541
|
+
} catch {
|
|
3542
|
+
return { kind: "transient" };
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
async function palbeMiddleware(request, opts) {
|
|
3546
|
+
const config = requireGlobalConfig(
|
|
3547
|
+
"import the generated palbe.gen.ts at the top of middleware.ts \u2014 Next bundles middleware as its own module graph, so the layout.tsx import does not reach it."
|
|
3548
|
+
);
|
|
3549
|
+
const { NextResponse } = await importNextServer("palbeMiddleware");
|
|
3550
|
+
const passThrough = () => opts?.response ?? NextResponse.next({ request });
|
|
3551
|
+
const ref = endpointRefFromApiKey(config.apiKey);
|
|
3552
|
+
const session = decodeSessionCookies((name) => request.cookies.get(name)?.value, ref);
|
|
3553
|
+
if (!session) return passThrough();
|
|
3554
|
+
if (session.expiresAt - Date.now() > (opts?.refreshMarginMs ?? DEFAULT_REFRESH_MARGIN_MS)) {
|
|
3555
|
+
return passThrough();
|
|
3556
|
+
}
|
|
3557
|
+
const outcome = await refreshSingleFlight(config, session.refreshToken);
|
|
3558
|
+
if (outcome.kind === "transient") return passThrough();
|
|
3559
|
+
const staleNames = clearedSessionCookieNames(ref, (name) => request.cookies.has(name));
|
|
3560
|
+
if (outcome.kind === "terminal") {
|
|
3561
|
+
for (const name of staleNames) request.cookies.delete(name);
|
|
3562
|
+
const response2 = opts?.response ?? NextResponse.next({ request });
|
|
3563
|
+
for (const name of staleNames) {
|
|
3564
|
+
response2.cookies.set(name, "", { ...SESSION_COOKIE_ATTRS, maxAge: 0 });
|
|
3565
|
+
}
|
|
3566
|
+
response2.headers.set("cache-control", "private, no-store");
|
|
3567
|
+
return response2;
|
|
3568
|
+
}
|
|
3569
|
+
const write = encodeSessionCookiesDecoded(ref, outcome.session);
|
|
3570
|
+
for (const name of staleNames) request.cookies.delete(name);
|
|
3571
|
+
for (const { name, value } of write.set) request.cookies.set(name, value);
|
|
3572
|
+
for (const name of write.clear) request.cookies.delete(name);
|
|
3573
|
+
const response = opts?.response ?? NextResponse.next({ request });
|
|
3574
|
+
const written = new Set(write.set.map((c) => c.name));
|
|
3575
|
+
for (const { name, value } of write.set) {
|
|
3576
|
+
response.cookies.set(name, value, SESSION_COOKIE_ATTRS);
|
|
3577
|
+
}
|
|
3578
|
+
for (const name of /* @__PURE__ */ new Set([...write.clear, ...staleNames])) {
|
|
3579
|
+
if (!written.has(name)) {
|
|
3580
|
+
response.cookies.set(name, "", { ...SESSION_COOKIE_ATTRS, maxAge: 0 });
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
response.headers.set("cache-control", "private, no-store");
|
|
3584
|
+
return response;
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3587
|
+
// src/next/server.ts
|
|
3588
|
+
function nextRequired(cause) {
|
|
3589
|
+
const detail = cause instanceof Error && cause.message ? ` (${cause.message})` : "";
|
|
3590
|
+
return new BackendError("validation", {
|
|
3591
|
+
code: "next_required",
|
|
3592
|
+
message: `pbServer() could not resolve a Next.js request cookie store: call it inside a Server Component, Server Action or Route Handler \u2014 or pass a store explicitly via pbServer({ cookies })${detail}.`
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
function isNextControlFlowError(err) {
|
|
3596
|
+
if (typeof err !== "object" || err === null) return false;
|
|
3597
|
+
const digest = err.digest;
|
|
3598
|
+
return typeof digest === "string" && (digest === "DYNAMIC_SERVER_USAGE" || digest.startsWith("NEXT_"));
|
|
3599
|
+
}
|
|
3600
|
+
async function nextCookieStore() {
|
|
3601
|
+
let cookies;
|
|
3602
|
+
try {
|
|
3603
|
+
({ cookies } = await import("next/headers"));
|
|
3604
|
+
} catch (cause) {
|
|
3605
|
+
throw nextRequired(cause);
|
|
3606
|
+
}
|
|
3607
|
+
try {
|
|
3608
|
+
return await cookies();
|
|
3609
|
+
} catch (cause) {
|
|
3610
|
+
if (isNextControlFlowError(cause)) throw cause;
|
|
3611
|
+
throw nextRequired(cause);
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
function removeCookie(store, name) {
|
|
3615
|
+
try {
|
|
3616
|
+
if (store.set) store.set(name, "", { ...SESSION_COOKIE_ATTRS, maxAge: 0 });
|
|
3617
|
+
else store.delete?.(name);
|
|
3618
|
+
} catch {
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
function serverCookieAdapter(store, endpointRef) {
|
|
3622
|
+
const present = (name) => store.get(name) !== void 0;
|
|
3623
|
+
return {
|
|
3624
|
+
load() {
|
|
3625
|
+
const stored = decodeSessionCookies((name) => store.get(name)?.value, endpointRef);
|
|
3626
|
+
if (!stored) return null;
|
|
3627
|
+
return stored.accessToken && stored.expiresAt > 0 ? {
|
|
3628
|
+
refreshToken: stored.refreshToken,
|
|
3629
|
+
accessToken: stored.accessToken,
|
|
3630
|
+
expiresAt: stored.expiresAt
|
|
3631
|
+
} : { refreshToken: stored.refreshToken };
|
|
3632
|
+
},
|
|
3633
|
+
save(session) {
|
|
3634
|
+
if (!session.accessToken || !session.expiresAt) return;
|
|
3635
|
+
for (const name of clearedSessionCookieNames(endpointRef, present)) {
|
|
3636
|
+
removeCookie(store, name);
|
|
3637
|
+
}
|
|
3638
|
+
const { set, clear } = encodeSessionCookiesDecoded(endpointRef, {
|
|
3639
|
+
accessToken: session.accessToken,
|
|
3640
|
+
refreshToken: session.refreshToken,
|
|
3641
|
+
expiresAt: session.expiresAt
|
|
3642
|
+
});
|
|
3643
|
+
for (const { name, value } of set) {
|
|
3644
|
+
try {
|
|
3645
|
+
store.set?.(name, value, { ...SESSION_COOKIE_ATTRS });
|
|
3646
|
+
} catch {
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
for (const name of clear) removeCookie(store, name);
|
|
3650
|
+
},
|
|
3651
|
+
clear() {
|
|
3652
|
+
for (const name of clearedSessionCookieNames(endpointRef, present)) {
|
|
3653
|
+
removeCookie(store, name);
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
};
|
|
3657
|
+
}
|
|
3658
|
+
async function pbServer(opts) {
|
|
3659
|
+
const config = requireGlobalConfig(
|
|
3660
|
+
"import the generated palbe.gen.ts once in app/layout.tsx \u2014 the root-layout import configures Server Components and Route Handlers too."
|
|
3661
|
+
);
|
|
3662
|
+
const store = opts?.cookies ?? await nextCookieStore();
|
|
3663
|
+
const ref = endpointRefFromApiKey(config.apiKey);
|
|
3664
|
+
const rt = buildRuntime({ ...config, storage: serverCookieAdapter(store, ref) });
|
|
3665
|
+
return createBoundClient(rt);
|
|
3666
|
+
}
|
|
3667
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3668
|
+
0 && (module.exports = {
|
|
3669
|
+
SESSION_COOKIE_ATTRS,
|
|
3670
|
+
clearedSessionCookieNames,
|
|
3671
|
+
decodeSessionCookies,
|
|
3672
|
+
encodeSessionCookies,
|
|
3673
|
+
encodeSessionCookiesDecoded,
|
|
3674
|
+
endpointRefFromApiKey,
|
|
3675
|
+
handleAuthCallback,
|
|
3676
|
+
palbeMiddleware,
|
|
3677
|
+
pbServer,
|
|
3678
|
+
sessionCookieName
|
|
3679
|
+
});
|
|
3680
|
+
//# sourceMappingURL=index.cjs.map
|