screeps-connectivity 0.14.0 → 0.15.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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 4526471: Namespace the pre-login `/api/authmod` session cache by host **and path**, not hostname alone. Behind `screeps-client-proxy` every backend is wrapped under one host (`localhost/(https://server)`), so hostname-only keys collided and one server's auth capabilities could be shown for another. Also disambiguates private servers that share a hostname on different ports. Matches the path-based namespacing already used for the persistent cache in `ScreepsClient`.
8
+
9
+ ## 0.15.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 66f88f8: Code panel branch management: create a new branch (clones the selected branch, with an inline name input) and set the selected branch to run on the server. The active-branch indicator now stays live via a new `set-active-branch` WebSocket subscription — `UserStore.subscribe('set-active-branch')` emits a `user:setActiveBranch` event whenever the active branch changes, including from another client or session.
14
+
15
+ The code panel can now add and delete modules: an add button in the module list opens an inline name input, and hovering a module reveals a delete button (the `main` entry module is protected). Both changes are staged locally and persisted on the next Save.
16
+
17
+ Also fixes a stale-response race in the code panel where switching branches while a previous branch's code fetch was still in flight could leave the editor showing the wrong branch's files.
18
+
19
+ - 66f88f8: WebSocket compression support (opt-in): setting the new `ScreepsClientOptions.gzip` to `true` sends `gzip on` after auth, so the server deflates event frames (room updates, map stats, memory) and only transmits the compressed `gz:` form when it's actually smaller. Defaults to `false` to match the official client, which never enables it. The `gz:` decode path is always active regardless, so this is a pure opt-in bandwidth trade with no downside on small control frames.
20
+
3
21
  ## 0.14.0
4
22
 
5
23
  ### Minor Changes
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2024-2026 Bastian Hoyer
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screeps-connectivity",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "repository": {
@@ -24,22 +24,22 @@
24
24
  "require": "./dist/file-storage.cjs"
25
25
  }
26
26
  },
27
+ "devDependencies": {
28
+ "@eslint/js": "^10.0.1",
29
+ "@types/node": "^26.1.0",
30
+ "@typescript-eslint/eslint-plugin": "^8.63.0",
31
+ "@typescript-eslint/parser": "^8.63.0",
32
+ "eslint": "^10.6.0",
33
+ "fake-indexeddb": "^6.0.0",
34
+ "globals": "^17.7.0",
35
+ "tsup": "^8.0.0",
36
+ "typescript": "^6.0.3",
37
+ "vitest": "^4.1.10"
38
+ },
27
39
  "scripts": {
28
40
  "build": "tsup",
29
41
  "test": "vitest run",
30
42
  "test:watch": "vitest",
31
43
  "lint": "eslint src tests"
32
- },
33
- "devDependencies": {
34
- "@eslint/js": "^10.0.1",
35
- "@types/node": "^25.7.0",
36
- "@typescript-eslint/eslint-plugin": "^8.0.0",
37
- "@typescript-eslint/parser": "^8.0.0",
38
- "eslint": "^9.0.0",
39
- "fake-indexeddb": "^6.0.0",
40
- "globals": "^17.6.0",
41
- "tsup": "^8.0.0",
42
- "typescript": "^5.5.0",
43
- "vitest": "^4.1.6"
44
44
  }
45
- }
45
+ }
@@ -41,6 +41,14 @@ export interface ScreepsClientOptions {
41
41
  tokenRefresh?: TokenRefreshOptions | false
42
42
  /** Override the /api/game/room-decorations response with static data (useful for dev/testing when the server doesn't support the endpoint). */
43
43
  decorationsMock?: ApiRoomDecorationsResponse
44
+ /**
45
+ * Enable WebSocket message compression by sending `gzip on` after auth. The
46
+ * server then deflates event frames (room updates, etc.) and only sends the
47
+ * compressed `gz:` form when it's smaller. Defaults to `false` — matching the
48
+ * official client, which never enables it. The `gz:` decode path is always
49
+ * active regardless, so enabling this is a pure opt-in bandwidth trade.
50
+ */
51
+ gzip?: boolean
44
52
  }
45
53
 
46
54
  export class ScreepsClient {
@@ -77,7 +85,7 @@ export class ScreepsClient {
77
85
  this.logger.log(`[screeps:client] init ${opts.url}`)
78
86
  this.cache = new Cache(namespace, opts.storage ?? null)
79
87
  this.http = new HttpClient({ url: opts.url, auth: opts.auth, logger: this.logger.child('http'), serverPassword: opts.serverPassword, decorationsMock: opts.decorationsMock })
80
- this.socket = new SocketClient({ url: opts.url, WebSocket: opts.WebSocket, logger: this.logger.child('socket') })
88
+ this.socket = new SocketClient({ url: opts.url, WebSocket: opts.WebSocket, logger: this.logger.child('socket'), gzip: opts.gzip })
81
89
  const map2Storage = new Map2Storage({
82
90
  adapter: opts.storage ?? null,
83
91
  namespace,
@@ -14,7 +14,15 @@ interface CachedEntry<T> {
14
14
 
15
15
  function sessionKey(suffix: string, url: string): string {
16
16
  try {
17
- return `screeps:${suffix}:${new URL(url).hostname}`
17
+ const parsed = new URL(url)
18
+ // Hostname alone isn't enough: multiple worlds can live under one host via a
19
+ // path (screeps.com vs screeps.com/season), and behind screeps-client-proxy
20
+ // every backend is wrapped under the same host (localhost/(https://server)),
21
+ // so the path must disambiguate them. Same path-based approach as the Cache
22
+ // namespace in ScreepsClient; host (incl. port) also separates private
23
+ // servers sharing a hostname on different ports.
24
+ const path = parsed.pathname.replace(/\/+$/, '')
25
+ return `screeps:${suffix}:${path ? `${parsed.host}${path}` : parsed.host}`
18
26
  } catch {
19
27
  return `screeps:${suffix}:${url}`
20
28
  }
@@ -60,7 +68,7 @@ export async function fetchServerVersion(url: string): Promise<ServerVersion> {
60
68
  /**
61
69
  * Fetch screepsmod-auth capabilities from `/api/authmod` without authentication.
62
70
  * Returns `null` if the server does not run screepsmod-auth.
63
- * The result is cached in `sessionStorage` for 5 minutes (per server hostname).
71
+ * The result is cached in `sessionStorage` for 5 minutes (per server host+path).
64
72
  */
65
73
  export async function fetchAuthModInfo(url: string): Promise<ApiAuthModInfoResponse | null> {
66
74
  const key = sessionKey('authmod', url)
@@ -8,6 +8,7 @@ export class SocketClient {
8
8
  private readonly wsUrl: string
9
9
  private readonly WS: WsConstructor
10
10
  private readonly logger: Logger
11
+ private readonly gzip: boolean
11
12
  private ws: WebSocket | null = null
12
13
  private token: string | null = null
13
14
  private authed = false
@@ -22,11 +23,14 @@ export class SocketClient {
22
23
  private readonly MAX_DELAY_MS = 60_000
23
24
  private _intentionalClose = false
24
25
 
25
- constructor(opts: { url: string; WebSocket?: WsConstructor; logger?: Logger }) {
26
+ constructor(opts: { url: string; WebSocket?: WsConstructor; logger?: Logger; gzip?: boolean }) {
26
27
  const base = opts.url.replace(/^http/, 'ws').replace(/\/$/, '')
27
28
  this.wsUrl = `${base}/socket/websocket`
28
29
  this.WS = opts.WebSocket ?? globalThis.WebSocket
29
30
  this.logger = opts.logger ?? Logger.create()
31
+ // Off by default — matches the official client, which never sends `gzip on`.
32
+ // The decode path is always available; opt in explicitly to enable it.
33
+ this.gzip = opts.gzip ?? false
30
34
  }
31
35
 
32
36
  get isConnected(): boolean {
@@ -64,6 +68,11 @@ export class SocketClient {
64
68
  this.token = cmd.token
65
69
  this.emit('socket:tokenRefresh', { token: cmd.token })
66
70
  }
71
+ // Ask the server to deflate outbound event frames before any
72
+ // subscribe is flushed, so the first updates already arrive as gz:.
73
+ // The server only sends the compressed form when it's actually
74
+ // smaller, so this never enlarges small control frames.
75
+ if (this.gzip) this.rawSend('gzip on')
67
76
  while (this.queue.length) this.rawSend(this.queue.shift()!)
68
77
  this.emit('connected', {})
69
78
  resolve()
@@ -82,7 +82,7 @@ export class UserStore extends TypedStore<UserStoreEvents> {
82
82
  return this.worldStatus()
83
83
  }
84
84
 
85
- subscribe(channel: 'console' | 'cpu' | 'code'): Subscription {
85
+ subscribe(channel: 'console' | 'cpu' | 'code' | 'set-active-branch'): Subscription {
86
86
  this.logger.log('subscribe', channel)
87
87
  let socketSub: Subscription | null = null
88
88
  let listenerSub: Subscription | null = null
@@ -115,6 +115,8 @@ export class UserStore extends TypedStore<UserStoreEvents> {
115
115
  this.emit('user:console', { messages: msg })
116
116
  } else if (channel === 'code') {
117
117
  this.emit('user:code', data as { branch: string; modules: Record<string, string> })
118
+ } else if (channel === 'set-active-branch') {
119
+ this.emit('user:setActiveBranch', data as { activeName: 'activeWorld' | 'activeSim'; branch: string })
118
120
  }
119
121
  })
120
122
  } catch (err) {
@@ -40,6 +40,7 @@ export interface UserStoreEvents {
40
40
  'user:cpu': CpuStats
41
41
  'user:console': { messages: ConsoleMessage }
42
42
  'user:code': { branch: string; modules: Record<string, string> }
43
+ 'user:setActiveBranch': { activeName: 'activeWorld' | 'activeSim'; branch: string }
43
44
  'user:stream': Record<string, unknown>
44
45
  'user:memory': { path: string; shard: string | null; value: unknown }
45
46
  'user:mapVisual': { shard: string | null; data: string }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { fetchAuthModInfo } from '../../src/http/fetchServerVersion.js'
3
+
4
+ // Minimal in-memory sessionStorage stub (the node test env has none).
5
+ class MemorySessionStorage {
6
+ private readonly map = new Map<string, string>()
7
+ getItem(k: string): string | null { return this.map.get(k) ?? null }
8
+ setItem(k: string, v: string): void { this.map.set(k, v) }
9
+ removeItem(k: string): void { this.map.delete(k) }
10
+ clear(): void { this.map.clear() }
11
+ }
12
+
13
+ describe('fetchAuthModInfo — session cache namespacing', () => {
14
+ beforeEach(() => {
15
+ vi.stubGlobal('sessionStorage', new MemorySessionStorage())
16
+ })
17
+ afterEach(() => { vi.unstubAllGlobals() })
18
+
19
+ it('does not collide between two backends wrapped under the same proxy host', async () => {
20
+ // Both URLs share host `localhost:8080`; only the /(backend) path differs —
21
+ // exactly how screeps-client-proxy addresses distinct servers. The cache key
22
+ // must include the path, otherwise the second lookup returns the first's data.
23
+ const worldUrl = 'http://localhost:8080/(https://screeps.com)'
24
+ const privUrl = 'http://localhost:8080/(http://my-private-server:21025)'
25
+ // Exact URL → payload map (no substring host matching).
26
+ const payloads: Record<string, string> = {
27
+ [`${worldUrl}/api/authmod`]: 'official',
28
+ [`${privUrl}/api/authmod`]: 'private',
29
+ }
30
+ const fetchMock = vi.fn().mockImplementation((input: string) => {
31
+ return Promise.resolve(
32
+ new Response(JSON.stringify({ ok: 1, backend: payloads[input] }), {
33
+ headers: { 'content-type': 'application/json' },
34
+ }),
35
+ )
36
+ })
37
+ vi.stubGlobal('fetch', fetchMock)
38
+
39
+ const world = await fetchAuthModInfo(worldUrl)
40
+ const priv = await fetchAuthModInfo(privUrl)
41
+
42
+ expect((world as { backend?: string })?.backend).toBe('official')
43
+ expect((priv as { backend?: string })?.backend).toBe('private')
44
+ // Both were real network fetches — neither served the other's cached entry.
45
+ expect(fetchMock).toHaveBeenCalledTimes(2)
46
+
47
+ // A repeat of the first URL is served from cache (no third fetch).
48
+ const worldAgain = await fetchAuthModInfo(worldUrl)
49
+ expect((worldAgain as { backend?: string })?.backend).toBe('official')
50
+ expect(fetchMock).toHaveBeenCalledTimes(2)
51
+ })
52
+ })
@@ -58,6 +58,31 @@ describe('SocketClient', () => {
58
58
  await expect(connectClient(client)).resolves.toBeDefined()
59
59
  })
60
60
 
61
+ it('does not send gzip on by default', async () => {
62
+ const client = makeClient()
63
+ const ws = await connectClient(client)
64
+ expect(ws.sent).not.toContain('gzip on')
65
+ })
66
+
67
+ it('sends gzip on when explicitly enabled, before any subscribe', async () => {
68
+ const client = new SocketClient({ url: 'https://screeps.com', gzip: true, WebSocket: MockWS as unknown as typeof WebSocket })
69
+ const connectPromise = client.connect('tok')
70
+ const ws = MockWS.instances[0]
71
+ ws.simulateOpen()
72
+ ws.simulateMessage('auth ok newtoken')
73
+ await connectPromise
74
+ expect(ws.sent).toContain('gzip on')
75
+ // gzip on must precede subscribes so the first updates already arrive as gz:
76
+ client.subscribe('room:shard0/W7N7')
77
+ expect(ws.sent.indexOf('gzip on')).toBeLessThan(ws.sent.indexOf('subscribe room:shard0/W7N7'))
78
+ })
79
+
80
+ it('explicit gzip:false does not send gzip on', async () => {
81
+ const client = new SocketClient({ url: 'https://screeps.com', gzip: false, WebSocket: MockWS as unknown as typeof WebSocket })
82
+ const ws = await connectClient(client)
83
+ expect(ws.sent).not.toContain('gzip on')
84
+ })
85
+
61
86
  it('subscribe sends subscribe message when authed', async () => {
62
87
  const client = makeClient()
63
88
  const ws = await connectClient(client)
package/tsconfig.json CHANGED
@@ -11,7 +11,8 @@
11
11
  "sourceMap": true,
12
12
  "outDir": "./dist",
13
13
  "rootDir": "./src",
14
- "verbatimModuleSyntax": true
14
+ "verbatimModuleSyntax": true,
15
+ "ignoreDeprecations": "6.0"
15
16
  },
16
17
  "include": ["src"]
17
18
  }