@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +292 -0
  3. package/dist/analytics-facade-DkOwkEpi.d.ts +454 -0
  4. package/dist/analytics-facade-t6UrFdn7.d.cts +454 -0
  5. package/dist/chunk-JVT65V4E.js +3384 -0
  6. package/dist/chunk-JVT65V4E.js.map +1 -0
  7. package/dist/chunk-VJXFABBW.js +94 -0
  8. package/dist/chunk-VJXFABBW.js.map +1 -0
  9. package/dist/errors-fDoNdTrJ.d.cts +35 -0
  10. package/dist/errors-fDoNdTrJ.d.ts +35 -0
  11. package/dist/index.cjs +2394 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +11 -0
  14. package/dist/index.d.ts +11 -0
  15. package/dist/index.js +27 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/internal.cjs +3403 -0
  18. package/dist/internal.cjs.map +1 -0
  19. package/dist/internal.d.cts +49 -0
  20. package/dist/internal.d.ts +49 -0
  21. package/dist/internal.js +19 -0
  22. package/dist/internal.js.map +1 -0
  23. package/dist/next/client.cjs +3131 -0
  24. package/dist/next/client.cjs.map +1 -0
  25. package/dist/next/client.d.cts +19 -0
  26. package/dist/next/client.d.ts +19 -0
  27. package/dist/next/client.js +75 -0
  28. package/dist/next/client.js.map +1 -0
  29. package/dist/next/index.cjs +3680 -0
  30. package/dist/next/index.cjs.map +1 -0
  31. package/dist/next/index.d.cts +238 -0
  32. package/dist/next/index.d.ts +238 -0
  33. package/dist/next/index.js +301 -0
  34. package/dist/next/index.js.map +1 -0
  35. package/dist/pb-BmgkAe97.d.ts +54 -0
  36. package/dist/pb-Cudze7Kb.d.cts +54 -0
  37. package/dist/react/index.cjs +649 -0
  38. package/dist/react/index.cjs.map +1 -0
  39. package/dist/react/index.d.cts +86 -0
  40. package/dist/react/index.d.ts +86 -0
  41. package/dist/react/index.js +156 -0
  42. package/dist/react/index.js.map +1 -0
  43. package/dist/storage-BPaeSG8K.d.cts +21 -0
  44. package/dist/storage-BPaeSG8K.d.ts +21 -0
  45. 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