@zweer/mage-space-client 1.1.2 → 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.mjs CHANGED
@@ -17,12 +17,38 @@ var AuthError = class extends MageSpaceError {
17
17
  };
18
18
  /** Action-hash discovery failures (page/chunk fetch, empty registry). */
19
19
  var DiscoveryError = class extends MageSpaceError {
20
+ /**
21
+ * `true` when the action name was absent even after a forced re-discovery
22
+ * (a fallback trigger); `false` for scrape failures (no chunks/no hashes).
23
+ */
24
+ notFound;
20
25
  constructor(message, options) {
21
26
  super(message, options);
22
27
  this.name = "DiscoveryError";
28
+ this.notFound = options?.notFound ?? false;
29
+ }
30
+ };
31
+ /**
32
+ * A required action is unavailable on the active host (a persistent 404 / HTML
33
+ * fallback that survived one in-place re-discovery). A fallback trigger.
34
+ */
35
+ var ActionUnavailableError = class extends MageSpaceError {
36
+ action;
37
+ targetKey;
38
+ constructor(action, targetKey, options) {
39
+ super(`Action "${action}" is unavailable on target "${targetKey}"`, options);
40
+ this.action = action;
41
+ this.targetKey = targetKey;
42
+ this.name = "ActionUnavailableError";
23
43
  }
24
44
  };
25
- /** A Server Action returned 404, indicating a stale (post-deploy) hash. */
45
+ /**
46
+ * A Server Action returned 404, indicating a stale (post-deploy) hash.
47
+ *
48
+ * @deprecated Legacy export kept for backward compatibility; no longer thrown.
49
+ * The current signal for a definitively unavailable action is
50
+ * {@link ActionUnavailableError}.
51
+ */
26
52
  var StaleActionError = class extends MageSpaceError {
27
53
  action;
28
54
  constructor(action, options) {
@@ -214,7 +240,7 @@ var ActionRegistry = class {
214
240
  snapshot = await this.refresh();
215
241
  hash = snapshot.hashes[action];
216
242
  }
217
- if (hash === void 0) throw new DiscoveryError(`Action "${action}" not found after discovery`);
243
+ if (hash === void 0) throw new DiscoveryError(`Action "${action}" not found after discovery`, { notFound: true });
218
244
  return {
219
245
  hash,
220
246
  deploymentId: snapshot.deploymentId
@@ -326,9 +352,11 @@ function resolveFlightValue(value, rows, seen = /* @__PURE__ */ new Set()) {
326
352
  if (ref) {
327
353
  const refId = ref[1];
328
354
  if (refId === void 0 || seen.has(refId)) return;
329
- seen.add(refId);
330
355
  const target = rows.get(refId);
331
- return target === void 0 ? void 0 : resolveFlightValue(target, rows, seen);
356
+ if (target === void 0) return;
357
+ const nextSeen = new Set(seen);
358
+ nextSeen.add(refId);
359
+ return resolveFlightValue(target, rows, nextSeen);
332
360
  }
333
361
  if (value.startsWith("$$")) return value.slice(1);
334
362
  if (value.startsWith("$D")) return new Date(value.slice(2));
@@ -375,6 +403,26 @@ function unwrapResult(value) {
375
403
  /** Character actions are served from the `/explore` page. */
376
404
  const CHARACTERS_PATH = "/explore";
377
405
  const DEFAULT_SEARCH_LIMIT = 10;
406
+ /** Server-enforced username constraints (from module 64428). */
407
+ const USERNAME_MIN = 1;
408
+ const USERNAME_MAX$1 = 15;
409
+ const USERNAME_RE$1 = /^[a-z][a-z0-9_-]*$/;
410
+ /**
411
+ * Generate a username from a display name, matching the web UI logic:
412
+ * lowercase, strip special chars, append a 4-char random suffix, truncate to 15.
413
+ */
414
+ function generateUsername$1(name) {
415
+ const suffix = Math.random().toString(36).substring(2, 6);
416
+ const base = name.toLowerCase().trim().replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
417
+ const maxBase = USERNAME_MAX$1 - suffix.length - 1;
418
+ return `${base.slice(0, maxBase).replace(/[-_]+$/, "")}-${suffix}`;
419
+ }
420
+ /** Validate a username and throw early with a clear message instead of a server 500. */
421
+ function validateUsername$1(username) {
422
+ if (username.length < USERNAME_MIN) throw new MageSpaceError("Username is required (min 1 character)");
423
+ if (username.length > USERNAME_MAX$1) throw new MageSpaceError(`Username "${username}" is too long (${username.length} chars, max ${USERNAME_MAX$1})`);
424
+ if (!USERNAME_RE$1.test(username)) throw new MageSpaceError(`Username "${username}" is invalid: must start with a lowercase letter and contain only a-z, 0-9, _ or -`);
425
+ }
378
426
  var CharactersService = class {
379
427
  http;
380
428
  auth;
@@ -402,10 +450,12 @@ var CharactersService = class {
402
450
  }
403
451
  /** Create a new character from an already-uploaded image URL. */
404
452
  async create(input) {
453
+ const username = input.username ?? generateUsername$1(input.name);
454
+ validateUsername$1(username);
405
455
  const session = await this.auth.getSession();
406
456
  const body = {
407
457
  name: input.name,
408
- username: input.username,
458
+ username,
409
459
  image_url: input.image_url,
410
460
  audio_url: input.audio_url ?? null,
411
461
  description: input.description ?? null,
@@ -592,7 +642,7 @@ const SESSION_DEFAULT_TTL_MS = 864e5;
592
642
  const ID_TOKEN_DEFAULT_TTL_S = 3600;
593
643
  var AuthManager = class {
594
644
  refreshToken;
595
- http;
645
+ getHttp;
596
646
  fetchImpl;
597
647
  apiKey;
598
648
  sessionPath;
@@ -600,11 +650,19 @@ var AuthManager = class {
600
650
  session = null;
601
651
  constructor(opts) {
602
652
  this.refreshToken = opts.refreshToken;
603
- this.http = opts.http;
653
+ this.getHttp = opts.getHttp;
604
654
  this.fetchImpl = opts.fetch;
605
655
  this.apiKey = opts.firebaseApiKey ?? DEFAULT_FIREBASE_API_KEY;
606
656
  this.sessionPath = opts.sessionPath ?? "/creations";
607
657
  }
658
+ /**
659
+ * Drop the cached `__session` cookie so the next {@link getSession} mints a
660
+ * fresh one against the (newly) active host. The Firebase token chain is kept
661
+ * because it is host-independent.
662
+ */
663
+ resetSession() {
664
+ this.session = null;
665
+ }
608
666
  /** Return a valid Firebase ID token, refreshing via the refresh token if needed. */
609
667
  async getIdToken() {
610
668
  if (this.tokens !== null && Date.now() < this.tokens.expiresAt - EXPIRY_BUFFER_MS) return this.tokens.idToken;
@@ -619,7 +677,7 @@ var AuthManager = class {
619
677
  async getSession() {
620
678
  if (this.session !== null && Date.now() < this.session.expiresAt - EXPIRY_BUFFER_MS) return this.session.cookie;
621
679
  const idToken = await this.getIdToken();
622
- const cookie = extractSessionCookie((await this.http.callAction({
680
+ const cookie = extractSessionCookie((await this.getHttp().callAction({
623
681
  action: "createUserSession",
624
682
  path: this.sessionPath,
625
683
  args: [idToken]
@@ -677,9 +735,58 @@ function extractSessionCookie(headers) {
677
735
  return null;
678
736
  }
679
737
  //#endregion
738
+ //#region lib/targets.ts
739
+ /** Resolve a logical action to its host-specific wire name (identity if unlisted). */
740
+ function resolveActionName(target, logical) {
741
+ return target.actionNames[logical] ?? logical;
742
+ }
743
+ /**
744
+ * Production target.
745
+ *
746
+ * @remarks
747
+ * Maps each logical action to the wire name the current production deploy
748
+ * actually serves (all VERIFIED against `lib/seed.ts` / the existing MSW tests):
749
+ * `submitGeneration → runArchitecture`, `cancelJob → cancelArchitectureJob`,
750
+ * `listHistory → getHistoryPaginated`, `listCreations → getCreationsPaginatedParallel`,
751
+ * and `getHistoryById → getHistoryById` (identity). The table is explicit rather
752
+ * than empty so production behaviour is reproduced exactly regardless of the
753
+ * logical-name vocabulary.
754
+ */
755
+ const PRODUCTION_TARGET = {
756
+ key: "production",
757
+ baseUrl: "https://www.mage.space",
758
+ actionNames: {
759
+ submitGeneration: "runArchitecture",
760
+ getHistoryById: "getHistoryById",
761
+ cancelJob: "cancelArchitectureJob",
762
+ listHistory: "getHistoryPaginated",
763
+ listCreations: "getCreationsPaginatedParallel"
764
+ },
765
+ pollStrategy: "byId"
766
+ };
767
+ /**
768
+ * Beta target. Beta facts are assumptions pending a reverse-engineering capture
769
+ * under `docs/api/beta` — treat them as unverified until that doc lands.
770
+ */
771
+ const BETA_TARGET = {
772
+ key: "beta",
773
+ baseUrl: "https://beta.mage.space",
774
+ actionNames: {
775
+ submitGeneration: "runArchitecture",
776
+ cancelJob: "cancelArchitectureJob",
777
+ listHistory: "getHistoryPaginated",
778
+ listCreations: "getCreationsPaginated"
779
+ },
780
+ pollStrategy: "paginated"
781
+ };
782
+ /** Default ordered fallback chain: production first, then beta. */
783
+ const DEFAULT_TARGET_CHAIN = [PRODUCTION_TARGET, BETA_TARGET];
784
+ //#endregion
680
785
  //#region lib/generation.ts
681
786
  /** Generation actions are served from the `/explore` page. */
682
787
  const GENERATION_PATH = "/explore";
788
+ /** Mirrors HistoryService.HISTORY_PATH ('/creations'); kept local to avoid coupling the two services. */
789
+ const HISTORY_PATH$1 = "/creations";
683
790
  /** RSC sentinel for an absent value. */
684
791
  const UNDEFINED$1 = "$undefined";
685
792
  /** Default concept id for mango-v3-pro (from docs — verify per model via rev-eng). */
@@ -691,9 +798,14 @@ const DEFAULT_TIMEOUT_MS = 3e5;
691
798
  var GenerationService = class {
692
799
  http;
693
800
  auth;
694
- constructor(http, auth) {
801
+ target;
802
+ /** Single-status-read primitive; overridable so the client can make it fallback-aware. */
803
+ pollOnce;
804
+ constructor(http, auth, target, pollOnce) {
695
805
  this.http = http;
696
806
  this.auth = auth;
807
+ this.target = target;
808
+ this.pollOnce = pollOnce ?? ((id) => this.getHistory(id));
697
809
  }
698
810
  /** Submit an image generation job and return its `historyId`. */
699
811
  async submit(opts) {
@@ -745,7 +857,7 @@ var GenerationService = class {
745
857
  generationMode
746
858
  }];
747
859
  const { data } = await this.http.callAction({
748
- action: "runArchitecture",
860
+ action: resolveActionName(this.target, "submitGeneration"),
749
861
  path: GENERATION_PATH,
750
862
  args,
751
863
  session
@@ -758,22 +870,68 @@ var GenerationService = class {
758
870
  if (typeof historyId !== "string") throw new GenerationError("runArchitecture did not return a history_id");
759
871
  return { historyId };
760
872
  }
761
- /** Fetch a single history record by id. */
873
+ /** Fetch a single history record by id, dispatching on the target's poll strategy. */
762
874
  async getHistory(historyId) {
875
+ if (this.target.pollStrategy === "paginated") return this.getHistoryPaginated(historyId);
763
876
  const session = await this.auth.getSession();
764
877
  const { data } = await this.http.callAction({
765
- action: "getHistoryById",
878
+ action: resolveActionName(this.target, "getHistoryById"),
766
879
  path: GENERATION_PATH,
767
880
  args: [historyId],
768
881
  session
769
882
  });
770
883
  return data;
771
884
  }
885
+ /**
886
+ * Beta status read: `getHistoryById` is absent, so page `getHistoryPaginated`
887
+ * and find the item whose `id === historyId`.
888
+ *
889
+ * @remarks
890
+ * ASSUMED (pending docs/api/beta): each page window is swept in two phases —
891
+ * `running` first, then the raw `$undefined` sentinel (terminal items) — since
892
+ * which filter surfaces a running job on beta is not yet documented. Bounded by
893
+ * {@link PAGINATED_POLL_MAX_PAGES} pages of {@link PAGINATED_POLL_PAGE_SIZE}.
894
+ */
895
+ async getHistoryPaginated(historyId) {
896
+ const uid = await this.auth.getUserId();
897
+ const limit = 20;
898
+ let offset = 0;
899
+ for (let pageIndex = 0; pageIndex < 10; pageIndex += 1) {
900
+ for (const status of ["running", UNDEFINED$1]) {
901
+ const page = await this.fetchHistoryPage(uid, status, limit, offset);
902
+ const found = page.histories.find((h) => h.id === historyId);
903
+ if (found !== void 0) return found;
904
+ if (status === UNDEFINED$1 && !page.hasMore) throw new GenerationError(`history ${historyId} not found on beta`);
905
+ }
906
+ offset += limit;
907
+ }
908
+ throw new GenerationError(`history ${historyId} not found on beta`);
909
+ }
910
+ /** Fetch one page of history via `listHistory` (resolved per target) on `/creations`. */
911
+ async fetchHistoryPage(uid, status, limit, offset) {
912
+ const session = await this.auth.getSession();
913
+ const filters = {
914
+ status,
915
+ type: UNDEFINED$1
916
+ };
917
+ const { data } = await this.http.callAction({
918
+ action: resolveActionName(this.target, "listHistory"),
919
+ path: HISTORY_PATH$1,
920
+ args: [
921
+ uid,
922
+ limit,
923
+ offset,
924
+ filters
925
+ ],
926
+ session
927
+ });
928
+ return unwrapResult(data);
929
+ }
772
930
  /** Cancel a running generation job. */
773
931
  async cancelJob(historyId) {
774
932
  const session = await this.auth.getSession();
775
933
  await this.http.callAction({
776
- action: "cancelArchitectureJob",
934
+ action: resolveActionName(this.target, "cancelJob"),
777
935
  path: GENERATION_PATH,
778
936
  args: [historyId],
779
937
  session
@@ -786,7 +944,7 @@ var GenerationService = class {
786
944
  const deadline = Date.now() + timeoutMs;
787
945
  for (;;) {
788
946
  if (opts.signal?.aborted === true) throw new TimeoutError("waitForResult was aborted");
789
- const item = await this.getHistory(historyId);
947
+ const item = await this.pollOnce(historyId);
790
948
  if (item.status === "success") return item;
791
949
  if (item.status === "error") throw new GenerationError(`Generation failed: ${JSON.stringify(item.error ?? null)}`);
792
950
  if (Date.now() > deadline) throw new TimeoutError(`Generation timed out after ${timeoutMs}ms (last status: ${item.status})`);
@@ -832,9 +990,11 @@ const UNDEFINED = "$undefined";
832
990
  var HistoryService = class {
833
991
  http;
834
992
  auth;
835
- constructor(http, auth) {
993
+ target;
994
+ constructor(http, auth, target) {
836
995
  this.http = http;
837
996
  this.auth = auth;
997
+ this.target = target;
838
998
  }
839
999
  /** List generation history for the authenticated user. */
840
1000
  async list(opts = {}) {
@@ -846,7 +1006,7 @@ var HistoryService = class {
846
1006
  type: UNDEFINED
847
1007
  };
848
1008
  const { data } = await this.http.callAction({
849
- action: "getHistoryPaginated",
1009
+ action: resolveActionName(this.target, "listHistory"),
850
1010
  path: HISTORY_PATH,
851
1011
  args: [
852
1012
  uid,
@@ -864,7 +1024,7 @@ var HistoryService = class {
864
1024
  const limit = opts.limit ?? DEFAULT_LIMIT;
865
1025
  const offset = opts.offset ?? 0;
866
1026
  const { data } = await this.http.callAction({
867
- action: "getCreationsPaginatedParallel",
1027
+ action: resolveActionName(this.target, "listCreations"),
868
1028
  path: HISTORY_PATH,
869
1029
  args: [
870
1030
  uid,
@@ -949,75 +1109,21 @@ var HistoryService = class {
949
1109
  }
950
1110
  };
951
1111
  //#endregion
952
- //#region lib/http.ts
953
- 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";
954
- /**
955
- * Known RSC error digests mapped to human-readable messages.
956
- * Discovered via reverse engineering; the server does not expose error details.
957
- */
958
- const KNOWN_DIGESTS = {
959
- "709776019": "Username already taken (duplicate character/reference username)",
960
- "1853426377": "Username already taken (duplicate character/reference username)"
961
- };
962
- var HttpClient = class {
963
- registry;
964
- fetchImpl;
965
- baseUrl;
966
- userAgent;
967
- constructor(registry, fetchImpl, baseUrl, userAgent = DEFAULT_USER_AGENT) {
968
- this.registry = registry;
969
- this.fetchImpl = fetchImpl;
970
- this.baseUrl = baseUrl;
971
- this.userAgent = userAgent;
972
- }
973
- async callAction(params, retried = false) {
974
- const { hash, deploymentId } = await this.registry.getHash(params.action);
975
- const headers = {
976
- accept: "text/x-component",
977
- "content-type": "text/plain;charset=UTF-8",
978
- "next-action": hash,
979
- "x-deployment-id": deploymentId,
980
- origin: this.baseUrl,
981
- referer: `${this.baseUrl}${params.path}`,
982
- "user-agent": this.userAgent
983
- };
984
- if (params.session !== void 0) headers.cookie = `__session=${params.session}`;
985
- const res = await this.fetchImpl(`${this.baseUrl}${params.path}`, {
986
- method: "POST",
987
- headers,
988
- body: JSON.stringify(params.args)
989
- });
990
- const contentType = res.headers.get("content-type") ?? "";
991
- const isHtmlFallback = res.ok && contentType.includes("text/html");
992
- if ((res.status === 404 || isHtmlFallback) && !retried) {
993
- await safeText(res);
994
- await this.registry.invalidate();
995
- return this.callAction(params, true);
996
- }
997
- if (!res.ok) {
998
- const body = await safeText(res);
999
- const digest = body.match(/E\{"digest":"([^"]+)"\}/)?.[1];
1000
- const knownDigest = digest !== void 0 ? KNOWN_DIGESTS[digest] : void 0;
1001
- const hint = knownDigest ? ` — ${knownDigest}` : digest ? ` (server digest: ${digest})` : "";
1002
- const raw = body.length > 0 ? ` — ${body.slice(0, 200)}` : "";
1003
- throw new MageSpaceError(`Action "${params.action}" failed: HTTP ${res.status}${hint}${raw}`);
1004
- }
1005
- return {
1006
- data: parseServerActionResponse(await res.text()),
1007
- headers: res.headers,
1008
- deploymentId: res.headers.get("x-deployment-id") ?? deploymentId
1009
- };
1010
- }
1011
- };
1012
- async function safeText(res) {
1013
- try {
1014
- return await res.text();
1015
- } catch {
1016
- return "";
1017
- }
1018
- }
1019
- //#endregion
1020
1112
  //#region lib/references.ts
1113
+ /** Server-enforced username constraints (shared with characters, module 64428). */
1114
+ const USERNAME_MAX = 15;
1115
+ const USERNAME_RE = /^[a-z][a-z0-9_-]*$/;
1116
+ /** Generate a username from a display name (same logic as characters). */
1117
+ function generateUsername(name) {
1118
+ const suffix = Math.random().toString(36).substring(2, 6);
1119
+ const base = name.toLowerCase().trim().replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1120
+ const maxBase = USERNAME_MAX - suffix.length - 1;
1121
+ return `${base.slice(0, maxBase).replace(/[-_]+$/, "")}-${suffix}`;
1122
+ }
1123
+ function validateUsername(username) {
1124
+ if (username.length > USERNAME_MAX) throw new MageSpaceError(`Username "${username}" is too long (${username.length} chars, max ${USERNAME_MAX})`);
1125
+ if (!USERNAME_RE.test(username)) throw new MageSpaceError(`Username "${username}" is invalid: must start with a lowercase letter and contain only a-z, 0-9, _ or -`);
1126
+ }
1021
1127
  /** Reference actions are served from the `/explore` page. */
1022
1128
  const REFERENCES_PATH = "/explore";
1023
1129
  const DEFAULT_PAGE_LIMIT = 20;
@@ -1045,10 +1151,12 @@ var ReferencesService = class {
1045
1151
  }
1046
1152
  /** Create a reusable reference from an uploaded image/audio URL. */
1047
1153
  async create(input) {
1154
+ const username = input.username ?? generateUsername(input.name);
1155
+ validateUsername(username);
1048
1156
  const session = await this.auth.getSession();
1049
1157
  const body = {
1050
1158
  name: input.name,
1051
- username: input.username,
1159
+ username,
1052
1160
  image_url: input.image_url,
1053
1161
  audio_url: input.audio_url ?? null,
1054
1162
  modality: input.modality ?? "image",
@@ -1241,105 +1349,307 @@ const SEED_SNAPSHOT = {
1241
1349
  }
1242
1350
  };
1243
1351
  //#endregion
1352
+ //#region lib/http.ts
1353
+ 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";
1354
+ /**
1355
+ * HTTP statuses that mean "this action is unavailable on this host". A stale
1356
+ * (post-deploy) action hash surfaces as a 404 on the production host and as a
1357
+ * 409 ("Server Action unavailable") on the beta host — both mean the same
1358
+ * thing: invalidate the registry, re-discover, and (if still unavailable) fall
1359
+ * back to the next host.
1360
+ */
1361
+ const UNAVAILABLE_STATUSES = /* @__PURE__ */ new Set([404, 409]);
1362
+ /**
1363
+ * Known RSC error digests mapped to human-readable messages.
1364
+ * Discovered via reverse engineering; the server does not expose error details.
1365
+ */
1366
+ const KNOWN_DIGESTS = {
1367
+ "709776019": "Username already taken (duplicate character/reference username)",
1368
+ "1853426377": "Invalid username (must be 1–15 lowercase chars, start with a letter, only a-z 0-9 _ -)"
1369
+ };
1370
+ var HttpClient = class {
1371
+ registry;
1372
+ fetchImpl;
1373
+ baseUrl;
1374
+ targetKey;
1375
+ userAgent;
1376
+ constructor(registry, fetchImpl, baseUrl, targetKey, userAgent = DEFAULT_USER_AGENT) {
1377
+ this.registry = registry;
1378
+ this.fetchImpl = fetchImpl;
1379
+ this.baseUrl = baseUrl;
1380
+ this.targetKey = targetKey;
1381
+ this.userAgent = userAgent;
1382
+ }
1383
+ /** A response that means the action is unavailable (stale hash or gone). */
1384
+ isUnavailable(res) {
1385
+ const contentType = res.headers.get("content-type") ?? "";
1386
+ const isHtmlFallback = res.ok && contentType.includes("text/html");
1387
+ return UNAVAILABLE_STATUSES.has(res.status) || isHtmlFallback;
1388
+ }
1389
+ async callAction(params, retried = false) {
1390
+ const { hash, deploymentId } = await this.registry.getHash(params.action);
1391
+ const headers = {
1392
+ accept: "text/x-component",
1393
+ "content-type": "text/plain;charset=UTF-8",
1394
+ "next-action": hash,
1395
+ "x-deployment-id": deploymentId,
1396
+ origin: this.baseUrl,
1397
+ referer: `${this.baseUrl}${params.path}`,
1398
+ "user-agent": this.userAgent
1399
+ };
1400
+ if (params.session !== void 0) headers.cookie = `__session=${params.session}`;
1401
+ const res = await this.fetchImpl(`${this.baseUrl}${params.path}`, {
1402
+ method: "POST",
1403
+ headers,
1404
+ body: JSON.stringify(params.args)
1405
+ });
1406
+ if (this.isUnavailable(res) && !retried) {
1407
+ await safeText(res);
1408
+ await this.registry.invalidate();
1409
+ return this.callAction(params, true);
1410
+ }
1411
+ if (this.isUnavailable(res)) {
1412
+ await safeText(res);
1413
+ throw new ActionUnavailableError(params.action, this.targetKey);
1414
+ }
1415
+ if (!res.ok) {
1416
+ const body = await safeText(res);
1417
+ const digest = body.match(/E\{"digest":"([^"]+)"\}/)?.[1];
1418
+ const knownDigest = digest !== void 0 ? KNOWN_DIGESTS[digest] : void 0;
1419
+ const hint = knownDigest ? ` — ${knownDigest}` : digest ? ` (server digest: ${digest})` : "";
1420
+ const raw = body.length > 0 ? ` — ${body.slice(0, 200)}` : "";
1421
+ throw new MageSpaceError(`Action "${params.action}" failed: HTTP ${res.status}${hint}${raw}`);
1422
+ }
1423
+ return {
1424
+ data: parseServerActionResponse(await res.text()),
1425
+ headers: res.headers,
1426
+ deploymentId: res.headers.get("x-deployment-id") ?? deploymentId
1427
+ };
1428
+ }
1429
+ };
1430
+ async function safeText(res) {
1431
+ try {
1432
+ return await res.text();
1433
+ } catch {
1434
+ return "";
1435
+ }
1436
+ }
1437
+ //#endregion
1438
+ //#region lib/target-context.ts
1439
+ /**
1440
+ * A {@link TargetContext} bundles one host's {@link ActionRegistry} and
1441
+ * {@link HttpClient} so the client can hold one pair per target and switch by
1442
+ * selecting the other pair (rather than mutating a shared `baseUrl`).
1443
+ */
1444
+ /**
1445
+ * Build the registry + HTTP client for a single target.
1446
+ *
1447
+ * @remarks
1448
+ * The caller's `cache` and the production `seed` are production-only concerns:
1449
+ * the bundled seed holds production hashes and the single-slot cache cannot be
1450
+ * namespaced per host. Non-production targets always discover fresh with their
1451
+ * own {@link InMemoryActionCache} and no seed.
1452
+ */
1453
+ function createTargetContext(target, deps) {
1454
+ const isProduction = target.key === "production";
1455
+ const registry = new ActionRegistry({
1456
+ fetch: deps.fetch,
1457
+ baseUrl: target.baseUrl,
1458
+ cache: isProduction ? deps.cache : new InMemoryActionCache(),
1459
+ seed: isProduction ? deps.seed : void 0
1460
+ });
1461
+ return {
1462
+ target,
1463
+ registry,
1464
+ http: new HttpClient(registry, deps.fetch, target.baseUrl, target.key, deps.userAgent)
1465
+ };
1466
+ }
1467
+ //#endregion
1244
1468
  //#region lib/client.ts
1245
1469
  /**
1246
1470
  * {@link MageSpaceClient} — the public facade that wires together action
1247
1471
  * discovery, authentication, generation, and history.
1472
+ *
1473
+ * The client models each mage.space host as a {@link Target} and holds an
1474
+ * ordered chain (production first, then beta). When the active host can no
1475
+ * longer serve a required action, the client advances to the next target and
1476
+ * retries once. The switch is sticky for the lifetime of the instance.
1248
1477
  */
1249
- const DEFAULT_BASE_URL = "https://www.mage.space";
1250
1478
  var MageSpaceClient = class {
1251
- registry;
1252
1479
  auth;
1253
- generationService;
1254
- historyService;
1255
- /** Character management (search, CRUD, pagination, uploads). */
1480
+ chain;
1481
+ auto;
1482
+ contextDeps;
1483
+ contexts = /* @__PURE__ */ new Map();
1484
+ activeIndex;
1485
+ activeContext;
1486
+ activeBundle;
1487
+ /**
1488
+ * Character management (search, CRUD, pagination, uploads).
1489
+ *
1490
+ * @remarks
1491
+ * Bound to the initial host (production by default) and does not follow an
1492
+ * automatic beta fallback. Construct a client with `fallback: { start: 'beta' }`
1493
+ * to use these against beta.
1494
+ */
1256
1495
  characters;
1257
- /** Reference asset management (upload, create, update, list, feed). */
1496
+ /**
1497
+ * Reference asset management (upload, create, update, list, feed).
1498
+ *
1499
+ * @remarks
1500
+ * Bound to the initial host (production by default) and does not follow an
1501
+ * automatic beta fallback. Construct a client with `fallback: { start: 'beta' }`
1502
+ * to use these against beta.
1503
+ */
1258
1504
  references;
1259
1505
  constructor(options) {
1260
1506
  if (!options.refreshToken) throw new Error("MageSpaceClient requires a refreshToken");
1261
- const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
1262
1507
  const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
1263
- this.registry = new ActionRegistry({
1508
+ const fallback = options.fallback;
1509
+ const baseUrlPinned = typeof options.baseUrl === "string" && options.baseUrl.length > 0;
1510
+ 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");
1511
+ if (baseUrlPinned) {
1512
+ this.chain = [{
1513
+ key: "production",
1514
+ baseUrl: options.baseUrl,
1515
+ actionNames: {},
1516
+ pollStrategy: "byId"
1517
+ }];
1518
+ this.auto = false;
1519
+ } else {
1520
+ const chain = fallback?.chain ?? DEFAULT_TARGET_CHAIN;
1521
+ if (chain.length === 0) throw new Error("fallback.chain must be a non-empty array");
1522
+ this.chain = chain;
1523
+ this.auto = fallback?.auto ?? true;
1524
+ }
1525
+ const startKey = baseUrlPinned ? "production" : fallback?.start ?? "production";
1526
+ const startIndex = this.chain.findIndex((t) => t.key === startKey);
1527
+ if (startIndex === -1) throw new Error(`fallback.start '${startKey}' is not in the target chain`);
1528
+ this.contextDeps = {
1264
1529
  fetch: fetchImpl,
1265
- baseUrl,
1530
+ userAgent: options.userAgent,
1266
1531
  cache: options.cache,
1267
1532
  seed: options.seed ?? SEED_SNAPSHOT
1268
- });
1269
- const http = new HttpClient(this.registry, fetchImpl, baseUrl, options.userAgent);
1533
+ };
1534
+ this.activeIndex = startIndex;
1535
+ this.activeContext = this.getContext(startIndex);
1270
1536
  this.auth = new AuthManager({
1271
1537
  refreshToken: options.refreshToken,
1272
- http,
1538
+ getHttp: () => this.activeContext.http,
1273
1539
  fetch: fetchImpl,
1274
1540
  firebaseApiKey: options.firebaseApiKey
1275
1541
  });
1276
- this.generationService = new GenerationService(http, this.auth);
1277
- this.historyService = new HistoryService(http, this.auth);
1278
- this.characters = new CharactersService(http, this.auth);
1279
- this.references = new ReferencesService(http, this.auth);
1542
+ this.activeBundle = this.buildBundle(this.activeContext);
1543
+ this.characters = new CharactersService(this.activeContext.http, this.auth);
1544
+ this.references = new ReferencesService(this.activeContext.http, this.auth);
1545
+ }
1546
+ /** Lazily build (and memoise) the {@link TargetContext} for a chain index. */
1547
+ getContext(index) {
1548
+ const target = this.chain[index];
1549
+ const existing = this.contexts.get(target.key);
1550
+ if (existing !== void 0) return existing;
1551
+ const context = createTargetContext(target, this.contextDeps);
1552
+ this.contexts.set(target.key, context);
1553
+ return context;
1554
+ }
1555
+ /** Build the generation/history service bundle for a context. */
1556
+ buildBundle(context) {
1557
+ const pollOnce = (id) => this.withFallback((bundle) => bundle.generationService.getHistory(id));
1558
+ return {
1559
+ target: context.target,
1560
+ generationService: new GenerationService(context.http, this.auth, context.target, pollOnce),
1561
+ historyService: new HistoryService(context.http, this.auth, context.target)
1562
+ };
1563
+ }
1564
+ /** Advance to the next target (sticky). Returns `null` if none or `auto` is off. */
1565
+ advanceTarget() {
1566
+ if (this.auto === false || this.activeIndex >= this.chain.length - 1) return null;
1567
+ this.activeIndex += 1;
1568
+ this.activeContext = this.getContext(this.activeIndex);
1569
+ this.activeBundle = this.buildBundle(this.activeContext);
1570
+ return this.activeBundle;
1571
+ }
1572
+ /** Whether an error is a definitive action-unavailable signal (a fallback trigger). */
1573
+ shouldFallback(err) {
1574
+ return err instanceof ActionUnavailableError || err instanceof DiscoveryError && err.notFound === true;
1575
+ }
1576
+ /** Run `op` against the active bundle, switching to the next target once on a trigger. */
1577
+ async withFallback(op) {
1578
+ try {
1579
+ return await op(this.activeBundle);
1580
+ } catch (err) {
1581
+ if (!this.shouldFallback(err)) throw err;
1582
+ if (this.advanceTarget() === null) throw err;
1583
+ this.auth.resetSession();
1584
+ return op(this.activeBundle);
1585
+ }
1280
1586
  }
1281
1587
  /** Submit a generation job; returns its `historyId`. */
1282
1588
  generate(options) {
1283
- return this.generationService.submit(options);
1589
+ return this.withFallback((b) => b.generationService.submit(options));
1284
1590
  }
1285
1591
  /** Submit a video generation job (Berry-2 family); returns its `historyId`. */
1286
1592
  generateVideo(options) {
1287
- return this.generationService.submitVideo(options);
1593
+ return this.withFallback((b) => b.generationService.submitVideo(options));
1288
1594
  }
1289
1595
  /** Fetch the current state of a generation by id. */
1290
1596
  getResult(historyId) {
1291
- return this.generationService.getHistory(historyId);
1597
+ return this.withFallback((b) => b.generationService.getHistory(historyId));
1292
1598
  }
1293
1599
  /** Poll until a generation finishes (or fails / times out). */
1294
1600
  waitForResult(historyId, options) {
1295
- return this.generationService.waitForResult(historyId, options);
1601
+ return this.withFallback((b) => b.generationService.waitForResult(historyId, options));
1296
1602
  }
1297
1603
  /** Cancel a running generation job. */
1298
1604
  cancelJob(historyId) {
1299
- return this.generationService.cancelJob(historyId);
1605
+ return this.withFallback((b) => b.generationService.cancelJob(historyId));
1300
1606
  }
1301
1607
  /** List past generations for the authenticated user. */
1302
1608
  listHistory(options) {
1303
- return this.historyService.list(options);
1609
+ return this.withFallback((b) => b.historyService.list(options));
1304
1610
  }
1305
1611
  /** List the user's permanently saved creations. */
1306
1612
  listCreations(options) {
1307
- return this.historyService.listCreations(options);
1613
+ return this.withFallback((b) => b.historyService.listCreations(options));
1308
1614
  }
1309
1615
  /** Save a completed generation result as a permanent creation. */
1310
1616
  saveCreation(result) {
1311
- return this.historyService.saveCreation(result);
1617
+ return this.withFallback((b) => b.historyService.saveCreation(result));
1312
1618
  }
1313
1619
  /** Permanently delete a history item. Irreversible — requires `{ confirm: true }`. */
1314
1620
  deleteHistory(historyId, options) {
1315
- return this.historyService.deleteHistory(historyId, options);
1621
+ return this.withFallback((b) => b.historyService.deleteHistory(historyId, options));
1316
1622
  }
1317
1623
  /** Permanently delete many history items. Irreversible — requires `{ confirm: true }`. */
1318
1624
  deleteManyHistories(historyIds, options) {
1319
- return this.historyService.deleteManyHistories(historyIds, options);
1625
+ return this.withFallback((b) => b.historyService.deleteManyHistories(historyIds, options));
1320
1626
  }
1321
1627
  /** Permanently delete a saved creation. Irreversible — requires `{ confirm: true }`. */
1322
1628
  deleteCreation(creationId, options) {
1323
- return this.historyService.deleteCreation(creationId, options);
1629
+ return this.withFallback((b) => b.historyService.deleteCreation(creationId, options));
1324
1630
  }
1325
1631
  /** Permanently delete many creations. Irreversible — requires `{ confirm: true }`. */
1326
1632
  deleteManyCreations(creationIds, options) {
1327
- return this.historyService.deleteManyCreations(creationIds, options);
1633
+ return this.withFallback((b) => b.historyService.deleteManyCreations(creationIds, options));
1328
1634
  }
1329
- /** Force re-discovery of the Server Action hashes. */
1635
+ /** Force re-discovery of the Server Action hashes on the active host. */
1330
1636
  refreshActions() {
1331
- return this.registry.refresh();
1637
+ return this.activeContext.registry.refresh();
1332
1638
  }
1333
1639
  /** Return the authenticated user's Firebase uid. */
1334
1640
  getUid() {
1335
1641
  return this.auth.getUserId();
1336
1642
  }
1643
+ /** Return the key of the currently active target (`'production'` or `'beta'`). */
1644
+ getActiveTarget() {
1645
+ return this.chain[this.activeIndex].key;
1646
+ }
1337
1647
  };
1338
1648
  //#endregion
1339
1649
  //#region lib/index.ts
1340
1650
  /** Public entry point for `@zweer/mage-space-client`. */
1341
1651
  const VERSION = "0.0.0";
1342
1652
  //#endregion
1343
- export { ActionRegistry, AuthError, CharactersService, DiscoveryError, GenerationError, InMemoryActionCache, MageSpaceClient, MageSpaceError, ReferencesService, RscParseError, SEED_SNAPSHOT, StaleActionError, TimeoutError, VERSION, discoverActions, parseFlightRows, parseServerActionResponse, resolveFlightValue, unwrapResult };
1653
+ export { ActionRegistry, ActionUnavailableError, AuthError, BETA_TARGET, CharactersService, DEFAULT_TARGET_CHAIN, DiscoveryError, GenerationError, InMemoryActionCache, MageSpaceClient, MageSpaceError, PRODUCTION_TARGET, ReferencesService, RscParseError, SEED_SNAPSHOT, StaleActionError, TimeoutError, VERSION, discoverActions, parseFlightRows, parseServerActionResponse, resolveActionName, resolveFlightValue, unwrapResult };
1344
1654
 
1345
1655
  //# sourceMappingURL=index.mjs.map