screeps-connectivity 0.12.0 → 0.13.1
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 +29 -0
- package/package.json +1 -1
- package/src/http/HttpClient.ts +15 -1
- package/src/http/auth/TokenAuth.ts +10 -2
- package/src/http/endpoints/game.ts +4 -2
- package/src/http/fetchServerVersion.ts +14 -1
- package/src/index.ts +2 -1
- package/src/socket/SocketClient.ts +1 -1
- package/src/stores/ServerStore.ts +2 -2
- package/src/types/api.ts +7 -0
- package/src/types/events.ts +1 -1
- package/src/types/game.ts +20 -0
- package/tests/http/HttpClient.test.ts +20 -0
- package/tests/socket/SocketClient.test.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 661a53f: Don't show the "Connection lost" modal after an intentional disconnect. A guest hitting Login (or any user logging out) tore the session down synchronously, but the socket's async `onclose` still fired `server:disconnected` with `willReconnect: false`, which re-raised a fatal session error over the login screen. The disconnected event now carries an `intentional` flag so the client can distinguish a user-initiated close from a genuinely lost connection.
|
|
8
|
+
- 9bc39f8: History viewer fixes and a couple of supporting connectivity additions:
|
|
9
|
+
|
|
10
|
+
- Render terrain when reloading directly into history mode. Terrain loading was gated behind the room-subscription effect, which is skipped in history mode, so a fresh reload into a `#tick=` URL showed no terrain. Terrain/decoration loading now runs independently of history mode.
|
|
11
|
+
- Open the history view at the start of the previous, fully-written chunk instead of the current tick (whose chunk isn't flushed yet), avoiding an immediate 404 + fallback round-trip.
|
|
12
|
+
- Suppress the red failure toast when a history chunk is missing (404). `roomHistory` requests are now `silent`, and the viewer shows an in-room "No data available for this tick" hint instead, prompting the user to pick another tick from the timeline.
|
|
13
|
+
- During playback, skip to the start of the next chunk when the current chunk is missing, instead of re-fetching the same non-existent file on every tick.
|
|
14
|
+
- Expose `serverData.historyKeepTicks` (non-official field from the xxscreeps history mod) on the version types, used to size the history timeline's replayable range (falls back to a default window when absent).
|
|
15
|
+
- HTTP errors thrown by `HttpClient` now carry a `status` property so callers can distinguish a 404 from other failures.
|
|
16
|
+
- Dev-only: proxy `/room-history` to `VITE_PROXY_TARGET` in the Vite dev server.
|
|
17
|
+
|
|
18
|
+
- 1e85697: Accept the xxscreeps `{ error: "actually, it was fine" }` sentinel (returned with status 200 by the `create-construction` route to signal success) as a successful response instead of throwing.
|
|
19
|
+
|
|
20
|
+
## 0.13.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- 94a6658: Add Discord OAuth login: getDiscordFeature() helper in screeps-connectivity, and a "Login with Discord" button in the web and desktop login forms.
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- 46b5e2d: Fix Steam/password logins silently expiring after ~5 minutes on screepsmod-auth servers. These logins reconnect via a rotating, TTL-limited session token, but `TokenAuth` was hard-coded to ignore the server-issued `X-Token`, so the client kept replaying the original token until the server expired it — surfacing as a sudden `401` on `/api/user/world-status` (and every other authed request) even while actively using the client.
|
|
29
|
+
|
|
30
|
+
`TokenAuth` now accepts `supportsTokenRefresh` (default `false`, preserving durable personal-API-token behavior). The client enables it for Steam/password-derived session tokens so the rotated `X-Token` is adopted on every response, keeping the session alive.
|
|
31
|
+
|
|
3
32
|
## 0.12.0
|
|
4
33
|
|
|
5
34
|
### Minor Changes
|
package/package.json
CHANGED
package/src/http/HttpClient.ts
CHANGED
|
@@ -11,6 +11,12 @@ import { createRegisterEndpoints, type RegisterEndpoints } from './endpoints/reg
|
|
|
11
11
|
import type { HttpClientEvents } from '../types/events.js'
|
|
12
12
|
import type { Subscription } from '../subscription/index.js'
|
|
13
13
|
|
|
14
|
+
/** xxscreeps' /api/game/create-construction route returns this in an { error }
|
|
15
|
+
* field with status 200 to signal success. Official Screeps inserts the site
|
|
16
|
+
* directly and returns an id, but xxscreeps returns this fake error and relies
|
|
17
|
+
* on the room socket to show the site next tick. It is not a real error. */
|
|
18
|
+
const XXSCREEPS_FALSE_ERROR = 'actually, it was fine'
|
|
19
|
+
|
|
14
20
|
export interface RateLimitInfo {
|
|
15
21
|
limit: number
|
|
16
22
|
remaining: number
|
|
@@ -133,7 +139,8 @@ export class HttpClient extends EventTarget {
|
|
|
133
139
|
if (!res.ok && res.status !== 304) {
|
|
134
140
|
let body = ''
|
|
135
141
|
try { body = await res.text() } catch { /* ignore */ }
|
|
136
|
-
const error = new Error(`HTTP ${res.status}: ${body}`)
|
|
142
|
+
const error = new Error(`HTTP ${res.status}: ${body}`) as Error & { status?: number }
|
|
143
|
+
error.status = res.status
|
|
137
144
|
this.emit('http:error', { method, path, status: res.status, error, silent: opts.silent })
|
|
138
145
|
throw error
|
|
139
146
|
}
|
|
@@ -142,6 +149,13 @@ export class HttpClient extends EventTarget {
|
|
|
142
149
|
|
|
143
150
|
const data = await res.json() as Record<string, unknown>
|
|
144
151
|
|
|
152
|
+
// xxscreeps' create-construction route returns { error: 'actually, it was
|
|
153
|
+
// fine' } with status 200 to signal success (see XXSCREEPS_FALSE_ERROR).
|
|
154
|
+
// Treat this sentinel as success, not an error.
|
|
155
|
+
if (data['error'] === XXSCREEPS_FALSE_ERROR) {
|
|
156
|
+
delete data['error']
|
|
157
|
+
}
|
|
158
|
+
|
|
145
159
|
if (typeof data['error'] === 'string') {
|
|
146
160
|
const error = new Error(`Screeps API error: ${data['error']}`)
|
|
147
161
|
this.emit('http:error', { method, path, status: res.status, error, silent: opts.silent })
|
|
@@ -2,11 +2,19 @@ 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
|
|
5
|
+
readonly supportsTokenRefresh: boolean
|
|
6
6
|
private readonly token: string
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* @param opts.supportsTokenRefresh Set true when the token is a rotating,
|
|
10
|
+
* TTL-limited session token (e.g. obtained from a screepsmod-auth steam/password
|
|
11
|
+
* exchange) so the client adopts the server-issued `X-Token` and the session
|
|
12
|
+
* stays alive. Leave false (default) for a durable personal API token, which
|
|
13
|
+
* must never be replaced by a server-issued token.
|
|
14
|
+
*/
|
|
15
|
+
constructor(opts: { token: string; supportsTokenRefresh?: boolean }) {
|
|
9
16
|
this.token = opts.token
|
|
17
|
+
this.supportsTokenRefresh = opts.supportsTokenRefresh ?? false
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
async authenticate(_http: HttpClient): Promise<string> {
|
|
@@ -98,9 +98,11 @@ export function createGameEndpoints(http: HttpClient, decorationsMock?: ApiRoomD
|
|
|
98
98
|
addObjectIntent: (id, room, name, intent, shard) => http.request('POST', '/api/game/add-object-intent', withShard({ _id: id, room, name, intent }, shard)),
|
|
99
99
|
addGlobalIntent: (name, intent) => http.request('POST', '/api/game/add-global-intent', { name, intent }),
|
|
100
100
|
roomHistory: (room, time, shard) =>
|
|
101
|
+
// silent: a missing chunk 404s while history is still being written; the caller
|
|
102
|
+
// handles that gracefully, so don't surface a global "request failed" toast.
|
|
101
103
|
shard
|
|
102
|
-
? http.request('GET', `/room-history/${encodeURIComponent(shard)}/${encodeURIComponent(room)}/${time}.json
|
|
103
|
-
: http.request('GET', '/room-history', { room, time }),
|
|
104
|
+
? http.request('GET', `/room-history/${encodeURIComponent(shard)}/${encodeURIComponent(room)}/${time}.json`, undefined, { silent: true })
|
|
105
|
+
: http.request('GET', '/room-history', { room, time }, { silent: true }),
|
|
104
106
|
setNotifyWhenAttacked: (id, enabled) => http.request('POST', '/api/game/set-notify-when-attacked', { _id: id, enabled }),
|
|
105
107
|
createInvader: (room, x, y, size, type, boosted) => http.request('POST', '/api/game/create-invader', { room, x, y, size, type, ...(boosted != null ? { boosted } : {}) }),
|
|
106
108
|
removeInvader: (id) => http.request('POST', '/api/game/remove-invader', { _id: id }),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ServerVersion } from '../types/game.js'
|
|
2
|
-
import type { ScreepsmodAuthFeature, ServerFeature, XxscreepsModClientFeature } from '../types/game.js'
|
|
2
|
+
import type { DiscordFeature, ScreepsmodAuthFeature, ServerFeature, XxscreepsModClientFeature } from '../types/game.js'
|
|
3
3
|
import type { ApiAuthMeResponse, ApiAuthModInfoResponse, ApiRegisterCheckResponse } from '../types/api.js'
|
|
4
4
|
import { getFetch } from './fetchFn.js'
|
|
5
5
|
|
|
@@ -193,3 +193,16 @@ export function getScreepsmodAuth(version: ServerVersion): ScreepsmodAuthFeature
|
|
|
193
193
|
export function getXxscreepsModClientFeature(version: ServerVersion): XxscreepsModClientFeature | undefined {
|
|
194
194
|
return getServerFeature<XxscreepsModClientFeature>(version, 'xxscreeps-mod-client')
|
|
195
195
|
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Returns the `discord` feature entry (published by `xxscreeps-mod-discord`) if
|
|
199
|
+
* present, otherwise `undefined`. Use `discordLogin` to gate the "Login with
|
|
200
|
+
* Discord" button before showing it.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* const discord = getDiscordFeature(version)
|
|
204
|
+
* if (discord?.discordLogin) showDiscordButton()
|
|
205
|
+
*/
|
|
206
|
+
export function getDiscordFeature(version: ServerVersion): DiscordFeature | undefined {
|
|
207
|
+
return getServerFeature<DiscordFeature>(version, 'discord')
|
|
208
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export type {
|
|
|
33
33
|
ServerFeature,
|
|
34
34
|
ScreepsmodAuthFeature,
|
|
35
35
|
XxscreepsModClientFeature,
|
|
36
|
+
DiscordFeature,
|
|
36
37
|
ShardInfo,
|
|
37
38
|
WorldInfo,
|
|
38
39
|
Badge,
|
|
@@ -41,7 +42,7 @@ export type {
|
|
|
41
42
|
MapVisualEntry,
|
|
42
43
|
} from './types/game.js'
|
|
43
44
|
|
|
44
|
-
export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, fetchAuthMeWithToken, completeProviderRegistration, getServerFeature, getScreepsmodAuth, getXxscreepsModClientFeature } from './http/fetchServerVersion.js'
|
|
45
|
+
export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, fetchAuthMeWithToken, completeProviderRegistration, getServerFeature, getScreepsmodAuth, getXxscreepsModClientFeature, getDiscordFeature } from './http/fetchServerVersion.js'
|
|
45
46
|
export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.js'
|
|
46
47
|
export type { ApiAuthMeResponse, 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'
|
|
47
48
|
export { ROOM_DECORATIONS_MOCK } from './mocks/roomDecorations.js'
|
|
@@ -79,7 +79,7 @@ export class SocketClient {
|
|
|
79
79
|
this.authed = false
|
|
80
80
|
this.authSub?.dispose()
|
|
81
81
|
this.authSub = null
|
|
82
|
-
this.emit('disconnected', { willReconnect: this.reconnecting })
|
|
82
|
+
this.emit('disconnected', { willReconnect: this.reconnecting, intentional: this._intentionalClose })
|
|
83
83
|
void this.scheduleReconnect()
|
|
84
84
|
}
|
|
85
85
|
this.ws.onerror = (err) => {
|
|
@@ -29,8 +29,8 @@ export class ServerStore extends TypedStore<ServerStoreEvents> {
|
|
|
29
29
|
this.emit('server:connected', {})
|
|
30
30
|
}))
|
|
31
31
|
this.socketSubs.push(socket.on('disconnected', (data) => {
|
|
32
|
-
const d = data as { willReconnect: boolean }
|
|
33
|
-
this.emit('server:disconnected', { willReconnect: d.willReconnect })
|
|
32
|
+
const d = data as { willReconnect: boolean; intentional?: boolean }
|
|
33
|
+
this.emit('server:disconnected', { willReconnect: d.willReconnect, intentional: d.intentional ?? false })
|
|
34
34
|
}))
|
|
35
35
|
this.socketSubs.push(socket.on('socket:error', (data) => {
|
|
36
36
|
this.emit('server:error', { error: data instanceof Error ? data : new Error(String(data)) })
|
package/src/types/api.ts
CHANGED
|
@@ -102,6 +102,13 @@ export interface ApiVersionResponse {
|
|
|
102
102
|
users: number
|
|
103
103
|
serverData: {
|
|
104
104
|
historyChunkSize: number
|
|
105
|
+
/**
|
|
106
|
+
* Retention window for room history, in ticks. Non-official field added by the
|
|
107
|
+
* xxscreeps history mod. When present, the earliest replayable tick is roughly
|
|
108
|
+
* `currentTick - historyKeepTicks`; a value of `0` means history is kept forever
|
|
109
|
+
* (unbounded). Absent on servers without the mod.
|
|
110
|
+
*/
|
|
111
|
+
historyKeepTicks?: number
|
|
105
112
|
features: Array<{ name: string }>
|
|
106
113
|
shards: string[]
|
|
107
114
|
customObjectTypes?: unknown
|
package/src/types/events.ts
CHANGED
|
@@ -47,7 +47,7 @@ export interface UserStoreEvents {
|
|
|
47
47
|
|
|
48
48
|
export interface ServerStoreEvents {
|
|
49
49
|
'server:connected': Record<string, never>
|
|
50
|
-
'server:disconnected': { willReconnect: boolean }
|
|
50
|
+
'server:disconnected': { willReconnect: boolean; intentional: boolean }
|
|
51
51
|
'server:error': { error: Error }
|
|
52
52
|
'server:version': ServerVersion
|
|
53
53
|
'server:shards': ShardInfo[]
|
package/src/types/game.ts
CHANGED
|
@@ -125,6 +125,18 @@ export interface XxscreepsModClientFeature extends ServerFeature {
|
|
|
125
125
|
steamLogin: boolean
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Published by `xxscreeps-mod-discord` at `/api/version` when Discord login is
|
|
130
|
+
* configured. `discordLogin` gates the UI; `loginUrl` is the OAuth entry point
|
|
131
|
+
* (a popup navigates there to start the flow).
|
|
132
|
+
*/
|
|
133
|
+
export interface DiscordFeature extends ServerFeature {
|
|
134
|
+
name: 'discord'
|
|
135
|
+
version: number
|
|
136
|
+
discordLogin: boolean
|
|
137
|
+
loginUrl: string
|
|
138
|
+
}
|
|
139
|
+
|
|
128
140
|
export interface ServerVersion {
|
|
129
141
|
ok: number
|
|
130
142
|
package: number
|
|
@@ -133,6 +145,14 @@ export interface ServerVersion {
|
|
|
133
145
|
users: number
|
|
134
146
|
serverData: {
|
|
135
147
|
historyChunkSize: number
|
|
148
|
+
/**
|
|
149
|
+
* Retention window for room history, in ticks. Non-official field added by the
|
|
150
|
+
* xxscreeps history mod. When present, the earliest replayable tick is roughly
|
|
151
|
+
* `currentTick - historyKeepTicks`; a value of `0` means history is kept forever
|
|
152
|
+
* (unbounded). Absent on servers without the mod — callers should fall back to a
|
|
153
|
+
* sensible default in that case.
|
|
154
|
+
*/
|
|
155
|
+
historyKeepTicks?: number
|
|
136
156
|
features: ServerFeature[]
|
|
137
157
|
shards: Array<string | null>
|
|
138
158
|
welcomeText?: string
|
|
@@ -98,6 +98,16 @@ describe('HttpClient', () => {
|
|
|
98
98
|
expect(http.token).toBe('my-static-token')
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
+
it('DOES update token from x-token header when TokenAuth opts into refresh (session token)', async () => {
|
|
102
|
+
fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
|
|
103
|
+
headers: { 'content-type': 'application/json', 'x-token': 'rotated' },
|
|
104
|
+
}))
|
|
105
|
+
const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'session-token', supportsTokenRefresh: true }) })
|
|
106
|
+
http.token = 'session-token'
|
|
107
|
+
await http.request('GET', '/api/version')
|
|
108
|
+
expect(http.token).toBe('rotated')
|
|
109
|
+
})
|
|
110
|
+
|
|
101
111
|
it('retries once on 401 after re-authenticating', async () => {
|
|
102
112
|
let calls = 0
|
|
103
113
|
fetchMock.mockImplementation(() => {
|
|
@@ -195,6 +205,16 @@ describe('HttpClient', () => {
|
|
|
195
205
|
await expect(http.request('GET', '/api/game/create-flag')).rejects.toThrow('Screeps API error: invalid params')
|
|
196
206
|
})
|
|
197
207
|
|
|
208
|
+
it('treats the xxscreeps "actually, it was fine" sentinel as success', async () => {
|
|
209
|
+
fetchMock.mockResolvedValue(mockResponse({ error: 'actually, it was fine' }))
|
|
210
|
+
const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
|
|
211
|
+
const errorHandler = vi.fn()
|
|
212
|
+
http.on('http:error', errorHandler)
|
|
213
|
+
const res = await http.request<{ error?: string }>('POST', '/api/game/create-construction')
|
|
214
|
+
expect(res.error).toBeUndefined()
|
|
215
|
+
expect(errorHandler).not.toHaveBeenCalled()
|
|
216
|
+
})
|
|
217
|
+
|
|
198
218
|
it('emits http:error on 200 with error in response body', async () => {
|
|
199
219
|
fetchMock.mockResolvedValue(mockResponse({ ok: 0, error: 'flags limit exceeded' }))
|
|
200
220
|
const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
|
|
@@ -141,4 +141,15 @@ describe('SocketClient', () => {
|
|
|
141
141
|
client.disconnect()
|
|
142
142
|
expect(willReconnect).toBe(false)
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
it('disconnected event is flagged intentional when disconnect() is called', async () => {
|
|
146
|
+
const client = makeClient()
|
|
147
|
+
const _ws = await connectClient(client)
|
|
148
|
+
let intentional: boolean | undefined
|
|
149
|
+
client.on('disconnected', (data) => {
|
|
150
|
+
intentional = (data as { intentional: boolean }).intentional
|
|
151
|
+
})
|
|
152
|
+
client.disconnect()
|
|
153
|
+
expect(intentional).toBe(true)
|
|
154
|
+
})
|
|
144
155
|
})
|