screeps-connectivity 0.8.1 → 0.10.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/CHANGELOG.md +18 -0
- package/README.md +182 -0
- package/package.json +1 -1
- package/src/ScreepsClient.ts +19 -29
- package/src/http/HttpClient.ts +3 -2
- package/src/http/auth/AuthStrategy.ts +2 -0
- package/src/http/auth/TokenAuth.ts +1 -0
- package/src/http/endpoints/leaderboard.ts +5 -3
- package/src/http/endpoints/user.ts +4 -10
- package/src/http/fetchFn.ts +17 -0
- package/src/http/fetchServerVersion.ts +6 -5
- package/src/index.ts +3 -1
- package/src/types/api.ts +22 -2
- package/src/types/game.ts +9 -0
- package/tests/ScreepsClient.test.ts +62 -14
- package/tests/http/HttpClient.test.ts +25 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1539f52: Add NotifyPrefs type and notifyPrefs field on UserInfo for email notification preference support.
|
|
8
|
+
- de39cf0: Expose public-profile data through the API: `power` on the `user.find` response, an optional `id` argument on `user.stats`, and a typed `leaderboard.find` response.
|
|
9
|
+
- 4b8f9d9: Add `setFetch()` to `screeps-connectivity` so consumers can supply a custom fetch implementation (e.g. Tauri's HTTP plugin) without patching `window.fetch`. `screeps-client` uses this to enable CORS-free HTTP in the standalone Tauri desktop app without affecting the browser runtime.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 882bea5: Fix `TokenAuth` always using its static token — server-issued `X-Token` headers, WebSocket token rotation, and the idle keep-alive timer are now skipped when the auth strategy sets `supportsTokenRefresh: false`.
|
|
14
|
+
|
|
15
|
+
## 0.9.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- e73c85f: Add `setFetch()` to `screeps-connectivity` so consumers can supply a custom fetch implementation (e.g. Tauri's HTTP plugin) without patching `window.fetch`. `screeps-client` uses this to enable CORS-free HTTP in the standalone Tauri desktop app without affecting the browser runtime.
|
|
20
|
+
|
|
3
21
|
## 0.8.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# screeps-connectivity
|
|
2
|
+
|
|
3
|
+
TypeScript library for connecting to [Screeps](https://screeps.com) servers. Handles HTTP, WebSocket, authentication, data stores, caching, and persistent storage — with **zero production dependencies**.
|
|
4
|
+
|
|
5
|
+
Works in the browser and Node.js. Ships as ESM + CJS via tsup.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install screeps-connectivity
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`FileStorage` (Node.js only) is a separate entry point so browser bundles stay clean:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { FileStorage } from 'screeps-connectivity/file-storage'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { ScreepsClient, TokenAuth, IndexedDBStorage } from 'screeps-connectivity'
|
|
27
|
+
|
|
28
|
+
const client = new ScreepsClient({
|
|
29
|
+
url: 'https://screeps.com',
|
|
30
|
+
auth: new TokenAuth({ token: 'your-auth-token' }),
|
|
31
|
+
storage: new IndexedDBStorage('my-app'),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Subscribe before connecting — fires once the eager fetch completes
|
|
35
|
+
client.stores.user.on('user:me', (info) => {
|
|
36
|
+
console.log('Connected as', info.username)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
await client.connect()
|
|
40
|
+
|
|
41
|
+
// Load terrain
|
|
42
|
+
const terrain = await client.stores.room.terrain('W7N7', 'shard0')
|
|
43
|
+
|
|
44
|
+
// Subscribe to live room updates
|
|
45
|
+
const roomSub = client.stores.room.subscribe('W7N7', 'shard0')
|
|
46
|
+
client.stores.room.on('room:update', ({ gameTime, objects }) => {
|
|
47
|
+
console.log('Tick', gameTime, '— objects:', Object.keys(objects).length)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Cleanup
|
|
51
|
+
roomSub.dispose()
|
|
52
|
+
client.disconnect()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
### Authentication
|
|
60
|
+
|
|
61
|
+
Three built-in strategies, or implement your own via the `AuthStrategy` interface:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// Pre-issued token (official server, private servers with token auth)
|
|
65
|
+
new TokenAuth({ token: 'your-token' })
|
|
66
|
+
|
|
67
|
+
// Email + password — exchanged for a token on connect
|
|
68
|
+
new PasswordAuth({ email: 'user@example.com', password: 'secret' })
|
|
69
|
+
|
|
70
|
+
// Read-only guest access (xxscreeps-compatible private servers only)
|
|
71
|
+
new GuestAuth()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Screeps servers rotate the session token on every request. `ScreepsClient` tracks the latest token across both HTTP and WebSocket transports, keeps them in sync, and issues an idle keep-alive if no traffic has been seen for 30 seconds.
|
|
75
|
+
|
|
76
|
+
### Stores (event-based data layer)
|
|
77
|
+
|
|
78
|
+
All stores extend `EventTarget`. `store.on(type, handler)` returns a `Subscription` with a `dispose()` method. `SubscriptionGroup` composes multiple subscriptions for batch teardown.
|
|
79
|
+
|
|
80
|
+
| Store | `client.stores.*` | What it manages |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `UserStore` | `user` | Identity, CPU stats, console output, code change events |
|
|
83
|
+
| `ServerStore` | `server` | Server version, shard list, world bounds, connection lifecycle |
|
|
84
|
+
| `RoomStore` | `room` | Terrain (binary), live object state + diffs, subscriptions |
|
|
85
|
+
| `MapStore` | `map` | `roomMap2` mini-map data, diff detection, LRU cache |
|
|
86
|
+
| `NavigationStore` | `navigation` | Bounded room-navigation history with back/forward |
|
|
87
|
+
|
|
88
|
+
### Room subscriptions
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// Terrain — Uint8Array(2500), one byte per tile (values 0–3)
|
|
92
|
+
const terrain = await client.stores.room.terrain('W7N7', 'shard0')
|
|
93
|
+
|
|
94
|
+
// Live objects — first message is full state; subsequent messages are diffs
|
|
95
|
+
const sub = client.stores.room.subscribe('W7N7', 'shard0')
|
|
96
|
+
client.stores.room.on('room:update', ({ gameTime, objects, diff }) => { /* … */ })
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### World map subscriptions
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
// Subscribe up to 500 concurrent roomMap2 channels (configurable)
|
|
103
|
+
// Excess rooms queue on a FIFO waitlist and are promoted as slots free
|
|
104
|
+
client.stores.map.subscribe('W7N7', 'shard0')
|
|
105
|
+
client.stores.map.on('map:roomMap2', ({ roomName, data }) => { /* … */ })
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### World bounds
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// Detects which quadrants (E/W, N/S) actually contain rooms
|
|
112
|
+
// Works for standard servers, private servers, and single-quadrant maps
|
|
113
|
+
const info = await client.stores.server.worldInfo()
|
|
114
|
+
// → { width, height, minX, maxX, minY, maxY }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Storage adapters
|
|
118
|
+
|
|
119
|
+
| Adapter | Environment | Notes |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `IndexedDBStorage` | Browser | Persists terrain and map cache across reloads |
|
|
122
|
+
| `FileStorage` | Node.js | Import from `screeps-connectivity/file-storage` |
|
|
123
|
+
| `NullStorage` | Any | Disables persistence (in-memory only) |
|
|
124
|
+
|
|
125
|
+
Implement `StorageAdapter` (`get`, `set`, `delete`, `keys`) to plug in any backend.
|
|
126
|
+
|
|
127
|
+
### Pre-login server info
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { fetchServerVersion, getScreepsmodAuth } from 'screeps-connectivity'
|
|
131
|
+
|
|
132
|
+
const version = await fetchServerVersion('http://my-server:21025')
|
|
133
|
+
const auth = getScreepsmodAuth(version)
|
|
134
|
+
// auth?.authTypes → ['password', 'steam']
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Architecture
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
ScreepsClient — facade; wires everything together
|
|
143
|
+
├─ HttpClient — fetch wrapper, auth headers, rate limiting, gzip
|
|
144
|
+
│ └─ endpoints/ — auth · game · user · leaderboard · experimental
|
|
145
|
+
└─ SocketClient — WebSocket lifecycle, exponential-backoff reconnect
|
|
146
|
+
└─ MessageParser — plain-text commands + JSON-array messages, gzip
|
|
147
|
+
DataStores — UserStore · ServerStore · RoomStore · MapStore · NavigationStore
|
|
148
|
+
Cache — two-tier: in-memory Map + optional StorageAdapter
|
|
149
|
+
StorageAdapter — binary interface (Uint8Array)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Node.js usage
|
|
155
|
+
|
|
156
|
+
Pass a custom `WebSocket` constructor for Node 18/20 compatibility:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { WebSocket } from 'ws'
|
|
160
|
+
import { ScreepsClient, TokenAuth, FileStorage } from 'screeps-connectivity'
|
|
161
|
+
// FileStorage is a separate entry point:
|
|
162
|
+
// import { FileStorage } from 'screeps-connectivity/file-storage'
|
|
163
|
+
|
|
164
|
+
const client = new ScreepsClient({
|
|
165
|
+
url: 'https://screeps.com',
|
|
166
|
+
auth: new TokenAuth({ token: 'your-token' }),
|
|
167
|
+
storage: new FileStorage('./screeps-cache'),
|
|
168
|
+
WebSocket,
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## API reference
|
|
175
|
+
|
|
176
|
+
Full documentation — all stores, events, options, and types — is in [`docs/screeps-connectivity.md`](../docs/screeps-connectivity.md).
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
ISC
|
package/package.json
CHANGED
package/src/ScreepsClient.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type { ApiRoomDecorationsResponse } from './types/api.js'
|
|
|
18
18
|
type WsConstructor = typeof globalThis.WebSocket
|
|
19
19
|
|
|
20
20
|
export interface TokenRefreshOptions {
|
|
21
|
-
/**
|
|
21
|
+
/** Interval in milliseconds between world-status polls. Default 30_000. */
|
|
22
22
|
intervalMs?: number
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -35,9 +35,8 @@ export interface ScreepsClientOptions {
|
|
|
35
35
|
maxCacheEntries?: number
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* Pass `false` to disable.
|
|
38
|
+
* Polls `/api/user/world-status` on a fixed interval to keep world status current.
|
|
39
|
+
* Default `{ intervalMs: 30_000 }`. Pass `false` to disable.
|
|
41
40
|
*/
|
|
42
41
|
tokenRefresh?: TokenRefreshOptions | false
|
|
43
42
|
/** Override the /api/game/room-decorations response with static data (useful for dev/testing when the server doesn't support the endpoint). */
|
|
@@ -60,7 +59,6 @@ export class ScreepsClient {
|
|
|
60
59
|
private readonly tokenRefreshIntervalMs: number | null
|
|
61
60
|
private readonly tokenSyncSubs: Subscription[] = []
|
|
62
61
|
private tokenRefreshTimer: ReturnType<typeof setInterval> | null = null
|
|
63
|
-
private lastHttpActivity = 0
|
|
64
62
|
private refreshInFlight = false
|
|
65
63
|
|
|
66
64
|
constructor(opts: ScreepsClientOptions) {
|
|
@@ -93,23 +91,21 @@ export class ScreepsClient {
|
|
|
93
91
|
? null
|
|
94
92
|
: (opts.tokenRefresh?.intervalMs ?? 30_000)
|
|
95
93
|
|
|
96
|
-
this.wireTokenSync()
|
|
94
|
+
this.wireTokenSync(opts.auth.supportsTokenRefresh ?? true)
|
|
97
95
|
}
|
|
98
96
|
|
|
99
|
-
private wireTokenSync(): void {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
this.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
this.lastHttpActivity = Date.now()
|
|
112
|
-
}))
|
|
97
|
+
private wireTokenSync(supportsRefresh: boolean): void {
|
|
98
|
+
if (supportsRefresh) {
|
|
99
|
+
// HTTP rotates token via X-Token → propagate to WS so a later reconnect uses the fresh one.
|
|
100
|
+
this.tokenSyncSubs.push(this.http.on('http:tokenRefresh', ({ token }) => {
|
|
101
|
+
this.socket.setToken(token)
|
|
102
|
+
}))
|
|
103
|
+
// WS issues a new token on auth → keep HTTP side in sync.
|
|
104
|
+
this.tokenSyncSubs.push(this.socket.on('socket:tokenRefresh', (data) => {
|
|
105
|
+
const detail = data as { token: string }
|
|
106
|
+
this.http.setToken(detail.token)
|
|
107
|
+
}))
|
|
108
|
+
}
|
|
113
109
|
}
|
|
114
110
|
|
|
115
111
|
get isConnected(): boolean {
|
|
@@ -137,15 +133,9 @@ export class ScreepsClient {
|
|
|
137
133
|
private startTokenRefresh(): void {
|
|
138
134
|
if (this.tokenRefreshIntervalMs === null) return
|
|
139
135
|
if (this.tokenRefreshTimer !== null) return
|
|
140
|
-
const intervalMs = this.tokenRefreshIntervalMs
|
|
141
|
-
this.lastHttpActivity = Date.now()
|
|
142
|
-
// Tick at half the interval so we react within intervalMs of idleness.
|
|
143
|
-
const tickMs = Math.max(1_000, Math.floor(intervalMs / 2))
|
|
144
136
|
this.tokenRefreshTimer = setInterval(() => {
|
|
145
|
-
const idleFor = Date.now() - this.lastHttpActivity
|
|
146
|
-
if (idleFor < intervalMs) return
|
|
147
137
|
void this.refreshTokenNow()
|
|
148
|
-
},
|
|
138
|
+
}, this.tokenRefreshIntervalMs)
|
|
149
139
|
}
|
|
150
140
|
|
|
151
141
|
private stopTokenRefresh(): void {
|
|
@@ -159,10 +149,10 @@ export class ScreepsClient {
|
|
|
159
149
|
if (this.refreshInFlight) return
|
|
160
150
|
this.refreshInFlight = true
|
|
161
151
|
try {
|
|
162
|
-
this.logger.log('[screeps:client]
|
|
152
|
+
this.logger.log('[screeps:client] world status refresh')
|
|
163
153
|
await this.stores.user.refreshWorldStatus()
|
|
164
154
|
} catch (err) {
|
|
165
|
-
this.logger.log('[screeps:client]
|
|
155
|
+
this.logger.log('[screeps:client] world status refresh failed', err)
|
|
166
156
|
} finally {
|
|
167
157
|
this.refreshInFlight = false
|
|
168
158
|
}
|
package/src/http/HttpClient.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { decompressGzip } from './decompress.js'
|
|
2
|
+
import { getFetch } from './fetchFn.js'
|
|
2
3
|
import { Logger } from '../logger.js'
|
|
3
4
|
import type { AuthStrategy } from './auth/AuthStrategy.js'
|
|
4
5
|
import { createAuthEndpoints, type AuthEndpoints } from './endpoints/auth.js'
|
|
@@ -113,10 +114,10 @@ export class HttpClient extends EventTarget {
|
|
|
113
114
|
init.body = JSON.stringify(body)
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
const res = await
|
|
117
|
+
const res = await getFetch()(url.toString(), init)
|
|
117
118
|
|
|
118
119
|
const newToken = res.headers.get('x-token')
|
|
119
|
-
if (newToken) {
|
|
120
|
+
if (newToken && (this.authStrategy.supportsTokenRefresh ?? true)) {
|
|
120
121
|
this.token = newToken
|
|
121
122
|
this.emit('http:tokenRefresh', { token: newToken })
|
|
122
123
|
}
|
|
@@ -2,4 +2,6 @@ import type { HttpClient } from '../HttpClient.js'
|
|
|
2
2
|
|
|
3
3
|
export interface AuthStrategy {
|
|
4
4
|
authenticate(http: HttpClient): Promise<string>
|
|
5
|
+
/** False means the token is static and must never be replaced by server-issued tokens. Default: true. */
|
|
6
|
+
readonly supportsTokenRefresh?: boolean
|
|
5
7
|
}
|
|
@@ -2,6 +2,7 @@ import type { AuthStrategy } from './AuthStrategy.js'
|
|
|
2
2
|
import type { HttpClient } from '../HttpClient.js'
|
|
3
3
|
|
|
4
4
|
export class TokenAuth implements AuthStrategy {
|
|
5
|
+
readonly supportsTokenRefresh = false
|
|
5
6
|
private readonly token: string
|
|
6
7
|
|
|
7
8
|
constructor(opts: { token: string }) {
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import type { HttpClient } from '../HttpClient.js'
|
|
2
|
-
import type { ApiLeaderboardListResponse, ApiLeaderboardSeasonsResponse } from '../../types/api.js'
|
|
2
|
+
import type { ApiLeaderboardFindResponse, ApiLeaderboardListResponse, ApiLeaderboardSeasonsResponse } from '../../types/api.js'
|
|
3
3
|
|
|
4
4
|
export interface LeaderboardEndpoints {
|
|
5
5
|
list(limit?: number, mode?: 'world' | 'power', offset?: number, season?: string): Promise<ApiLeaderboardListResponse>
|
|
6
|
-
find(username: string, mode?: string, season?: string): Promise<
|
|
6
|
+
find(username: string, mode?: string, season?: string): Promise<ApiLeaderboardFindResponse>
|
|
7
7
|
seasons(): Promise<ApiLeaderboardSeasonsResponse>
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function createLeaderboardEndpoints(http: HttpClient): LeaderboardEndpoints {
|
|
11
11
|
return {
|
|
12
12
|
list: (limit = 10, mode = 'world', offset = 0, season) => http.request('GET', '/api/leaderboard/list', { limit, mode, offset, season }),
|
|
13
|
-
|
|
13
|
+
// Best-effort: ranks feed the public profile and not every server implements
|
|
14
|
+
// them, so a miss shouldn't raise a user-facing toast.
|
|
15
|
+
find: (username, mode = 'world', season = '') => http.request('GET', '/api/leaderboard/find', { username, mode, season }, { silent: true }),
|
|
14
16
|
seasons: () => http.request('GET', '/api/leaderboard/seasons'),
|
|
15
17
|
}
|
|
16
18
|
}
|
|
@@ -5,17 +5,11 @@ import type {
|
|
|
5
5
|
ApiUserMoneyHistoryResponse,
|
|
6
6
|
ApiUserOverviewResponse,
|
|
7
7
|
ApiUserRoomsResponse,
|
|
8
|
+
ApiUserStatsResponse,
|
|
8
9
|
} from '../../types/api.js'
|
|
10
|
+
import type { NotifyPrefs } from '../../types/game.js'
|
|
9
11
|
import { createUserMessagesEndpoints, type UserMessagesEndpoints } from './user-messages.js'
|
|
10
12
|
|
|
11
|
-
export interface NotifyPrefs {
|
|
12
|
-
disabled: boolean
|
|
13
|
-
disabledOnMessages: boolean
|
|
14
|
-
sendOnline: boolean
|
|
15
|
-
interval: number
|
|
16
|
-
errorsInterval: number
|
|
17
|
-
}
|
|
18
|
-
|
|
19
13
|
export interface UserEndpoints {
|
|
20
14
|
branches(): Promise<ApiUserBranchesResponse>
|
|
21
15
|
code: {
|
|
@@ -31,7 +25,7 @@ export interface UserEndpoints {
|
|
|
31
25
|
}
|
|
32
26
|
}
|
|
33
27
|
console(expression: string, shard?: string | null): Promise<unknown>
|
|
34
|
-
stats(interval: number): Promise<
|
|
28
|
+
stats(interval: number, id?: string): Promise<ApiUserStatsResponse>
|
|
35
29
|
rooms(id: string): Promise<ApiUserRoomsResponse>
|
|
36
30
|
overview(interval: number, statName: string): Promise<ApiUserOverviewResponse>
|
|
37
31
|
worldStatus(): Promise<{ ok: number; status: 'normal' | 'lost' | 'empty' }>
|
|
@@ -74,7 +68,7 @@ export function createUserEndpoints(http: HttpClient): UserEndpoints {
|
|
|
74
68
|
},
|
|
75
69
|
},
|
|
76
70
|
console: (expression, shard) => http.request('POST', '/api/user/console', withShard({ expression }, shard)),
|
|
77
|
-
stats: (interval) => http.request('GET', '/api/user/stats', { interval }),
|
|
71
|
+
stats: (interval, id) => http.request('GET', '/api/user/stats', id != null ? { interval, id } : { interval }, { silent: true }),
|
|
78
72
|
// Best-effort dashboard data: not every server implements these, and the
|
|
79
73
|
// Overview page degrades gracefully (zeros / no tiles), so a failure here
|
|
80
74
|
// shouldn't raise a user-facing error toast — mark them silent.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
let _fetch: typeof globalThis.fetch | undefined
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Override the fetch implementation used by all screeps-connectivity HTTP calls.
|
|
5
|
+
* Call this once at app startup when the platform requires a custom transport
|
|
6
|
+
* (e.g. Tauri desktop, where the native WKWebView fetch cannot reach cross-origin
|
|
7
|
+
* Screeps servers and the Tauri HTTP plugin must be used instead).
|
|
8
|
+
*
|
|
9
|
+
* When not called, screeps-connectivity uses `globalThis.fetch`.
|
|
10
|
+
*/
|
|
11
|
+
export function setFetch(fetchFn: typeof globalThis.fetch): void {
|
|
12
|
+
_fetch = fetchFn
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getFetch(): typeof globalThis.fetch {
|
|
16
|
+
return _fetch ?? globalThis.fetch
|
|
17
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ServerVersion } from '../types/game.js'
|
|
2
2
|
import type { ScreepsmodAuthFeature, ServerFeature } from '../types/game.js'
|
|
3
3
|
import type { ApiAuthModInfoResponse, ApiRegisterCheckResponse } from '../types/api.js'
|
|
4
|
+
import { getFetch } from './fetchFn.js'
|
|
4
5
|
|
|
5
6
|
export type { ApiAuthModInfoResponse }
|
|
6
7
|
|
|
@@ -56,7 +57,7 @@ export async function fetchServerVersion(url: string): Promise<ServerVersion> {
|
|
|
56
57
|
const cached = readFromSession<ServerVersion>(key)
|
|
57
58
|
if (cached) return cached
|
|
58
59
|
|
|
59
|
-
const res = await
|
|
60
|
+
const res = await getFetch()(`${baseUrl(url)}api/version`)
|
|
60
61
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
61
62
|
const data = await res.json() as ServerVersion
|
|
62
63
|
writeToSession(key, data)
|
|
@@ -73,7 +74,7 @@ export async function fetchAuthModInfo(url: string): Promise<ApiAuthModInfoRespo
|
|
|
73
74
|
const cached = readFromSession<ApiAuthModInfoResponse>(key)
|
|
74
75
|
if (cached) return cached
|
|
75
76
|
|
|
76
|
-
const res = await
|
|
77
|
+
const res = await getFetch()(`${baseUrl(url)}api/authmod`)
|
|
77
78
|
if (!res.ok) return null
|
|
78
79
|
const data = await res.json() as ApiAuthModInfoResponse
|
|
79
80
|
if (!data.ok) return null
|
|
@@ -87,7 +88,7 @@ export async function fetchAuthModInfo(url: string): Promise<ApiAuthModInfoRespo
|
|
|
87
88
|
*/
|
|
88
89
|
export async function checkUsername(url: string, username: string): Promise<ApiRegisterCheckResponse> {
|
|
89
90
|
const params = new URLSearchParams({ username })
|
|
90
|
-
const res = await
|
|
91
|
+
const res = await getFetch()(`${baseUrl(url)}api/register/check-username?${params}`)
|
|
91
92
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
92
93
|
return res.json() as Promise<ApiRegisterCheckResponse>
|
|
93
94
|
}
|
|
@@ -98,7 +99,7 @@ export async function checkUsername(url: string, username: string): Promise<ApiR
|
|
|
98
99
|
*/
|
|
99
100
|
export async function checkEmail(url: string, email: string): Promise<ApiRegisterCheckResponse> {
|
|
100
101
|
const params = new URLSearchParams({ email })
|
|
101
|
-
const res = await
|
|
102
|
+
const res = await getFetch()(`${baseUrl(url)}api/register/check-email?${params}`)
|
|
102
103
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
103
104
|
return res.json() as Promise<ApiRegisterCheckResponse>
|
|
104
105
|
}
|
|
@@ -113,7 +114,7 @@ export async function registerUser(
|
|
|
113
114
|
email: string,
|
|
114
115
|
password: string,
|
|
115
116
|
): Promise<{ ok: number; error?: string }> {
|
|
116
|
-
const res = await
|
|
117
|
+
const res = await getFetch()(`${baseUrl(url)}api/register/submit`, {
|
|
117
118
|
method: 'POST',
|
|
118
119
|
headers: { 'Content-Type': 'application/json' },
|
|
119
120
|
body: JSON.stringify({ username, email, password }),
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ScreepsClient } from './ScreepsClient.js'
|
|
2
|
+
export { setFetch } from './http/fetchFn.js'
|
|
2
3
|
export type { ScreepsClientOptions } from './ScreepsClient.js'
|
|
3
4
|
|
|
4
5
|
export { Logger } from './logger.js'
|
|
@@ -24,6 +25,7 @@ export type {
|
|
|
24
25
|
RoomObjectDiff,
|
|
25
26
|
RoomMap2Data,
|
|
26
27
|
UserInfo,
|
|
28
|
+
NotifyPrefs,
|
|
27
29
|
CpuStats,
|
|
28
30
|
WorldStatus,
|
|
29
31
|
ConsoleMessage,
|
|
@@ -40,7 +42,7 @@ export type {
|
|
|
40
42
|
|
|
41
43
|
export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, getServerFeature, getScreepsmodAuth } from './http/fetchServerVersion.js'
|
|
42
44
|
export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.js'
|
|
43
|
-
export type { RoomHistoryChunk, ApiRoomDecorationsResponse, ApiRoomDecorationItem, ApiRoomDecorationDef, ApiRoomDecorationGraphic, ApiRoomDecorationActive, ApiUserOverviewResponse, ApiUserOverviewTotals, ApiUserRoomsResponse, ApiUserMoneyHistoryResponse, ApiMarketOrder, ApiMarketOrdersIndexResponse, ApiMarketOrdersResponse, ApiMarketMyOrdersResponse, ApiMarketStat, ApiMarketStatsResponse, ApiPowerCreep, ApiPowerCreepsListResponse } from './types/api.js'
|
|
45
|
+
export type { RoomHistoryChunk, ApiRoomDecorationsResponse, ApiRoomDecorationItem, ApiRoomDecorationDef, ApiRoomDecorationGraphic, ApiRoomDecorationActive, ApiUserOverviewResponse, ApiUserOverviewTotals, ApiUserRoomsResponse, ApiUserStatsResponse, ApiLeaderboardFindResponse, ApiUserMoneyHistoryResponse, ApiMarketOrder, ApiMarketOrdersIndexResponse, ApiMarketOrdersResponse, ApiMarketMyOrdersResponse, ApiMarketStat, ApiMarketStatsResponse, ApiPowerCreep, ApiPowerCreepsListResponse, ApiUserMessage, ApiUserMessagesListResponse, ApiUserMessagesIndexEntry, ApiUserMessagesIndexResponse, ApiUserMessagesUnreadCountResponse, ApiUserFindResponse } from './types/api.js'
|
|
44
46
|
export { ROOM_DECORATIONS_MOCK } from './mocks/roomDecorations.js'
|
|
45
47
|
|
|
46
48
|
export { badgeToSvg } from './badge/index.js'
|
package/src/types/api.ts
CHANGED
|
@@ -239,9 +239,28 @@ export interface ApiUserFindResponse {
|
|
|
239
239
|
username: string
|
|
240
240
|
badge: import('./game.js').Badge
|
|
241
241
|
gcl: number
|
|
242
|
+
// Lifetime power (GPL) — present in the public lookup alongside gcl.
|
|
243
|
+
power?: number
|
|
242
244
|
}
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
/** Response of GET /api/user/stats?id=&interval=. `stats` maps a metric to per-tick buckets; consumers sum the bucket values over the interval. */
|
|
248
|
+
export interface ApiUserStatsResponse {
|
|
249
|
+
ok: number
|
|
250
|
+
stats?: Record<string, Array<{ value: number; endTime?: number }>>
|
|
251
|
+
statsMax?: Record<string, number>
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Response of GET /api/leaderboard/find?username=&mode=&season=. Servers return either a flat list of per-season records or a single season-scoped record at the top level. */
|
|
255
|
+
export interface ApiLeaderboardFindResponse {
|
|
256
|
+
ok: number
|
|
257
|
+
list?: Array<{ season: string; rank: number; score: number; user: string }>
|
|
258
|
+
rank?: number
|
|
259
|
+
score?: number
|
|
260
|
+
season?: string
|
|
261
|
+
user?: string
|
|
262
|
+
}
|
|
263
|
+
|
|
245
264
|
export interface ApiUserMoneyHistoryResponse {
|
|
246
265
|
ok: number
|
|
247
266
|
page: number
|
|
@@ -318,6 +337,7 @@ export interface ApiUserMessage {
|
|
|
318
337
|
respondent: string
|
|
319
338
|
user: string
|
|
320
339
|
text: string
|
|
340
|
+
type?: 'in' | 'out'
|
|
321
341
|
unread: boolean
|
|
322
342
|
}
|
|
323
343
|
|
|
@@ -329,12 +349,12 @@ export interface ApiUserMessagesListResponse {
|
|
|
329
349
|
export interface ApiUserMessagesIndexEntry {
|
|
330
350
|
_id: string
|
|
331
351
|
message: ApiUserMessage
|
|
332
|
-
user: { _id: string; username: string; badge: import('./game.js').Badge }
|
|
333
352
|
}
|
|
334
353
|
|
|
335
354
|
export interface ApiUserMessagesIndexResponse {
|
|
336
355
|
ok: number
|
|
337
|
-
|
|
356
|
+
messages: ApiUserMessagesIndexEntry[]
|
|
357
|
+
users: Record<string, { _id: string; username: string; badge: import('./game.js').Badge }>
|
|
338
358
|
}
|
|
339
359
|
|
|
340
360
|
export interface ApiUserMessagesUnreadCountResponse {
|
package/src/types/game.ts
CHANGED
|
@@ -60,6 +60,14 @@ export interface RoomObject {
|
|
|
60
60
|
export type RoomObjectMap = Record<string, RoomObject>
|
|
61
61
|
export type RoomObjectDiff = Record<string, Partial<RoomObject> | null>
|
|
62
62
|
|
|
63
|
+
export interface NotifyPrefs {
|
|
64
|
+
disabled: boolean
|
|
65
|
+
disabledOnMessages: boolean
|
|
66
|
+
sendOnline: boolean
|
|
67
|
+
interval: number
|
|
68
|
+
errorsInterval: number
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
export interface UserInfo {
|
|
64
72
|
_id: string
|
|
65
73
|
username: string
|
|
@@ -72,6 +80,7 @@ export interface UserInfo {
|
|
|
72
80
|
badge: Badge
|
|
73
81
|
/** True when the account has a password set. Absent for password-less accounts (e.g. Steam-only logins). Only present for the authenticated user's own info. */
|
|
74
82
|
password?: boolean
|
|
83
|
+
notifyPrefs?: Partial<NotifyPrefs>
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
export interface CpuStats {
|
|
@@ -104,6 +104,9 @@ describe('ScreepsClient', () => {
|
|
|
104
104
|
})
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
+
// Dynamic-token auth strategy (supportsTokenRefresh defaults to true) used for token-rotation tests.
|
|
108
|
+
const dynamicAuth = (initial = 'initial') => ({ authenticate: async () => initial })
|
|
109
|
+
|
|
107
110
|
describe('ScreepsClient — token sync', () => {
|
|
108
111
|
it('rotates SocketClient token when an HTTP response carries x-token', async () => {
|
|
109
112
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
|
@@ -114,7 +117,7 @@ describe('ScreepsClient — token sync', () => {
|
|
|
114
117
|
|
|
115
118
|
const client = new ScreepsClient({
|
|
116
119
|
url: 'http://test.local',
|
|
117
|
-
auth:
|
|
120
|
+
auth: dynamicAuth(),
|
|
118
121
|
storage: null,
|
|
119
122
|
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
120
123
|
tokenRefresh: false,
|
|
@@ -129,7 +132,7 @@ describe('ScreepsClient — token sync', () => {
|
|
|
129
132
|
it('rotates HttpClient token when WS auth response carries a new token', async () => {
|
|
130
133
|
const client = new ScreepsClient({
|
|
131
134
|
url: 'http://test.local',
|
|
132
|
-
auth:
|
|
135
|
+
auth: dynamicAuth(),
|
|
133
136
|
storage: null,
|
|
134
137
|
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
135
138
|
tokenRefresh: false,
|
|
@@ -147,16 +150,36 @@ describe('ScreepsClient — token sync', () => {
|
|
|
147
150
|
expect(httpSetToken).toHaveBeenCalledWith('ws-rotated-token')
|
|
148
151
|
expect(client.http.token).toBe('ws-rotated-token')
|
|
149
152
|
})
|
|
153
|
+
|
|
154
|
+
it('does NOT sync tokens when using TokenAuth (static token)', async () => {
|
|
155
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
|
156
|
+
new Response(JSON.stringify({ ok: 1 }), {
|
|
157
|
+
headers: { 'content-type': 'application/json', 'x-token': 'server-issued' },
|
|
158
|
+
})
|
|
159
|
+
))
|
|
160
|
+
|
|
161
|
+
const client = new ScreepsClient({
|
|
162
|
+
url: 'http://test.local',
|
|
163
|
+
auth: new TokenAuth({ token: 'my-static-token' }),
|
|
164
|
+
storage: null,
|
|
165
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const socketSetToken = vi.spyOn(client.socket, 'setToken')
|
|
169
|
+
await client.http.request('GET', '/api/auth/me')
|
|
170
|
+
|
|
171
|
+
expect(socketSetToken).not.toHaveBeenCalled()
|
|
172
|
+
})
|
|
150
173
|
})
|
|
151
174
|
|
|
152
|
-
describe('ScreepsClient —
|
|
175
|
+
describe('ScreepsClient — world status polling', () => {
|
|
153
176
|
beforeEach(() => { vi.useFakeTimers() })
|
|
154
177
|
afterEach(() => { vi.useRealTimers() })
|
|
155
178
|
|
|
156
179
|
async function buildConnected(opts: { tokenRefresh?: { intervalMs?: number } | false } = {}) {
|
|
157
180
|
const client = new ScreepsClient({
|
|
158
181
|
url: 'http://test.local',
|
|
159
|
-
auth:
|
|
182
|
+
auth: dynamicAuth('tok'),
|
|
160
183
|
storage: null,
|
|
161
184
|
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
162
185
|
tokenRefresh: opts.tokenRefresh,
|
|
@@ -170,13 +193,12 @@ describe('ScreepsClient — idle token refresh', () => {
|
|
|
170
193
|
return client
|
|
171
194
|
}
|
|
172
195
|
|
|
173
|
-
it('
|
|
196
|
+
it('polls world-status on a fixed interval', async () => {
|
|
174
197
|
const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
|
|
175
198
|
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
|
|
176
199
|
fetchMock.mockClear()
|
|
177
200
|
|
|
178
|
-
|
|
179
|
-
await vi.advanceTimersByTimeAsync(1_500)
|
|
201
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
180
202
|
|
|
181
203
|
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
182
204
|
expect(paths).toContain('/api/user/world-status')
|
|
@@ -184,19 +206,19 @@ describe('ScreepsClient — idle token refresh', () => {
|
|
|
184
206
|
client.disconnect()
|
|
185
207
|
})
|
|
186
208
|
|
|
187
|
-
it('
|
|
209
|
+
it('polls world-status even while other HTTP traffic is ongoing', async () => {
|
|
188
210
|
const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
|
|
189
211
|
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
|
|
190
212
|
fetchMock.mockClear()
|
|
191
213
|
|
|
192
|
-
//
|
|
193
|
-
for (let i = 0; i <
|
|
214
|
+
// Continuous HTTP traffic every 200ms should not suppress the fixed poll.
|
|
215
|
+
for (let i = 0; i < 6; i++) {
|
|
194
216
|
await client.http.request('GET', '/api/version')
|
|
195
|
-
await vi.advanceTimersByTimeAsync(
|
|
217
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
196
218
|
}
|
|
197
219
|
|
|
198
220
|
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
199
|
-
expect(paths).
|
|
221
|
+
expect(paths).toContain('/api/user/world-status')
|
|
200
222
|
|
|
201
223
|
client.disconnect()
|
|
202
224
|
})
|
|
@@ -209,11 +231,37 @@ describe('ScreepsClient — idle token refresh', () => {
|
|
|
209
231
|
await vi.advanceTimersByTimeAsync(60_000)
|
|
210
232
|
|
|
211
233
|
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
212
|
-
expect(paths).not.toContain('/api/
|
|
234
|
+
expect(paths).not.toContain('/api/user/world-status')
|
|
213
235
|
|
|
214
236
|
client.disconnect()
|
|
215
237
|
})
|
|
216
238
|
|
|
239
|
+
it('still polls world-status when using TokenAuth (token is never replaced)', async () => {
|
|
240
|
+
const client = new ScreepsClient({
|
|
241
|
+
url: 'http://test.local',
|
|
242
|
+
auth: new TokenAuth({ token: 'tok' }),
|
|
243
|
+
storage: null,
|
|
244
|
+
WebSocket: MockWS as unknown as typeof WebSocket,
|
|
245
|
+
tokenRefresh: { intervalMs: 1_000 },
|
|
246
|
+
})
|
|
247
|
+
const connectPromise = client.connect()
|
|
248
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
249
|
+
const ws = MockWS.instances[MockWS.instances.length - 1]
|
|
250
|
+
ws.simulateOpen()
|
|
251
|
+
ws.simulateMessage('auth ok tok')
|
|
252
|
+
await connectPromise
|
|
253
|
+
|
|
254
|
+
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>
|
|
255
|
+
fetchMock.mockClear()
|
|
256
|
+
await vi.advanceTimersByTimeAsync(1_500)
|
|
257
|
+
|
|
258
|
+
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
259
|
+
expect(paths).toContain('/api/user/world-status')
|
|
260
|
+
// Token must not have changed despite the response
|
|
261
|
+
expect(client.http.token).toBe('tok')
|
|
262
|
+
client.disconnect()
|
|
263
|
+
})
|
|
264
|
+
|
|
217
265
|
it('stops the refresh timer on disconnect()', async () => {
|
|
218
266
|
const client = await buildConnected({ tokenRefresh: { intervalMs: 1_000 } })
|
|
219
267
|
client.disconnect()
|
|
@@ -224,6 +272,6 @@ describe('ScreepsClient — idle token refresh', () => {
|
|
|
224
272
|
await vi.advanceTimersByTimeAsync(5_000)
|
|
225
273
|
|
|
226
274
|
const paths = fetchMock.mock.calls.map(([url]) => new URL(url as string).pathname)
|
|
227
|
-
expect(paths).not.toContain('/api/
|
|
275
|
+
expect(paths).not.toContain('/api/user/world-status')
|
|
228
276
|
})
|
|
229
277
|
})
|
|
@@ -81,12 +81,23 @@ describe('HttpClient', () => {
|
|
|
81
81
|
fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
|
|
82
82
|
headers: { 'content-type': 'application/json', 'x-token': 'refreshed' },
|
|
83
83
|
}))
|
|
84
|
-
|
|
84
|
+
// Use a plain object (supportsTokenRefresh defaults to true) to verify rotation works for dynamic-token strategies
|
|
85
|
+
const http = new HttpClient({ url: 'http://test.local', auth: { authenticate: async () => 'old' } })
|
|
85
86
|
http.token = 'old'
|
|
86
87
|
await http.request('GET', '/api/version')
|
|
87
88
|
expect(http.token).toBe('refreshed')
|
|
88
89
|
})
|
|
89
90
|
|
|
91
|
+
it('does NOT update token from x-token header when using TokenAuth (static token)', async () => {
|
|
92
|
+
fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
|
|
93
|
+
headers: { 'content-type': 'application/json', 'x-token': 'server-issued' },
|
|
94
|
+
}))
|
|
95
|
+
const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'my-static-token' }) })
|
|
96
|
+
http.token = 'my-static-token'
|
|
97
|
+
await http.request('GET', '/api/version')
|
|
98
|
+
expect(http.token).toBe('my-static-token')
|
|
99
|
+
})
|
|
100
|
+
|
|
90
101
|
it('retries once on 401 after re-authenticating', async () => {
|
|
91
102
|
let calls = 0
|
|
92
103
|
fetchMock.mockImplementation(() => {
|
|
@@ -136,13 +147,25 @@ describe('HttpClient', () => {
|
|
|
136
147
|
fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
|
|
137
148
|
headers: { 'content-type': 'application/json', 'x-token': 'new-tok' },
|
|
138
149
|
}))
|
|
139
|
-
|
|
150
|
+
// Use a plain object (supportsTokenRefresh defaults to true) to verify the event fires for dynamic-token strategies
|
|
151
|
+
const http = new HttpClient({ url: 'http://test.local', auth: { authenticate: async () => 'old' } })
|
|
140
152
|
const handler = vi.fn()
|
|
141
153
|
http.on('http:tokenRefresh', handler)
|
|
142
154
|
await http.request('GET', '/api/version')
|
|
143
155
|
expect(handler).toHaveBeenCalledWith({ token: 'new-tok' })
|
|
144
156
|
})
|
|
145
157
|
|
|
158
|
+
it('does NOT emit http:tokenRefresh when using TokenAuth (static token)', async () => {
|
|
159
|
+
fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
|
|
160
|
+
headers: { 'content-type': 'application/json', 'x-token': 'server-issued' },
|
|
161
|
+
}))
|
|
162
|
+
const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'my-static-token' }) })
|
|
163
|
+
const handler = vi.fn()
|
|
164
|
+
http.on('http:tokenRefresh', handler)
|
|
165
|
+
await http.request('GET', '/api/version')
|
|
166
|
+
expect(handler).not.toHaveBeenCalled()
|
|
167
|
+
})
|
|
168
|
+
|
|
146
169
|
it('emits http:success on 200 response', async () => {
|
|
147
170
|
fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
|
|
148
171
|
const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
|