@spacemolt/lib 15.1.0 → 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/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/index.d.ts +3 -2
- package/dist/index.js +232 -69
- package/dist/jitter.d.ts +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -136,6 +136,23 @@ account.onAny((frame) => console.log('push:', frame.type));
|
|
|
136
136
|
for await (const hit of account.events('battle_damage')) { /* ... */ }
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
+
`onAny` sees only unsolicited pushes. To log every frame on the wire, including
|
|
140
|
+
your own query/mutation replies, pass `onSend`/`onReceive` (the client variants
|
|
141
|
+
also receive the account id). Credentials are redacted in both directions:
|
|
142
|
+
`onSend` sees a `spacemolt_auth` frame with its `password`, `token`,
|
|
143
|
+
`registration_code`, or `device_code` (from `login_link_poll`) replaced by
|
|
144
|
+
`'[redacted]'`, and `onReceive` sees a `registered` push (which carries the
|
|
145
|
+
account's generated password) with that password redacted, and a `result`
|
|
146
|
+
frame carrying a `device_code` (`login_link`'s response) with that code
|
|
147
|
+
redacted too — the frame actually sent/routed is unaffected either way:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
new SpacemoltClient({
|
|
151
|
+
onSend: (id, frame) => console.log(id, '→', frame.tool, frame.action, frame.request_id),
|
|
152
|
+
onReceive: (id, frame) => console.log(id, '←', frame.type, frame.request_id),
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
139
156
|
### Subscriptions
|
|
140
157
|
|
|
141
158
|
Subscribe to a station's order book or to presence at your location; the
|
|
@@ -303,6 +320,40 @@ const { stations } = await fetchStations('https://game.spacemolt.com');
|
|
|
303
320
|
const { system } = await fetchMobileBase('https://game.spacemolt.com'); // the moving capital
|
|
304
321
|
```
|
|
305
322
|
|
|
323
|
+
Every one of these fetches is bounded by a timeout (30s by default, covering the
|
|
324
|
+
response body, not just the headers; pass `{ timeoutMs }`, or `httpTimeoutMs` on
|
|
325
|
+
the client, to change it — the catalog is several MB), retries a transient `429`/`503` a few times
|
|
326
|
+
(honoring `Retry-After`, jittered), and throws a typed `HttpError`
|
|
327
|
+
(`status`/`url`/`attempts`/`retryAfterMs`) on a retry-exhausted or otherwise
|
|
328
|
+
non-ok response. A timeout or network error throws the native error
|
|
329
|
+
(`DOMException`/`TypeError`) instead — it's not an `HttpError`. The 60s
|
|
330
|
+
retry-wait cap is on the *total* base wait summed across every attempt, not
|
|
331
|
+
each attempt checked in isolation — so three retries of a near-the-cap
|
|
332
|
+
`Retry-After` can't add up to several times the cap.
|
|
333
|
+
|
|
334
|
+
With a client, the same data is `client.catalog()`, `client.map()`,
|
|
335
|
+
`client.stations()` and `client.mobileBase()`. To observe HTTP traffic
|
|
336
|
+
(status/bytes/timing/retries), set `fetchImpl` once on the client — every
|
|
337
|
+
HTTP call it makes, including Clerk, goes through it:
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
const client = new SpacemoltClient({
|
|
341
|
+
fetchImpl: async (input, init) => {
|
|
342
|
+
const started = Date.now();
|
|
343
|
+
const res = await fetch(input, init);
|
|
344
|
+
console.log(res.status, String(input), `${Date.now() - started}ms`);
|
|
345
|
+
return res;
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The per-call timeout is delivered through `init.signal` (an `AbortSignal`), not
|
|
351
|
+
a separate argument — a wrapper must pass `init` through to the real `fetch`
|
|
352
|
+
unchanged, as above, or it silently loses the timeout.
|
|
353
|
+
|
|
354
|
+
The standalone functions above take the same option as a trailing
|
|
355
|
+
`{ fetchImpl }` for use without a client.
|
|
356
|
+
|
|
306
357
|
## Examples
|
|
307
358
|
|
|
308
359
|
Runnable scripts in [`examples/`](./examples), run with `bun run examples/<name>.ts`:
|
package/dist/account.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type { FleetPush, OkPush } from './push-frames.js';
|
|
|
18
18
|
import { ConnectionClosedError } from './errors.js';
|
|
19
19
|
import { type MarketBook } from './state/market.js';
|
|
20
20
|
import { type ObservationView } from './state/observation.js';
|
|
21
|
-
import type { GameState, MutationAck, MutationResult, QueryResult, RawFrame, StateSection, WelcomeFrame } from './protocol.js';
|
|
21
|
+
import type { GameState, MutationAck, MutationResult, QueryResult, InboundFrame, RawFrame, StateSection, WelcomeFrame } from './protocol.js';
|
|
22
22
|
import { type WebSocketFactory } from './transport/socket.js';
|
|
23
23
|
/**
|
|
24
24
|
* The `logged_in` frame payload, typed from the server's published
|
|
@@ -43,11 +43,36 @@ export interface ReconnectOptions {
|
|
|
43
43
|
/** Backoff ceiling in ms. Default 30000. */
|
|
44
44
|
maxDelayMs?: number;
|
|
45
45
|
}
|
|
46
|
+
/** Info reported to an `onRateLimited` listener — see `Account.onRateLimited`. */
|
|
47
|
+
export interface RateLimitedInfo {
|
|
48
|
+
/** The call being retried: `tool.action` for `query`/`mutate`, `'authenticate'` for auth. */
|
|
49
|
+
command: string;
|
|
50
|
+
/** 1-based retry attempt number. */
|
|
51
|
+
attempt: number;
|
|
52
|
+
/** How long this retry is waiting before resending, including jitter. */
|
|
53
|
+
delayMs: number;
|
|
54
|
+
}
|
|
46
55
|
export interface AccountOptions {
|
|
47
56
|
/** WebSocket URL of the v2 endpoint. Defaults to the production server. */
|
|
48
57
|
url?: string;
|
|
49
58
|
/** Inject a WebSocket implementation (tests, custom runtimes). */
|
|
50
59
|
webSocketFactory?: WebSocketFactory;
|
|
60
|
+
/**
|
|
61
|
+
* Observe every outbound frame (logging/review). Fires before it is sent.
|
|
62
|
+
* A `spacemolt_auth` frame's `password`, `token`, `registration_code`, or
|
|
63
|
+
* `device_code` (from `login_link_poll`) is replaced with `'[redacted]'`
|
|
64
|
+
* before this fires — the frame actually sent to the server is unaffected.
|
|
65
|
+
*/
|
|
66
|
+
onSend?: (frame: InboundFrame) => void;
|
|
67
|
+
/**
|
|
68
|
+
* Observe every parsed inbound frame (logging/review). Fires before
|
|
69
|
+
* routing. A `registered` push's `password` (the account's generated
|
|
70
|
+
* credential), and a `result` frame's `device_code` (`login_link`'s
|
|
71
|
+
* response), are replaced with `'[redacted]'` before this fires — the
|
|
72
|
+
* frame that reaches `register()`'s/the correlator's own resolution is
|
|
73
|
+
* unaffected.
|
|
74
|
+
*/
|
|
75
|
+
onReceive?: (frame: RawFrame) => void;
|
|
51
76
|
/**
|
|
52
77
|
* After authenticating, issue a `get_status` query to seed the local state
|
|
53
78
|
* cache with the canonical full state. Default `true`. Disable to avoid the
|
|
@@ -136,6 +161,8 @@ export declare class Account {
|
|
|
136
161
|
private readonly seedState;
|
|
137
162
|
private readonly url;
|
|
138
163
|
private readonly webSocketFactory?;
|
|
164
|
+
private readonly onSend?;
|
|
165
|
+
private readonly onReceive?;
|
|
139
166
|
private readonly reconnectConfig;
|
|
140
167
|
private readonly credentialsProvider?;
|
|
141
168
|
private readonly fetchImpl?;
|
|
@@ -148,6 +175,8 @@ export declare class Account {
|
|
|
148
175
|
private requestSeq;
|
|
149
176
|
private _welcome;
|
|
150
177
|
private _authenticated;
|
|
178
|
+
/** How many `reconnectOnce` calls are mid-swap; a count, so overlapping calls can't lift each other's guard. */
|
|
179
|
+
private reconnectsPending;
|
|
151
180
|
private _loginPayload;
|
|
152
181
|
private _currentTick;
|
|
153
182
|
private _commands;
|
|
@@ -169,6 +198,7 @@ export declare class Account {
|
|
|
169
198
|
private readonly reconnectedListeners;
|
|
170
199
|
private readonly reconnectingListeners;
|
|
171
200
|
private readonly disconnectedListeners;
|
|
201
|
+
private readonly rateLimitedListeners;
|
|
172
202
|
constructor(opts?: AccountOptions);
|
|
173
203
|
private makeSocket;
|
|
174
204
|
/** Live view of the cached game state. Treat as read-only. */
|
|
@@ -446,6 +476,13 @@ export declare class Account {
|
|
|
446
476
|
onReconnected(listener: () => void): () => void;
|
|
447
477
|
/** Fired at the start of each reconnect attempt (1-based). Returns an unsubscribe function. */
|
|
448
478
|
onReconnecting(listener: (attempt: number) => void): () => void;
|
|
479
|
+
/**
|
|
480
|
+
* Fired before each rate-limit retry sleep (`query`/`mutate`/`authenticate`
|
|
481
|
+
* auto-retrying a `rate_limited` error) — the delay includes jitter, so
|
|
482
|
+
* accounts rate-limited together don't retry in lockstep; this is what a
|
|
483
|
+
* caller sees per attempt. Returns an unsubscribe function.
|
|
484
|
+
*/
|
|
485
|
+
onRateLimited(listener: (info: RateLimitedInfo) => void): () => void;
|
|
449
486
|
/**
|
|
450
487
|
* Fired when the connection is gone for good (non-reconnectable or retries
|
|
451
488
|
* exhausted). Returns an unsubscribe function.
|
|
@@ -454,8 +491,15 @@ export declare class Account {
|
|
|
454
491
|
private authExchange;
|
|
455
492
|
/** Best-effort seed of the state cache after auth; failures are non-fatal. */
|
|
456
493
|
private maybeSeedState;
|
|
494
|
+
/** Returns whether the auth exchange was accepted — a caller must not send its frame when it wasn't. */
|
|
457
495
|
private beginAuth;
|
|
458
496
|
private sendFrame;
|
|
497
|
+
/**
|
|
498
|
+
* Calls an optional single-callback hook (`onSend`/`onReceive`), swallowing
|
|
499
|
+
* a throw so a broken observer can't stop the frame being sent or routed —
|
|
500
|
+
* same handling as `notifyListeners` for a `Set` of listeners.
|
|
501
|
+
*/
|
|
502
|
+
private callHook;
|
|
459
503
|
private nextRequestId;
|
|
460
504
|
/**
|
|
461
505
|
* Pick the `request_id` for one frame. A caller-supplied id is echoed by the
|
|
@@ -471,6 +515,16 @@ export declare class Account {
|
|
|
471
515
|
private claimRequestId;
|
|
472
516
|
private routeFrame;
|
|
473
517
|
private warnMalformedFrame;
|
|
518
|
+
/**
|
|
519
|
+
* Fails everything waiting on the current socket: in-flight correlator
|
|
520
|
+
* entries (query/mutate), a pending `welcome` wait, and a pending auth
|
|
521
|
+
* exchange (`login`/`register`/`loginToken`). Deliberately excludes
|
|
522
|
+
* `emitter.closeStreams()`: `handleClose` (a real close) still ends the
|
|
523
|
+
* player's event async iterators itself, but a `reconnectOnce` forced on a
|
|
524
|
+
* live socket leaves them running. Shared by `handleClose` and
|
|
525
|
+
* `reconnectOnce`, which both strand work on a socket that's going away.
|
|
526
|
+
*/
|
|
527
|
+
private failPendingWork;
|
|
474
528
|
private handleClose;
|
|
475
529
|
private shouldReconnect;
|
|
476
530
|
private reconnectLoop;
|
|
@@ -490,6 +544,13 @@ export declare class Account {
|
|
|
490
544
|
*/
|
|
491
545
|
reconnectOnce(): Promise<void>;
|
|
492
546
|
private resubscribe;
|
|
547
|
+
/**
|
|
548
|
+
* Runs `fn`, retrying on a `rate_limited` `SpacemoltError` up to
|
|
549
|
+
* `maxRateLimitRetries` times. The wait is jittered (see `jitteredDelayMs`)
|
|
550
|
+
* so accounts rate-limited together don't all retry in lockstep, and fires
|
|
551
|
+
* `onRateLimited` before each sleep. `command` identifies the call for that
|
|
552
|
+
* listener (`tool.action`, or `'authenticate'`).
|
|
553
|
+
*/
|
|
493
554
|
private withRateLimitRetry;
|
|
494
555
|
/** Serialize mutations: each runs only after the previous settles. */
|
|
495
556
|
private enqueueMutation;
|
package/dist/client.d.ts
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
import { Account } from './account.js';
|
|
11
11
|
import { type AuthCredentials, type CredentialStore, type StoredAccount } from './auth/credentials.js';
|
|
12
12
|
import type { WebSocketFactory } from './transport/socket.js';
|
|
13
|
+
import type { InboundFrame, RawFrame } from './protocol.js';
|
|
13
14
|
import type { ReconnectOptions, RegisterParams, RegisterResult } from './account.js';
|
|
14
15
|
import { CatalogCache } from './data/catalog.js';
|
|
15
16
|
import { MapCache } from './data/map.js';
|
|
17
|
+
import { type MobileBaseLocation } from './data/mobile-base.js';
|
|
18
|
+
import { type StationList } from './data/stations.js';
|
|
16
19
|
import { type ClerkPlayer } from './auth/clerk.js';
|
|
17
20
|
import { type ConnectionClosedError } from './errors.js';
|
|
18
21
|
export interface SpacemoltClientOptions {
|
|
@@ -22,6 +25,10 @@ export interface SpacemoltClientOptions {
|
|
|
22
25
|
store?: CredentialStore;
|
|
23
26
|
/** Inject a WebSocket implementation (tests, custom runtimes). */
|
|
24
27
|
webSocketFactory?: WebSocketFactory;
|
|
28
|
+
/** Observe every outbound frame on every managed account (see Account). */
|
|
29
|
+
onSend?: (accountId: string, frame: InboundFrame) => void;
|
|
30
|
+
/** Observe every inbound frame on every managed account (see Account). */
|
|
31
|
+
onReceive?: (accountId: string, frame: RawFrame) => void;
|
|
25
32
|
/** Seed each account's state cache after auth (see Account). Default true. */
|
|
26
33
|
seedState?: boolean;
|
|
27
34
|
/**
|
|
@@ -94,8 +101,19 @@ export interface SpacemoltClientOptions {
|
|
|
94
101
|
* Generate one from the website; keep it secret.
|
|
95
102
|
*/
|
|
96
103
|
clerkApiKey?: string;
|
|
97
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Inject a `fetch` implementation (tests, custom runtimes). The per-call
|
|
106
|
+
* timeout is delivered via `init.signal` (an `AbortSignal`), not a
|
|
107
|
+
* separate argument — a wrapper must pass `init` through to the real
|
|
108
|
+
* `fetch` unchanged or it silently loses the timeout.
|
|
109
|
+
*/
|
|
98
110
|
fetchImpl?: typeof fetch;
|
|
111
|
+
/**
|
|
112
|
+
* Abort each HTTP data fetch (catalog, map, stations, mobile base) after this
|
|
113
|
+
* many ms, body included. Default 30000. Raise it on a slow link: the catalog
|
|
114
|
+
* is several MB.
|
|
115
|
+
*/
|
|
116
|
+
httpTimeoutMs?: number;
|
|
99
117
|
/**
|
|
100
118
|
* How long to wait for the server's `welcome` frame (post-WS-upgrade) and
|
|
101
119
|
* for a `logged_in`/error response to an auth attempt, before giving up.
|
|
@@ -196,6 +214,7 @@ export declare class SpacemoltClient {
|
|
|
196
214
|
private notifyAccountDisconnected;
|
|
197
215
|
/** HTTP origin used for bulk data fetches (catalog, map). */
|
|
198
216
|
get httpBaseUrl(): string;
|
|
217
|
+
private get httpOptions();
|
|
199
218
|
/**
|
|
200
219
|
* The bulk catalog. Fetched on first use and cached, then revalidated against
|
|
201
220
|
* the server once the cache is older than `catalogMaxAgeMs` (default 1h) so a
|
|
@@ -207,6 +226,10 @@ export declare class SpacemoltClient {
|
|
|
207
226
|
catalog(force?: boolean): Promise<CatalogCache>;
|
|
208
227
|
/** The static galaxy map, fetched once and cached. Pass `force` to refetch. */
|
|
209
228
|
map(force?: boolean): Promise<MapCache>;
|
|
229
|
+
/** The live station directory. Not cached — it changes as the game runs. */
|
|
230
|
+
stations(): Promise<StationList>;
|
|
231
|
+
/** The mobile base's current system. Not cached — it moves. */
|
|
232
|
+
mobileBase(): Promise<MobileBaseLocation>;
|
|
210
233
|
/** The credential store backing this client. */
|
|
211
234
|
get credentialStore(): CredentialStore;
|
|
212
235
|
/** Persist username/password credentials. Returns the account id (username). */
|
package/dist/data/catalog.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CatalogAchievement, CatalogDump, FacilityDefinition, Item, Module, Recipe, ShipClass, SkillDefinition } from '../generated/openapi/types.gen.js';
|
|
2
|
+
import { type FetchOptions } from './http.js';
|
|
2
3
|
/**
|
|
3
4
|
* Local copy of the bulk game catalog (`GET /api/catalog.json`).
|
|
4
5
|
*
|
|
@@ -52,10 +53,14 @@ export interface CatalogFetchResult {
|
|
|
52
53
|
* `etag` is passed and the server confirms the catalog is unchanged, this
|
|
53
54
|
* returns `{ notModified: true }` after a ~0-byte `304` — no multi-MB download
|
|
54
55
|
* or re-parse. Otherwise it returns the fresh catalog and its new `ETag`.
|
|
56
|
+
*
|
|
57
|
+
* Bounded by a request timeout and retried on a transient `429`/`503`; a
|
|
58
|
+
* failure that isn't a 304 throws `HttpError`. Pass `fetchImpl` to inject a
|
|
59
|
+
* `fetch` (tests, or an observability wrapper — see `httpGet`).
|
|
55
60
|
*/
|
|
56
|
-
export declare function fetchCatalogConditional(httpBaseUrl: string, etag?: string): Promise<CatalogFetchResult>;
|
|
57
|
-
/** Unconditionally fetch and normalize the catalog. */
|
|
58
|
-
export declare function fetchCatalog(httpBaseUrl: string): Promise<Catalog>;
|
|
61
|
+
export declare function fetchCatalogConditional(httpBaseUrl: string, etag?: string, opts?: FetchOptions): Promise<CatalogFetchResult>;
|
|
62
|
+
/** Unconditionally fetch and normalize the catalog. See `fetchCatalogConditional` for `fetchImpl`. */
|
|
63
|
+
export declare function fetchCatalog(httpBaseUrl: string, opts?: FetchOptions): Promise<Catalog>;
|
|
59
64
|
export declare class CatalogCache {
|
|
60
65
|
readonly catalog: Catalog;
|
|
61
66
|
/** The server `ETag` this catalog was fetched with, for conditional reloads. */
|
|
@@ -70,13 +75,13 @@ export declare class CatalogCache {
|
|
|
70
75
|
/** The server `ETag` this catalog was fetched with, for conditional reloads. */
|
|
71
76
|
etag?: string | undefined);
|
|
72
77
|
/** Fetch the catalog and wrap it in a cache. */
|
|
73
|
-
static load(httpBaseUrl: string): Promise<CatalogCache>;
|
|
78
|
+
static load(httpBaseUrl: string, opts?: FetchOptions): Promise<CatalogCache>;
|
|
74
79
|
/**
|
|
75
80
|
* Conditionally refresh via `If-None-Match`. Returns `this` unchanged when the
|
|
76
81
|
* server confirms the catalog is still current (a cheap `304`), or a new
|
|
77
82
|
* `CatalogCache` when the catalog has changed.
|
|
78
83
|
*/
|
|
79
|
-
revalidate(httpBaseUrl: string): Promise<CatalogCache>;
|
|
84
|
+
revalidate(httpBaseUrl: string, opts?: FetchOptions): Promise<CatalogCache>;
|
|
80
85
|
/** Server release this catalog was built for; `''` if the response omitted it. */
|
|
81
86
|
get version(): string;
|
|
82
87
|
ship(id: string): CatalogShip | undefined;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Options every public data fetcher accepts. */
|
|
2
|
+
export interface FetchOptions {
|
|
3
|
+
/** Inject a `fetch` implementation (tests, observability). Defaults to global `fetch`. */
|
|
4
|
+
fetchImpl?: typeof fetch;
|
|
5
|
+
/** Abort each request after this many ms, body included. Default 30000. */
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface HttpGetOptions {
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
/** Abort the request after this many ms (`AbortSignal.timeout`). Default 30000. */
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Inject a `fetch` implementation — tests, custom runtimes, or an
|
|
14
|
+
* observability wrapper (status/bytes/timing/retries) a consumer supplies
|
|
15
|
+
* via `SpacemoltClientOptions.fetchImpl` / the per-call `fetchImpl` option.
|
|
16
|
+
* Defaults to global `fetch`.
|
|
17
|
+
*/
|
|
18
|
+
fetchImpl?: typeof fetch;
|
|
19
|
+
/** Response statuses besides 2xx to return rather than treat as a failure (e.g. 304). */
|
|
20
|
+
okStatuses?: readonly number[];
|
|
21
|
+
/**
|
|
22
|
+
* Total (un-jittered) base retry wait allowed across every attempt before
|
|
23
|
+
* giving up — not re-checked per attempt in isolation. Default
|
|
24
|
+
* `MAX_RETRY_WAIT_MS` (60000). Mainly for tests that need a small cap to
|
|
25
|
+
* exercise this without waiting real seconds.
|
|
26
|
+
*/
|
|
27
|
+
maxRetryWaitMs?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `GET url`, bounded by `timeoutMs` (headers and body both) and retried up to
|
|
31
|
+
* `MAX_RETRIES` times on `429`/`503` (waiting `Retry-After` when present,
|
|
32
|
+
* else a short backoff — either way, jittered). A timeout or network error is
|
|
33
|
+
* not retried — it throws the native error as-is, not an `HttpError`. Any
|
|
34
|
+
* other non-ok, non-`okStatuses` response throws an `HttpError`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function httpGet(url: string, opts?: HttpGetOptions): Promise<Response>;
|
package/dist/data/map.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MapData, MapDataSystem } from '../generated/openapi/types.gen.js';
|
|
2
|
+
import { type FetchOptions } from './http.js';
|
|
2
3
|
/**
|
|
3
4
|
* Local copy of the static galaxy map (`GET /api/map`).
|
|
4
5
|
*
|
|
@@ -15,13 +16,13 @@ import type { MapData, MapDataSystem } from '../generated/openapi/types.gen.js';
|
|
|
15
16
|
/** A system entry from `GET /api/map`. */
|
|
16
17
|
export type MapSystem = MapDataSystem;
|
|
17
18
|
export type GalaxyMap = MapData;
|
|
18
|
-
export declare function fetchMap(httpBaseUrl: string): Promise<GalaxyMap>;
|
|
19
|
+
export declare function fetchMap(httpBaseUrl: string, opts?: FetchOptions): Promise<GalaxyMap>;
|
|
19
20
|
export declare class MapCache {
|
|
20
21
|
readonly map: GalaxyMap;
|
|
21
22
|
private readonly bySystemId;
|
|
22
23
|
constructor(map: GalaxyMap);
|
|
23
24
|
/** Fetch the galaxy map and wrap it in a cache. */
|
|
24
|
-
static load(httpBaseUrl: string): Promise<MapCache>;
|
|
25
|
+
static load(httpBaseUrl: string, opts?: FetchOptions): Promise<MapCache>;
|
|
25
26
|
system(id: string): MapSystem | undefined;
|
|
26
27
|
get systems(): readonly MapSystem[];
|
|
27
28
|
/** Empire id -> display colour. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MobileBaseLocation } from '../generated/openapi/types.gen.js';
|
|
2
|
+
import { type FetchOptions } from './http.js';
|
|
2
3
|
/**
|
|
3
4
|
* Where the galaxy's single moving capital currently is
|
|
4
5
|
* (`GET /wheres-mobile-base`).
|
|
@@ -8,6 +9,6 @@ import type { MobileBaseLocation } from '../generated/openapi/types.gen.js';
|
|
|
8
9
|
* is the spec's published `MobileBaseLocation` (`{ system }`), a system id that
|
|
9
10
|
* indexes into the galaxy map.
|
|
10
11
|
*/
|
|
11
|
-
/** Fetch the mobile base's current system id. */
|
|
12
|
-
export declare function fetchMobileBase(httpBaseUrl: string): Promise<MobileBaseLocation>;
|
|
12
|
+
/** Fetch the mobile base's current system id. Pass `fetchImpl` to inject a `fetch` (tests, observability). */
|
|
13
|
+
export declare function fetchMobileBase(httpBaseUrl: string, opts?: FetchOptions): Promise<MobileBaseLocation>;
|
|
13
14
|
export type { MobileBaseLocation };
|
package/dist/data/stations.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FetchOptions } from './http.js';
|
|
1
2
|
/**
|
|
2
3
|
* Station directory (`GET /api/stations`).
|
|
3
4
|
*
|
|
@@ -53,5 +54,5 @@ export interface StationList {
|
|
|
53
54
|
stations: StationSummary[];
|
|
54
55
|
empires: StationEmpire[];
|
|
55
56
|
}
|
|
56
|
-
/** Fetch the public station directory. */
|
|
57
|
-
export declare function fetchStations(httpBaseUrl: string): Promise<StationList>;
|
|
57
|
+
/** Fetch the public station directory. Pass `fetchImpl` to inject a `fetch` (tests, observability). */
|
|
58
|
+
export declare function fetchStations(httpBaseUrl: string, opts?: FetchOptions): Promise<StationList>;
|
package/dist/errors.d.ts
CHANGED
|
@@ -24,6 +24,25 @@ export declare class SpacemoltError extends Error {
|
|
|
24
24
|
}
|
|
25
25
|
export declare function errorFromFrame(frame: ErrorFrame): SpacemoltError;
|
|
26
26
|
export declare function errorFromActionFrame(frame: ActionErrorFrame): SpacemoltError;
|
|
27
|
+
/**
|
|
28
|
+
* Raised by a bulk/live HTTP data fetch (catalog, map, stations, mobile-base)
|
|
29
|
+
* that didn't come back ok — after retries, for a retryable status. Message
|
|
30
|
+
* text matches the old bare-`Error` format (`GET <url> -> <status> <text>`).
|
|
31
|
+
*/
|
|
32
|
+
export declare class HttpError extends Error {
|
|
33
|
+
readonly status: number;
|
|
34
|
+
readonly url: string;
|
|
35
|
+
/** How many requests were made in total, including the one that finally failed. */
|
|
36
|
+
readonly attempts: number;
|
|
37
|
+
/** The server's `Retry-After` on the failing response, parsed to ms, if any. */
|
|
38
|
+
readonly retryAfterMs?: number;
|
|
39
|
+
constructor(message: string, opts: {
|
|
40
|
+
status: number;
|
|
41
|
+
url: string;
|
|
42
|
+
attempts: number;
|
|
43
|
+
retryAfterMs?: number;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
27
46
|
/** Raised against every in-flight request when the socket closes. */
|
|
28
47
|
export declare class ConnectionClosedError extends Error {
|
|
29
48
|
readonly code?: number;
|
package/dist/index.d.ts
CHANGED
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
* subsequent milestones.
|
|
11
11
|
*/
|
|
12
12
|
export * from './protocol.js';
|
|
13
|
-
export { Account, type AccountOptions, type ReconnectOptions, type RegisterParams, type RegisterResult, type LoggedInPayload, } from './account.js';
|
|
13
|
+
export { Account, type AccountOptions, type ReconnectOptions, type RegisterParams, type RegisterResult, type LoggedInPayload, type RateLimitedInfo, } from './account.js';
|
|
14
14
|
export { SpacemoltClient, type SpacemoltClientOptions } from './client.js';
|
|
15
15
|
export { ClerkSource, mintWsToken, type ClerkAuth, type ClerkAuthHeaders, type ClerkPlayer, type ClerkRegistration, type ClerkSourceOptions, } from './auth/clerk.js';
|
|
16
16
|
export { type CredentialStore, type StoredAccount, type AuthCredentials, MemoryCredentialStore, } from './auth/credentials.js';
|
|
17
|
-
export { SpacemoltError, ConnectionClosedError, CLOSE_CODE, retryAfterMsFromClose } from './errors.js';
|
|
17
|
+
export { SpacemoltError, ConnectionClosedError, HttpError, CLOSE_CODE, retryAfterMsFromClose } from './errors.js';
|
|
18
18
|
export { StateCache } from './state/cache.js';
|
|
19
19
|
export { MarketCache, type MarketBook, type MarketItem } from './state/market.js';
|
|
20
20
|
export { ObservationCache, type ObservationView, type ObservedPlayer, type CloakedContact, } from './state/observation.js';
|
|
21
21
|
export { TypedEmitter, EventStream } from './events/emitter.js';
|
|
22
22
|
export { type Commands, type CommandDispatch, buildCommands } from './generated/commands.gen.js';
|
|
23
23
|
export { CatalogCache, fetchCatalog, fetchCatalogConditional, type Catalog, type CatalogAchievement, type CatalogFacility, type CatalogFetchResult, type CatalogItem, type CatalogRecipe, type CatalogShip, type CatalogSkill, } from './data/catalog.js';
|
|
24
|
+
export type { FetchOptions } from './data/http.js';
|
|
24
25
|
export { MapCache, fetchMap, httpBaseFromWs, type GalaxyMap, type MapSystem } from './data/map.js';
|
|
25
26
|
export { fetchStations, type StationEmpire, type StationList, type StationSummary } from './data/stations.js';
|
|
26
27
|
export { fetchMobileBase, type MobileBaseLocation } from './data/mobile-base.js';
|
package/dist/index.js
CHANGED
|
@@ -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",
|
|
@@ -5678,6 +5840,7 @@ export {
|
|
|
5678
5840
|
EventStream,
|
|
5679
5841
|
FLEET_PUSH_ACTIONS,
|
|
5680
5842
|
GENERATED_SPEC_VERSION,
|
|
5843
|
+
HttpError,
|
|
5681
5844
|
MapCache,
|
|
5682
5845
|
MarketCache,
|
|
5683
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.
|
|
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",
|