@owlmeans/client-socket 0.1.18-rc.4 → 0.1.18-rc.40

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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@owlmeans/client-socket",
3
- "version": "0.1.18-rc.4",
3
+ "version": "0.1.18-rc.40",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsc -b",
8
8
  "dev": "sleep 132 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
9
- "watch": "tsc -b -w --preserveWatchOutput --pretty"
9
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
10
+ "test": "bun test ./tests"
10
11
  },
11
12
  "main": "build/index.js",
12
13
  "module": "build/index.js",
@@ -25,19 +26,21 @@
25
26
  },
26
27
  "devDependencies": {
27
28
  "@owlmeans/dep-config": "workspace:*",
29
+ "@types/bun": "^1.4.0",
28
30
  "@types/react": "^19.2.17",
29
31
  "nodemon": "^3.1.14",
30
32
  "typescript": "^7.0.2"
31
33
  },
32
34
  "dependencies": {
33
- "@owlmeans/auth": "^0.1.18-rc.4",
34
- "@owlmeans/basic-envelope": "^0.1.18-rc.4",
35
- "@owlmeans/client": "^0.1.18-rc.4",
36
- "@owlmeans/client-context": "^0.1.18-rc.4",
37
- "@owlmeans/client-entrypoint": "^0.1.18-rc.4",
38
- "@owlmeans/context": "^0.1.18-rc.4",
39
- "@owlmeans/entrypoint": "^0.1.18-rc.4",
40
- "@owlmeans/socket": "^0.1.18-rc.4"
35
+ "@owlmeans/auth": "^0.1.18-rc.36",
36
+ "@owlmeans/basic-envelope": "^0.1.18-rc.37",
37
+ "@owlmeans/basic-ids": "^0.1.18-rc.33",
38
+ "@owlmeans/client": "^0.1.18-rc.42",
39
+ "@owlmeans/client-context": "^0.1.18-rc.39",
40
+ "@owlmeans/client-entrypoint": "^0.1.18-rc.38",
41
+ "@owlmeans/context": "^0.1.18-rc.32",
42
+ "@owlmeans/entrypoint": "^0.1.18-rc.37",
43
+ "@owlmeans/socket": "^0.1.18-rc.34"
41
44
  },
42
45
  "publishConfig": {
43
46
  "access": "public"
package/src/consts.ts ADDED
@@ -0,0 +1,18 @@
1
+
2
+ import type { ReconnectPolicy } from './types.js'
3
+
4
+ /**
5
+ * 0.2s → 3s geometric backoff, retried for up to 10 minutes before the carrier gives up and
6
+ * reports the connection lost. See the client-socket skill's "Disconnects" section.
7
+ */
8
+ export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = {
9
+ minDelay: 200,
10
+ maxDelay: 3_000,
11
+ factor: 2,
12
+ jitter: 0.1,
13
+ budget: 600_000,
14
+ reviveBudget: 15_000,
15
+ stableAfter: 10_000,
16
+ heartbeat: 30_000,
17
+ pongTimeout: 10_000,
18
+ }
package/src/helper.ts CHANGED
@@ -1,61 +1,173 @@
1
1
  import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
2
2
  import type { AbstractRequest } from '@owlmeans/entrypoint'
3
3
  import { provideRequest } from '@owlmeans/client-entrypoint'
4
- import { EntrypointOutcome } from '@owlmeans/entrypoint'
5
4
  import type { Connection } from '@owlmeans/socket'
6
- import { SocketInitializationError } from '@owlmeans/socket'
5
+ import { SocketConnectionError } from '@owlmeans/socket'
7
6
  import { makeConnection } from './utils/connection.js'
8
7
  import { assertContext } from '@owlmeans/context'
9
- import type { Config, Context } from './types.js'
8
+ import type { EntrypointReference } from '@owlmeans/context'
9
+ import type { Config, Context, ConnectOptions, ReconnectPolicy, SocketStatusServiceAppend, WsOptions } from './types.js'
10
10
  import { useContext, useValue } from '@owlmeans/client'
11
11
  import { AUTH_QUERY } from '@owlmeans/auth'
12
- import { urlCall } from '@owlmeans/client-entrypoint/utils'
13
- import { useEffect, useMemo } from 'react'
12
+ import { entrypointUrl } from '@owlmeans/client-entrypoint/utils'
13
+ import { useEffect, useMemo, useState } from 'react'
14
+ import { createIdOfLength } from '@owlmeans/basic-ids'
15
+ import { DEFAULT_RECONNECT_POLICY } from './consts.js'
16
+ import { SOCKET_STATUS } from './status.js'
14
17
 
15
- export const ws = async (module: ClientEntrypoint<string>, request?: AbstractRequest<{ token?: string }>): Promise<Connection> => {
16
- const ctx = assertContext<Config, Context>(module.ctx as Context, 'client-ws')
17
- request = request ?? provideRequest(module.getAlias(), module.getPath())
18
- const [url, state] = await urlCall({ ref: module })(request)
18
+ const resolvePolicy = (
19
+ ctx: Context, options?: ConnectOptions
20
+ ): { policy: ReconnectPolicy, retry: boolean } => {
21
+ const requested = options?.reconnect !== undefined ? options.reconnect : ctx.cfg.socket?.reconnect
22
+ const retry = requested !== false
23
+ const overrides = requested === false || requested == null ? {} : requested
24
+ return { policy: { ...DEFAULT_RECONNECT_POLICY, ...overrides }, retry }
25
+ }
19
26
 
20
- if (state !== EntrypointOutcome.Ok) {
21
- throw new SocketInitializationError
22
- }
27
+ const openRawSocket = async (url: string): Promise<WebSocket> => {
23
28
  const socket = new WebSocket(url)
29
+ return await new Promise<WebSocket>((resolve, reject) => {
30
+ const cleanup = () => {
31
+ socket.removeEventListener('open', onOpen)
32
+ socket.removeEventListener('error', onFail)
33
+ socket.removeEventListener('close', onFail)
34
+ }
35
+ const onOpen = () => { cleanup(); resolve(socket) }
36
+ const onFail = () => { cleanup(); reject(new SocketConnectionError('handshake')) }
37
+ socket.addEventListener('open', onOpen)
38
+ socket.addEventListener('error', onFail)
39
+ socket.addEventListener('close', onFail)
40
+ })
41
+ }
24
42
 
25
- return new Promise(resolve => {
26
- socket.onopen = () => {
27
- const heartbeat = setInterval(() => {
28
- if (socket.readyState === WebSocket.OPEN) {
29
- socket.send(JSON.stringify({ type: 'ping' }))
30
- }
31
- }, 30_000)
43
+ const establish = async (
44
+ ctx: Context, policy: ReconnectPolicy, retry: boolean, open: () => Promise<WebSocket>
45
+ ): Promise<Connection> => {
46
+ const statusId = createIdOfLength(8)
47
+ const status = ctx.hasService(SOCKET_STATUS)
48
+ ? (ctx as unknown as SocketStatusServiceAppend).socketStatus() : null
32
49
 
33
- socket.addEventListener('close', () => {
34
- console.info('WebSocket connection closed, clearing heartbeat interval.')
35
- clearInterval(heartbeat)
36
- })
50
+ let opened = false
51
+ let lost = false
52
+ const managed = makeConnection({
53
+ policy, retry, open, onStatus: state => {
54
+ lost = state === 'lost'
55
+ status?.report(statusId, state, state === 'online' ? undefined : revive)
56
+ }
57
+ })
58
+ const { connection, ready } = managed
59
+
60
+ const baseClose = connection.close
61
+ connection.close = async () => {
62
+ await baseClose()
63
+ status?.release(statusId)
64
+ }
37
65
 
38
- resolve(makeConnection(socket, ctx))
66
+ const revive = () => {
67
+ if (opened || !lost) {
68
+ managed.revive()
69
+ return
39
70
  }
71
+ // Never opened, so `ws()` already rejected and nobody holds this connection to hand a revived
72
+ // socket to: drop it for good and leave re-dialing to its owner (`useWs` does, on `onRetry`).
73
+ void baseClose()
74
+ status?.release(statusId)
75
+ }
76
+
77
+ // A rejection leaves this id reporting `'lost'` rather than releasing it — there is no
78
+ // `Connection` to hand back for a caller to close, and the aggregate is what puts
79
+ // `SocketReloadDialog` up. It is released by `revive` above, on the next `retry()`.
80
+ await ready
81
+ opened = true
82
+
83
+ return connection
84
+ }
85
+
86
+ /**
87
+ * Open a socket with no declared entrypoint behind it — the primitive `ws()` builds on, and what
88
+ * a test drives directly against a bare WebSocket server.
89
+ */
90
+ export const connect = async (
91
+ address: () => Promise<string> | string, ctx: Context, options?: ConnectOptions
92
+ ): Promise<Connection> => {
93
+ const { policy, retry } = resolvePolicy(ctx, options)
94
+ return await establish(ctx, policy, retry, async () => openRawSocket(await address()))
95
+ }
96
+
97
+ /**
98
+ * Open a socket through a declared entrypoint protocol, restoring it on its own after a network
99
+ * failure — see the client-socket skill's "Disconnects" section for the full policy. Resolves
100
+ * once the first attempt opens; rejects with `SocketConnectionError('lost')` if the retry budget
101
+ * elapses before that ever happens (or the single attempt fails, with `reconnect: false`).
102
+ */
103
+ export const ws = async (
104
+ module: ClientEntrypoint<string>, request?: AbstractRequest<{ token?: string }>, options?: WsOptions
105
+ ): Promise<Connection> => {
106
+ const ctx = assertContext<Config, Context>(module.ctx as Context, 'client-ws')
107
+ request = request ?? provideRequest(module.alias, module.path())
108
+ const { policy, retry } = resolvePolicy(ctx, options)
109
+
110
+ return await establish(ctx, policy, retry, async () => {
111
+ await options?.beforeConnect?.(request!)
112
+ const url = await entrypointUrl({ ref: module }, request)
113
+ return await openRawSocket(url)
40
114
  })
41
115
  }
42
116
 
43
- export const useWs = (module: string | ClientEntrypoint<any>, request?: Partial<AbstractRequest<any>>): Connection | null => {
117
+ /**
118
+ * Open a socket through a declared entrypoint protocol.
119
+ *
120
+ * String aliases remain an adapter input for dynamically addressed integrations, while
121
+ * application callers pass their immutable protocol declaration and receive its bound client
122
+ * entrypoint here at the transport boundary.
123
+ *
124
+ * Re-opens on its own after a network drop (see `ws()`). `null` until the first attempt opens,
125
+ * and again once the retry budget elapses without one ever opening — dialed afresh on the next
126
+ * `SocketStatusService.retry()`. A screen that must tell those two apart reads
127
+ * `useSocketStatus()` (from `./status.js`) alongside this.
128
+ */
129
+ export const useWs = (
130
+ module: EntrypointReference | string, request?: Partial<AbstractRequest<any>>, options?: WsOptions
131
+ ): Connection | null => {
44
132
  const ctx = useContext()
45
133
  const mod = useMemo(
46
- () => typeof module === 'string' ? ctx.entrypoint<ClientEntrypoint>(module) : module,
134
+ () => ctx.entrypoint<ClientEntrypoint>(module),
47
135
  [module]
48
136
  )
49
- const connection = useValue<Connection>(async () => {
50
- const _request = provideRequest(mod.getAlias(), mod.getPath())
137
+ const [failed, setFailed] = useState(false)
138
+ const [dial, setDial] = useState(0)
139
+ const connection = useValue<Connection | null>(async (cancel) => {
140
+ const _request = provideRequest(mod.alias, mod.path())
51
141
  Object.assign(_request, request)
52
- return await ws(mod, _request)
142
+ try {
143
+ const conn = await ws(mod, _request, options)
144
+ if (cancel?.current) {
145
+ // The component unmounted while the handshake (or its retries) were still in flight —
146
+ // there is no `useEffect` cleanup coming for this value, so close it here instead of
147
+ // leaving a live socket and its retry timers attached to nothing.
148
+ void conn.close()
149
+ return null
150
+ }
151
+ return conn
152
+ } catch {
153
+ if (!cancel?.current) setFailed(true)
154
+ return null
155
+ }
53
156
  }, [
54
- mod.getAlias(),
157
+ mod.alias,
55
158
  request?.query?.[AUTH_QUERY],
56
- request?.params ? JSON.stringify(request.params) : undefined
159
+ request?.params ? JSON.stringify(request.params) : undefined,
160
+ dial
57
161
  ])
58
162
 
163
+ useEffect(() => {
164
+ if (!failed || !ctx.hasService(SOCKET_STATUS)) return
165
+ return (ctx as unknown as SocketStatusServiceAppend).socketStatus().onRetry(() => {
166
+ setFailed(false)
167
+ setDial(n => n + 1)
168
+ })
169
+ }, [failed])
170
+
59
171
  useEffect(() => {
60
172
  if (connection != null) {
61
173
  return () => {
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
 
2
2
  export type * from './types.js'
3
+ export * from './consts.js'
3
4
  export * from './helper.js'
5
+ export * from './status.js'
package/src/status.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { createService } from '@owlmeans/context'
2
+ import { useContext } from '@owlmeans/client'
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import type {
5
+ Config, Context, SocketConnectionState, SocketStatusService, SocketStatusServiceAppend
6
+ } from './types.js'
7
+
8
+ export const SOCKET_STATUS = 'socket-status'
9
+
10
+ const RANK: Record<SocketConnectionState, number> = { online: 0, reconnecting: 1, lost: 2 }
11
+
12
+ /**
13
+ * Aggregates the coarse health of every `ws()`/`useWs()` connection an app has open into one
14
+ * worst-of-all state. A page with several sockets (thinking stream, file watch, notifications…)
15
+ * still shows one dialog, not one per socket — so the aggregate, not any single connection, is
16
+ * what a UI like `SocketReloadDialog` reads.
17
+ */
18
+ export const createSocketStatusService = (alias: string = SOCKET_STATUS): SocketStatusService => {
19
+ const states = new Map<string, SocketConnectionState>()
20
+ const revivers = new Map<string, () => void>()
21
+ const listeners = new Set<(state: SocketConnectionState) => void>()
22
+ const retryListeners = new Set<() => void>()
23
+ let current: SocketConnectionState = 'online'
24
+
25
+ const recompute = () => {
26
+ let worst: SocketConnectionState = 'online'
27
+ for (const state of states.values()) {
28
+ if (RANK[state] > RANK[worst]) worst = state
29
+ }
30
+ if (worst !== current) {
31
+ current = worst
32
+ listeners.forEach(listener => listener(current))
33
+ }
34
+ }
35
+
36
+ // Connections still retrying are nudged too, so one whose older budget is about to run out does
37
+ // not report `'lost'` right after its lost siblings were revived.
38
+ const retry = (): boolean => {
39
+ const pending = [...states].filter(([, state]) => state !== 'online')
40
+ pending.forEach(([id]) => revivers.get(id)?.())
41
+ if (!pending.some(([, state]) => state === 'lost')) return false
42
+ retryListeners.forEach(listener => listener())
43
+ return true
44
+ }
45
+
46
+ // A backgrounded tab is where sockets die unnoticed (throttled timers, a sleeping machine), so
47
+ // the moment it is looked at again is the moment to retry — before anyone reaches for a button.
48
+ const watchActivation = () => {
49
+ if (typeof document === 'undefined' || typeof window === 'undefined') return
50
+ document.addEventListener('visibilitychange', () => {
51
+ if (document.visibilityState === 'visible') retry()
52
+ })
53
+ window.addEventListener('focus', () => { retry() })
54
+ window.addEventListener('online', () => { retry() })
55
+ }
56
+
57
+ return createService<SocketStatusService>(alias, {
58
+ report: (id, state, revive) => {
59
+ states.set(id, state)
60
+ if (revive != null) revivers.set(id, revive)
61
+ else revivers.delete(id)
62
+ recompute()
63
+ },
64
+ release: (id) => { states.delete(id); revivers.delete(id); recompute() },
65
+ state: () => current,
66
+ subscribe: listener => {
67
+ listeners.add(listener)
68
+ return () => { listeners.delete(listener) }
69
+ },
70
+ retry,
71
+ onRetry: listener => {
72
+ retryListeners.add(listener)
73
+ return () => { retryListeners.delete(listener) }
74
+ },
75
+ }, service => async () => {
76
+ watchActivation()
77
+ service.initialized = true
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Register the shared socket-status aggregator. Idempotent, and cheap enough for a host package
83
+ * (`@owlmeans/web-panel`) to call unconditionally on every context: every `ws()`/`useWs()`
84
+ * connection reports itself here if — and only if — the context carries this service, so an app
85
+ * that never calls this behaves exactly as it did before reconnect support existed.
86
+ */
87
+ export const appendSocketStatus = <C extends Config = Config, T extends Context<C> = Context<C>>(
88
+ ctx: T, alias: string = SOCKET_STATUS
89
+ ): T & SocketStatusServiceAppend => {
90
+ const _ctx = ctx as T & SocketStatusServiceAppend
91
+ if (!ctx.hasService(alias)) {
92
+ ctx.registerService(createSocketStatusService(alias))
93
+ }
94
+ _ctx.socketStatus = () => ctx.service(alias)
95
+ return _ctx
96
+ }
97
+
98
+ const readSocketStatusService = (
99
+ ctx: Context & Partial<SocketStatusServiceAppend>
100
+ ): SocketStatusService | null => {
101
+ try {
102
+ return typeof ctx.hasService === 'function' && ctx.hasService(SOCKET_STATUS)
103
+ ? ctx.socketStatus?.() ?? null : null
104
+ } catch {
105
+ // Registered but not yet run through the context's OWN `init()` — a component that renders
106
+ // as a sibling of the router (as `SocketReloadDialog` does) mounts before that finishes,
107
+ // since `context.configure().init()` runs from the router's effect, not before React's
108
+ // first commit. Treat it as "not ready yet" rather than crash the render.
109
+ return null
110
+ }
111
+ }
112
+
113
+ /** The status service once the context can hand it out; `null` while it cannot, or never will. */
114
+ const useStatusService = (): SocketStatusService | null => {
115
+ const ctx = useContext() as unknown as Context & Partial<SocketStatusServiceAppend>
116
+ const [service, setService] = useState<SocketStatusService | null>(() => readSocketStatusService(ctx))
117
+
118
+ useEffect(() => {
119
+ if (service != null) return
120
+ let cancelled = false
121
+ void ctx.waitForInitialized?.().then(() => { if (!cancelled) setService(readSocketStatusService(ctx)) })
122
+ return () => { cancelled = true }
123
+ }, [ctx, service])
124
+
125
+ return service
126
+ }
127
+
128
+ /**
129
+ * The worst state across every live `ws()`/`useWs()` connection, or `'online'` if the app never
130
+ * registered the status service (`appendSocketStatus`) or has not finished initializing yet.
131
+ */
132
+ export const useSocketStatus = (): SocketConnectionState => {
133
+ const service = useStatusService()
134
+ const [state, setState] = useState<SocketConnectionState>(() => service?.state() ?? 'online')
135
+
136
+ useEffect(() => {
137
+ if (service == null) return
138
+ setState(service.state())
139
+ return service.subscribe(setState)
140
+ }, [service])
141
+
142
+ return state
143
+ }
144
+
145
+ /**
146
+ * `retry` revives every lost connection now (`SocketStatusService.retry()`); `retrying` is true
147
+ * from any retry — this one, or the service's own on tab activation — until the aggregate settles
148
+ * back on `'online'` or `'lost'`.
149
+ */
150
+ export const useSocketRetry = (): { retry: () => void, retrying: boolean } => {
151
+ const service = useStatusService()
152
+ const [retrying, setRetrying] = useState(false)
153
+
154
+ useEffect(() => {
155
+ if (service == null) return
156
+ const stopRetry = service.onRetry(() => setRetrying(service.state() === 'reconnecting'))
157
+ const stopState = service.subscribe(state => { if (state !== 'reconnecting') setRetrying(false) })
158
+ return () => { stopRetry(); stopState() }
159
+ }, [service])
160
+
161
+ const retry = useCallback(() => { service?.retry() }, [service])
162
+
163
+ return { retry, retrying }
164
+ }
package/src/types.ts CHANGED
@@ -1,7 +1,90 @@
1
1
 
2
2
  import type { ClientConfig } from '@owlmeans/client-context'
3
3
  import type { ClientContext } from '@owlmeans/client'
4
+ import type { AbstractRequest } from '@owlmeans/entrypoint'
5
+ import type { InitializedService } from '@owlmeans/context'
4
6
 
5
- export interface Config extends ClientConfig { }
7
+ export interface Config extends ClientConfig {
8
+ socket?: SocketClientSettings
9
+ }
6
10
 
7
11
  export interface Context<C extends Config = Config> extends ClientContext<C> { }
12
+
13
+ /**
14
+ * How aggressively a dropped socket is retried, and when the carrier gives up on it.
15
+ *
16
+ * The delay grows geometrically from `minDelay` to `maxDelay` (±`jitter`, a fraction of the
17
+ * computed delay) and stays there. `budget` is measured from the connection's FIRST failed
18
+ * attempt in an outage — a socket that opens and drops again right away does not get a fresh
19
+ * budget — and `stableAfter` is how long a reopened socket must stay up before the attempt
20
+ * counter and the outage clock reset, so a flapping link is never read as "recovered" between
21
+ * two drops a second apart.
22
+ */
23
+ export interface ReconnectPolicy {
24
+ /** First retry delay, in ms. Default 200. */
25
+ minDelay: number
26
+ /** Delay ceiling, in ms — the backoff never grows past this. Default 3000. */
27
+ maxDelay: number
28
+ /** Geometric growth factor applied to the delay after every failed attempt. Default 2. */
29
+ factor: number
30
+ /** Randomization applied to each computed delay, as a fraction of it. Default 0.1 (±10%). */
31
+ jitter: number
32
+ /** Total time, in ms, the carrier keeps retrying before it reports the socket lost. Default
33
+ * 600000 (10 minutes). */
34
+ budget: number
35
+ /** Retry window, in ms, of an outage restarted on a lost socket by `SocketStatusService.retry()`
36
+ * (a "Try again" press, the tab coming back) — short, so a retry that cannot succeed reports
37
+ * `'lost'` again while someone is still looking. Default 15000. */
38
+ reviveBudget: number
39
+ /** How long a reopened socket must stay open before the attempt count and the outage clock
40
+ * reset, in ms. Default 10000. */
41
+ stableAfter: number
42
+ /** Heartbeat ping interval, in ms. Default 30000. */
43
+ heartbeat: number
44
+ /** How long to wait for ANY inbound frame after a ping before treating the socket as dead and
45
+ * forcing it closed, in ms. Default 10000. */
46
+ pongTimeout: number
47
+ }
48
+
49
+ export interface SocketClientSettings {
50
+ /** `false` disables reconnection outright — a dropped socket is reported gone and stays gone,
51
+ * the pre-reconnect-support behaviour. Anything else merges over the defaults. */
52
+ reconnect?: false | Partial<ReconnectPolicy>
53
+ }
54
+
55
+ export interface ConnectOptions {
56
+ reconnect?: false | Partial<ReconnectPolicy>
57
+ }
58
+
59
+ export interface WsOptions extends ConnectOptions {
60
+ /**
61
+ * Run before every connection attempt, the first one included, given the request that will be
62
+ * turned into the socket URL — where a caller refreshes a token or other query/param value
63
+ * that may have changed since the previous attempt.
64
+ */
65
+ beforeConnect?: (request: AbstractRequest<any>) => void | Promise<void>
66
+ }
67
+
68
+ /** The coarse health of a socket connection — the worst of everything a status service tracks. */
69
+ export type SocketConnectionState = 'online' | 'reconnecting' | 'lost'
70
+
71
+ export interface SocketStatusService extends InitializedService {
72
+ /** Record this connection's current state under `id` — call again to update it. `revive` is
73
+ * how `retry()` brings it back (`'lost'`) or hurries it (`'reconnecting'`); every report
74
+ * replaces the previous one. */
75
+ report: (id: string, state: SocketConnectionState, revive?: () => void) => void
76
+ /** Stop tracking `id` (the connection closed on purpose). */
77
+ release: (id: string) => void
78
+ /** The worst state across every tracked connection; `'online'` when none are tracked. */
79
+ state: () => SocketConnectionState
80
+ subscribe: (listener: (state: SocketConnectionState) => void) => () => void
81
+ /** Revive every `'lost'` connection and hurry every `'reconnecting'` one, then tell `onRetry`
82
+ * listeners. `false` (and no listener call) when nothing was lost. Also fires by itself when a
83
+ * browser tab becomes visible, its window gains focus, or the network comes back online. */
84
+ retry: () => boolean
85
+ onRetry: (listener: () => void) => () => void
86
+ }
87
+
88
+ export interface SocketStatusServiceAppend {
89
+ socketStatus: () => SocketStatusService
90
+ }