@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 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
- /** Inject a `fetch` implementation (tests, custom runtimes). */
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). */
@@ -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>;
@@ -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 };
@@ -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 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",
@@ -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,
@@ -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.1.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",