@linxin666/dsh-remote-web-ui 0.3.5 → 0.3.9

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 (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17621
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -141
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -553
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -56
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -773
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -231
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -677
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -263
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1295
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
@@ -1,84 +0,0 @@
1
- // @vitest-environment jsdom
2
- /** mobile-theme: light default, persisted dark toggle, document wiring. */
3
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
- import {
5
- getMobileTheme, setMobileTheme, subscribeMobileTheme, toggleMobileTheme,
6
- } from './mobile-theme.ts'
7
-
8
- const STORAGE_KEY = 'dsh.remote.theme'
9
-
10
- /** jsdom in this setup ships a bare localStorage object; install a real fake. */
11
- function makeStorage(): Storage {
12
- const map = new Map<string, string>()
13
- return {
14
- getItem: (key: string) => map.get(key) ?? null,
15
- setItem: (key: string, value: string) => { map.set(key, value) },
16
- removeItem: (key: string) => { map.delete(key) },
17
- clear: () => { map.clear() },
18
- key: (index: number) => [...map.keys()][index] ?? null,
19
- get length() { return map.size },
20
- } as Storage
21
- }
22
-
23
- let storage: Storage
24
-
25
- beforeEach(() => {
26
- storage = makeStorage()
27
- vi.stubGlobal('localStorage', storage)
28
- setMobileTheme('light')
29
- document.documentElement.removeAttribute('data-theme')
30
- })
31
-
32
- afterEach(() => {
33
- vi.unstubAllGlobals()
34
- })
35
-
36
- describe('mobile-theme', () => {
37
- it('defaults to light with nothing stored', () => {
38
- expect(getMobileTheme()).toBe('light')
39
- })
40
-
41
- it('toggles to dark, persists it, and wires the document', () => {
42
- expect(toggleMobileTheme()).toBe('dark')
43
- expect(getMobileTheme()).toBe('dark')
44
- expect(document.documentElement.dataset.theme).toBe('dark')
45
- expect(storage.getItem(STORAGE_KEY)).toBe('dark')
46
- })
47
-
48
- it('toggles back to light', () => {
49
- toggleMobileTheme()
50
- expect(toggleMobileTheme()).toBe('light')
51
- expect(document.documentElement.dataset.theme).toBe('light')
52
- expect(storage.getItem(STORAGE_KEY)).toBe('light')
53
- })
54
-
55
- it('updates the browser chrome color along with the theme', () => {
56
- const meta = document.createElement('meta')
57
- meta.name = 'theme-color'
58
- document.head.appendChild(meta)
59
- setMobileTheme('dark')
60
- expect(meta.content).toBe('#111418')
61
- setMobileTheme('light')
62
- expect(meta.content).toBe('#f3f5f9')
63
- meta.remove()
64
- })
65
-
66
- it('boot reads the stored theme and initMobileTheme applies it', async () => {
67
- storage.setItem(STORAGE_KEY, 'dark')
68
- vi.resetModules()
69
- const fresh = await import('./mobile-theme.ts')
70
- expect(fresh.getMobileTheme()).toBe('dark')
71
- fresh.initMobileTheme()
72
- expect(document.documentElement.dataset.theme).toBe('dark')
73
- })
74
-
75
- it('notifies subscribers on change', () => {
76
- const seen: string[] = []
77
- const unsubscribe = subscribeMobileTheme(() => { seen.push(getMobileTheme()) })
78
- setMobileTheme('dark')
79
- setMobileTheme('light')
80
- unsubscribe()
81
- setMobileTheme('dark')
82
- expect(seen).toEqual(['dark', 'light'])
83
- })
84
- })
@@ -1,76 +0,0 @@
1
- /**
2
- * Mobile-surface theme: light by default, dark via an explicit persisted
3
- * toggle. The standalone page boots without the shell, so there is no theme
4
- * system to inherit — the choice is stored in localStorage and applied as a
5
- * `data-theme` attribute on <html>; the stylesheet defines both palettes
6
- * under `:root` (light) / `:root[data-theme='dark']` (dark).
7
- *
8
- * A tiny module store (subscribe/get) keeps the toggle button and the boot
9
- * path in sync without threading props through the three view levels.
10
- */
11
-
12
- export type MobileTheme = 'light' | 'dark'
13
-
14
- const STORAGE_KEY = 'dsh.remote.theme'
15
- const THEME_COLOR_META_NAME = 'theme-color'
16
- const LIGHT_THEME_COLOR = '#f3f5f9'
17
- const DARK_THEME_COLOR = '#111418'
18
-
19
- let current: MobileTheme = readStored() ?? 'light'
20
- const listeners = new Set<() => void>()
21
-
22
- function readStored(): MobileTheme | undefined {
23
- try {
24
- const value = localStorage.getItem(STORAGE_KEY)
25
- return value === 'dark' || value === 'light' ? value : undefined
26
- } catch {
27
- return undefined
28
- }
29
- }
30
-
31
- function persist(theme: MobileTheme): void {
32
- try {
33
- localStorage.setItem(STORAGE_KEY, theme)
34
- } catch {
35
- // Private-mode storage failures are non-fatal; the session keeps the theme.
36
- }
37
- }
38
-
39
- /** Apply the theme to the document: data-theme attribute + browser chrome color. */
40
- function applyToDocument(theme: MobileTheme): void {
41
- if (typeof document === 'undefined') return
42
- document.documentElement.dataset.theme = theme
43
- const meta = document.querySelector<HTMLMetaElement>(`meta[name="${THEME_COLOR_META_NAME}"]`)
44
- if (meta !== null) meta.content = theme === 'dark' ? DARK_THEME_COLOR : LIGHT_THEME_COLOR
45
- }
46
-
47
- /** Current theme (light unless the user explicitly toggled to dark). */
48
- export function getMobileTheme(): MobileTheme {
49
- return current
50
- }
51
-
52
- /** Subscribe to theme changes; returns the unsubscribe function. */
53
- export function subscribeMobileTheme(listener: () => void): () => void {
54
- listeners.add(listener)
55
- return () => { listeners.delete(listener) }
56
- }
57
-
58
- /** Set the theme explicitly (persisted + applied to the document). */
59
- export function setMobileTheme(theme: MobileTheme): void {
60
- if (theme === current) return
61
- current = theme
62
- persist(theme)
63
- applyToDocument(theme)
64
- for (const listener of [...listeners]) listener()
65
- }
66
-
67
- /** Flip light/dark and return the new theme. */
68
- export function toggleMobileTheme(): MobileTheme {
69
- setMobileTheme(current === 'light' ? 'dark' : 'light')
70
- return current
71
- }
72
-
73
- /** Apply the persisted (or default) theme once at boot, before first paint. */
74
- export function initMobileTheme(): void {
75
- applyToDocument(current)
76
- }
@@ -1,314 +0,0 @@
1
- /** mux: live-event client, SSE delivery + stall-driven polling fallback. */
2
- import { beforeEach, describe, expect, it, vi } from 'vitest'
3
- import { MuxClient, type EventSourceLike } from './mux.ts'
4
- import type { HistoryPage } from './api.ts'
5
-
6
- /** A recorded fake EventSource (delivery driven by the test). */
7
- interface FakeSource extends EventSourceLike {
8
- url: string
9
- closed: boolean
10
- close: () => void
11
- }
12
-
13
- /** Create an EventSource factory that records every opened source. */
14
- function makeSources(): { factory: (url: string) => EventSourceLike; sources: FakeSource[] } {
15
- const sources: FakeSource[] = []
16
- const factory = (url: string): EventSourceLike => {
17
- const source: FakeSource = {
18
- url,
19
- onmessage: null,
20
- onerror: null,
21
- closed: false,
22
- close: () => { source.closed = true },
23
- }
24
- sources.push(source)
25
- return source
26
- }
27
- return { factory, sources }
28
- }
29
-
30
- /** One history page whose events carry sequential ids. */
31
- function pageOf(seqs: readonly number[]): HistoryPage {
32
- return {
33
- hasMore: false,
34
- events: seqs.map(seq => ({
35
- event: { type: 'user/message', seq, time: seq * 1_000, data: { text: String(seq) } },
36
- })),
37
- } as unknown as HistoryPage
38
- }
39
-
40
- /** A server-request envelope carrying one mux frame (the SSE wire shape). */
41
- function envelopeWith(payload: unknown): string {
42
- return JSON.stringify({ type: 'server-request', rpcId: 'r1', method: 'events.mux', payload })
43
- }
44
-
45
- /** Options common to every test: tight clocks, injected data source. */
46
- function baseOptions(pollLatest: (sessionId: string) => Promise<HistoryPage>, factory: (url: string) => EventSourceLike) {
47
- return { sourceFactory: factory, pollLatest, stallThresholdMs: 800, pollIntervalMs: 400 }
48
- }
49
-
50
- describe('MuxClient polling fallback', () => {
51
- beforeEach(() => {
52
- vi.useFakeTimers()
53
- })
54
-
55
- it('does not poll while the SSE channel is fresh', async () => {
56
- const { factory } = makeSources()
57
- const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
58
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
59
- const frames: unknown[] = []
60
- client.onFrame(frame => { frames.push(frame) })
61
- client.start()
62
- client.observe('s1')
63
- await vi.advanceTimersByTimeAsync(400) // well under the stall threshold
64
- expect(pollLatest).not.toHaveBeenCalled()
65
- expect(frames).toHaveLength(0)
66
- client.stop()
67
- })
68
-
69
- it('starts polling after silence and emits appended events as session/event frames', async () => {
70
- const { factory } = makeSources()
71
- const pages = [pageOf([0]), pageOf([0, 1])]
72
- const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
73
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
74
- const frames: Array<{ type: string; sessionId: string; event?: { seq: number } }> = []
75
- client.onFrame(frame => { frames.push(frame as never) })
76
- client.start()
77
- client.observe('s1')
78
-
79
- // Nothing live within a poll interval until the stall window passes.
80
- await vi.advanceTimersByTimeAsync(400)
81
- expect(pollLatest).not.toHaveBeenCalled()
82
-
83
- // The single tick (400 ms) crosses the 800 ms stall threshold at 1200 ms:
84
- // the same tick then runs the first poll and emits seq 0.
85
- await vi.advanceTimersByTimeAsync(800) // 1200ms total -> first stall crossing tick
86
- expect(pollLatest).toHaveBeenCalledWith('s1')
87
- expect(frames).toHaveLength(1)
88
- expect(frames[0]).toMatchObject({ type: 'session/event', sessionId: 's1' })
89
- expect(frames[0]?.event).toMatchObject({ seq: 0 })
90
-
91
- // The next poll emits only the appended event (seq 1), not seq 0 again.
92
- await vi.advanceTimersByTimeAsync(400)
93
- expect(frames).toHaveLength(2)
94
- expect(frames[1]?.event).toMatchObject({ seq: 1 })
95
- client.stop()
96
- })
97
-
98
- it('sorts an out-of-order history page before advancing the watermark', async () => {
99
- const { factory } = makeSources()
100
- const pollLatest = vi.fn(async (_sessionId: string) => pageOf([2, 1, 3]))
101
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
102
- const seqs: number[] = []
103
- client.onFrame((frame) => {
104
- if (frame.type === 'session/event' && typeof frame.event.seq === 'number') seqs.push(frame.event.seq)
105
- })
106
- client.start()
107
- client.observe('s1')
108
-
109
- await vi.advanceTimersByTimeAsync(1200)
110
- expect(seqs).toEqual([1, 2, 3])
111
- client.stop()
112
- })
113
-
114
- it('keeps the watermark so a repeated page never re-emits old events', async () => {
115
- const { factory } = makeSources()
116
- // Two calls return the same page: the second must emit nothing.
117
- const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0, 1, 2]))
118
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
119
- const frames: Array<{ type: string; event?: { seq: number } }> = []
120
- client.onFrame(frame => { frames.push(frame as never) })
121
- client.start()
122
- client.observe('s1')
123
-
124
- await vi.advanceTimersByTimeAsync(1200) // first poll -> 3 events
125
- expect(frames).toHaveLength(3)
126
-
127
- await vi.advanceTimersByTimeAsync(400) // second poll -> same page, nothing new
128
- expect(frames).toHaveLength(3)
129
- client.stop()
130
- })
131
-
132
- it('stops polling when observe is cleared and keeps it stopped', async () => {
133
- const { factory } = makeSources()
134
- const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
135
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
136
- client.start()
137
- client.observe('s1')
138
-
139
- await vi.advanceTimersByTimeAsync(1200)
140
- expect(pollLatest).toHaveBeenCalled()
141
-
142
- client.observe(undefined)
143
- const callsAfterClear = pollLatest.mock.calls.length
144
- await vi.advanceTimersByTimeAsync(2000)
145
- expect(pollLatest.mock.calls.length).toBe(callsAfterClear)
146
- client.stop()
147
- })
148
-
149
- it('stops polling on stop(), closing any live source', async () => {
150
- const { factory, sources } = makeSources()
151
- const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
152
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
153
- client.start()
154
- client.observe('s1')
155
-
156
- await vi.advanceTimersByTimeAsync(1200)
157
- expect(pollLatest).toHaveBeenCalled()
158
-
159
- client.stop()
160
- const callsAfterStop = pollLatest.mock.calls.length
161
- await vi.advanceTimersByTimeAsync(2000)
162
- expect(pollLatest.mock.calls.length).toBe(callsAfterStop)
163
- expect(sources[0]?.closed).toBe(true)
164
- })
165
-
166
- it('returns to SSE when a frame arrives, dropping the fallback poller', async () => {
167
- const { factory, sources } = makeSources()
168
- const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
169
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
170
- const frames: unknown[] = []
171
- client.onFrame(frame => { frames.push(frame) })
172
- client.start()
173
- client.observe('s1')
174
-
175
- // Stall into polling.
176
- await vi.advanceTimersByTimeAsync(1200)
177
- expect(pollLatest).toHaveBeenCalledTimes(1)
178
-
179
- // A live mux frame proves SSE delivers again -> fallback stops.
180
- sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) })
181
-
182
- await vi.advanceTimersByTimeAsync(2000)
183
- expect(pollLatest.mock.calls.length).toBe(1) // polling stopped after the live frame
184
- const live = frames.filter(frame => (frame as { type?: string })?.type === 'session/subscribed')
185
- expect(live).toHaveLength(1)
186
- expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' })
187
- client.stop()
188
- })
189
-
190
- it('recovers when a previously-live SSE stream becomes silently stalled', async () => {
191
- const { factory, sources } = makeSources()
192
- const pollLatest = vi.fn(async (_sessionId: string) => pageOf([5]))
193
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
194
- const frames: Array<{ type: string; event?: { seq: number } }> = []
195
- client.onFrame(frame => { frames.push(frame as never) })
196
- client.start()
197
- client.observe('s1')
198
-
199
- sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) })
200
- await vi.advanceTimersByTimeAsync(2400)
201
- expect(pollLatest).not.toHaveBeenCalled()
202
-
203
- // A once-live stream gets three stall windows; the next scheduler tick
204
- // crosses that boundary and starts the ordinary-HTTP recovery path.
205
- await vi.advanceTimersByTimeAsync(400)
206
- expect(pollLatest).toHaveBeenCalledTimes(1)
207
- expect(frames.at(-1)?.event).toMatchObject({ seq: 5 })
208
- client.stop()
209
- })
210
-
211
- it('backs empty polls off and resets to the base cadence after progress', async () => {
212
- const { factory } = makeSources()
213
- const pages = [pageOf([]), pageOf([1]), pageOf([1, 2])]
214
- const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
215
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
216
- client.start()
217
- client.observe('s1')
218
-
219
- await vi.advanceTimersByTimeAsync(1200)
220
- expect(pollLatest).toHaveBeenCalledTimes(1)
221
- await vi.advanceTimersByTimeAsync(400)
222
- expect(pollLatest).toHaveBeenCalledTimes(1)
223
- await vi.advanceTimersByTimeAsync(400)
224
- expect(pollLatest).toHaveBeenCalledTimes(2)
225
-
226
- // The productive second poll resets the next delay from 800 ms to 400 ms.
227
- await vi.advanceTimersByTimeAsync(400)
228
- expect(pollLatest).toHaveBeenCalledTimes(3)
229
- await vi.advanceTimersByTimeAsync(400)
230
- expect(pollLatest).toHaveBeenCalledTimes(4)
231
- await vi.advanceTimersByTimeAsync(400)
232
- expect(pollLatest).toHaveBeenCalledTimes(4)
233
- await vi.advanceTimersByTimeAsync(400)
234
- expect(pollLatest).toHaveBeenCalledTimes(5)
235
- client.stop()
236
- })
237
-
238
- it('drives the whole lifecycle on a single scheduler tick (one interval)', async () => {
239
- const { factory } = makeSources()
240
- const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
241
- try {
242
- const pages = [pageOf([0]), pageOf([0, 1])]
243
- const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
244
- const client = new MuxClient('/m/api/events.mux', {
245
- sourceFactory: factory,
246
- pollLatest,
247
- stallThresholdMs: 1500,
248
- pollIntervalMs: 2000,
249
- })
250
- client.start()
251
- client.observe('s1')
252
-
253
- // Exactly one interval is ever created: the single tick scheduler.
254
- expect(setIntervalSpy).toHaveBeenCalledTimes(1)
255
-
256
- // The tick fires below the stall threshold without polling.
257
- await vi.advanceTimersByTimeAsync(1000)
258
- expect(pollLatest).not.toHaveBeenCalled()
259
-
260
- // The same interval arms the fallback once silence passes the threshold
261
- // (tick at 2000 crosses 1500) and runs the first poll immediately.
262
- await vi.advanceTimersByTimeAsync(1100) // 2100ms total
263
- expect(pollLatest).toHaveBeenCalledTimes(1)
264
- expect(setIntervalSpy).toHaveBeenCalledTimes(1) // still one timer
265
-
266
- // Subsequent polls ride the same tick at the poll cadence (2000 ms).
267
- await vi.advanceTimersByTimeAsync(2000)
268
- expect(pollLatest).toHaveBeenCalledTimes(2)
269
- client.stop()
270
- } finally {
271
- setIntervalSpy.mockRestore()
272
- }
273
- })
274
-
275
- it('paces polls on the tick only after the stall phase ends', async () => {
276
- const { factory } = makeSources()
277
- const pages = [pageOf([0]), pageOf([0, 1]), pageOf([0, 1, 2]), pageOf([0, 1, 2, 3])]
278
- const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
279
- const client = new MuxClient('/m/api/events.mux', {
280
- sourceFactory: factory,
281
- pollLatest,
282
- stallThresholdMs: 800,
283
- pollIntervalMs: 400,
284
- })
285
- client.start()
286
- client.observe('s1')
287
-
288
- // At 1200 ms the stall threshold is crossed on this single 400 ms tick.
289
- await vi.advanceTimersByTimeAsync(1200)
290
- expect(pollLatest).toHaveBeenCalledTimes(1)
291
-
292
- // Each subsequent 400 ms tick is a poll, matching the poll cadence.
293
- await vi.advanceTimersByTimeAsync(400)
294
- await vi.advanceTimersByTimeAsync(400)
295
- await vi.advanceTimersByTimeAsync(400)
296
- expect(pollLatest).toHaveBeenCalledTimes(4)
297
- client.stop()
298
- })
299
-
300
- it('observe() already in the stall window starts the single-tick poller immediately', async () => {
301
- const { factory } = makeSources()
302
- const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
303
- const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
304
- client.start()
305
- // Advance well past the stall threshold while nothing is observed.
306
- await vi.advanceTimersByTimeAsync(2000)
307
- expect(pollLatest).not.toHaveBeenCalled()
308
-
309
- // Observing a session now is already in the stall window: poll right away.
310
- client.observe('s1')
311
- expect(pollLatest).toHaveBeenCalledTimes(1)
312
- client.stop()
313
- })
314
- })