@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/COMMANDS.md +1 -1
- package/README.md +51 -0
- package/dist/account.d.ts +62 -1
- package/dist/client.d.ts +24 -1
- package/dist/data/catalog.d.ts +10 -5
- package/dist/data/http.d.ts +36 -0
- package/dist/data/map.d.ts +3 -2
- package/dist/data/mobile-base.d.ts +3 -2
- package/dist/data/stations.d.ts +3 -2
- package/dist/errors.d.ts +19 -0
- package/dist/generated/actions.gen.d.ts +1 -1
- package/dist/generated/notifications.gen.d.ts +4 -3
- package/dist/generated/openapi/types.gen.d.ts +117 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +234 -70
- package/dist/jitter.d.ts +8 -0
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
4398
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
5205
|
-
|
|
5206
|
-
|
|
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
|
|
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
|
|
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
|
|
5643
|
+
await delay3(batchWaitMs);
|
|
5462
5644
|
ranInCurrentBatch = 0;
|
|
5463
5645
|
} else if (stagger > 0) {
|
|
5464
|
-
await
|
|
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,
|
package/dist/jitter.d.ts
ADDED
|
@@ -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
|
|
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",
|