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.
Files changed (75) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/eslint.config.js +54 -0
  3. package/package.json +45 -0
  4. package/src/ScreepsClient.ts +172 -0
  5. package/src/badge/colors.ts +83 -0
  6. package/src/badge/generateSvg.ts +70 -0
  7. package/src/badge/index.ts +1 -0
  8. package/src/badge/paths.ts +385 -0
  9. package/src/cache/Cache.ts +71 -0
  10. package/src/cache/Map2Storage.ts +112 -0
  11. package/src/file-storage.ts +2 -0
  12. package/src/http/HttpClient.ts +160 -0
  13. package/src/http/auth/AuthStrategy.ts +5 -0
  14. package/src/http/auth/GuestAuth.ts +13 -0
  15. package/src/http/auth/PasswordAuth.ts +17 -0
  16. package/src/http/auth/SteamTicketAuth.ts +17 -0
  17. package/src/http/auth/TokenAuth.ts +14 -0
  18. package/src/http/decompress.ts +37 -0
  19. package/src/http/endpoints/auth.ts +23 -0
  20. package/src/http/endpoints/experimental.ts +13 -0
  21. package/src/http/endpoints/game.ts +103 -0
  22. package/src/http/endpoints/leaderboard.ts +16 -0
  23. package/src/http/endpoints/power-creeps.ts +24 -0
  24. package/src/http/endpoints/register.ts +19 -0
  25. package/src/http/endpoints/user-messages.ts +20 -0
  26. package/src/http/endpoints/user.ts +95 -0
  27. package/src/http/fetchServerVersion.ts +151 -0
  28. package/src/index.ts +55 -0
  29. package/src/logger.ts +25 -0
  30. package/src/socket/MessageParser.ts +44 -0
  31. package/src/socket/SocketClient.ts +203 -0
  32. package/src/storage/FileStorage.ts +44 -0
  33. package/src/storage/IndexedDBStorage.ts +77 -0
  34. package/src/storage/NullStorage.ts +8 -0
  35. package/src/storage/StorageAdapter.ts +6 -0
  36. package/src/stores/MapStatsStore.ts +115 -0
  37. package/src/stores/MapStore.ts +254 -0
  38. package/src/stores/NavigationStore.ts +61 -0
  39. package/src/stores/RoomStore.ts +264 -0
  40. package/src/stores/ServerStore.ts +128 -0
  41. package/src/stores/TypedStore.ts +31 -0
  42. package/src/stores/UserStore.ts +189 -0
  43. package/src/subscription/index.ts +18 -0
  44. package/src/types/api.ts +252 -0
  45. package/src/types/events.ts +72 -0
  46. package/src/types/game.ts +160 -0
  47. package/tests/.gitkeep +0 -0
  48. package/tests/ScreepsClient.test.ts +229 -0
  49. package/tests/badge/generateSvg.test.ts +174 -0
  50. package/tests/cache/Cache.test.ts +99 -0
  51. package/tests/cache/Map2Storage.test.ts +130 -0
  52. package/tests/http/HttpClient.test.ts +188 -0
  53. package/tests/http/decompress.test.ts +52 -0
  54. package/tests/http/endpoints/auth.test.ts +126 -0
  55. package/tests/http/endpoints/game.test.ts +210 -0
  56. package/tests/http/endpoints/power-creeps.test.ts +81 -0
  57. package/tests/http/endpoints/user-messages.test.ts +68 -0
  58. package/tests/http/endpoints/user.test.ts +139 -0
  59. package/tests/socket/MessageParser.test.ts +55 -0
  60. package/tests/socket/SocketClient.test.ts +144 -0
  61. package/tests/storage/FileStorage.test.ts +64 -0
  62. package/tests/storage/IndexedDBStorage.test.ts +36 -0
  63. package/tests/storage/NullStorage.test.ts +24 -0
  64. package/tests/stores/MapStatsStore.test.ts +234 -0
  65. package/tests/stores/MapStore.test.ts +537 -0
  66. package/tests/stores/NavigationStore.test.ts +166 -0
  67. package/tests/stores/RoomStore.test.ts +130 -0
  68. package/tests/stores/ServerStore.test.ts +48 -0
  69. package/tests/stores/TypedStore.test.ts +54 -0
  70. package/tests/stores/UserStore.test.ts +136 -0
  71. package/tests/subscription/SubscriptionGroup.test.ts +34 -0
  72. package/tests/types/game.test.ts +42 -0
  73. package/tsconfig.json +17 -0
  74. package/tsup.config.ts +9 -0
  75. 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
+ }
@@ -0,0 +1,6 @@
1
+ export interface StorageAdapter {
2
+ get(key: string): Promise<Uint8Array | null>
3
+ set(key: string, data: Uint8Array): Promise<void>
4
+ delete(key: string): Promise<void>
5
+ clear(): Promise<void>
6
+ }