@zweer/mage-space-client 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -18,12 +18,38 @@ var AuthError = class extends MageSpaceError {
18
18
  };
19
19
  /** Action-hash discovery failures (page/chunk fetch, empty registry). */
20
20
  var DiscoveryError = class extends MageSpaceError {
21
+ /**
22
+ * `true` when the action name was absent even after a forced re-discovery
23
+ * (a fallback trigger); `false` for scrape failures (no chunks/no hashes).
24
+ */
25
+ notFound;
21
26
  constructor(message, options) {
22
27
  super(message, options);
23
28
  this.name = "DiscoveryError";
29
+ this.notFound = options?.notFound ?? false;
30
+ }
31
+ };
32
+ /**
33
+ * A required action is unavailable on the active host (a persistent 404 / HTML
34
+ * fallback that survived one in-place re-discovery). A fallback trigger.
35
+ */
36
+ var ActionUnavailableError = class extends MageSpaceError {
37
+ action;
38
+ targetKey;
39
+ constructor(action, targetKey, options) {
40
+ super(`Action "${action}" is unavailable on target "${targetKey}"`, options);
41
+ this.action = action;
42
+ this.targetKey = targetKey;
43
+ this.name = "ActionUnavailableError";
24
44
  }
25
45
  };
26
- /** A Server Action returned 404, indicating a stale (post-deploy) hash. */
46
+ /**
47
+ * A Server Action returned 404, indicating a stale (post-deploy) hash.
48
+ *
49
+ * @deprecated Legacy export kept for backward compatibility; no longer thrown.
50
+ * The current signal for a definitively unavailable action is
51
+ * {@link ActionUnavailableError}.
52
+ */
27
53
  var StaleActionError = class extends MageSpaceError {
28
54
  action;
29
55
  constructor(action, options) {
@@ -215,7 +241,7 @@ var ActionRegistry = class {
215
241
  snapshot = await this.refresh();
216
242
  hash = snapshot.hashes[action];
217
243
  }
218
- if (hash === void 0) throw new DiscoveryError(`Action "${action}" not found after discovery`);
244
+ if (hash === void 0) throw new DiscoveryError(`Action "${action}" not found after discovery`, { notFound: true });
219
245
  return {
220
246
  hash,
221
247
  deploymentId: snapshot.deploymentId
@@ -327,9 +353,11 @@ function resolveFlightValue(value, rows, seen = /* @__PURE__ */ new Set()) {
327
353
  if (ref) {
328
354
  const refId = ref[1];
329
355
  if (refId === void 0 || seen.has(refId)) return;
330
- seen.add(refId);
331
356
  const target = rows.get(refId);
332
- return target === void 0 ? void 0 : resolveFlightValue(target, rows, seen);
357
+ if (target === void 0) return;
358
+ const nextSeen = new Set(seen);
359
+ nextSeen.add(refId);
360
+ return resolveFlightValue(target, rows, nextSeen);
333
361
  }
334
362
  if (value.startsWith("$$")) return value.slice(1);
335
363
  if (value.startsWith("$D")) return new Date(value.slice(2));
@@ -615,7 +643,7 @@ const SESSION_DEFAULT_TTL_MS = 864e5;
615
643
  const ID_TOKEN_DEFAULT_TTL_S = 3600;
616
644
  var AuthManager = class {
617
645
  refreshToken;
618
- http;
646
+ getHttp;
619
647
  fetchImpl;
620
648
  apiKey;
621
649
  sessionPath;
@@ -623,11 +651,19 @@ var AuthManager = class {
623
651
  session = null;
624
652
  constructor(opts) {
625
653
  this.refreshToken = opts.refreshToken;
626
- this.http = opts.http;
654
+ this.getHttp = opts.getHttp;
627
655
  this.fetchImpl = opts.fetch;
628
656
  this.apiKey = opts.firebaseApiKey ?? DEFAULT_FIREBASE_API_KEY;
629
657
  this.sessionPath = opts.sessionPath ?? "/creations";
630
658
  }
659
+ /**
660
+ * Drop the cached `__session` cookie so the next {@link getSession} mints a
661
+ * fresh one against the (newly) active host. The Firebase token chain is kept
662
+ * because it is host-independent.
663
+ */
664
+ resetSession() {
665
+ this.session = null;
666
+ }
631
667
  /** Return a valid Firebase ID token, refreshing via the refresh token if needed. */
632
668
  async getIdToken() {
633
669
  if (this.tokens !== null && Date.now() < this.tokens.expiresAt - EXPIRY_BUFFER_MS) return this.tokens.idToken;
@@ -642,7 +678,7 @@ var AuthManager = class {
642
678
  async getSession() {
643
679
  if (this.session !== null && Date.now() < this.session.expiresAt - EXPIRY_BUFFER_MS) return this.session.cookie;
644
680
  const idToken = await this.getIdToken();
645
- const cookie = extractSessionCookie((await this.http.callAction({
681
+ const cookie = extractSessionCookie((await this.getHttp().callAction({
646
682
  action: "createUserSession",
647
683
  path: this.sessionPath,
648
684
  args: [idToken]
@@ -700,9 +736,58 @@ function extractSessionCookie(headers) {
700
736
  return null;
701
737
  }
702
738
  //#endregion
739
+ //#region lib/targets.ts
740
+ /** Resolve a logical action to its host-specific wire name (identity if unlisted). */
741
+ function resolveActionName(target, logical) {
742
+ return target.actionNames[logical] ?? logical;
743
+ }
744
+ /**
745
+ * Production target.
746
+ *
747
+ * @remarks
748
+ * Maps each logical action to the wire name the current production deploy
749
+ * actually serves (all VERIFIED against `lib/seed.ts` / the existing MSW tests):
750
+ * `submitGeneration → runArchitecture`, `cancelJob → cancelArchitectureJob`,
751
+ * `listHistory → getHistoryPaginated`, `listCreations → getCreationsPaginatedParallel`,
752
+ * and `getHistoryById → getHistoryById` (identity). The table is explicit rather
753
+ * than empty so production behaviour is reproduced exactly regardless of the
754
+ * logical-name vocabulary.
755
+ */
756
+ const PRODUCTION_TARGET = {
757
+ key: "production",
758
+ baseUrl: "https://www.mage.space",
759
+ actionNames: {
760
+ submitGeneration: "runArchitecture",
761
+ getHistoryById: "getHistoryById",
762
+ cancelJob: "cancelArchitectureJob",
763
+ listHistory: "getHistoryPaginated",
764
+ listCreations: "getCreationsPaginatedParallel"
765
+ },
766
+ pollStrategy: "byId"
767
+ };
768
+ /**
769
+ * Beta target. Beta facts are assumptions pending a reverse-engineering capture
770
+ * under `docs/api/beta` — treat them as unverified until that doc lands.
771
+ */
772
+ const BETA_TARGET = {
773
+ key: "beta",
774
+ baseUrl: "https://beta.mage.space",
775
+ actionNames: {
776
+ submitGeneration: "runArchitecture",
777
+ cancelJob: "cancelArchitectureJob",
778
+ listHistory: "getHistoryPaginated",
779
+ listCreations: "getCreationsPaginated"
780
+ },
781
+ pollStrategy: "paginated"
782
+ };
783
+ /** Default ordered fallback chain: production first, then beta. */
784
+ const DEFAULT_TARGET_CHAIN = [PRODUCTION_TARGET, BETA_TARGET];
785
+ //#endregion
703
786
  //#region lib/generation.ts
704
787
  /** Generation actions are served from the `/explore` page. */
705
788
  const GENERATION_PATH = "/explore";
789
+ /** Mirrors HistoryService.HISTORY_PATH ('/creations'); kept local to avoid coupling the two services. */
790
+ const HISTORY_PATH$1 = "/creations";
706
791
  /** RSC sentinel for an absent value. */
707
792
  const UNDEFINED$1 = "$undefined";
708
793
  /** Default concept id for mango-v3-pro (from docs — verify per model via rev-eng). */
@@ -714,9 +799,14 @@ const DEFAULT_TIMEOUT_MS = 3e5;
714
799
  var GenerationService = class {
715
800
  http;
716
801
  auth;
717
- constructor(http, auth) {
802
+ target;
803
+ /** Single-status-read primitive; overridable so the client can make it fallback-aware. */
804
+ pollOnce;
805
+ constructor(http, auth, target, pollOnce) {
718
806
  this.http = http;
719
807
  this.auth = auth;
808
+ this.target = target;
809
+ this.pollOnce = pollOnce ?? ((id) => this.getHistory(id));
720
810
  }
721
811
  /** Submit an image generation job and return its `historyId`. */
722
812
  async submit(opts) {
@@ -768,7 +858,7 @@ var GenerationService = class {
768
858
  generationMode
769
859
  }];
770
860
  const { data } = await this.http.callAction({
771
- action: "runArchitecture",
861
+ action: resolveActionName(this.target, "submitGeneration"),
772
862
  path: GENERATION_PATH,
773
863
  args,
774
864
  session
@@ -781,22 +871,68 @@ var GenerationService = class {
781
871
  if (typeof historyId !== "string") throw new GenerationError("runArchitecture did not return a history_id");
782
872
  return { historyId };
783
873
  }
784
- /** Fetch a single history record by id. */
874
+ /** Fetch a single history record by id, dispatching on the target's poll strategy. */
785
875
  async getHistory(historyId) {
876
+ if (this.target.pollStrategy === "paginated") return this.getHistoryPaginated(historyId);
786
877
  const session = await this.auth.getSession();
787
878
  const { data } = await this.http.callAction({
788
- action: "getHistoryById",
879
+ action: resolveActionName(this.target, "getHistoryById"),
789
880
  path: GENERATION_PATH,
790
881
  args: [historyId],
791
882
  session
792
883
  });
793
884
  return data;
794
885
  }
886
+ /**
887
+ * Beta status read: `getHistoryById` is absent, so page `getHistoryPaginated`
888
+ * and find the item whose `id === historyId`.
889
+ *
890
+ * @remarks
891
+ * ASSUMED (pending docs/api/beta): each page window is swept in two phases —
892
+ * `running` first, then the raw `$undefined` sentinel (terminal items) — since
893
+ * which filter surfaces a running job on beta is not yet documented. Bounded by
894
+ * {@link PAGINATED_POLL_MAX_PAGES} pages of {@link PAGINATED_POLL_PAGE_SIZE}.
895
+ */
896
+ async getHistoryPaginated(historyId) {
897
+ const uid = await this.auth.getUserId();
898
+ const limit = 20;
899
+ let offset = 0;
900
+ for (let pageIndex = 0; pageIndex < 10; pageIndex += 1) {
901
+ for (const status of ["running", UNDEFINED$1]) {
902
+ const page = await this.fetchHistoryPage(uid, status, limit, offset);
903
+ const found = page.histories.find((h) => h.id === historyId);
904
+ if (found !== void 0) return found;
905
+ if (status === UNDEFINED$1 && !page.hasMore) throw new GenerationError(`history ${historyId} not found on beta`);
906
+ }
907
+ offset += limit;
908
+ }
909
+ throw new GenerationError(`history ${historyId} not found on beta`);
910
+ }
911
+ /** Fetch one page of history via `listHistory` (resolved per target) on `/creations`. */
912
+ async fetchHistoryPage(uid, status, limit, offset) {
913
+ const session = await this.auth.getSession();
914
+ const filters = {
915
+ status,
916
+ type: UNDEFINED$1
917
+ };
918
+ const { data } = await this.http.callAction({
919
+ action: resolveActionName(this.target, "listHistory"),
920
+ path: HISTORY_PATH$1,
921
+ args: [
922
+ uid,
923
+ limit,
924
+ offset,
925
+ filters
926
+ ],
927
+ session
928
+ });
929
+ return unwrapResult(data);
930
+ }
795
931
  /** Cancel a running generation job. */
796
932
  async cancelJob(historyId) {
797
933
  const session = await this.auth.getSession();
798
934
  await this.http.callAction({
799
- action: "cancelArchitectureJob",
935
+ action: resolveActionName(this.target, "cancelJob"),
800
936
  path: GENERATION_PATH,
801
937
  args: [historyId],
802
938
  session
@@ -809,7 +945,7 @@ var GenerationService = class {
809
945
  const deadline = Date.now() + timeoutMs;
810
946
  for (;;) {
811
947
  if (opts.signal?.aborted === true) throw new TimeoutError("waitForResult was aborted");
812
- const item = await this.getHistory(historyId);
948
+ const item = await this.pollOnce(historyId);
813
949
  if (item.status === "success") return item;
814
950
  if (item.status === "error") throw new GenerationError(`Generation failed: ${JSON.stringify(item.error ?? null)}`);
815
951
  if (Date.now() > deadline) throw new TimeoutError(`Generation timed out after ${timeoutMs}ms (last status: ${item.status})`);
@@ -855,9 +991,11 @@ const UNDEFINED = "$undefined";
855
991
  var HistoryService = class {
856
992
  http;
857
993
  auth;
858
- constructor(http, auth) {
994
+ target;
995
+ constructor(http, auth, target) {
859
996
  this.http = http;
860
997
  this.auth = auth;
998
+ this.target = target;
861
999
  }
862
1000
  /** List generation history for the authenticated user. */
863
1001
  async list(opts = {}) {
@@ -869,7 +1007,7 @@ var HistoryService = class {
869
1007
  type: UNDEFINED
870
1008
  };
871
1009
  const { data } = await this.http.callAction({
872
- action: "getHistoryPaginated",
1010
+ action: resolveActionName(this.target, "listHistory"),
873
1011
  path: HISTORY_PATH,
874
1012
  args: [
875
1013
  uid,
@@ -887,7 +1025,7 @@ var HistoryService = class {
887
1025
  const limit = opts.limit ?? DEFAULT_LIMIT;
888
1026
  const offset = opts.offset ?? 0;
889
1027
  const { data } = await this.http.callAction({
890
- action: "getCreationsPaginatedParallel",
1028
+ action: resolveActionName(this.target, "listCreations"),
891
1029
  path: HISTORY_PATH,
892
1030
  args: [
893
1031
  uid,
@@ -972,74 +1110,6 @@ var HistoryService = class {
972
1110
  }
973
1111
  };
974
1112
  //#endregion
975
- //#region lib/http.ts
976
- const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
977
- /**
978
- * Known RSC error digests mapped to human-readable messages.
979
- * Discovered via reverse engineering; the server does not expose error details.
980
- */
981
- const KNOWN_DIGESTS = {
982
- "709776019": "Username already taken (duplicate character/reference username)",
983
- "1853426377": "Invalid username (must be 1–15 lowercase chars, start with a letter, only a-z 0-9 _ -)"
984
- };
985
- var HttpClient = class {
986
- registry;
987
- fetchImpl;
988
- baseUrl;
989
- userAgent;
990
- constructor(registry, fetchImpl, baseUrl, userAgent = DEFAULT_USER_AGENT) {
991
- this.registry = registry;
992
- this.fetchImpl = fetchImpl;
993
- this.baseUrl = baseUrl;
994
- this.userAgent = userAgent;
995
- }
996
- async callAction(params, retried = false) {
997
- const { hash, deploymentId } = await this.registry.getHash(params.action);
998
- const headers = {
999
- accept: "text/x-component",
1000
- "content-type": "text/plain;charset=UTF-8",
1001
- "next-action": hash,
1002
- "x-deployment-id": deploymentId,
1003
- origin: this.baseUrl,
1004
- referer: `${this.baseUrl}${params.path}`,
1005
- "user-agent": this.userAgent
1006
- };
1007
- if (params.session !== void 0) headers.cookie = `__session=${params.session}`;
1008
- const res = await this.fetchImpl(`${this.baseUrl}${params.path}`, {
1009
- method: "POST",
1010
- headers,
1011
- body: JSON.stringify(params.args)
1012
- });
1013
- const contentType = res.headers.get("content-type") ?? "";
1014
- const isHtmlFallback = res.ok && contentType.includes("text/html");
1015
- if ((res.status === 404 || isHtmlFallback) && !retried) {
1016
- await safeText(res);
1017
- await this.registry.invalidate();
1018
- return this.callAction(params, true);
1019
- }
1020
- if (!res.ok) {
1021
- const body = await safeText(res);
1022
- const digest = body.match(/E\{"digest":"([^"]+)"\}/)?.[1];
1023
- const knownDigest = digest !== void 0 ? KNOWN_DIGESTS[digest] : void 0;
1024
- const hint = knownDigest ? ` — ${knownDigest}` : digest ? ` (server digest: ${digest})` : "";
1025
- const raw = body.length > 0 ? ` — ${body.slice(0, 200)}` : "";
1026
- throw new MageSpaceError(`Action "${params.action}" failed: HTTP ${res.status}${hint}${raw}`);
1027
- }
1028
- return {
1029
- data: parseServerActionResponse(await res.text()),
1030
- headers: res.headers,
1031
- deploymentId: res.headers.get("x-deployment-id") ?? deploymentId
1032
- };
1033
- }
1034
- };
1035
- async function safeText(res) {
1036
- try {
1037
- return await res.text();
1038
- } catch {
1039
- return "";
1040
- }
1041
- }
1042
- //#endregion
1043
1113
  //#region lib/references.ts
1044
1114
  /** Server-enforced username constraints (shared with characters, module 64428). */
1045
1115
  const USERNAME_MAX = 15;
@@ -1280,99 +1350,301 @@ const SEED_SNAPSHOT = {
1280
1350
  }
1281
1351
  };
1282
1352
  //#endregion
1353
+ //#region lib/http.ts
1354
+ const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
1355
+ /**
1356
+ * HTTP statuses that mean "this action is unavailable on this host". A stale
1357
+ * (post-deploy) action hash surfaces as a 404 on the production host and as a
1358
+ * 409 ("Server Action unavailable") on the beta host — both mean the same
1359
+ * thing: invalidate the registry, re-discover, and (if still unavailable) fall
1360
+ * back to the next host.
1361
+ */
1362
+ const UNAVAILABLE_STATUSES = /* @__PURE__ */ new Set([404, 409]);
1363
+ /**
1364
+ * Known RSC error digests mapped to human-readable messages.
1365
+ * Discovered via reverse engineering; the server does not expose error details.
1366
+ */
1367
+ const KNOWN_DIGESTS = {
1368
+ "709776019": "Username already taken (duplicate character/reference username)",
1369
+ "1853426377": "Invalid username (must be 1–15 lowercase chars, start with a letter, only a-z 0-9 _ -)"
1370
+ };
1371
+ var HttpClient = class {
1372
+ registry;
1373
+ fetchImpl;
1374
+ baseUrl;
1375
+ targetKey;
1376
+ userAgent;
1377
+ constructor(registry, fetchImpl, baseUrl, targetKey, userAgent = DEFAULT_USER_AGENT) {
1378
+ this.registry = registry;
1379
+ this.fetchImpl = fetchImpl;
1380
+ this.baseUrl = baseUrl;
1381
+ this.targetKey = targetKey;
1382
+ this.userAgent = userAgent;
1383
+ }
1384
+ /** A response that means the action is unavailable (stale hash or gone). */
1385
+ isUnavailable(res) {
1386
+ const contentType = res.headers.get("content-type") ?? "";
1387
+ const isHtmlFallback = res.ok && contentType.includes("text/html");
1388
+ return UNAVAILABLE_STATUSES.has(res.status) || isHtmlFallback;
1389
+ }
1390
+ async callAction(params, retried = false) {
1391
+ const { hash, deploymentId } = await this.registry.getHash(params.action);
1392
+ const headers = {
1393
+ accept: "text/x-component",
1394
+ "content-type": "text/plain;charset=UTF-8",
1395
+ "next-action": hash,
1396
+ "x-deployment-id": deploymentId,
1397
+ origin: this.baseUrl,
1398
+ referer: `${this.baseUrl}${params.path}`,
1399
+ "user-agent": this.userAgent
1400
+ };
1401
+ if (params.session !== void 0) headers.cookie = `__session=${params.session}`;
1402
+ const res = await this.fetchImpl(`${this.baseUrl}${params.path}`, {
1403
+ method: "POST",
1404
+ headers,
1405
+ body: JSON.stringify(params.args)
1406
+ });
1407
+ if (this.isUnavailable(res) && !retried) {
1408
+ await safeText(res);
1409
+ await this.registry.invalidate();
1410
+ return this.callAction(params, true);
1411
+ }
1412
+ if (this.isUnavailable(res)) {
1413
+ await safeText(res);
1414
+ throw new ActionUnavailableError(params.action, this.targetKey);
1415
+ }
1416
+ if (!res.ok) {
1417
+ const body = await safeText(res);
1418
+ const digest = body.match(/E\{"digest":"([^"]+)"\}/)?.[1];
1419
+ const knownDigest = digest !== void 0 ? KNOWN_DIGESTS[digest] : void 0;
1420
+ const hint = knownDigest ? ` — ${knownDigest}` : digest ? ` (server digest: ${digest})` : "";
1421
+ const raw = body.length > 0 ? ` — ${body.slice(0, 200)}` : "";
1422
+ throw new MageSpaceError(`Action "${params.action}" failed: HTTP ${res.status}${hint}${raw}`);
1423
+ }
1424
+ return {
1425
+ data: parseServerActionResponse(await res.text()),
1426
+ headers: res.headers,
1427
+ deploymentId: res.headers.get("x-deployment-id") ?? deploymentId
1428
+ };
1429
+ }
1430
+ };
1431
+ async function safeText(res) {
1432
+ try {
1433
+ return await res.text();
1434
+ } catch {
1435
+ return "";
1436
+ }
1437
+ }
1438
+ //#endregion
1439
+ //#region lib/target-context.ts
1440
+ /**
1441
+ * A {@link TargetContext} bundles one host's {@link ActionRegistry} and
1442
+ * {@link HttpClient} so the client can hold one pair per target and switch by
1443
+ * selecting the other pair (rather than mutating a shared `baseUrl`).
1444
+ */
1445
+ /**
1446
+ * Build the registry + HTTP client for a single target.
1447
+ *
1448
+ * @remarks
1449
+ * The caller's `cache` and the production `seed` are production-only concerns:
1450
+ * the bundled seed holds production hashes and the single-slot cache cannot be
1451
+ * namespaced per host. Non-production targets always discover fresh with their
1452
+ * own {@link InMemoryActionCache} and no seed.
1453
+ */
1454
+ function createTargetContext(target, deps) {
1455
+ const isProduction = target.key === "production";
1456
+ const registry = new ActionRegistry({
1457
+ fetch: deps.fetch,
1458
+ baseUrl: target.baseUrl,
1459
+ cache: isProduction ? deps.cache : new InMemoryActionCache(),
1460
+ seed: isProduction ? deps.seed : void 0
1461
+ });
1462
+ return {
1463
+ target,
1464
+ registry,
1465
+ http: new HttpClient(registry, deps.fetch, target.baseUrl, target.key, deps.userAgent)
1466
+ };
1467
+ }
1468
+ //#endregion
1283
1469
  //#region lib/client.ts
1284
1470
  /**
1285
1471
  * {@link MageSpaceClient} — the public facade that wires together action
1286
1472
  * discovery, authentication, generation, and history.
1473
+ *
1474
+ * The client models each mage.space host as a {@link Target} and holds an
1475
+ * ordered chain (production first, then beta). When the active host can no
1476
+ * longer serve a required action, the client advances to the next target and
1477
+ * retries once. The switch is sticky for the lifetime of the instance.
1287
1478
  */
1288
- const DEFAULT_BASE_URL = "https://www.mage.space";
1289
1479
  var MageSpaceClient = class {
1290
- registry;
1291
1480
  auth;
1292
- generationService;
1293
- historyService;
1294
- /** Character management (search, CRUD, pagination, uploads). */
1481
+ chain;
1482
+ auto;
1483
+ contextDeps;
1484
+ contexts = /* @__PURE__ */ new Map();
1485
+ activeIndex;
1486
+ activeContext;
1487
+ activeBundle;
1488
+ /**
1489
+ * Character management (search, CRUD, pagination, uploads).
1490
+ *
1491
+ * @remarks
1492
+ * Bound to the initial host (production by default) and does not follow an
1493
+ * automatic beta fallback. Construct a client with `fallback: { start: 'beta' }`
1494
+ * to use these against beta.
1495
+ */
1295
1496
  characters;
1296
- /** Reference asset management (upload, create, update, list, feed). */
1497
+ /**
1498
+ * Reference asset management (upload, create, update, list, feed).
1499
+ *
1500
+ * @remarks
1501
+ * Bound to the initial host (production by default) and does not follow an
1502
+ * automatic beta fallback. Construct a client with `fallback: { start: 'beta' }`
1503
+ * to use these against beta.
1504
+ */
1297
1505
  references;
1298
1506
  constructor(options) {
1299
1507
  if (!options.refreshToken) throw new Error("MageSpaceClient requires a refreshToken");
1300
- const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
1301
1508
  const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
1302
- this.registry = new ActionRegistry({
1509
+ const fallback = options.fallback;
1510
+ const baseUrlPinned = typeof options.baseUrl === "string" && options.baseUrl.length > 0;
1511
+ if (baseUrlPinned && fallback !== void 0 && (fallback.chain !== void 0 || fallback.start !== void 0 || fallback.auto !== void 0)) throw new Error("baseUrl pins a single host and cannot be combined with fallback options");
1512
+ if (baseUrlPinned) {
1513
+ this.chain = [{
1514
+ key: "production",
1515
+ baseUrl: options.baseUrl,
1516
+ actionNames: {},
1517
+ pollStrategy: "byId"
1518
+ }];
1519
+ this.auto = false;
1520
+ } else {
1521
+ const chain = fallback?.chain ?? DEFAULT_TARGET_CHAIN;
1522
+ if (chain.length === 0) throw new Error("fallback.chain must be a non-empty array");
1523
+ this.chain = chain;
1524
+ this.auto = fallback?.auto ?? true;
1525
+ }
1526
+ const startKey = baseUrlPinned ? "production" : fallback?.start ?? "production";
1527
+ const startIndex = this.chain.findIndex((t) => t.key === startKey);
1528
+ if (startIndex === -1) throw new Error(`fallback.start '${startKey}' is not in the target chain`);
1529
+ this.contextDeps = {
1303
1530
  fetch: fetchImpl,
1304
- baseUrl,
1531
+ userAgent: options.userAgent,
1305
1532
  cache: options.cache,
1306
1533
  seed: options.seed ?? SEED_SNAPSHOT
1307
- });
1308
- const http = new HttpClient(this.registry, fetchImpl, baseUrl, options.userAgent);
1534
+ };
1535
+ this.activeIndex = startIndex;
1536
+ this.activeContext = this.getContext(startIndex);
1309
1537
  this.auth = new AuthManager({
1310
1538
  refreshToken: options.refreshToken,
1311
- http,
1539
+ getHttp: () => this.activeContext.http,
1312
1540
  fetch: fetchImpl,
1313
1541
  firebaseApiKey: options.firebaseApiKey
1314
1542
  });
1315
- this.generationService = new GenerationService(http, this.auth);
1316
- this.historyService = new HistoryService(http, this.auth);
1317
- this.characters = new CharactersService(http, this.auth);
1318
- this.references = new ReferencesService(http, this.auth);
1543
+ this.activeBundle = this.buildBundle(this.activeContext);
1544
+ this.characters = new CharactersService(this.activeContext.http, this.auth);
1545
+ this.references = new ReferencesService(this.activeContext.http, this.auth);
1546
+ }
1547
+ /** Lazily build (and memoise) the {@link TargetContext} for a chain index. */
1548
+ getContext(index) {
1549
+ const target = this.chain[index];
1550
+ const existing = this.contexts.get(target.key);
1551
+ if (existing !== void 0) return existing;
1552
+ const context = createTargetContext(target, this.contextDeps);
1553
+ this.contexts.set(target.key, context);
1554
+ return context;
1555
+ }
1556
+ /** Build the generation/history service bundle for a context. */
1557
+ buildBundle(context) {
1558
+ const pollOnce = (id) => this.withFallback((bundle) => bundle.generationService.getHistory(id));
1559
+ return {
1560
+ target: context.target,
1561
+ generationService: new GenerationService(context.http, this.auth, context.target, pollOnce),
1562
+ historyService: new HistoryService(context.http, this.auth, context.target)
1563
+ };
1564
+ }
1565
+ /** Advance to the next target (sticky). Returns `null` if none or `auto` is off. */
1566
+ advanceTarget() {
1567
+ if (this.auto === false || this.activeIndex >= this.chain.length - 1) return null;
1568
+ this.activeIndex += 1;
1569
+ this.activeContext = this.getContext(this.activeIndex);
1570
+ this.activeBundle = this.buildBundle(this.activeContext);
1571
+ return this.activeBundle;
1572
+ }
1573
+ /** Whether an error is a definitive action-unavailable signal (a fallback trigger). */
1574
+ shouldFallback(err) {
1575
+ return err instanceof ActionUnavailableError || err instanceof DiscoveryError && err.notFound === true;
1576
+ }
1577
+ /** Run `op` against the active bundle, switching to the next target once on a trigger. */
1578
+ async withFallback(op) {
1579
+ try {
1580
+ return await op(this.activeBundle);
1581
+ } catch (err) {
1582
+ if (!this.shouldFallback(err)) throw err;
1583
+ if (this.advanceTarget() === null) throw err;
1584
+ this.auth.resetSession();
1585
+ return op(this.activeBundle);
1586
+ }
1319
1587
  }
1320
1588
  /** Submit a generation job; returns its `historyId`. */
1321
1589
  generate(options) {
1322
- return this.generationService.submit(options);
1590
+ return this.withFallback((b) => b.generationService.submit(options));
1323
1591
  }
1324
1592
  /** Submit a video generation job (Berry-2 family); returns its `historyId`. */
1325
1593
  generateVideo(options) {
1326
- return this.generationService.submitVideo(options);
1594
+ return this.withFallback((b) => b.generationService.submitVideo(options));
1327
1595
  }
1328
1596
  /** Fetch the current state of a generation by id. */
1329
1597
  getResult(historyId) {
1330
- return this.generationService.getHistory(historyId);
1598
+ return this.withFallback((b) => b.generationService.getHistory(historyId));
1331
1599
  }
1332
1600
  /** Poll until a generation finishes (or fails / times out). */
1333
1601
  waitForResult(historyId, options) {
1334
- return this.generationService.waitForResult(historyId, options);
1602
+ return this.withFallback((b) => b.generationService.waitForResult(historyId, options));
1335
1603
  }
1336
1604
  /** Cancel a running generation job. */
1337
1605
  cancelJob(historyId) {
1338
- return this.generationService.cancelJob(historyId);
1606
+ return this.withFallback((b) => b.generationService.cancelJob(historyId));
1339
1607
  }
1340
1608
  /** List past generations for the authenticated user. */
1341
1609
  listHistory(options) {
1342
- return this.historyService.list(options);
1610
+ return this.withFallback((b) => b.historyService.list(options));
1343
1611
  }
1344
1612
  /** List the user's permanently saved creations. */
1345
1613
  listCreations(options) {
1346
- return this.historyService.listCreations(options);
1614
+ return this.withFallback((b) => b.historyService.listCreations(options));
1347
1615
  }
1348
1616
  /** Save a completed generation result as a permanent creation. */
1349
1617
  saveCreation(result) {
1350
- return this.historyService.saveCreation(result);
1618
+ return this.withFallback((b) => b.historyService.saveCreation(result));
1351
1619
  }
1352
1620
  /** Permanently delete a history item. Irreversible — requires `{ confirm: true }`. */
1353
1621
  deleteHistory(historyId, options) {
1354
- return this.historyService.deleteHistory(historyId, options);
1622
+ return this.withFallback((b) => b.historyService.deleteHistory(historyId, options));
1355
1623
  }
1356
1624
  /** Permanently delete many history items. Irreversible — requires `{ confirm: true }`. */
1357
1625
  deleteManyHistories(historyIds, options) {
1358
- return this.historyService.deleteManyHistories(historyIds, options);
1626
+ return this.withFallback((b) => b.historyService.deleteManyHistories(historyIds, options));
1359
1627
  }
1360
1628
  /** Permanently delete a saved creation. Irreversible — requires `{ confirm: true }`. */
1361
1629
  deleteCreation(creationId, options) {
1362
- return this.historyService.deleteCreation(creationId, options);
1630
+ return this.withFallback((b) => b.historyService.deleteCreation(creationId, options));
1363
1631
  }
1364
1632
  /** Permanently delete many creations. Irreversible — requires `{ confirm: true }`. */
1365
1633
  deleteManyCreations(creationIds, options) {
1366
- return this.historyService.deleteManyCreations(creationIds, options);
1634
+ return this.withFallback((b) => b.historyService.deleteManyCreations(creationIds, options));
1367
1635
  }
1368
- /** Force re-discovery of the Server Action hashes. */
1636
+ /** Force re-discovery of the Server Action hashes on the active host. */
1369
1637
  refreshActions() {
1370
- return this.registry.refresh();
1638
+ return this.activeContext.registry.refresh();
1371
1639
  }
1372
1640
  /** Return the authenticated user's Firebase uid. */
1373
1641
  getUid() {
1374
1642
  return this.auth.getUserId();
1375
1643
  }
1644
+ /** Return the key of the currently active target (`'production'` or `'beta'`). */
1645
+ getActiveTarget() {
1646
+ return this.chain[this.activeIndex].key;
1647
+ }
1376
1648
  };
1377
1649
  //#endregion
1378
1650
  //#region lib/index.ts
@@ -1380,13 +1652,17 @@ var MageSpaceClient = class {
1380
1652
  const VERSION = "0.0.0";
1381
1653
  //#endregion
1382
1654
  exports.ActionRegistry = ActionRegistry;
1655
+ exports.ActionUnavailableError = ActionUnavailableError;
1383
1656
  exports.AuthError = AuthError;
1657
+ exports.BETA_TARGET = BETA_TARGET;
1384
1658
  exports.CharactersService = CharactersService;
1659
+ exports.DEFAULT_TARGET_CHAIN = DEFAULT_TARGET_CHAIN;
1385
1660
  exports.DiscoveryError = DiscoveryError;
1386
1661
  exports.GenerationError = GenerationError;
1387
1662
  exports.InMemoryActionCache = InMemoryActionCache;
1388
1663
  exports.MageSpaceClient = MageSpaceClient;
1389
1664
  exports.MageSpaceError = MageSpaceError;
1665
+ exports.PRODUCTION_TARGET = PRODUCTION_TARGET;
1390
1666
  exports.ReferencesService = ReferencesService;
1391
1667
  exports.RscParseError = RscParseError;
1392
1668
  exports.SEED_SNAPSHOT = SEED_SNAPSHOT;
@@ -1396,5 +1672,6 @@ exports.VERSION = VERSION;
1396
1672
  exports.discoverActions = discoverActions;
1397
1673
  exports.parseFlightRows = parseFlightRows;
1398
1674
  exports.parseServerActionResponse = parseServerActionResponse;
1675
+ exports.resolveActionName = resolveActionName;
1399
1676
  exports.resolveFlightValue = resolveFlightValue;
1400
1677
  exports.unwrapResult = unwrapResult;