@spacemolt/lib 15.0.1 → 15.2.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.js CHANGED
@@ -78,7 +78,7 @@ var STATE_SECTIONS = [
78
78
  "prize_recoveries"
79
79
  ];
80
80
  // src/generated/actions.gen.ts
81
- var GENERATED_SPEC_VERSION = "v0.609.4";
81
+ var GENERATED_SPEC_VERSION = "v0.612.0";
82
82
  var ACTIONS = {
83
83
  "spacemolt_arena/accept": {
84
84
  tool: "spacemolt_arena",
@@ -3641,6 +3641,21 @@ function errorFromActionFrame(frame) {
3641
3641
  });
3642
3642
  }
3643
3643
 
3644
+ class HttpError extends Error {
3645
+ status;
3646
+ url;
3647
+ attempts;
3648
+ retryAfterMs;
3649
+ constructor(message, opts) {
3650
+ super(message);
3651
+ this.name = "HttpError";
3652
+ this.status = opts.status;
3653
+ this.url = opts.url;
3654
+ this.attempts = opts.attempts;
3655
+ this.retryAfterMs = opts.retryAfterMs;
3656
+ }
3657
+ }
3658
+
3644
3659
  class ConnectionClosedError extends Error {
3645
3660
  code;
3646
3661
  reason;
@@ -3902,6 +3917,11 @@ class ObservationCache {
3902
3917
  }
3903
3918
  }
3904
3919
 
3920
+ // src/jitter.ts
3921
+ function jitteredDelayMs(baseMs) {
3922
+ return baseMs + Math.random() * Math.max(baseMs * 0.25, 250);
3923
+ }
3924
+
3905
3925
  // src/state/cache.ts
3906
3926
  function copySection(target, source, section) {
3907
3927
  const value = source[section];
@@ -4239,6 +4259,36 @@ function retryAfterMs(err) {
4239
4259
  const seconds = fromDetails ?? (match ? Number(match[1]) : undefined);
4240
4260
  return Math.max(250, (seconds ?? 1) * 1000);
4241
4261
  }
4262
+ var AUTH_SECRET_PAYLOAD_FIELDS = new Set(["password", "token", "registration_code", "device_code"]);
4263
+ function redactForOnSend(frame) {
4264
+ if (frame.tool !== "spacemolt_auth" || !isRecord(frame.payload))
4265
+ return frame;
4266
+ let redacted;
4267
+ for (const field of AUTH_SECRET_PAYLOAD_FIELDS) {
4268
+ if (field in frame.payload) {
4269
+ redacted ??= { ...frame.payload };
4270
+ redacted[field] = "[redacted]";
4271
+ }
4272
+ }
4273
+ return redacted ? { ...frame, payload: redacted } : frame;
4274
+ }
4275
+ function redactForOnReceive(frame) {
4276
+ if (frame.type === "registered" && isRecord(frame.payload) && "password" in frame.payload) {
4277
+ return { ...frame, payload: { ...frame.payload, password: "[redacted]" } };
4278
+ }
4279
+ if (frame.type === "result" && isRecord(frame.payload)) {
4280
+ const { structuredContent } = frame.payload;
4281
+ if (isRecord(structuredContent) && typeof structuredContent.device_code === "string" && structuredContent.device_code) {
4282
+ const code = structuredContent.device_code;
4283
+ const result = typeof frame.payload.result === "string" && frame.payload.result.includes(code) ? frame.payload.result.split(code).join("[redacted]") : frame.payload.result;
4284
+ return {
4285
+ ...frame,
4286
+ payload: { ...frame.payload, result, structuredContent: { ...structuredContent, device_code: "[redacted]" } }
4287
+ };
4288
+ }
4289
+ }
4290
+ return frame;
4291
+ }
4242
4292
  function requireStructuredContent(result, command) {
4243
4293
  if (result.structuredContent === undefined) {
4244
4294
  throw new SpacemoltError("invalid_response", `${command} returned no structured content`);
@@ -4256,6 +4306,8 @@ class Account {
4256
4306
  seedState;
4257
4307
  url;
4258
4308
  webSocketFactory;
4309
+ onSend;
4310
+ onReceive;
4259
4311
  reconnectConfig;
4260
4312
  credentialsProvider;
4261
4313
  fetchImpl;
@@ -4268,6 +4320,7 @@ class Account {
4268
4320
  requestSeq = 0;
4269
4321
  _welcome = null;
4270
4322
  _authenticated = false;
4323
+ reconnectsPending = 0;
4271
4324
  _loginPayload = null;
4272
4325
  _currentTick = 0;
4273
4326
  _commands = null;
@@ -4287,10 +4340,13 @@ class Account {
4287
4340
  reconnectedListeners = new Set;
4288
4341
  reconnectingListeners = new Set;
4289
4342
  disconnectedListeners = new Set;
4343
+ rateLimitedListeners = new Set;
4290
4344
  constructor(opts = {}) {
4291
4345
  this.seedState = opts.seedState ?? true;
4292
4346
  this.url = opts.url ?? DEFAULT_URL;
4293
4347
  this.webSocketFactory = opts.webSocketFactory;
4348
+ this.onSend = opts.onSend;
4349
+ this.onReceive = opts.onReceive;
4294
4350
  this.credentialsProvider = opts.credentials;
4295
4351
  this.fetchImpl = opts.fetchImpl;
4296
4352
  this.maxRateLimitRetries = opts.maxRateLimitRetries ?? 5;
@@ -4384,37 +4440,47 @@ class Account {
4384
4440
  return this.open();
4385
4441
  }
4386
4442
  async open() {
4443
+ const socket = this.socket;
4387
4444
  this._welcome = null;
4388
- await this.socket.connect();
4445
+ await socket.connect();
4389
4446
  if (this._welcome)
4390
4447
  return this._welcome;
4448
+ let waiter;
4391
4449
  const waitForWelcome = new Promise((resolve, reject) => {
4392
- this.welcomeWaiter = { resolve, reject };
4450
+ waiter = { resolve, reject };
4451
+ this.welcomeWaiter = waiter;
4393
4452
  });
4394
4453
  try {
4395
4454
  return await withTimeout(waitForWelcome, this.connectTimeoutMs, `No welcome frame received within ${this.connectTimeoutMs}ms`);
4396
4455
  } catch (err) {
4397
- this.welcomeWaiter = null;
4398
- this.socket.close();
4456
+ if (waiter && this.welcomeWaiter === waiter)
4457
+ this.welcomeWaiter = null;
4458
+ if (this.socket === socket)
4459
+ socket.close();
4399
4460
  throw err;
4400
4461
  }
4401
4462
  }
4402
4463
  async register(params) {
4464
+ let authEntry;
4403
4465
  const registerPromise = new Promise((resolve, reject) => {
4404
- this.beginAuth((state, registered) => {
4466
+ const accepted = this.beginAuth((state, registered) => {
4405
4467
  if (!registered) {
4406
4468
  reject(new SpacemoltError("missing_credentials", "register succeeded but no credentials frame was received"));
4407
4469
  return;
4408
4470
  }
4409
4471
  resolve({ password: registered.password, player_id: registered.player_id, state });
4410
4472
  }, reject);
4411
- this.sendFrame("spacemolt_auth", "register", { ...params }, this.nextRequestId());
4473
+ if (accepted)
4474
+ authEntry = this.pendingAuth ?? undefined;
4475
+ if (accepted)
4476
+ this.sendFrame("spacemolt_auth", "register", { ...params }, this.nextRequestId());
4412
4477
  });
4413
4478
  let result;
4414
4479
  try {
4415
4480
  result = await withTimeout(registerPromise, this.connectTimeoutMs, `No register response received within ${this.connectTimeoutMs}ms`);
4416
4481
  } catch (err) {
4417
- this.pendingAuth = null;
4482
+ if (authEntry && this.pendingAuth === authEntry)
4483
+ this.pendingAuth = null;
4418
4484
  throw err;
4419
4485
  }
4420
4486
  this._loginPayload = result.state;
@@ -4428,7 +4494,7 @@ class Account {
4428
4494
  return this.authExchange("login_token", { token });
4429
4495
  }
4430
4496
  authenticate(creds) {
4431
- return this.withRateLimitRetry(() => this.authenticateOnce(creds));
4497
+ return this.withRateLimitRetry("authenticate", () => this.authenticateOnce(creds));
4432
4498
  }
4433
4499
  async authenticateOnce(creds) {
4434
4500
  switch (creds.kind) {
@@ -4486,7 +4552,7 @@ class Account {
4486
4552
  this._authenticated = false;
4487
4553
  }
4488
4554
  query(tool, action, payload, requestId) {
4489
- return this.withRateLimitRetry(async () => {
4555
+ return this.withRateLimitRetry(`${tool}.${action}`, async () => {
4490
4556
  const id = this.claimRequestId(requestId);
4491
4557
  const promise = this.correlator.awaitQuery(id);
4492
4558
  try {
@@ -4500,7 +4566,7 @@ class Account {
4500
4566
  }
4501
4567
  mutate(tool, action, payload, onAck, requestId) {
4502
4568
  const mutationTimeoutMs = TRANSIT_ACTIONS.has(`${tool}/${action}`) ? this.mutationTimeoutMs : this.fastMutationTimeoutMs;
4503
- return this.enqueueMutation(() => this.withRateLimitRetry(() => {
4569
+ return this.enqueueMutation(() => this.withRateLimitRetry(`${tool}.${action}`, () => {
4504
4570
  const id = this.claimRequestId(requestId);
4505
4571
  return this.awaitMutationWithTimeout(id, mutationTimeoutMs, () => this.sendFrame(tool, action, payload, id), onAck);
4506
4572
  }));
@@ -4779,6 +4845,12 @@ class Account {
4779
4845
  this.reconnectingListeners.delete(listener);
4780
4846
  };
4781
4847
  }
4848
+ onRateLimited(listener) {
4849
+ this.rateLimitedListeners.add(listener);
4850
+ return () => {
4851
+ this.rateLimitedListeners.delete(listener);
4852
+ };
4853
+ }
4782
4854
  onDisconnected(listener) {
4783
4855
  this.disconnectedListeners.add(listener);
4784
4856
  return () => {
@@ -4786,15 +4858,20 @@ class Account {
4786
4858
  };
4787
4859
  }
4788
4860
  async authExchange(action, payload) {
4861
+ let authEntry;
4789
4862
  const authPromise = new Promise((resolve, reject) => {
4790
- this.beginAuth(resolve, reject);
4791
- this.sendFrame("spacemolt_auth", action, payload, this.nextRequestId());
4863
+ const accepted = this.beginAuth(resolve, reject);
4864
+ if (accepted)
4865
+ authEntry = this.pendingAuth ?? undefined;
4866
+ if (accepted)
4867
+ this.sendFrame("spacemolt_auth", action, payload, this.nextRequestId());
4792
4868
  });
4793
4869
  let state;
4794
4870
  try {
4795
4871
  state = await withTimeout(authPromise, this.connectTimeoutMs, `No auth response received within ${this.connectTimeoutMs}ms`);
4796
4872
  } catch (err) {
4797
- this.pendingAuth = null;
4873
+ if (authEntry && this.pendingAuth === authEntry)
4874
+ this.pendingAuth = null;
4798
4875
  throw err;
4799
4876
  }
4800
4877
  this._loginPayload = state;
@@ -4811,16 +4888,32 @@ class Account {
4811
4888
  beginAuth(onLoggedIn, onError) {
4812
4889
  if (this.pendingAuth) {
4813
4890
  onError(new SpacemoltError("auth_in_progress", "another auth exchange is already in flight"));
4814
- return;
4891
+ return false;
4815
4892
  }
4816
4893
  if (this._authenticated) {
4817
4894
  onError(new SpacemoltError("already_authenticated", "this connection is already authenticated"));
4818
- return;
4895
+ return false;
4819
4896
  }
4820
4897
  this.pendingAuth = { onLoggedIn, onError };
4898
+ return true;
4821
4899
  }
4822
4900
  sendFrame(tool, action, payload, requestId) {
4823
- this.socket.send({ tool, action, ...payload ? { payload } : {}, request_id: requestId });
4901
+ const isAuthHandshake = tool === "spacemolt_auth" && action !== "logout";
4902
+ if (this.reconnectsPending > 0 && !this._authenticated && !isAuthHandshake) {
4903
+ throw new ConnectionClosedError("cannot send: account is reconnecting");
4904
+ }
4905
+ const frame = { tool, action, ...payload ? { payload } : {}, request_id: requestId };
4906
+ this.callHook(this.onSend, "onSend", redactForOnSend(frame));
4907
+ this.socket.send(frame);
4908
+ }
4909
+ callHook(hook, label, ...args) {
4910
+ if (!hook)
4911
+ return;
4912
+ try {
4913
+ hook(...args);
4914
+ } catch (err) {
4915
+ console.warn(`[spacemolt] ${label} hook threw: ${err}`);
4916
+ }
4824
4917
  }
4825
4918
  nextRequestId() {
4826
4919
  let id = `r${++this.requestSeq}`;
@@ -4837,6 +4930,7 @@ class Account {
4837
4930
  return requestId;
4838
4931
  }
4839
4932
  routeFrame(frame) {
4933
+ this.callHook(this.onReceive, "onReceive", redactForOnReceive(frame));
4840
4934
  if (isRecord(frame.payload))
4841
4935
  this.observeTick(frame.payload.tick);
4842
4936
  switch (frame.type) {
@@ -4924,10 +5018,9 @@ class Account {
4924
5018
  warnMalformedFrame(frame) {
4925
5019
  console.warn(`[spacemolt] dropped malformed ${frame.type} frame`);
4926
5020
  }
4927
- handleClose(err) {
5021
+ failPendingWork(err) {
4928
5022
  this._authenticated = false;
4929
5023
  this.correlator.rejectAll(err);
4930
- this.emitter.closeStreams();
4931
5024
  if (this.welcomeWaiter) {
4932
5025
  const waiter = this.welcomeWaiter;
4933
5026
  this.welcomeWaiter = null;
@@ -4938,6 +5031,10 @@ class Account {
4938
5031
  this.pendingAuth = null;
4939
5032
  auth.onError(err);
4940
5033
  }
5034
+ }
5035
+ handleClose(err) {
5036
+ this.failPendingWork(err);
5037
+ this.emitter.closeStreams();
4941
5038
  if (this.reconnecting) {
4942
5039
  this.attemptClose = err;
4943
5040
  return;
@@ -5001,11 +5098,17 @@ class Account {
5001
5098
  };
5002
5099
  stopIfClosed();
5003
5100
  this.socket.close();
5004
- this.makeSocket();
5005
- await this.open();
5006
- stopIfClosed();
5007
- await this.authenticate(await this.credentialsProvider());
5008
- stopIfClosed();
5101
+ this.reconnectsPending++;
5102
+ this.failPendingWork(new ConnectionClosedError("account is reconnecting"));
5103
+ try {
5104
+ this.makeSocket();
5105
+ await this.open();
5106
+ stopIfClosed();
5107
+ await this.authenticate(await this.credentialsProvider());
5108
+ stopIfClosed();
5109
+ } finally {
5110
+ this.reconnectsPending--;
5111
+ }
5009
5112
  await this.resubscribe();
5010
5113
  }
5011
5114
  async resubscribe() {
@@ -5020,13 +5123,15 @@ class Account {
5020
5123
  } catch {}
5021
5124
  }
5022
5125
  }
5023
- async withRateLimitRetry(fn) {
5126
+ async withRateLimitRetry(command, fn) {
5024
5127
  for (let attempt = 0;; attempt++) {
5025
5128
  try {
5026
5129
  return await fn();
5027
5130
  } catch (err) {
5028
5131
  if (err instanceof SpacemoltError && err.code === "rate_limited" && attempt < this.maxRateLimitRetries) {
5029
- await delay(retryAfterMs(err));
5132
+ const delayMs = jitteredDelayMs(retryAfterMs(err));
5133
+ notifyListeners(this.rateLimitedListeners, "onRateLimited", { command, attempt: attempt + 1, delayMs });
5134
+ await delay(delayMs);
5030
5135
  continue;
5031
5136
  }
5032
5137
  throw err;
@@ -5062,6 +5167,46 @@ class MemoryCredentialStore {
5062
5167
  }
5063
5168
  }
5064
5169
 
5170
+ // src/data/http.ts
5171
+ var MAX_RETRIES = 3;
5172
+ var BASE_BACKOFF_MS = 500;
5173
+ var MAX_RETRY_WAIT_MS = 60000;
5174
+ var delay2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5175
+ function parseRetryAfterMs(header) {
5176
+ if (!header)
5177
+ return;
5178
+ const seconds = Number(header);
5179
+ if (!Number.isNaN(seconds))
5180
+ return Math.max(0, seconds * 1000);
5181
+ const dateMs = Date.parse(header);
5182
+ return Number.isNaN(dateMs) ? undefined : Math.max(0, dateMs - Date.now());
5183
+ }
5184
+ async function httpGet(url, opts = {}) {
5185
+ const { headers, timeoutMs = 30000, fetchImpl = fetch, okStatuses = [], maxRetryWaitMs = MAX_RETRY_WAIT_MS } = opts;
5186
+ let totalBaseWaitMs = 0;
5187
+ for (let attempt = 1;; attempt++) {
5188
+ const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
5189
+ if (res.ok || okStatuses.includes(res.status))
5190
+ return res;
5191
+ const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
5192
+ const baseWaitMs = retryAfterMs ?? BASE_BACKOFF_MS * 2 ** (attempt - 1);
5193
+ const retryable = (res.status === 429 || res.status === 503) && totalBaseWaitMs + baseWaitMs <= maxRetryWaitMs;
5194
+ if (retryable && attempt <= MAX_RETRIES) {
5195
+ totalBaseWaitMs += baseWaitMs;
5196
+ await res.body?.cancel().catch(() => {});
5197
+ await delay2(jitteredDelayMs(baseWaitMs));
5198
+ continue;
5199
+ }
5200
+ await res.body?.cancel().catch(() => {});
5201
+ throw new HttpError(`GET ${url} -> ${res.status} ${res.statusText}`, {
5202
+ status: res.status,
5203
+ url,
5204
+ attempts: attempt,
5205
+ retryAfterMs
5206
+ });
5207
+ }
5208
+ }
5209
+
5065
5210
  // src/data/catalog.ts
5066
5211
  function catalogEntries(value) {
5067
5212
  return Array.isArray(value) ? value.filter(isRecord) : [];
@@ -5082,16 +5227,14 @@ function normalizeCatalog(value) {
5082
5227
  hidden_faction_achievement_count: typeof data.hidden_faction_achievement_count === "number" ? data.hidden_faction_achievement_count : 0
5083
5228
  };
5084
5229
  }
5085
- async function fetchCatalogConditional(httpBaseUrl, etag) {
5230
+ async function fetchCatalogConditional(httpBaseUrl, etag, opts = {}) {
5086
5231
  const url = `${httpBaseUrl.replace(/\/$/, "")}/api/catalog.json`;
5087
5232
  const headers = { accept: "application/json" };
5088
5233
  if (etag)
5089
5234
  headers["if-none-match"] = etag;
5090
- const res = await fetch(url, { headers });
5235
+ const res = await httpGet(url, { headers, fetchImpl: opts.fetchImpl, timeoutMs: opts.timeoutMs, okStatuses: [304] });
5091
5236
  if (res.status === 304)
5092
5237
  return { notModified: true, etag };
5093
- if (!res.ok)
5094
- throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
5095
5238
  const data = await res.json();
5096
5239
  return {
5097
5240
  notModified: false,
@@ -5099,8 +5242,8 @@ async function fetchCatalogConditional(httpBaseUrl, etag) {
5099
5242
  etag: res.headers?.get?.("etag") ?? undefined
5100
5243
  };
5101
5244
  }
5102
- async function fetchCatalog(httpBaseUrl) {
5103
- const { catalog } = await fetchCatalogConditional(httpBaseUrl);
5245
+ async function fetchCatalog(httpBaseUrl, opts = {}) {
5246
+ const { catalog } = await fetchCatalogConditional(httpBaseUrl, undefined, opts);
5104
5247
  if (!catalog)
5105
5248
  throw new Error("unconditional catalog fetch returned no catalog");
5106
5249
  return catalog;
@@ -5125,14 +5268,14 @@ class CatalogCache {
5125
5268
  this.facilitiesById = index(catalog.facilities);
5126
5269
  this.achievementsById = index([...catalog.achievements, ...catalog.faction_achievements]);
5127
5270
  }
5128
- static async load(httpBaseUrl) {
5129
- const { catalog, etag } = await fetchCatalogConditional(httpBaseUrl);
5271
+ static async load(httpBaseUrl, opts = {}) {
5272
+ const { catalog, etag } = await fetchCatalogConditional(httpBaseUrl, undefined, opts);
5130
5273
  if (!catalog)
5131
5274
  throw new Error("unconditional catalog fetch returned no catalog");
5132
5275
  return new CatalogCache(catalog, etag);
5133
5276
  }
5134
- async revalidate(httpBaseUrl) {
5135
- const result = await fetchCatalogConditional(httpBaseUrl, this.etag);
5277
+ async revalidate(httpBaseUrl, opts = {}) {
5278
+ const result = await fetchCatalogConditional(httpBaseUrl, this.etag, opts);
5136
5279
  if (result.notModified)
5137
5280
  return this;
5138
5281
  if (!result.catalog)
@@ -5199,11 +5342,13 @@ function index(entries) {
5199
5342
  }
5200
5343
 
5201
5344
  // src/data/map.ts
5202
- async function fetchMap(httpBaseUrl) {
5345
+ async function fetchMap(httpBaseUrl, opts = {}) {
5203
5346
  const url = `${httpBaseUrl.replace(/\/$/, "")}/api/map`;
5204
- const res = await fetch(url, { headers: { accept: "application/json" } });
5205
- if (!res.ok)
5206
- throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
5347
+ const res = await httpGet(url, {
5348
+ headers: { accept: "application/json" },
5349
+ fetchImpl: opts.fetchImpl,
5350
+ timeoutMs: opts.timeoutMs
5351
+ });
5207
5352
  const data = requireRecord(await res.json(), "map response");
5208
5353
  const systems = Array.isArray(data.systems) ? data.systems.filter((system) => isRecord(system)) : [];
5209
5354
  const empires = isRecord(data.empires) ? Object.fromEntries(Object.entries(data.empires).filter((entry) => typeof entry[1] === "string")) : {};
@@ -5222,8 +5367,8 @@ class MapCache {
5222
5367
  this.bySystemId.set(id, system);
5223
5368
  }
5224
5369
  }
5225
- static async load(httpBaseUrl) {
5226
- return new MapCache(await fetchMap(httpBaseUrl));
5370
+ static async load(httpBaseUrl, opts = {}) {
5371
+ return new MapCache(await fetchMap(httpBaseUrl, opts));
5227
5372
  }
5228
5373
  system(id) {
5229
5374
  return this.bySystemId.get(id);
@@ -5245,11 +5390,39 @@ function httpBaseFromWs(wsUrl) {
5245
5390
  }
5246
5391
  }
5247
5392
 
5393
+ // src/data/mobile-base.ts
5394
+ async function fetchMobileBase(httpBaseUrl, opts = {}) {
5395
+ const url = `${httpBaseUrl.replace(/\/$/, "")}/wheres-mobile-base`;
5396
+ const res = await httpGet(url, {
5397
+ headers: { accept: "application/json" },
5398
+ fetchImpl: opts.fetchImpl,
5399
+ timeoutMs: opts.timeoutMs
5400
+ });
5401
+ const data = requireRecord(await res.json(), "mobile base response");
5402
+ if (typeof data.system !== "string")
5403
+ throw new Error("mobile base response is missing a system id");
5404
+ return { system: data.system };
5405
+ }
5406
+
5407
+ // src/data/stations.ts
5408
+ async function fetchStations(httpBaseUrl, opts = {}) {
5409
+ const url = `${httpBaseUrl.replace(/\/$/, "")}/api/stations`;
5410
+ const res = await httpGet(url, {
5411
+ headers: { accept: "application/json" },
5412
+ fetchImpl: opts.fetchImpl,
5413
+ timeoutMs: opts.timeoutMs
5414
+ });
5415
+ const data = requireRecord(await res.json(), "stations response");
5416
+ const stations = Array.isArray(data.stations) ? data.stations.filter(isRecord) : [];
5417
+ const empires = Array.isArray(data.empires) ? data.empires.filter((empire) => isRecord(empire) && typeof empire.id === "string" && typeof empire.name === "string") : [];
5418
+ return { stations, empires };
5419
+ }
5420
+
5248
5421
  // src/client.ts
5249
5422
  var DEFAULT_HTTP_BASE = "https://game.spacemolt.com";
5250
5423
  var DEFAULT_CONNECT_RETRY = { maxRetries: 8, baseDelayMs: 2000, maxDelayMs: 60000 };
5251
5424
  var CONNECT_RATE_WINDOW_MS = 60000;
5252
- var delay2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5425
+ var delay3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5253
5426
 
5254
5427
  class SpacemoltClient {
5255
5428
  opts;
@@ -5293,21 +5466,30 @@ class SpacemoltClient {
5293
5466
  get httpBaseUrl() {
5294
5467
  return this.opts.httpBaseUrl ?? (this.opts.url ? httpBaseFromWs(this.opts.url) : DEFAULT_HTTP_BASE);
5295
5468
  }
5469
+ get httpOptions() {
5470
+ return { fetchImpl: this.opts.fetchImpl, timeoutMs: this.opts.httpTimeoutMs };
5471
+ }
5296
5472
  async catalog(force = false) {
5297
5473
  const maxAgeMs = this.opts.catalogMaxAgeMs ?? 3600000;
5298
5474
  const cached = this.catalogCache;
5299
5475
  const fresh = cached && !force && Date.now() - this.catalogFetchedAt < maxAgeMs;
5300
5476
  if (fresh)
5301
5477
  return cached;
5302
- this.catalogCache = !this.catalogCache || force ? await CatalogCache.load(this.httpBaseUrl) : await this.catalogCache.revalidate(this.httpBaseUrl);
5478
+ this.catalogCache = !this.catalogCache || force ? await CatalogCache.load(this.httpBaseUrl, this.httpOptions) : await this.catalogCache.revalidate(this.httpBaseUrl, this.httpOptions);
5303
5479
  this.catalogFetchedAt = Date.now();
5304
5480
  return this.catalogCache;
5305
5481
  }
5306
5482
  async map(force = false) {
5307
5483
  if (force || !this.mapCache)
5308
- this.mapCache = await MapCache.load(this.httpBaseUrl);
5484
+ this.mapCache = await MapCache.load(this.httpBaseUrl, this.httpOptions);
5309
5485
  return this.mapCache;
5310
5486
  }
5487
+ stations() {
5488
+ return fetchStations(this.httpBaseUrl, this.httpOptions);
5489
+ }
5490
+ mobileBase() {
5491
+ return fetchMobileBase(this.httpBaseUrl, this.httpOptions);
5492
+ }
5311
5493
  get credentialStore() {
5312
5494
  return this.store;
5313
5495
  }
@@ -5355,7 +5537,7 @@ class SpacemoltClient {
5355
5537
  lastErr = err;
5356
5538
  if (i < retry.maxRetries) {
5357
5539
  const retryAfterMs = retryAfterMsFromClose(err);
5358
- await delay2(retryAfterMs ?? Math.min(retry.baseDelayMs * 2 ** i, retry.maxDelayMs));
5540
+ await delay3(retryAfterMs ?? Math.min(retry.baseDelayMs * 2 ** i, retry.maxDelayMs));
5359
5541
  }
5360
5542
  }
5361
5543
  }
@@ -5458,10 +5640,10 @@ class SpacemoltClient {
5458
5640
  while (this.rateLimitedQueue.length > 0) {
5459
5641
  if (!isFirst) {
5460
5642
  if (batchSize > 0 && ranInCurrentBatch >= batchSize) {
5461
- await delay2(batchWaitMs);
5643
+ await delay3(batchWaitMs);
5462
5644
  ranInCurrentBatch = 0;
5463
5645
  } else if (stagger > 0) {
5464
- await delay2(stagger);
5646
+ await delay3(stagger);
5465
5647
  }
5466
5648
  }
5467
5649
  isFirst = false;
@@ -5520,6 +5702,8 @@ class SpacemoltClient {
5520
5702
  id,
5521
5703
  url: this.opts.url,
5522
5704
  webSocketFactory: this.opts.webSocketFactory,
5705
+ onSend: this.opts.onSend && ((f) => this.opts.onSend?.(id, f)),
5706
+ onReceive: this.opts.onReceive && ((f) => this.opts.onReceive?.(id, f)),
5523
5707
  seedState: this.opts.seedState,
5524
5708
  reconnect: false,
5525
5709
  fetchImpl: this.opts.fetchImpl,
@@ -5542,28 +5726,6 @@ class SpacemoltClient {
5542
5726
  }
5543
5727
  }
5544
5728
  }
5545
- // src/data/stations.ts
5546
- async function fetchStations(httpBaseUrl) {
5547
- const url = `${httpBaseUrl.replace(/\/$/, "")}/api/stations`;
5548
- const res = await fetch(url, { headers: { accept: "application/json" } });
5549
- if (!res.ok)
5550
- throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
5551
- const data = requireRecord(await res.json(), "stations response");
5552
- const stations = Array.isArray(data.stations) ? data.stations.filter(isRecord) : [];
5553
- const empires = Array.isArray(data.empires) ? data.empires.filter((empire) => isRecord(empire) && typeof empire.id === "string" && typeof empire.name === "string") : [];
5554
- return { stations, empires };
5555
- }
5556
- // src/data/mobile-base.ts
5557
- async function fetchMobileBase(httpBaseUrl) {
5558
- const url = `${httpBaseUrl.replace(/\/$/, "")}/wheres-mobile-base`;
5559
- const res = await fetch(url, { headers: { accept: "application/json" } });
5560
- if (!res.ok)
5561
- throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
5562
- const data = requireRecord(await res.json(), "mobile base response");
5563
- if (typeof data.system !== "string")
5564
- throw new Error("mobile base response is missing a system id");
5565
- return { system: data.system };
5566
- }
5567
5729
  // src/generated/notifications.gen.ts
5568
5730
  var TYPED_NOTIFICATION_TYPES = [
5569
5731
  "achievement_unlocked",
@@ -5599,6 +5761,7 @@ var TYPED_NOTIFICATION_TYPES = [
5599
5761
  "faction_peace_proposal",
5600
5762
  "faction_war_declared",
5601
5763
  "fleet",
5764
+ "gift_received",
5602
5765
  "market_update",
5603
5766
  "mining_yield",
5604
5767
  "observation_update",
@@ -5677,6 +5840,7 @@ export {
5677
5840
  EventStream,
5678
5841
  FLEET_PUSH_ACTIONS,
5679
5842
  GENERATED_SPEC_VERSION,
5843
+ HttpError,
5680
5844
  MapCache,
5681
5845
  MarketCache,
5682
5846
  MemoryCredentialStore,
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Adds random jitter to a retry delay, so multiple callers retrying together
3
+ * (e.g. a fleet of accounts sharing a per-IP rate limit) don't all wake up
4
+ * and retry in the same instant. Extra is uniform in `[0, max(25% of base,
5
+ * 250ms))`. Shared by the WebSocket rate-limit retry (`account.ts`) and the
6
+ * HTTP retry (`data/http.ts`).
7
+ */
8
+ export declare function jitteredDelayMs(baseMs: number): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spacemolt/lib",
3
- "version": "15.0.1",
3
+ "version": "15.2.0",
4
4
  "description": "The definitive TypeScript library for SpaceMolt. WebSocket-v2-first, multi-account, with local state caches. Internals are regenerated from the server's OpenAPI spec.",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",