screeps-connectivity 0.15.0 → 0.15.2

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.2
4
+
5
+ ### Patch Changes
6
+
7
+ - b39a0c1: Fix duplicated console output when multiple parts of the app subscribe to the same `UserStore` channel. `subscribe()` now installs a single shared, ref-counted socket subscription and listener per channel instead of one listener per caller, so each incoming frame is processed and re-emitted exactly once. The server subscription and listener are torn down only after the last subscriber disposes.
8
+
9
+ ## 0.15.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 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`.
14
+
3
15
  ## 0.15.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screeps-connectivity",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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)
@@ -22,6 +22,15 @@ export class UserStore extends TypedStore<UserStoreEvents> {
22
22
  private _worldStatus: WorldStatus | null = null
23
23
  get worldStatusValue(): WorldStatus | null { return this._worldStatus }
24
24
  private _mePromise: Promise<UserInfo> | null = null
25
+ // Ref-counted store-level fan-out, keyed by channel. SocketClient.subscribe() already ref-counts the
26
+ // server subscription, but SocketClient.on() installs one listener per call — so a shared listener
27
+ // must be installed once and reused, otherwise N callers process (and re-emit) every frame N times.
28
+ private readonly channelSubs = new Map<string, {
29
+ refCount: number
30
+ disposed: boolean
31
+ socketSub: Subscription | null
32
+ listenerSub: Subscription | null
33
+ }>()
25
34
 
26
35
  constructor(http: HttpClient, socket: SocketClient, cache: Cache, logger?: Logger, maxConsoleSize = 100) {
27
36
  super(logger)
@@ -84,56 +93,70 @@ export class UserStore extends TypedStore<UserStoreEvents> {
84
93
 
85
94
  subscribe(channel: 'console' | 'cpu' | 'code' | 'set-active-branch'): Subscription {
86
95
  this.logger.log('subscribe', channel)
87
- let socketSub: Subscription | null = null
88
- let listenerSub: Subscription | null = null
89
- let disposed = false
90
96
 
91
- const setup = async () => {
92
- try {
93
- const uid = this._userId ?? (await this.me())._id
94
- if (disposed) return
95
- const fullChannel = `user:${uid}/${channel}`
96
- socketSub = this.socket.subscribe(fullChannel)
97
- listenerSub = this.socket.on(fullChannel, (data) => {
98
- if (channel === 'cpu') {
99
- this._cpu = data as CpuStats
100
- this.emit('user:cpu', this._cpu)
101
- } else if (channel === 'console') {
102
- const raw = data as { messages?: ConsoleMessage, error?: string }
103
- const msg: ConsoleMessage = {
104
- log: raw.messages?.log ?? [],
105
- results: raw.messages?.results ?? [],
106
- error: raw.messages?.error ?? [],
107
- }
108
- if (raw.error) {
109
- msg.error.push(raw.error)
110
- }
111
- this.console.push(msg)
112
- if (this.console.length > this.maxConsoleSize) {
113
- this.console.splice(0, this.console.length - this.maxConsoleSize)
97
+ // First caller for this channel installs the shared socket subscription + listener; later callers
98
+ // only bump the ref count so the frame is processed and re-emitted exactly once.
99
+ let entry = this.channelSubs.get(channel)
100
+ if (entry) {
101
+ entry.refCount++
102
+ } else {
103
+ entry = { refCount: 1, disposed: false, socketSub: null, listenerSub: null }
104
+ this.channelSubs.set(channel, entry)
105
+ const created = entry
106
+ const setup = async () => {
107
+ try {
108
+ const uid = this._userId ?? (await this.me())._id
109
+ if (created.disposed) return
110
+ const fullChannel = `user:${uid}/${channel}`
111
+ created.socketSub = this.socket.subscribe(fullChannel)
112
+ created.listenerSub = this.socket.on(fullChannel, (data) => {
113
+ if (channel === 'cpu') {
114
+ this._cpu = data as CpuStats
115
+ this.emit('user:cpu', this._cpu)
116
+ } else if (channel === 'console') {
117
+ const raw = data as { messages?: ConsoleMessage, error?: string }
118
+ const msg: ConsoleMessage = {
119
+ log: raw.messages?.log ?? [],
120
+ results: raw.messages?.results ?? [],
121
+ error: raw.messages?.error ?? [],
122
+ }
123
+ if (raw.error) {
124
+ msg.error.push(raw.error)
125
+ }
126
+ this.console.push(msg)
127
+ if (this.console.length > this.maxConsoleSize) {
128
+ this.console.splice(0, this.console.length - this.maxConsoleSize)
129
+ }
130
+ this.emit('user:console', { messages: msg })
131
+ } else if (channel === 'code') {
132
+ this.emit('user:code', data as { branch: string; modules: Record<string, string> })
133
+ } else if (channel === 'set-active-branch') {
134
+ this.emit('user:setActiveBranch', data as { activeName: 'activeWorld' | 'activeSim'; branch: string })
114
135
  }
115
- this.emit('user:console', { messages: msg })
116
- } else if (channel === 'code') {
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 })
136
+ })
137
+ } catch (err) {
138
+ if (!created.disposed) {
139
+ this.dispatchEvent(new ErrorEvent('error', { error: err instanceof Error ? err : new Error(String(err)) }))
120
140
  }
121
- })
122
- } catch (err) {
123
- if (!disposed) {
124
- this.dispatchEvent(new ErrorEvent('error', { error: err instanceof Error ? err : new Error(String(err)) }))
125
141
  }
126
142
  }
143
+ void setup()
127
144
  }
128
145
 
129
- void setup()
130
-
146
+ let disposed = false
131
147
  return {
132
148
  dispose: () => {
133
- this.logger.log('unsubscribe', channel)
149
+ if (disposed) return
134
150
  disposed = true
135
- socketSub?.dispose()
136
- listenerSub?.dispose()
151
+ this.logger.log('unsubscribe', channel)
152
+ const current = this.channelSubs.get(channel)
153
+ if (!current) return
154
+ if (--current.refCount <= 0) {
155
+ current.disposed = true
156
+ current.socketSub?.dispose()
157
+ current.listenerSub?.dispose()
158
+ this.channelSubs.delete(channel)
159
+ }
137
160
  },
138
161
  }
139
162
  }
@@ -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
+ })
@@ -133,4 +133,49 @@ describe('UserStore', () => {
133
133
  sub.dispose()
134
134
  expect(mockDispose).toHaveBeenCalled()
135
135
  })
136
+
137
+ it('multiple console subscribers share one socket listener and do not double output', async () => {
138
+ const { store, socket } = makeStore()
139
+ await store.me()
140
+ const handlers: Array<(data: unknown) => void> = []
141
+ ;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
142
+ handlers.push(cb)
143
+ return { dispose: vi.fn() }
144
+ })
145
+ const eventSpy = vi.fn()
146
+ store.on('user:console', eventSpy)
147
+
148
+ // Two independent parts of the app subscribe to the same channel (e.g. the console panel and the
149
+ // custom-UI store). Regression: this used to install two socket listeners, doubling every frame.
150
+ store.subscribe('console')
151
+ store.subscribe('console')
152
+ await new Promise(r => setTimeout(r, 0))
153
+
154
+ // Exactly one socket listener and one server subscription regardless of caller count.
155
+ expect(handlers).toHaveLength(1)
156
+ expect(socket.subscribe).toHaveBeenCalledTimes(1)
157
+
158
+ // One incoming frame produces exactly one console entry and one event.
159
+ handlers[0]!({ messages: { log: ['line1'], results: [] } })
160
+ expect(store.console).toHaveLength(1)
161
+ expect(eventSpy).toHaveBeenCalledTimes(1)
162
+ })
163
+
164
+ it('console socket subscription is ref-counted and dropped only after the last subscriber', async () => {
165
+ const { store, socket } = makeStore()
166
+ await store.me()
167
+ const mockDispose = vi.fn()
168
+ ;(socket.subscribe as ReturnType<typeof vi.fn>).mockReturnValue({ dispose: mockDispose })
169
+
170
+ const a = store.subscribe('console')
171
+ const b = store.subscribe('console')
172
+ await new Promise(r => setTimeout(r, 0))
173
+ expect(socket.subscribe).toHaveBeenCalledTimes(1)
174
+
175
+ a.dispose()
176
+ expect(mockDispose).not.toHaveBeenCalled() // second subscriber still active
177
+
178
+ b.dispose()
179
+ expect(mockDispose).toHaveBeenCalledTimes(1)
180
+ })
136
181
  })