screeps-connectivity 0.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/CHANGELOG.md +40 -0
- package/eslint.config.js +54 -0
- package/package.json +45 -0
- package/src/ScreepsClient.ts +172 -0
- package/src/badge/colors.ts +83 -0
- package/src/badge/generateSvg.ts +70 -0
- package/src/badge/index.ts +1 -0
- package/src/badge/paths.ts +385 -0
- package/src/cache/Cache.ts +71 -0
- package/src/cache/Map2Storage.ts +112 -0
- package/src/file-storage.ts +2 -0
- package/src/http/HttpClient.ts +160 -0
- package/src/http/auth/AuthStrategy.ts +5 -0
- package/src/http/auth/GuestAuth.ts +13 -0
- package/src/http/auth/PasswordAuth.ts +17 -0
- package/src/http/auth/SteamTicketAuth.ts +17 -0
- package/src/http/auth/TokenAuth.ts +14 -0
- package/src/http/decompress.ts +37 -0
- package/src/http/endpoints/auth.ts +23 -0
- package/src/http/endpoints/experimental.ts +13 -0
- package/src/http/endpoints/game.ts +103 -0
- package/src/http/endpoints/leaderboard.ts +16 -0
- package/src/http/endpoints/power-creeps.ts +24 -0
- package/src/http/endpoints/register.ts +19 -0
- package/src/http/endpoints/user-messages.ts +20 -0
- package/src/http/endpoints/user.ts +95 -0
- package/src/http/fetchServerVersion.ts +151 -0
- package/src/index.ts +55 -0
- package/src/logger.ts +25 -0
- package/src/socket/MessageParser.ts +44 -0
- package/src/socket/SocketClient.ts +203 -0
- package/src/storage/FileStorage.ts +44 -0
- package/src/storage/IndexedDBStorage.ts +77 -0
- package/src/storage/NullStorage.ts +8 -0
- package/src/storage/StorageAdapter.ts +6 -0
- package/src/stores/MapStatsStore.ts +115 -0
- package/src/stores/MapStore.ts +254 -0
- package/src/stores/NavigationStore.ts +61 -0
- package/src/stores/RoomStore.ts +264 -0
- package/src/stores/ServerStore.ts +128 -0
- package/src/stores/TypedStore.ts +31 -0
- package/src/stores/UserStore.ts +189 -0
- package/src/subscription/index.ts +18 -0
- package/src/types/api.ts +252 -0
- package/src/types/events.ts +72 -0
- package/src/types/game.ts +160 -0
- package/tests/.gitkeep +0 -0
- package/tests/ScreepsClient.test.ts +229 -0
- package/tests/badge/generateSvg.test.ts +174 -0
- package/tests/cache/Cache.test.ts +99 -0
- package/tests/cache/Map2Storage.test.ts +130 -0
- package/tests/http/HttpClient.test.ts +188 -0
- package/tests/http/decompress.test.ts +52 -0
- package/tests/http/endpoints/auth.test.ts +126 -0
- package/tests/http/endpoints/game.test.ts +210 -0
- package/tests/http/endpoints/power-creeps.test.ts +81 -0
- package/tests/http/endpoints/user-messages.test.ts +68 -0
- package/tests/http/endpoints/user.test.ts +139 -0
- package/tests/socket/MessageParser.test.ts +55 -0
- package/tests/socket/SocketClient.test.ts +144 -0
- package/tests/storage/FileStorage.test.ts +64 -0
- package/tests/storage/IndexedDBStorage.test.ts +36 -0
- package/tests/storage/NullStorage.test.ts +24 -0
- package/tests/stores/MapStatsStore.test.ts +234 -0
- package/tests/stores/MapStore.test.ts +537 -0
- package/tests/stores/NavigationStore.test.ts +166 -0
- package/tests/stores/RoomStore.test.ts +130 -0
- package/tests/stores/ServerStore.test.ts +48 -0
- package/tests/stores/TypedStore.test.ts +54 -0
- package/tests/stores/UserStore.test.ts +136 -0
- package/tests/subscription/SubscriptionGroup.test.ts +34 -0
- package/tests/types/game.test.ts +42 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ServerVersion } from '../types/game.js'
|
|
2
|
+
import type { ScreepsmodAuthFeature, ServerFeature } from '../types/game.js'
|
|
3
|
+
import type { ApiAuthModInfoResponse, ApiRegisterCheckResponse } from '../types/api.js'
|
|
4
|
+
|
|
5
|
+
export type { ApiAuthModInfoResponse }
|
|
6
|
+
|
|
7
|
+
const SESSION_CACHE_TTL_MS = 5 * 60_000
|
|
8
|
+
|
|
9
|
+
interface CachedEntry<T> {
|
|
10
|
+
data: T
|
|
11
|
+
expires: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sessionKey(suffix: string, url: string): string {
|
|
15
|
+
try {
|
|
16
|
+
return `screeps:${suffix}:${new URL(url).hostname}`
|
|
17
|
+
} catch {
|
|
18
|
+
return `screeps:${suffix}:${url}`
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readFromSession<T>(key: string): T | null {
|
|
23
|
+
if (typeof sessionStorage === 'undefined') return null
|
|
24
|
+
try {
|
|
25
|
+
const raw = sessionStorage.getItem(key)
|
|
26
|
+
if (!raw) return null
|
|
27
|
+
const entry = JSON.parse(raw) as CachedEntry<T>
|
|
28
|
+
if (Date.now() > entry.expires) {
|
|
29
|
+
sessionStorage.removeItem(key)
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
return entry.data
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeToSession<T>(key: string, data: T): void {
|
|
39
|
+
if (typeof sessionStorage === 'undefined') return
|
|
40
|
+
try {
|
|
41
|
+
sessionStorage.setItem(key, JSON.stringify({ data, expires: Date.now() + SESSION_CACHE_TTL_MS }))
|
|
42
|
+
} catch { /* quota exceeded or SSR — ignore */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function baseUrl(url: string): string {
|
|
46
|
+
return url.endsWith('/') ? url : `${url}/`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetch `/api/version` from a Screeps server without authentication.
|
|
51
|
+
* The result is cached in `sessionStorage` for 5 minutes (per server hostname).
|
|
52
|
+
* Useful for pre-login UI: showing the welcome text and detecting installed mods.
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchServerVersion(url: string): Promise<ServerVersion> {
|
|
55
|
+
const key = sessionKey('version', url)
|
|
56
|
+
const cached = readFromSession<ServerVersion>(key)
|
|
57
|
+
if (cached) return cached
|
|
58
|
+
|
|
59
|
+
const res = await fetch(`${baseUrl(url)}api/version`)
|
|
60
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
61
|
+
const data = await res.json() as ServerVersion
|
|
62
|
+
writeToSession(key, data)
|
|
63
|
+
return data
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch screepsmod-auth capabilities from `/api/authmod` without authentication.
|
|
68
|
+
* Returns `null` if the server does not run screepsmod-auth.
|
|
69
|
+
* The result is cached in `sessionStorage` for 5 minutes (per server hostname).
|
|
70
|
+
*/
|
|
71
|
+
export async function fetchAuthModInfo(url: string): Promise<ApiAuthModInfoResponse | null> {
|
|
72
|
+
const key = sessionKey('authmod', url)
|
|
73
|
+
const cached = readFromSession<ApiAuthModInfoResponse>(key)
|
|
74
|
+
if (cached) return cached
|
|
75
|
+
|
|
76
|
+
const res = await fetch(`${baseUrl(url)}api/authmod`)
|
|
77
|
+
if (!res.ok) return null
|
|
78
|
+
const data = await res.json() as ApiAuthModInfoResponse
|
|
79
|
+
if (!data.ok) return null
|
|
80
|
+
writeToSession(key, data)
|
|
81
|
+
return data
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check whether a username is available on a screepsmod-auth server.
|
|
86
|
+
* Returns `{ ok: 1 }` if available, `{ ok: 0, error: 'User Exists' }` if taken.
|
|
87
|
+
*/
|
|
88
|
+
export async function checkUsername(url: string, username: string): Promise<ApiRegisterCheckResponse> {
|
|
89
|
+
const params = new URLSearchParams({ username })
|
|
90
|
+
const res = await fetch(`${baseUrl(url)}api/register/check-username?${params}`)
|
|
91
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
92
|
+
return res.json() as Promise<ApiRegisterCheckResponse>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check whether an email address is available on a screepsmod-auth server.
|
|
97
|
+
* Returns `{ ok: 1 }` if available, `{ ok: 0, error: 'User Exists' }` if taken.
|
|
98
|
+
*/
|
|
99
|
+
export async function checkEmail(url: string, email: string): Promise<ApiRegisterCheckResponse> {
|
|
100
|
+
const params = new URLSearchParams({ email })
|
|
101
|
+
const res = await fetch(`${baseUrl(url)}api/register/check-email?${params}`)
|
|
102
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
103
|
+
return res.json() as Promise<ApiRegisterCheckResponse>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Register a new user account on a screepsmod-auth private server.
|
|
108
|
+
* No authentication required. Throws if the server returns an error response.
|
|
109
|
+
*/
|
|
110
|
+
export async function registerUser(
|
|
111
|
+
url: string,
|
|
112
|
+
username: string,
|
|
113
|
+
email: string,
|
|
114
|
+
password: string,
|
|
115
|
+
): Promise<{ ok: number; error?: string }> {
|
|
116
|
+
const res = await fetch(`${baseUrl(url)}api/register/submit`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'Content-Type': 'application/json' },
|
|
119
|
+
body: JSON.stringify({ username, email, password }),
|
|
120
|
+
})
|
|
121
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
122
|
+
return res.json() as Promise<{ ok: number; error?: string }>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extract a specific feature entry from a `ServerVersion` response by name.
|
|
127
|
+
* Returns `undefined` if the feature is not present.
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* const authMod = getServerFeature<ScreepsmodAuthFeature>(version, 'screepsmod-auth')
|
|
131
|
+
* if (authMod) console.log('Auth types:', authMod.authTypes)
|
|
132
|
+
*/
|
|
133
|
+
export function getServerFeature<T extends ServerFeature = ServerFeature>(
|
|
134
|
+
version: ServerVersion,
|
|
135
|
+
name: string,
|
|
136
|
+
): T | undefined {
|
|
137
|
+
return version.serverData.features.find(f => f.name === name) as T | undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Returns the `screepsmod-auth` feature entry if present, otherwise `undefined`.
|
|
142
|
+
* Use this to check whether the server supports password login, registration,
|
|
143
|
+
* Steam auth, etc., before showing those UI options.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* const auth = getScreepsmodAuth(version)
|
|
147
|
+
* if (auth?.authTypes.includes('password')) showPasswordForm()
|
|
148
|
+
*/
|
|
149
|
+
export function getScreepsmodAuth(version: ServerVersion): ScreepsmodAuthFeature | undefined {
|
|
150
|
+
return getServerFeature<ScreepsmodAuthFeature>(version, 'screepsmod-auth')
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export { ScreepsClient } from './ScreepsClient.js'
|
|
2
|
+
export type { ScreepsClientOptions } from './ScreepsClient.js'
|
|
3
|
+
|
|
4
|
+
export { Logger } from './logger.js'
|
|
5
|
+
export type { LogFn } from './logger.js'
|
|
6
|
+
|
|
7
|
+
export { TokenAuth } from './http/auth/TokenAuth.js'
|
|
8
|
+
export { PasswordAuth } from './http/auth/PasswordAuth.js'
|
|
9
|
+
export { GuestAuth } from './http/auth/GuestAuth.js'
|
|
10
|
+
export { SteamTicketAuth } from './http/auth/SteamTicketAuth.js'
|
|
11
|
+
export type { AuthStrategy } from './http/auth/AuthStrategy.js'
|
|
12
|
+
|
|
13
|
+
export { IndexedDBStorage } from './storage/IndexedDBStorage.js'
|
|
14
|
+
export { NullStorage } from './storage/NullStorage.js'
|
|
15
|
+
export type { StorageAdapter } from './storage/StorageAdapter.js'
|
|
16
|
+
|
|
17
|
+
export { SubscriptionGroup } from './subscription/index.js'
|
|
18
|
+
export type { Subscription } from './subscription/index.js'
|
|
19
|
+
|
|
20
|
+
export { TerrainType, RoomTerrain } from './types/game.js'
|
|
21
|
+
export type {
|
|
22
|
+
RoomObject,
|
|
23
|
+
RoomObjectMap,
|
|
24
|
+
RoomObjectDiff,
|
|
25
|
+
RoomMap2Data,
|
|
26
|
+
UserInfo,
|
|
27
|
+
CpuStats,
|
|
28
|
+
WorldStatus,
|
|
29
|
+
ConsoleMessage,
|
|
30
|
+
ServerVersion,
|
|
31
|
+
ServerFeature,
|
|
32
|
+
ScreepsmodAuthFeature,
|
|
33
|
+
ShardInfo,
|
|
34
|
+
WorldInfo,
|
|
35
|
+
Badge,
|
|
36
|
+
VisualStyle,
|
|
37
|
+
RoomVisualEntry,
|
|
38
|
+
} from './types/game.js'
|
|
39
|
+
|
|
40
|
+
export { fetchServerVersion, fetchAuthModInfo, checkUsername, checkEmail, registerUser, getServerFeature, getScreepsmodAuth } from './http/fetchServerVersion.js'
|
|
41
|
+
export type { ApiAuthModInfoResponse } from './http/fetchServerVersion.js'
|
|
42
|
+
|
|
43
|
+
export { badgeToSvg } from './badge/index.js'
|
|
44
|
+
export type { RoomStoreEvents, UserStoreEvents, ServerStoreEvents, MapStoreEvents, Map2SubscriptionStatus, HttpClientEvents } from './types/events.js'
|
|
45
|
+
|
|
46
|
+
export type { UserMessagesEndpoints } from './http/endpoints/user-messages.js'
|
|
47
|
+
export type { PowerCreepsEndpoints } from './http/endpoints/power-creeps.js'
|
|
48
|
+
export type { RegisterEndpoints } from './http/endpoints/register.js'
|
|
49
|
+
export type { HttpClient, RateLimitInfo } from './http/HttpClient.js'
|
|
50
|
+
export type { SocketClient } from './socket/SocketClient.js'
|
|
51
|
+
export type { RoomStore } from './stores/RoomStore.js'
|
|
52
|
+
export type { UserStore } from './stores/UserStore.js'
|
|
53
|
+
export type { ServerStore } from './stores/ServerStore.js'
|
|
54
|
+
export type { MapStore, Map2Subscription, MapStoreOptions } from './stores/MapStore.js'
|
|
55
|
+
export type { NavigationStore, NavigationState, NavigationStoreEvents } from './stores/NavigationStore.js'
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type LogFn = (...args: unknown[]) => void
|
|
2
|
+
|
|
3
|
+
export class Logger {
|
|
4
|
+
private readonly fn: LogFn | null
|
|
5
|
+
|
|
6
|
+
private constructor(fn: LogFn | null) {
|
|
7
|
+
this.fn = fn
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
static create(debug?: boolean | LogFn): Logger {
|
|
11
|
+
if (debug === true) return new Logger(console.debug.bind(console))
|
|
12
|
+
if (typeof debug === 'function') return new Logger(debug)
|
|
13
|
+
return new Logger(null)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
child(namespace: string): Logger {
|
|
17
|
+
if (!this.fn) return this
|
|
18
|
+
const parent = this.fn
|
|
19
|
+
return new Logger((...args: unknown[]) => parent(`[screeps:${namespace}]`, ...args))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
log(...args: unknown[]): void {
|
|
23
|
+
this.fn?.(...args)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { decompressZlib } from '../http/decompress.js'
|
|
2
|
+
|
|
3
|
+
export type ServerCommand =
|
|
4
|
+
| { type: 'auth'; status: 'ok' | 'failed'; token: string | undefined }
|
|
5
|
+
| { type: 'time'; time: number }
|
|
6
|
+
| { type: 'protocol'; protocol: number }
|
|
7
|
+
| { type: 'package'; package: number }
|
|
8
|
+
|
|
9
|
+
export interface ChannelMessage {
|
|
10
|
+
channel: string
|
|
11
|
+
data: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ParsedMessage =
|
|
15
|
+
| { kind: 'server'; command: ServerCommand }
|
|
16
|
+
| { kind: 'channel'; message: ChannelMessage }
|
|
17
|
+
|
|
18
|
+
export async function parseMessage(raw: string | MessageEvent): Promise<ParsedMessage> {
|
|
19
|
+
let msg = typeof raw === 'string' ? raw : (raw.data as string)
|
|
20
|
+
|
|
21
|
+
if (msg.startsWith('gz:')) {
|
|
22
|
+
msg = JSON.stringify(await decompressZlib(msg))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (msg.startsWith('[')) {
|
|
26
|
+
const [channel, data] = JSON.parse(msg) as [string, unknown]
|
|
27
|
+
return { kind: 'channel', message: { channel, data } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const [cmd, ...rest] = msg.split(' ')
|
|
31
|
+
|
|
32
|
+
switch (cmd) {
|
|
33
|
+
case 'auth':
|
|
34
|
+
return { kind: 'server', command: { type: 'auth', status: rest[0] as 'ok' | 'failed', token: rest[1] } }
|
|
35
|
+
case 'time':
|
|
36
|
+
return { kind: 'server', command: { type: 'time', time: parseInt(rest[0], 10) } }
|
|
37
|
+
case 'protocol':
|
|
38
|
+
return { kind: 'server', command: { type: 'protocol', protocol: parseInt(rest[0], 10) } }
|
|
39
|
+
case 'package':
|
|
40
|
+
return { kind: 'server', command: { type: 'package', package: parseInt(rest[0], 10) } }
|
|
41
|
+
default:
|
|
42
|
+
throw new Error(`Unknown server command: ${cmd}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { parseMessage } from './MessageParser.js'
|
|
2
|
+
import { Logger } from '../logger.js'
|
|
3
|
+
import type { Subscription } from '../subscription/index.js'
|
|
4
|
+
|
|
5
|
+
type WsConstructor = typeof globalThis.WebSocket
|
|
6
|
+
|
|
7
|
+
export class SocketClient {
|
|
8
|
+
private readonly wsUrl: string
|
|
9
|
+
private readonly WS: WsConstructor
|
|
10
|
+
private readonly logger: Logger
|
|
11
|
+
private ws: WebSocket | null = null
|
|
12
|
+
private token: string | null = null
|
|
13
|
+
private authed = false
|
|
14
|
+
private _connected = false
|
|
15
|
+
private reconnecting = false
|
|
16
|
+
private authSub: Subscription | null = null
|
|
17
|
+
private readonly queue: string[] = []
|
|
18
|
+
private readonly subs = new Map<string, number>()
|
|
19
|
+
private readonly listeners = new Map<string, Set<(data: unknown) => void>>()
|
|
20
|
+
|
|
21
|
+
private readonly MAX_RETRIES = 10
|
|
22
|
+
private readonly MAX_DELAY_MS = 60_000
|
|
23
|
+
private _intentionalClose = false
|
|
24
|
+
|
|
25
|
+
constructor(opts: { url: string; WebSocket?: WsConstructor; logger?: Logger }) {
|
|
26
|
+
const base = opts.url.replace(/^http/, 'ws').replace(/\/$/, '')
|
|
27
|
+
this.wsUrl = `${base}/socket/websocket`
|
|
28
|
+
this.WS = opts.WebSocket ?? globalThis.WebSocket
|
|
29
|
+
this.logger = opts.logger ?? Logger.create()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get isConnected(): boolean {
|
|
33
|
+
return this._connected
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Update the stored token. Used to keep WS and HTTP token in sync after an HTTP rotation. */
|
|
37
|
+
setToken(token: string): void {
|
|
38
|
+
this.token = token
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
connect(token: string): Promise<void> {
|
|
42
|
+
this.logger.log('connect', this.wsUrl)
|
|
43
|
+
// Note: do NOT reset _intentionalClose here. If disconnect() ran while a
|
|
44
|
+
// reconnect attempt was awaiting this connect(), resetting the flag would
|
|
45
|
+
// silently re-open the socket the user already asked to close. The flag
|
|
46
|
+
// is only cleared on an explicit external connect (see below).
|
|
47
|
+
if (!this.reconnecting) this._intentionalClose = false
|
|
48
|
+
this.token = token
|
|
49
|
+
this.authSub?.dispose()
|
|
50
|
+
this.authSub = null
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
this.ws = new this.WS(this.wsUrl) as WebSocket
|
|
53
|
+
this.ws.onopen = () => {
|
|
54
|
+
this.logger.log('WebSocket opened')
|
|
55
|
+
this._connected = true
|
|
56
|
+
this.reconnecting = false
|
|
57
|
+
this.rawSend(`auth ${this.token}`)
|
|
58
|
+
this.authSub = this.once('auth', (data) => {
|
|
59
|
+
const cmd = data as { status: string; token?: string }
|
|
60
|
+
if (cmd.status === 'ok') {
|
|
61
|
+
this.logger.log('auth ok')
|
|
62
|
+
this.authed = true
|
|
63
|
+
if (cmd.token) {
|
|
64
|
+
this.token = cmd.token
|
|
65
|
+
this.emit('socket:tokenRefresh', { token: cmd.token })
|
|
66
|
+
}
|
|
67
|
+
while (this.queue.length) this.rawSend(this.queue.shift()!)
|
|
68
|
+
this.emit('connected', {})
|
|
69
|
+
resolve()
|
|
70
|
+
} else {
|
|
71
|
+
this.logger.log('auth failed')
|
|
72
|
+
reject(new Error('WebSocket auth failed'))
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
this.ws.onclose = () => {
|
|
77
|
+
this.logger.log('WebSocket closed')
|
|
78
|
+
this._connected = false
|
|
79
|
+
this.authed = false
|
|
80
|
+
this.authSub?.dispose()
|
|
81
|
+
this.authSub = null
|
|
82
|
+
this.emit('disconnected', { willReconnect: this.reconnecting })
|
|
83
|
+
void this.scheduleReconnect()
|
|
84
|
+
}
|
|
85
|
+
this.ws.onerror = (err) => {
|
|
86
|
+
if (!this._connected) reject(err)
|
|
87
|
+
}
|
|
88
|
+
this.ws.onmessage = (event) => {
|
|
89
|
+
this.handleMessage(event).catch(err => {
|
|
90
|
+
this.logger.log('message parse error', err)
|
|
91
|
+
this.emit('socket:error', err instanceof Error ? err : new Error(String(err)))
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
disconnect(): void {
|
|
98
|
+
this.logger.log('disconnect')
|
|
99
|
+
this._intentionalClose = true
|
|
100
|
+
this.reconnecting = false
|
|
101
|
+
this.ws?.close()
|
|
102
|
+
this.ws = null
|
|
103
|
+
this._connected = false
|
|
104
|
+
this.authed = false
|
|
105
|
+
this.queue.length = 0
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private activeSubs(): string {
|
|
109
|
+
return Array.from(this.subs.entries())
|
|
110
|
+
.map(([ch, count]) => `${ch}(${count})`)
|
|
111
|
+
.join(', ') || '(none)'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
subscribe(channel: string): Subscription {
|
|
115
|
+
const count = this.subs.get(channel) ?? 0
|
|
116
|
+
this.subs.set(channel, count + 1)
|
|
117
|
+
if (count === 0) {
|
|
118
|
+
this.logger.log('subscribe', channel, 'active:', this.activeSubs())
|
|
119
|
+
this.sendOrQueue(`subscribe ${channel}`)
|
|
120
|
+
} else {
|
|
121
|
+
this.logger.log('subscribe', channel, `(refs: ${count + 1})`, 'active:', this.activeSubs())
|
|
122
|
+
}
|
|
123
|
+
return { dispose: () => this.doUnsubscribe(channel) }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
on(channel: string, cb: (data: unknown) => void): Subscription {
|
|
127
|
+
let set = this.listeners.get(channel)
|
|
128
|
+
if (!set) { set = new Set(); this.listeners.set(channel, set) }
|
|
129
|
+
set.add(cb)
|
|
130
|
+
return { dispose: () => { this.listeners.get(channel)?.delete(cb) } }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private once(channel: string, cb: (data: unknown) => void): Subscription {
|
|
134
|
+
const sub = this.on(channel, (data) => { sub.dispose(); cb(data) })
|
|
135
|
+
return sub
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private doUnsubscribe(channel: string): void {
|
|
139
|
+
const count = this.subs.get(channel) ?? 0
|
|
140
|
+
if (count <= 1) {
|
|
141
|
+
this.subs.delete(channel)
|
|
142
|
+
if (this.authed) this.rawSend(`unsubscribe ${channel}`)
|
|
143
|
+
this.logger.log('unsubscribe', channel, 'active:', this.activeSubs())
|
|
144
|
+
} else {
|
|
145
|
+
this.subs.set(channel, count - 1)
|
|
146
|
+
this.logger.log('unsubscribe', channel, `(refs: ${count - 1})`, 'active:', this.activeSubs())
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private rawSend(data: string): void {
|
|
151
|
+
this.ws?.send(data)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private sendOrQueue(data: string): void {
|
|
155
|
+
if (this.authed) this.rawSend(data)
|
|
156
|
+
else this.queue.push(data)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private emit(channel: string, data: unknown): void {
|
|
160
|
+
this.listeners.get(channel)?.forEach(cb => cb(data))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async handleMessage(event: MessageEvent): Promise<void> {
|
|
164
|
+
const parsed = await parseMessage(event)
|
|
165
|
+
if (parsed.kind === 'server') {
|
|
166
|
+
this.emit(parsed.command.type, parsed.command)
|
|
167
|
+
} else {
|
|
168
|
+
this.emit(parsed.message.channel, parsed.message.data)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async scheduleReconnect(): Promise<void> {
|
|
173
|
+
if (this.reconnecting || this._intentionalClose) return
|
|
174
|
+
if (!this.token) { this.reconnecting = false; return }
|
|
175
|
+
this.reconnecting = true
|
|
176
|
+
let retries = 0
|
|
177
|
+
while (retries < this.MAX_RETRIES && this.reconnecting && !this._intentionalClose) {
|
|
178
|
+
const delay = Math.min(Math.pow(2, retries) * 100, this.MAX_DELAY_MS)
|
|
179
|
+
this.logger.log(`reconnect attempt ${retries + 1}/${this.MAX_RETRIES} in ${delay}ms`)
|
|
180
|
+
await new Promise(r => setTimeout(r, delay))
|
|
181
|
+
if (!this.reconnecting || this._intentionalClose) return
|
|
182
|
+
try {
|
|
183
|
+
await this.connect(this.token!)
|
|
184
|
+
// disconnect() may have run while connect() was in flight — honor it
|
|
185
|
+
if (this._intentionalClose) {
|
|
186
|
+
this.ws?.close()
|
|
187
|
+
this.ws = null
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
for (const channel of this.subs.keys()) {
|
|
191
|
+
this.rawSend(`subscribe ${channel}`)
|
|
192
|
+
}
|
|
193
|
+
return
|
|
194
|
+
} catch {
|
|
195
|
+
retries++
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.reconnecting = false
|
|
199
|
+
if (retries >= this.MAX_RETRIES && !this._intentionalClose) {
|
|
200
|
+
this.emit('socket:error', new Error(`WebSocket reconnection failed after ${this.MAX_RETRIES} retries`))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, unlink, rm } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { StorageAdapter } from './StorageAdapter.js'
|
|
4
|
+
|
|
5
|
+
export class FileStorage implements StorageAdapter {
|
|
6
|
+
private readonly dir: string
|
|
7
|
+
|
|
8
|
+
constructor(baseDir: string, namespace: string) {
|
|
9
|
+
const sanitized = namespace.replace(/[^a-zA-Z0-9.-]/g, '_')
|
|
10
|
+
this.dir = join(baseDir, sanitized)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private keyPath(key: string): string {
|
|
14
|
+
const hex = Buffer.from(key).toString('hex')
|
|
15
|
+
return join(this.dir, `${hex}.bin`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async get(key: string): Promise<Uint8Array | null> {
|
|
19
|
+
try {
|
|
20
|
+
const buf = await readFile(this.keyPath(key))
|
|
21
|
+
return new Uint8Array(buf)
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
24
|
+
throw err
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async set(key: string, data: Uint8Array): Promise<void> {
|
|
29
|
+
await mkdir(this.dir, { recursive: true })
|
|
30
|
+
await writeFile(this.keyPath(key), data)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async delete(key: string): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
await unlink(this.keyPath(key))
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async clear(): Promise<void> {
|
|
42
|
+
await rm(this.dir, { recursive: true, force: true })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { StorageAdapter } from './StorageAdapter.js'
|
|
2
|
+
|
|
3
|
+
const DB_VERSION = 1
|
|
4
|
+
const STORE_NAME = 'data'
|
|
5
|
+
|
|
6
|
+
export class IndexedDBStorage implements StorageAdapter {
|
|
7
|
+
private readonly namespace: string
|
|
8
|
+
private db: IDBDatabase | null = null
|
|
9
|
+
private openPromise: Promise<IDBDatabase> | null = null
|
|
10
|
+
|
|
11
|
+
constructor(namespace: string) {
|
|
12
|
+
this.namespace = namespace
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private get dbName(): string {
|
|
16
|
+
return `screeps:${this.namespace}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private open(): Promise<IDBDatabase> {
|
|
20
|
+
if (this.db) return Promise.resolve(this.db)
|
|
21
|
+
if (this.openPromise) return this.openPromise
|
|
22
|
+
this.openPromise = new Promise((resolve, reject) => {
|
|
23
|
+
const req = indexedDB.open(this.dbName, DB_VERSION)
|
|
24
|
+
req.onupgradeneeded = () => {
|
|
25
|
+
req.result.createObjectStore(STORE_NAME)
|
|
26
|
+
}
|
|
27
|
+
req.onsuccess = () => {
|
|
28
|
+
this.db = req.result
|
|
29
|
+
this.openPromise = null
|
|
30
|
+
resolve(req.result)
|
|
31
|
+
}
|
|
32
|
+
req.onerror = () => reject(req.error)
|
|
33
|
+
req.onblocked = () => reject(new Error(`IndexedDB open blocked: ${this.dbName}`))
|
|
34
|
+
})
|
|
35
|
+
return this.openPromise
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async get(key: string): Promise<Uint8Array | null> {
|
|
39
|
+
const db = await this.open()
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const tx = db.transaction(STORE_NAME, 'readonly')
|
|
42
|
+
const req = tx.objectStore(STORE_NAME).get(key)
|
|
43
|
+
req.onsuccess = () => resolve((req.result as Uint8Array | undefined) ?? null)
|
|
44
|
+
req.onerror = () => reject(req.error)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async set(key: string, data: Uint8Array): Promise<void> {
|
|
49
|
+
const db = await this.open()
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
52
|
+
const req = tx.objectStore(STORE_NAME).put(data, key)
|
|
53
|
+
req.onsuccess = () => resolve()
|
|
54
|
+
req.onerror = () => reject(req.error)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async delete(key: string): Promise<void> {
|
|
59
|
+
const db = await this.open()
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
62
|
+
const req = tx.objectStore(STORE_NAME).delete(key)
|
|
63
|
+
req.onsuccess = () => resolve()
|
|
64
|
+
req.onerror = () => reject(req.error)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async clear(): Promise<void> {
|
|
69
|
+
const db = await this.open()
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
72
|
+
const req = tx.objectStore(STORE_NAME).clear()
|
|
73
|
+
req.onsuccess = () => resolve()
|
|
74
|
+
req.onerror = () => reject(req.error)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { StorageAdapter } from './StorageAdapter.js'
|
|
2
|
+
|
|
3
|
+
export class NullStorage implements StorageAdapter {
|
|
4
|
+
async get(_key: string): Promise<Uint8Array | null> { return null }
|
|
5
|
+
async set(_key: string, _data: Uint8Array): Promise<void> {}
|
|
6
|
+
async delete(_key: string): Promise<void> {}
|
|
7
|
+
async clear(): Promise<void> {}
|
|
8
|
+
}
|