screeps-connectivity 0.9.0 → 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 +12 -0
- package/package.json +1 -1
- package/src/ScreepsClient.ts +19 -29
- package/src/http/HttpClient.ts +1 -1
- 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/index.ts +2 -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,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 0.9.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
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
|
@@ -117,7 +117,7 @@ export class HttpClient extends EventTarget {
|
|
|
117
117
|
const res = await getFetch()(url.toString(), init)
|
|
118
118
|
|
|
119
119
|
const newToken = res.headers.get('x-token')
|
|
120
|
-
if (newToken) {
|
|
120
|
+
if (newToken && (this.authStrategy.supportsTokenRefresh ?? true)) {
|
|
121
121
|
this.token = newToken
|
|
122
122
|
this.emit('http:tokenRefresh', { token: newToken })
|
|
123
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.
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type {
|
|
|
25
25
|
RoomObjectDiff,
|
|
26
26
|
RoomMap2Data,
|
|
27
27
|
UserInfo,
|
|
28
|
+
NotifyPrefs,
|
|
28
29
|
CpuStats,
|
|
29
30
|
WorldStatus,
|
|
30
31
|
ConsoleMessage,
|
|
@@ -41,7 +42,7 @@ export type {
|
|
|
41
42
|
|
|
42
43
|
export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, getServerFeature, getScreepsmodAuth } from './http/fetchServerVersion.js'
|
|
43
44
|
export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.js'
|
|
44
|
-
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'
|
|
45
46
|
export { ROOM_DECORATIONS_MOCK } from './mocks/roomDecorations.js'
|
|
46
47
|
|
|
47
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' }) })
|