dsh-zen-remote 2.3.11 → 2.4.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.
Files changed (249) hide show
  1. package/CHANGELOG.md +45 -78
  2. package/README.md +94 -84
  3. package/README.zh-CN.md +90 -81
  4. package/bin/zen-remote.mjs +5 -0
  5. package/docs/interface.md +6 -6
  6. package/docs/remote-access.en.md +6 -4
  7. package/docs/remote-access.md +6 -4
  8. package/dsh-push.mjs +96 -10
  9. package/lib/adapters/dsh-better-sidebar/index.js +7 -3
  10. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  11. package/lib/admin-routes.js +10 -2
  12. package/lib/admin-routes.js.map +1 -1
  13. package/lib/cli/args.js +251 -0
  14. package/lib/cli/args.js.map +1 -0
  15. package/lib/cli/bootstrap.js +449 -0
  16. package/lib/cli/bootstrap.js.map +1 -0
  17. package/lib/cli/command-setup.js +484 -0
  18. package/lib/cli/command-setup.js.map +1 -0
  19. package/lib/cli/commands-client.js +359 -0
  20. package/lib/cli/commands-client.js.map +1 -0
  21. package/lib/cli/commands-host.js +1108 -0
  22. package/lib/cli/commands-host.js.map +1 -0
  23. package/lib/cli/commands-status.js +279 -0
  24. package/lib/cli/commands-status.js.map +1 -0
  25. package/lib/cli/config-writer.js +334 -0
  26. package/lib/cli/config-writer.js.map +1 -0
  27. package/lib/cli/context.js +10 -0
  28. package/lib/cli/context.js.map +1 -0
  29. package/lib/cli/i18n.js +620 -0
  30. package/lib/cli/i18n.js.map +1 -0
  31. package/lib/cli/ipc-client.js +160 -0
  32. package/lib/cli/ipc-client.js.map +1 -0
  33. package/lib/cli/ipc-server.js +464 -0
  34. package/lib/cli/ipc-server.js.map +1 -0
  35. package/lib/cli/main.js +245 -0
  36. package/lib/cli/main.js.map +1 -0
  37. package/lib/cli/qr-terminal.js +48 -0
  38. package/lib/cli/qr-terminal.js.map +1 -0
  39. package/lib/client-data/qr.js +286 -0
  40. package/lib/client-data/qr.js.map +1 -0
  41. package/lib/client-routes.js +308 -25
  42. package/lib/client-routes.js.map +1 -1
  43. package/lib/client.js +1013 -229
  44. package/lib/config.js +50 -12
  45. package/lib/config.js.map +1 -1
  46. package/lib/debug-report.js +28 -1
  47. package/lib/debug-report.js.map +1 -1
  48. package/lib/fetch-route-intercept.js +334 -26
  49. package/lib/fetch-route-intercept.js.map +1 -1
  50. package/lib/index.js +608 -375
  51. package/lib/index.js.map +1 -1
  52. package/lib/intercept.js +1833 -280
  53. package/lib/intercept.js.map +1 -1
  54. package/lib/lan-gate-server.cjs +36 -10
  55. package/lib/merge-streams.js +103 -11
  56. package/lib/merge-streams.js.map +1 -1
  57. package/lib/plugin-route-intercept.js +93 -63
  58. package/lib/plugin-route-intercept.js.map +1 -1
  59. package/lib/relay-access.js +8 -1
  60. package/lib/relay-access.js.map +1 -1
  61. package/lib/relay-client.js +133 -4
  62. package/lib/relay-client.js.map +1 -1
  63. package/lib/relay-pool.js +275 -0
  64. package/lib/relay-pool.js.map +1 -0
  65. package/lib/relay-server.js +269 -17
  66. package/lib/relay-server.js.map +1 -1
  67. package/lib/restart-fields.js +6 -4
  68. package/lib/restart-fields.js.map +1 -1
  69. package/lib/share-ops.js +68 -0
  70. package/lib/share-ops.js.map +1 -1
  71. package/lib/share-store.js +65 -27
  72. package/lib/share-store.js.map +1 -1
  73. package/lib/state-store.js +400 -0
  74. package/lib/state-store.js.map +1 -0
  75. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
  76. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  77. package/lib/types/admin-routes.d.ts.map +1 -1
  78. package/lib/types/cli/args.d.ts +78 -0
  79. package/lib/types/cli/args.d.ts.map +1 -0
  80. package/lib/types/cli/bootstrap.d.ts +170 -0
  81. package/lib/types/cli/bootstrap.d.ts.map +1 -0
  82. package/lib/types/cli/command-setup.d.ts +72 -0
  83. package/lib/types/cli/command-setup.d.ts.map +1 -0
  84. package/lib/types/cli/commands-client.d.ts +36 -0
  85. package/lib/types/cli/commands-client.d.ts.map +1 -0
  86. package/lib/types/cli/commands-host.d.ts +82 -0
  87. package/lib/types/cli/commands-host.d.ts.map +1 -0
  88. package/lib/types/cli/commands-status.d.ts +46 -0
  89. package/lib/types/cli/commands-status.d.ts.map +1 -0
  90. package/lib/types/cli/config-writer.d.ts +92 -0
  91. package/lib/types/cli/config-writer.d.ts.map +1 -0
  92. package/lib/types/cli/context.d.ts +39 -0
  93. package/lib/types/cli/context.d.ts.map +1 -0
  94. package/lib/types/cli/i18n.d.ts +32 -0
  95. package/lib/types/cli/i18n.d.ts.map +1 -0
  96. package/lib/types/cli/ipc-client.d.ts +83 -0
  97. package/lib/types/cli/ipc-client.d.ts.map +1 -0
  98. package/lib/types/cli/ipc-server.d.ts +113 -0
  99. package/lib/types/cli/ipc-server.d.ts.map +1 -0
  100. package/lib/types/cli/main.d.ts +57 -0
  101. package/lib/types/cli/main.d.ts.map +1 -0
  102. package/lib/types/cli/qr-terminal.d.ts +17 -0
  103. package/lib/types/cli/qr-terminal.d.ts.map +1 -0
  104. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
  105. package/lib/types/client/current-session-report.d.ts +4 -0
  106. package/lib/types/client/current-session-report.d.ts.map +1 -1
  107. package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
  108. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  109. package/lib/types/client/locales.d.ts +47 -3
  110. package/lib/types/client/locales.d.ts.map +1 -1
  111. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  112. package/lib/types/client/settings/settings-css.d.ts +1 -1
  113. package/lib/types/client/settings/settings-css.d.ts.map +1 -1
  114. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  115. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  116. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  117. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
  118. package/lib/types/client/wizard/wizard-css.d.ts +1 -1
  119. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
  120. package/lib/types/client-data/model-group-side.d.ts +23 -2
  121. package/lib/types/client-data/model-group-side.d.ts.map +1 -1
  122. package/lib/types/client-data/remote-status.d.ts +47 -1
  123. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  124. package/lib/types/client-data/settings-form.d.ts +85 -1
  125. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  126. package/lib/types/client-data/wizard.d.ts +56 -1
  127. package/lib/types/client-data/wizard.d.ts.map +1 -1
  128. package/lib/types/client-routes.d.ts +25 -1
  129. package/lib/types/client-routes.d.ts.map +1 -1
  130. package/lib/types/config.d.ts +40 -14
  131. package/lib/types/config.d.ts.map +1 -1
  132. package/lib/types/debug-report.d.ts.map +1 -1
  133. package/lib/types/fetch-route-intercept.d.ts +66 -8
  134. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  135. package/lib/types/index.d.ts +9 -2
  136. package/lib/types/index.d.ts.map +1 -1
  137. package/lib/types/intercept.d.ts +72 -10
  138. package/lib/types/intercept.d.ts.map +1 -1
  139. package/lib/types/merge-streams.d.ts +79 -1
  140. package/lib/types/merge-streams.d.ts.map +1 -1
  141. package/lib/types/plugin-route-intercept.d.ts +25 -9
  142. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  143. package/lib/types/relay-access.d.ts +3 -1
  144. package/lib/types/relay-access.d.ts.map +1 -1
  145. package/lib/types/relay-client.d.ts +23 -2
  146. package/lib/types/relay-client.d.ts.map +1 -1
  147. package/lib/types/relay-pool.d.ts +148 -0
  148. package/lib/types/relay-pool.d.ts.map +1 -0
  149. package/lib/types/relay-server.d.ts +30 -3
  150. package/lib/types/relay-server.d.ts.map +1 -1
  151. package/lib/types/restart-fields.d.ts +5 -5
  152. package/lib/types/restart-fields.d.ts.map +1 -1
  153. package/lib/types/share-ops.d.ts +47 -1
  154. package/lib/types/share-ops.d.ts.map +1 -1
  155. package/lib/types/share-store.d.ts.map +1 -1
  156. package/lib/types/state-store.d.ts +179 -0
  157. package/lib/types/state-store.d.ts.map +1 -0
  158. package/lib/types/upload-limits.d.ts +15 -0
  159. package/lib/types/upload-limits.d.ts.map +1 -1
  160. package/lib/types/virtual-id.d.ts +21 -0
  161. package/lib/types/virtual-id.d.ts.map +1 -1
  162. package/lib/types/wizard/probe.d.ts.map +1 -1
  163. package/lib/types/wizard/proxy-snippets.d.ts +3 -0
  164. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  165. package/lib/types/wizard/routes.d.ts +1 -0
  166. package/lib/types/wizard/routes.d.ts.map +1 -1
  167. package/lib/types/wizard/status.d.ts +9 -0
  168. package/lib/types/wizard/status.d.ts.map +1 -1
  169. package/lib/types/wizard/verify.d.ts +3 -0
  170. package/lib/types/wizard/verify.d.ts.map +1 -1
  171. package/lib/upload-limits.js +15 -0
  172. package/lib/upload-limits.js.map +1 -1
  173. package/lib/virtual-id.js +60 -0
  174. package/lib/virtual-id.js.map +1 -1
  175. package/lib/wizard/probe.js +46 -1
  176. package/lib/wizard/probe.js.map +1 -1
  177. package/lib/wizard/proxy-snippets.js +14 -1
  178. package/lib/wizard/proxy-snippets.js.map +1 -1
  179. package/lib/wizard/routes.js +23 -4
  180. package/lib/wizard/routes.js.map +1 -1
  181. package/lib/wizard/status.js +21 -0
  182. package/lib/wizard/status.js.map +1 -1
  183. package/lib/wizard/verify.js +31 -8
  184. package/lib/wizard/verify.js.map +1 -1
  185. package/package.json +8 -4
  186. package/pwa/icons/badge-96.png +0 -0
  187. package/pwa/icons/badge.svg +1 -0
  188. package/pwa/manifest.json +2 -1
  189. package/pwa/sw.js +3 -1
  190. package/scripts/check-debug-report.mjs +41 -1
  191. package/scripts/check-settings-form.mjs +14 -3
  192. package/scripts/check-settings-view.mjs +247 -0
  193. package/scripts/check-wizard-ui.mjs +37 -0
  194. package/src/adapters/dsh-better-sidebar/index.ts +7 -3
  195. package/src/admin-routes.ts +10 -2
  196. package/src/cli/args.ts +289 -0
  197. package/src/cli/bootstrap.ts +569 -0
  198. package/src/cli/command-setup.ts +540 -0
  199. package/src/cli/commands-client.ts +369 -0
  200. package/src/cli/commands-host.ts +1127 -0
  201. package/src/cli/commands-status.ts +296 -0
  202. package/src/cli/config-writer.ts +354 -0
  203. package/src/cli/context.ts +38 -0
  204. package/src/cli/i18n.ts +645 -0
  205. package/src/cli/ipc-client.ts +216 -0
  206. package/src/cli/ipc-server.ts +531 -0
  207. package/src/cli/main.ts +285 -0
  208. package/src/cli/qr-terminal.ts +49 -0
  209. package/src/client/RemoteStatusIcon.tsx +4 -1
  210. package/src/client/current-session-report.ts +28 -4
  211. package/src/client/effects/model-group-side.ts +19 -5
  212. package/src/client/effects/remote-session.ts +26 -6
  213. package/src/client/locales.ts +96 -6
  214. package/src/client/settings/SettingsSection.tsx +287 -92
  215. package/src/client/settings/settings-css.ts +18 -0
  216. package/src/client/styles/remote-session.css.ts +15 -1
  217. package/src/client/wizard/GatewayWizard.tsx +470 -109
  218. package/src/client/wizard/cards/ProxyCard.tsx +2 -1
  219. package/src/client/wizard/wizard-css.ts +23 -0
  220. package/src/client-data/model-group-side.ts +37 -10
  221. package/src/client-data/remote-status.ts +112 -17
  222. package/src/client-data/settings-form.ts +164 -8
  223. package/src/client-data/wizard.ts +112 -2
  224. package/src/client-routes.ts +334 -26
  225. package/src/config.ts +62 -16
  226. package/src/debug-report.ts +27 -1
  227. package/src/fetch-route-intercept.ts +381 -32
  228. package/src/index.ts +608 -380
  229. package/src/intercept.ts +1866 -286
  230. package/src/merge-streams.ts +155 -10
  231. package/src/plugin-route-intercept.ts +120 -71
  232. package/src/relay-access.ts +14 -3
  233. package/src/relay-client.ts +163 -5
  234. package/src/relay-pool.ts +387 -0
  235. package/src/relay-server.ts +312 -16
  236. package/src/restart-fields.ts +6 -4
  237. package/src/share-ops.ts +82 -1
  238. package/src/share-store.ts +59 -26
  239. package/src/state-store.ts +428 -0
  240. package/src/upload-limits.ts +16 -0
  241. package/src/virtual-id.ts +57 -0
  242. package/src/wizard/probe.ts +54 -2
  243. package/src/wizard/proxy-snippets.ts +17 -2
  244. package/src/wizard/routes.ts +24 -5
  245. package/src/wizard/status.ts +28 -0
  246. package/src/wizard/verify.ts +33 -11
  247. package/AGENTS.md +0 -185
  248. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
  249. package/docs/adr/0002-relay-emulates-websocket.md +0 -8
@@ -142,7 +142,7 @@
142
142
  * `zr~<serverId>~provider/model` fallback string (the pre-fix2 display).
143
143
  */
144
144
 
145
- import { fromVirtual, isVirtual, toVirtual, VIRTUAL_GROUP_NAME_MARK } from './virtual-id.js'
145
+ import { encodeServerIdZeroWidth, fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
146
146
 
147
147
  /** The (serverId, serverName) pair a merger virtualizes with. A re-handshake
148
148
  * with a different server retargets the SAME merger ({@link retarget}) so the
@@ -162,12 +162,26 @@ export interface MergerIdentity {
162
162
  * English forms live in src/client/locales.ts, `remoteGroup*` keys).
163
163
  */
164
164
  export type MergerAnnotation = 'none' | 'offline' | 'revoked' | 'unpaired' | 'mismatch'
165
+ export type MergerLang = 'zh' | 'en'
165
166
 
166
- const ANNOTATION_SUFFIX: Record<Exclude<MergerAnnotation, 'none'>, string> = {
167
- offline: '(离线)',
168
- revoked: '(令牌已吊销)',
169
- unpaired: '(已解除配对)',
170
- mismatch: '(版本有差异)',
167
+ const ANNOTATION_SUFFIX_BY_LANG: Record<MergerLang, Record<Exclude<MergerAnnotation, 'none'>, string>> = {
168
+ zh: {
169
+ offline: '(离线)',
170
+ revoked: '(令牌已吊销)',
171
+ unpaired: '(已解除配对)',
172
+ mismatch: '(版本有差异)',
173
+ },
174
+ en: {
175
+ offline: ' (offline)',
176
+ revoked: ' (token revoked)',
177
+ unpaired: ' (unpaired)',
178
+ mismatch: ' (version mismatch)',
179
+ },
180
+ }
181
+
182
+ function resolveMergerLang(lang: MergerLang | (() => MergerLang) | undefined): MergerLang {
183
+ const value = typeof lang === 'function' ? lang() : lang
184
+ return value === 'en' ? 'en' : 'zh'
171
185
  }
172
186
 
173
187
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -199,12 +213,13 @@ function virtualizeWorkspace(
199
213
  record: Record<string, unknown>,
200
214
  identity: MergerIdentity,
201
215
  annotation: MergerAnnotation,
216
+ lang: MergerLang = 'zh',
202
217
  ): Record<string, unknown> {
203
218
  const virtualize = (id: string): string => toVirtual(identity.serverId, id)
204
219
  const out: Record<string, unknown> = { ...record }
205
220
  if (typeof record.workspaceId === 'string') out.workspaceId = virtualize(record.workspaceId)
206
221
  if (typeof record.title === 'string') {
207
- const suffix = annotation === 'none' ? '' : ANNOTATION_SUFFIX[annotation]
222
+ const suffix = annotation === 'none' ? '' : ANNOTATION_SUFFIX_BY_LANG[lang][annotation]
208
223
  out.title = `${identity.serverName} · ${record.title}${suffix}`
209
224
  }
210
225
  if (Array.isArray(record.sessionIds)) {
@@ -220,6 +235,9 @@ export interface WorkspaceMergerOptions extends MergerIdentity {
220
235
  /** T65: the clock the tombstone hide-grace runs on. Default Date.now;
221
236
  * tests advance an injectable fake to cross the grace. */
222
237
  now?: () => number
238
+ /** Language for status annotation suffixes on group titles (`'zh'` default,
239
+ * `'en'` English). Accepts a getter for live resolution. */
240
+ lang?: MergerLang | (() => MergerLang) | undefined
223
241
  /** PB-25b: the known-real-session inventory report — every time the
224
242
  * session registry changes, the FULL current set of real ids this merger
225
243
  * knows (the sessionHome keys: live ids and tombstones alike, each one the
@@ -234,6 +252,22 @@ export interface WorkspaceMergerOptions extends MergerIdentity {
234
252
  onKnownSessions?: (ids: ReadonlySet<string>) => void
235
253
  }
236
254
 
255
+ /** The lists a workspace merger contributes to the MS-3 multi-server
256
+ * composition: the locally observed truth (identical across every slot fed
257
+ * the same local stream) and this server's virtualized remote contribution
258
+ * (shown workspaces in remote order, archived = remote archived + the T58
259
+ * hidden closed sessions, pinned). Snapshots — the caller may mutate them
260
+ * freely; nothing here hands out a live reference. */
261
+ export interface WorkspaceMergerParts {
262
+ local: { orderIds: string[]; archivedIds: string[]; pinnedIds: string[] }
263
+ remote: {
264
+ orderIds: string[]
265
+ archivedIds: string[]
266
+ pinnedIds: string[]
267
+ workspaces: Record<string, unknown>[]
268
+ }
269
+ }
270
+
237
271
  export interface WorkspaceMerger {
238
272
  readonly serverId: string
239
273
  readonly serverName: string
@@ -287,6 +321,13 @@ export interface WorkspaceMerger {
287
321
  * for a serverId change the remote state must be gone first (onRemoteGone
288
322
  * first); for a rename it may stay. */
289
323
  retarget(identity: MergerIdentity): void
324
+ /** MS-3: the composition inputs the multi-server orchestrator builds the
325
+ * canonical merged frames from — the locally observed lists and this
326
+ * server's virtualized remote contribution, as snapshots. Reading the
327
+ * workspaces runs the same {@link forwardVisible} visibility gate the
328
+ * frame paths use (T56/T58), so the parts can never expose a group the
329
+ * frames would withhold. */
330
+ parts(): WorkspaceMergerParts
290
331
  }
291
332
 
292
333
  /**
@@ -518,7 +559,7 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
518
559
  * session stays in its group so the host's「显示已归档」filter can show it
519
560
  * in place — hiding is purely the archived set. */
520
561
  function forwardWorkspace(record: Record<string, unknown>): Record<string, unknown> {
521
- const out = virtualizeWorkspace(record, identity, annotation)
562
+ const out = virtualizeWorkspace(record, identity, annotation, resolveMergerLang(options.lang))
522
563
  const workspaceId = typeof record.workspaceId === 'string' ? record.workspaceId : undefined
523
564
  const live = stringList(record.sessionIds)
524
565
  if (workspaceId === undefined || !Array.isArray(out.sessionIds)) return out
@@ -627,6 +668,22 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
627
668
  identity = { serverId: next.serverId, serverName: next.serverName }
628
669
  },
629
670
 
671
+ parts(): WorkspaceMergerParts {
672
+ return {
673
+ local: {
674
+ orderIds: [...localOrder],
675
+ archivedIds: [...localArchived],
676
+ pinnedIds: [...localPinned],
677
+ },
678
+ remote: {
679
+ orderIds: remoteIds(),
680
+ archivedIds: [...remoteArchivedVirtual(), ...[...hiddenSessions].map(virtualize)],
681
+ pinnedIds: remotePinnedVirtual(),
682
+ workspaces: virtualWorkspaces(),
683
+ },
684
+ }
685
+ },
686
+
630
687
  onLocal(frame: unknown): unknown[] {
631
688
  if (!isPlainObject(frame)) return [frame]
632
689
  observeLocal(frame)
@@ -1243,7 +1300,9 @@ export function mergeSessionList(localResult: unknown, remoteResult: unknown, se
1243
1300
  * order, RT dsh-client-ui-model-selection lib/client.js:510 — appended
1244
1301
  * groups render last); each group id becomes the virtual group id
1245
1302
  * `zr~<serverId>~<original>` and its name is prefixed
1246
- * `${VIRTUAL_GROUP_NAME_MARK}${serverName} · ${name}` — the same workspace
1303
+ * `${encodeServerIdZeroWidth(serverId)}${serverName} · ${name}` (MS-4: the
1304
+ * WORD JOINER mark plus the serverId as 32 zero-width bits, so the browser
1305
+ * can hide OTHER paired servers' groups too) — the same workspace
1247
1306
  * title discipline the merger uses, PLUS the invisible WORD JOINER (T74):
1248
1307
  * the model menu's groups stopped carrying their id in the markup (0.2.0's
1249
1308
  * MenuGroup renders `aria-labelledby={useId()}` only), so the browser
@@ -1289,7 +1348,7 @@ export function mergeModelCatalogs(localValue: unknown, remoteValue: unknown, id
1289
1348
  ...group,
1290
1349
  id,
1291
1350
  ...(typeof group.name === 'string'
1292
- ? { name: `${VIRTUAL_GROUP_NAME_MARK}${identity.serverName} · ${group.name}` }
1351
+ ? { name: `${encodeServerIdZeroWidth(identity.serverId)}${identity.serverName} · ${group.name}` }
1293
1352
  : {}),
1294
1353
  })
1295
1354
  }
@@ -1401,3 +1460,89 @@ export function sessionSummaryAddedFrames(remoteResult: unknown, serverId: strin
1401
1460
  }
1402
1461
  return frames
1403
1462
  }
1463
+
1464
+ // ---- MS-3: multi-server composition -----------------------------------------
1465
+
1466
+ /** The cumulative LOCAL lists one workspace merger's `local` side holds. */
1467
+ export interface LocalWorkspaceLists {
1468
+ orderIds: string[]
1469
+ archivedIds: string[]
1470
+ pinnedIds: string[]
1471
+ }
1472
+
1473
+ /**
1474
+ * MS-3: fold one LOCAL `workspace/follow` frame into the orchestrator's
1475
+ * cumulative local lists — the baseline replaces all three, `order` /
1476
+ * `archived` / `pinned` each replace their own list, and every other frame
1477
+ * (upsert / remove / junk) observes nothing. The parsing is the merger's own
1478
+ * `observeLocal` reading, extracted so the multi-server orchestrator keeps
1479
+ * the same truth WITHOUT a second shape definition drifting away from it.
1480
+ */
1481
+ export function localWorkspaceLists(frame: unknown, current: LocalWorkspaceLists): LocalWorkspaceLists {
1482
+ if (!isPlainObject(frame)) return current
1483
+ if (frame.type === 'baseline') {
1484
+ const value = isPlainObject(frame.value) ? frame.value : {}
1485
+ return {
1486
+ orderIds: idList(value.items),
1487
+ archivedIds: stringList(value.archivedSessionIds),
1488
+ pinnedIds: stringList(value.pinnedSessionIds),
1489
+ }
1490
+ }
1491
+ if (frame.type === 'order') return { ...current, orderIds: stringList(frame.workspaceIds) }
1492
+ if (frame.type === 'archived') return { ...current, archivedIds: stringList(frame.archivedSessionIds) }
1493
+ if (frame.type === 'pinned') return { ...current, pinnedIds: stringList(frame.pinnedSessionIds) }
1494
+ return current
1495
+ }
1496
+
1497
+ /** One slot's remote contribution for {@link composeMergedWorkspaceFrames}
1498
+ * — the `remote` half of {@link WorkspaceMergerParts}. */
1499
+ export interface RemoteWorkspaceContribution {
1500
+ orderIds: string[]
1501
+ archivedIds: string[]
1502
+ pinnedIds: string[]
1503
+ workspaces: Record<string, unknown>[]
1504
+ }
1505
+
1506
+ /**
1507
+ * MS-3: the canonical merged frames over N servers — local first, then each
1508
+ * server's contribution in the given (pairing) order. The order list is
1509
+ * plain concatenation, the archived set is DEDUPLICATED in that order (the
1510
+ * single-server merger dedupes the same way), and pinned concatenates (the
1511
+ * single-server pinned frame never deduped either). With no remotes the
1512
+ * frames collapse to the local-only form.
1513
+ */
1514
+ export function composeMergedWorkspaceFrames(
1515
+ local: LocalWorkspaceLists,
1516
+ remotes: readonly RemoteWorkspaceContribution[],
1517
+ ): {
1518
+ order: { type: 'order'; workspaceIds: string[] }
1519
+ archived: { type: 'archived'; archivedSessionIds: string[] }
1520
+ pinned: { type: 'pinned'; pinnedSessionIds: string[] }
1521
+ workspaces: Record<string, unknown>[]
1522
+ } {
1523
+ const orderIds = [...local.orderIds]
1524
+ const archived: string[] = []
1525
+ const seenArchived = new Set<string>()
1526
+ const addArchived = (ids: readonly string[]): void => {
1527
+ for (const id of ids) {
1528
+ if (seenArchived.has(id)) continue
1529
+ seenArchived.add(id)
1530
+ archived.push(id)
1531
+ }
1532
+ }
1533
+ addArchived(local.archivedIds)
1534
+ const pinnedIds = [...local.pinnedIds]
1535
+ const workspaces: Record<string, unknown>[] = []
1536
+ for (const remote of remotes) {
1537
+ orderIds.push(...remote.orderIds)
1538
+ addArchived(remote.archivedIds)
1539
+ pinnedIds.push(...remote.pinnedIds)
1540
+ workspaces.push(...remote.workspaces)
1541
+ }
1542
+ return {
1543
+ order: { type: 'order', workspaceIds: orderIds },
1544
+ archived: { type: 'archived', archivedSessionIds: archived },
1545
+ pinned: { type: 'pinned', pinnedSessionIds: pinnedIds },
1546
+ workspaces,
1547
+ }
1548
+ }
@@ -340,15 +340,19 @@ export interface InstallPluginRouteInterceptOptions {
340
340
  webServer: object
341
341
  /** The local declaration registry (the `zenRemote` service's table). */
342
342
  registry: BridgeRegistry
343
- /** The client relay client; `pluginRequest` is the forwarding door. */
344
- relay: RelayClient
345
- /** The CURRENT handshake's server id, read live per call; a virtual id
346
- * minted for another server (or before any handshake) refuses
347
- * `server-changed`. */
348
- getServerId: () => string | undefined
349
- /** The PB-2 bridge verdicts, computed live per call; a plugin that is
350
- * not bridged right now refuses with the status's own reason. */
351
- getStatus: () => BridgeStatus[]
343
+ /** SINGLE-SERVER mode: the client relay client; `pluginRequest` is the
344
+ * forwarding door. Required together with {@link getServerId} and
345
+ * {@link getStatus} unless the MS-4 per-server faces below are given
346
+ * (MS-5's pool wiring). */
347
+ relay?: RelayClient
348
+ /** SINGLE-SERVER mode: the CURRENT handshake's server id, read live per
349
+ * call; a virtual id minted for another server (or before any handshake)
350
+ * refuses `server-changed`. */
351
+ getServerId?: () => string | undefined
352
+ /** SINGLE-SERVER mode: the PB-2 bridge verdicts, computed live per call; a
353
+ * plugin that is not bridged right now refuses with the status's own
354
+ * reason. */
355
+ getStatus?: () => BridgeStatus[]
352
356
  /** The validated adapters (PB-11), as the wiring registered them.
353
357
  * Absent/empty — the pluggable default — means no adapter routes exist
354
358
  * and this interception behaves exactly as before. */
@@ -374,6 +378,18 @@ export interface InstallPluginRouteInterceptOptions {
374
378
  * consumption: nothing here mutates merge state. Absent → the scan is
375
379
  * exactly the request-local PB-22 behavior. */
376
380
  getKnownRealIds?: () => ReadonlySet<string>
381
+ /** MS-4: the per-server faces of the connection pool. A request whose
382
+ * located ids are `zr~<serverId>~…` is validated, forwarded and
383
+ * response-rewritten against THAT server alone: its relay client
384
+ * (`undefined` — a server the pool does not hold — refuses
385
+ * `server-changed`), its bridge verdicts and its known-real-id inventory.
386
+ * Each is optional and falls back on its own to the single-server face
387
+ * above: no `getRelayForServer` → `relay` exactly when the id belongs to
388
+ * `getServerId()`; no `getStatusForServer` → `getStatus()`; no
389
+ * `getKnownRealIdsForServer` → `getKnownRealIds()`. All read live. */
390
+ getRelayForServer?: (serverId: string) => RelayClient | undefined
391
+ getStatusForServer?: (serverId: string) => BridgeStatus[]
392
+ getKnownRealIdsForServer?: (serverId: string) => ReadonlySet<string>
377
393
  /** PB-26a: called with the virtual session ids a response rewrite
378
394
  * PRODUCED — a `rewriteIds.fields` route's wrapped positions, or the ids
379
395
  * an adapter's `client.response` hook passed through `virtualize` (e.g.
@@ -485,11 +501,55 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
485
501
  },
486
502
  }
487
503
  }
488
- const { webServer, registry, relay, getServerId, getStatus, log } = options
504
+ const { webServer, registry, log } = options
489
505
  const adapters = options.adapters ?? []
490
506
  const getCurrentSession = options.getCurrentSession
491
507
  const getKnownRealIds = options.getKnownRealIds
492
508
  const onMintedVirtualIds = options.onMintedVirtualIds
509
+ const { relay, getServerId, getStatus } = options
510
+ const { getRelayForServer, getStatusForServer, getKnownRealIdsForServer } = options
511
+ // Exactly one mode, like installIntercept: the pool faces or the single
512
+ // row client — a wiring that passes neither would refuse every forwarded
513
+ // request as server-changed / not-bridged, so it is refused here instead.
514
+ if (
515
+ (getRelayForServer === undefined && (relay === undefined || getServerId === undefined))
516
+ || (getStatusForServer === undefined && getStatus === undefined)
517
+ ) {
518
+ throw new Error(
519
+ 'installPluginRouteIntercept requires either `relay` + `getServerId` + `getStatus` (single server) or `getRelayForServer` + `getStatusForServer` (pool)',
520
+ )
521
+ }
522
+ // The single-server facts, proven present by the guard above.
523
+ const singleRelay = relay as RelayClient
524
+ const singleGetServerId = getServerId as () => string | undefined
525
+ const singleGetStatus = getStatus as () => BridgeStatus[]
526
+ /** MS-4: the relay client serving one server id, or `undefined` (the
527
+ * `server-changed` refusal). */
528
+ const relayOfServer = (serverId: string): RelayClient | undefined =>
529
+ getRelayForServer !== undefined ? getRelayForServer(serverId) : serverId === singleGetServerId() ? singleRelay : undefined
530
+ const statusesOfServer = (serverId: string): BridgeStatus[] =>
531
+ getStatusForServer !== undefined ? getStatusForServer(serverId) : singleGetStatus()
532
+ const knownRealIdsOfServer = (serverId: string): ReadonlySet<string> | undefined =>
533
+ getKnownRealIdsForServer !== undefined ? getKnownRealIdsForServer(serverId) : getKnownRealIds?.()
534
+ /** MS-4: the one server every located virtual id of a request belongs to,
535
+ * with its relay client — or the refusal reason, judged id by id in hit
536
+ * order (the single-server walls' order). A request naming ids of two
537
+ * servers is refused like a foreign one: it has no single home. */
538
+ const serverOfHits = (hits: readonly AdapterIdHit[]): { serverId: string; relay: RelayClient } | 'bad-query' | 'server-changed' => {
539
+ let located: { serverId: string; relay: RelayClient } | undefined
540
+ for (const idHit of hits) {
541
+ const parts = fromVirtual(idHit.value)
542
+ if (parts === undefined || parts.id === '') return 'bad-query'
543
+ if (located === undefined) {
544
+ const target = relayOfServer(parts.serverId)
545
+ if (target === undefined) return 'server-changed'
546
+ located = { serverId: parts.serverId, relay: target }
547
+ } else if (parts.serverId !== located.serverId) {
548
+ return 'server-changed'
549
+ }
550
+ }
551
+ return located ?? 'bad-query'
552
+ }
493
553
  const target = webServer as { match: (this: unknown, pathname: string) => unknown }
494
554
  const savedMatch = target.match
495
555
 
@@ -661,7 +721,8 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
661
721
  answerUnbridged(res, declared.plugin, 'bad-query')
662
722
  return
663
723
  }
664
- if (parts.serverId !== getServerId()) {
724
+ const targetRelay = relayOfServer(parts.serverId)
725
+ if (targetRelay === undefined) {
665
726
  answerUnbridged(res, declared.plugin, 'server-changed')
666
727
  return
667
728
  }
@@ -687,7 +748,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
687
748
  log?.('plugin request for %s refused locally: body-too-large (%s declared)', declared.path, req.headers['content-length'])
688
749
  return
689
750
  }
690
- const status = getStatus().find((entry) => entry.plugin === declared.plugin)
751
+ const status = statusesOfServer(parts.serverId).find((entry) => entry.plugin === declared.plugin)
691
752
  if (status === undefined || !status.bridged) {
692
753
  answerUnbridged(res, declared.plugin, status?.reason ?? 'not-on-server')
693
754
  return
@@ -757,7 +818,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
757
818
  bodyStream = Readable.toWeb(gate) as ReadableStream<Uint8Array>
758
819
  req.pipe(gate)
759
820
  }
760
- const response = await relay.pluginRequest(
821
+ const response = await targetRelay.pluginRequest(
761
822
  {
762
823
  plugin: declared.plugin,
763
824
  version: declared.version,
@@ -794,7 +855,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
794
855
  // known inventory, read live right here (undefined when the wiring
795
856
  // passed no getter — the scan stays request-local).
796
857
  if (declared.rewriteIds) {
797
- await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, getKnownRealIds?.(), onMintedVirtualIds)
858
+ await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, knownRealIdsOfServer(parts.serverId), onMintedVirtualIds)
798
859
  return
799
860
  }
800
861
  const contentType = response.headers.get('content-type')
@@ -989,7 +1050,6 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
989
1050
  answerUnbridged(res, plugin, 'method-not-bridged')
990
1051
  return
991
1052
  }
992
- const serverId = getServerId()
993
1053
  // PB-22: the request's own real→virtual pairs, collected while the
994
1054
  // wall validates each hit — the READ-ONLY mapping a rewriteIds
995
1055
  // route's answer scan consults (`zr~` carries the real id inside, so
@@ -1001,24 +1061,19 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1001
1061
  // ONE more source beside this mapping — the known-real-session
1002
1062
  // inventory, read live at the answer (never copied here) and
1003
1063
  // consumed read-only; nothing here writes any session table.
1004
- const idMapping = new Map<string, string>()
1005
- let locatedServerId: string | undefined
1006
- for (const idHit of verdict.hits) {
1007
- const parts = fromVirtual(idHit.value)
1008
- if (parts === undefined || parts.id === '') {
1009
- answerUnbridged(res, plugin, 'bad-query')
1010
- return
1011
- }
1012
- if (parts.serverId !== serverId) {
1013
- answerUnbridged(res, plugin, 'server-changed')
1014
- return
1015
- }
1016
- idMapping.set(parts.id, idHit.value)
1017
- locatedServerId = parts.serverId
1064
+ // MS-4: every located id must name ONE server the wiring serves;
1065
+ // that server's relay, verdicts and inventory carry the rest.
1066
+ const home = serverOfHits(verdict.hits)
1067
+ if (home === 'bad-query' || home === 'server-changed') {
1068
+ answerUnbridged(res, plugin, home)
1069
+ return
1018
1070
  }
1071
+ const locatedServerId = home.serverId
1072
+ const idMapping = new Map<string, string>()
1073
+ for (const idHit of verdict.hits) idMapping.set(fromVirtual(idHit.value)!.id, idHit.value)
1019
1074
  // The bridge verdicts judged from the SAME live table the query
1020
1075
  // half reads.
1021
- const status = getStatus().find((entry) => entry.plugin === plugin)
1076
+ const status = statusesOfServer(locatedServerId).find((entry) => entry.plugin === plugin)
1022
1077
  if (status === undefined || !status.bridged) {
1023
1078
  answerUnbridged(res, plugin, status?.reason ?? 'not-on-server')
1024
1079
  return
@@ -1078,7 +1133,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1078
1133
  req.pipe(gate)
1079
1134
  if (Number.isFinite(declaredLength) && declaredLength > 0) contentLength = declaredLength
1080
1135
  }
1081
- const response = await relay.pluginRequest(
1136
+ const response = await home.relay.pluginRequest(
1082
1137
  {
1083
1138
  plugin,
1084
1139
  version: declared.version,
@@ -1104,7 +1159,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1104
1159
  return
1105
1160
  }
1106
1161
  if (declared.rewriteIds) {
1107
- await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId!, getKnownRealIds?.(), onMintedVirtualIds)
1162
+ await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId, knownRealIdsOfServer(locatedServerId), onMintedVirtualIds)
1108
1163
  return
1109
1164
  }
1110
1165
  const contentType = response.headers.get('content-type')
@@ -1300,23 +1355,18 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1300
1355
  answerUnbridged(res, plugin, 'method-not-bridged')
1301
1356
  return
1302
1357
  }
1303
- const serverId = getServerId()
1304
- for (const idHit of verdict.hits) {
1305
- const parts = fromVirtual(idHit.value)
1306
- if (parts === undefined || parts.id === '') {
1307
- answerUnbridged(res, plugin, 'bad-query')
1308
- return
1309
- }
1310
- if (parts.serverId !== serverId) {
1311
- answerUnbridged(res, plugin, 'server-changed')
1312
- return
1313
- }
1358
+ // MS-4: one home server for every located id.
1359
+ const home = serverOfHits(verdict.hits)
1360
+ if (home === 'bad-query' || home === 'server-changed') {
1361
+ answerUnbridged(res, plugin, home)
1362
+ return
1314
1363
  }
1364
+ const serverId = home.serverId
1315
1365
  // The bridge verdicts (version skew, consent, offline…) judged from
1316
1366
  // the SAME live table the cooperative surrogate reads — the
1317
1367
  // adapter's registry entry makes the plugin indistinguishable from
1318
1368
  // a cooperative one here.
1319
- const status = getStatus().find((entry) => entry.plugin === plugin)
1369
+ const status = statusesOfServer(serverId).find((entry) => entry.plugin === plugin)
1320
1370
  if (status === undefined || !status.bridged) {
1321
1371
  answerUnbridged(res, plugin, status?.reason ?? 'not-on-server')
1322
1372
  return
@@ -1375,7 +1425,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1375
1425
  bodyStream = Readable.toWeb(gate) as ReadableStream<Uint8Array>
1376
1426
  req.pipe(gate)
1377
1427
  }
1378
- const response = await relay.pluginRequest(
1428
+ const response = await home.relay.pluginRequest(
1379
1429
  {
1380
1430
  plugin,
1381
1431
  version: adapter.declaration.version,
@@ -1480,7 +1530,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1480
1530
  pathname,
1481
1531
  body: JSON.parse(text),
1482
1532
  virtualize: (id: string) => {
1483
- const virtualId = toVirtual(serverId ?? '', id)
1533
+ const virtualId = toVirtual(serverId, id)
1484
1534
  if (isVirtual(virtualId)) minted.add(virtualId)
1485
1535
  return virtualId
1486
1536
  },
@@ -1744,6 +1794,9 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1744
1794
 
1745
1795
  // ---- the remote pipe: the PB-10 tunnel, frame by frame -------------
1746
1796
  let tunnelHandle: { tunnel: string; frames: AsyncIterable<Uint8Array> } | undefined
1797
+ // MS-4: the server the tunnel was opened on — set together with
1798
+ // tunnelHandle, the relay every later send and the close travel.
1799
+ let tunnelServer: { serverId: string; relay: RelayClient } | undefined
1747
1800
  // The connection-wide real→virtual mapping the rewriteIds deep scan
1748
1801
  // consults: EVERY validated remote frame's ids merge in (a watch list
1749
1802
  // that grows mid-connection makes the later answers rewritable too —
@@ -1764,7 +1817,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1764
1817
  * never a silent broken flag: that would leak `tunnelsCurrent` and
1765
1818
  * leave the browser hanging on a dead tunnel. */
1766
1819
  const sendTextUpstream = async (frame: Rfc6455Frame, parsedJson: unknown, hits: AdapterIdHit[]): Promise<void> => {
1767
- if (upstreamBroken || tunnelHandle === undefined) return
1820
+ if (upstreamBroken || tunnelHandle === undefined || tunnelServer === undefined) return
1768
1821
  const rewrite = hits.length > 0
1769
1822
  ? rewriteAdapterRequest(declared.view, declared.path, new URLSearchParams(), parsedJson, hits, (virtualId) => fromVirtual(virtualId)!.id)
1770
1823
  : undefined
@@ -1772,7 +1825,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1772
1825
  const seq = upstreamSeq
1773
1826
  upstreamSeq += 1
1774
1827
  try {
1775
- await relay.pluginTunnelSend(tunnelHandle.tunnel, seq, encodeRfc6455Frame(0x1, payload, true, frame.fin))
1828
+ await tunnelServer.relay.pluginTunnelSend(tunnelHandle.tunnel, seq, encodeRfc6455Frame(0x1, payload, true, frame.fin))
1776
1829
  } catch {
1777
1830
  killExchange()
1778
1831
  }
@@ -1819,7 +1872,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1819
1872
  })), 'utf8')
1820
1873
  hooked = true
1821
1874
  } else if (declared.rewriteIds) {
1822
- const scanned = deepRewriteIds(parsedJson, idMapping, getKnownRealIds?.(), serverId)
1875
+ const scanned = deepRewriteIds(parsedJson, idMapping, knownRealIdsOfServer(serverId), serverId)
1823
1876
  if (declared.rewriteFields !== undefined) rewriteIdsAtFields(scanned, declared.rewriteFields, serverId)
1824
1877
  outPayload = Buffer.from(JSON.stringify(scanned), 'utf8')
1825
1878
  hooked = true
@@ -1917,24 +1970,18 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1917
1970
  }
1918
1971
  return
1919
1972
  }
1920
- const serverId = getServerId()
1921
- if (serverId === undefined) {
1973
+ // MS-4: the frame's ids name one served server, and once the tunnel
1974
+ // is open every later frame must name the tunnel's own server.
1975
+ const home = serverOfHits(verdict.hits)
1976
+ if (home === 'bad-query' || home === 'server-changed' || (tunnelServer !== undefined && home.serverId !== tunnelServer.serverId)) {
1922
1977
  refuseAndDie('server-changed')
1923
1978
  return
1924
1979
  }
1925
- const mapping = new Map<string, string>()
1926
- for (const idHit of verdict.hits) {
1927
- const parts = fromVirtual(idHit.value)
1928
- if (parts === undefined || parts.id === '' || parts.serverId !== serverId) {
1929
- refuseAndDie('server-changed')
1930
- return
1931
- }
1932
- mapping.set(parts.id, idHit.value)
1933
- }
1980
+ const serverId = home.serverId
1934
1981
  // This frame's ids join the connection-wide mapping (see idMapping).
1935
- for (const [key, value] of mapping) idMapping.set(key, value)
1982
+ for (const idHit of verdict.hits) idMapping.set(fromVirtual(idHit.value)!.id, idHit.value)
1936
1983
  if (tunnelHandle === undefined) {
1937
- const status = getStatus().find((entry) => entry.plugin === plugin)
1984
+ const status = statusesOfServer(serverId).find((entry) => entry.plugin === plugin)
1938
1985
  if (status === undefined || !status.bridged) {
1939
1986
  refuseAndDie(status?.reason ?? 'not-on-server')
1940
1987
  return
@@ -1944,7 +1991,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1944
1991
  let opened: { tunnel: string; frames: AsyncIterable<Uint8Array> }
1945
1992
  try {
1946
1993
  log?.('opening tunnel for %s', declared.path)
1947
- opened = await relay.pluginTunnelOpen(
1994
+ opened = await home.relay.pluginTunnelOpen(
1948
1995
  {
1949
1996
  plugin,
1950
1997
  version: declared.version,
@@ -1962,6 +2009,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1962
2009
  }
1963
2010
  tunnelsCurrent += 1
1964
2011
  tunnelsOpened += 1
2012
+ tunnelServer = home
1965
2013
  tunnelHandle = opened
1966
2014
  log?.('tunnel open for %s: %s', declared.path, opened.tunnel)
1967
2015
  teardownTunnel = (): void => {
@@ -1969,7 +2017,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1969
2017
  upstreamBroken = true
1970
2018
  tunnelsCurrent -= 1
1971
2019
  tunnelsClosed += 1
1972
- void relay.pluginTunnelClose(opened.tunnel).catch(() => {})
2020
+ void home.relay.pluginTunnelClose(opened.tunnel).catch(() => {})
1973
2021
  }
1974
2022
  socket.on('close', teardownTunnel)
1975
2023
  socket.on('error', teardownTunnel)
@@ -1988,11 +2036,11 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
1988
2036
  localSocket.push(frame.raw)
1989
2037
  return
1990
2038
  }
1991
- if (tunnelHandle !== undefined && !upstreamBroken) {
2039
+ if (tunnelHandle !== undefined && tunnelServer !== undefined && !upstreamBroken) {
1992
2040
  const seq = upstreamSeq
1993
2041
  upstreamSeq += 1
1994
2042
  try {
1995
- await relay.pluginTunnelSend(tunnelHandle.tunnel, seq, frame.raw)
2043
+ await tunnelServer.relay.pluginTunnelSend(tunnelHandle.tunnel, seq, frame.raw)
1996
2044
  } catch {
1997
2045
  // The send died: tear the exchange down COMPLETELY — the
1998
2046
  // counter, the close, the browser socket (a bare broken flag
@@ -2100,7 +2148,8 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
2100
2148
  answerUnbridgedSocket(socket, declared.plugin, 'bad-query')
2101
2149
  return
2102
2150
  }
2103
- if (parts.serverId !== getServerId()) {
2151
+ const tunnelRelay = relayOfServer(parts.serverId)
2152
+ if (tunnelRelay === undefined) {
2104
2153
  answerUnbridgedSocket(socket, declared.plugin, 'server-changed')
2105
2154
  return
2106
2155
  }
@@ -2108,7 +2157,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
2108
2157
  // the tunnel opens only for a plugin bridged RIGHT NOW — an absent
2109
2158
  // or unbridged status refuses with its own reason, and the local
2110
2159
  // plugin handler is never reached.
2111
- const bridgeStatus = getStatus().find((entry) => entry.plugin === declared.plugin)
2160
+ const bridgeStatus = statusesOfServer(parts.serverId).find((entry) => entry.plugin === declared.plugin)
2112
2161
  if (bridgeStatus === undefined || !bridgeStatus.bridged) {
2113
2162
  answerUnbridgedSocket(socket, declared.plugin, bridgeStatus?.reason ?? 'not-on-server')
2114
2163
  return
@@ -2129,7 +2178,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
2129
2178
  let tunnel: { tunnel: string; frames: AsyncIterable<Uint8Array> }
2130
2179
  try {
2131
2180
  log?.('opening tunnel for %s', declared.path)
2132
- tunnel = await relay.pluginTunnelOpen(
2181
+ tunnel = await tunnelRelay.pluginTunnelOpen(
2133
2182
  {
2134
2183
  plugin: declared.plugin,
2135
2184
  version: declared.version,
@@ -2207,7 +2256,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
2207
2256
  pending = pending.then(async () => {
2208
2257
  if (upstreamBroken) return
2209
2258
  try {
2210
- await relay.pluginTunnelSend(tunnel.tunnel, seq, bytes, controller.signal)
2259
+ await tunnelRelay.pluginTunnelSend(tunnel.tunnel, seq, bytes, controller.signal)
2211
2260
  } catch {
2212
2261
  upstreamBroken = true
2213
2262
  }
@@ -2218,7 +2267,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
2218
2267
  upstreamBroken = true
2219
2268
  tunnelsCurrent -= 1
2220
2269
  tunnelsClosed += 1
2221
- void relay.pluginTunnelClose(tunnel.tunnel).catch(() => {})
2270
+ void tunnelRelay.pluginTunnelClose(tunnel.tunnel).catch(() => {})
2222
2271
  }
2223
2272
  socket.on('close', teardownTunnel)
2224
2273
  socket.on('error', teardownTunnel)