@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.
@@ -1,26 +1,303 @@
1
- import type { Config, Context } from '../types.js'
2
1
  import type { Connection, EventMessage as EMessage } from '@owlmeans/socket'
3
- import { createBasicConnection, MessageType } from '@owlmeans/socket'
2
+ import {
3
+ createBasicConnection, MessageType, SocketConnectionError, SocketSystemEvent,
4
+ SOCKET_HEARTBEAT_TIMEOUT_CODE
5
+ } from '@owlmeans/socket'
6
+ import { AuthenticationStage } from '@owlmeans/auth'
7
+ import type { ReconnectPolicy, SocketConnectionState } from '../types.js'
4
8
 
5
- export const makeConnection = <C extends Config = Config, T extends Context<C> = Context<C>>(
6
- conn: WebSocket, _context: T
7
- ): Connection => {
9
+ /** Close codes that mean "this connection is finished on purpose" — never worth retrying: a
10
+ * normal closure the SERVER initiated (1000, e.g. the protocol's own one-shot completion) and a
11
+ * policy violation (1008, e.g. the guard rejected the frame that opened it). A client-initiated
12
+ * close is handled separately, through `closedByClient`. */
13
+ const TERMINAL_CLOSE_CODES: ReadonlySet<number> = new Set([1000, 1008])
14
+
15
+ export interface ConnectionOpener {
16
+ (): Promise<WebSocket>
17
+ }
18
+
19
+ export interface ManagedConnectionOptions {
20
+ policy: ReconnectPolicy
21
+ /** Whether a drop schedules a retry at all. `false` keeps the pre-reconnect-support
22
+ * behaviour — one attempt, report and stop — while the heartbeat/liveness check below still
23
+ * runs exactly as it always did. */
24
+ retry: boolean
25
+ /** Opens a fresh socket for the first attempt and every retry — already reflecting whatever a
26
+ * caller's `beforeConnect` refreshed (a token, most often). Resolves once the socket is OPEN,
27
+ * rejects on a handshake failure. */
28
+ open: ConnectionOpener
29
+ /** Told this connection's coarse health, if a caller wants to aggregate it — the status
30
+ * service `@owlmeans/web-panel`'s reload dialog reads. */
31
+ onStatus?: (state: SocketConnectionState) => void
32
+ }
33
+
34
+ export interface ManagedConnection {
35
+ connection: Connection
36
+ /** Settles once the first socket opens; rejects with `SocketConnectionError('lost')` once the
37
+ * retry budget elapses first (or the single attempt fails, with `retry: false`). */
38
+ ready: Promise<void>
39
+ /** Restart retrying a connection that gave up after its budget: one attempt at once, then the
40
+ * usual backoff within `policy.reviveBudget`. On a connection still waiting out a backoff
41
+ * delay: attempt at once, and leave it at least `reviveBudget`. A no-op otherwise — a terminal
42
+ * close (client close, 1000/1008, `retry: false`) is never revived. */
43
+ revive: () => void
44
+ }
45
+
46
+ /**
47
+ * Build a `Connection` whose transport survives a dropped WebSocket.
48
+ *
49
+ * The connection MODEL (`createBasicConnection()`) is created exactly once and never replaced —
50
+ * every `observe`/`listen` a caller registered keeps firing across a reconnect, because only the
51
+ * underlying `WebSocket` is swapped underneath it. A server's own subscriptions do NOT survive a
52
+ * new TCP connection, so a caller whose server sets up state only after an in-band
53
+ * `authenticated` frame (as this platform's handlers do) must replay that frame itself on
54
+ * `SocketSystemEvent.Reconnected` — this carrier only re-opens the pipe.
55
+ */
56
+ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnection => {
57
+ const { policy, retry, open, onStatus } = opts
8
58
  const model = createBasicConnection()
9
59
 
10
- model.send = async message => {
11
- if (typeof message !== 'string') {
12
- model.prepare?.(message)
60
+ let current: WebSocket | null = null
61
+ let closedByClient = false
62
+ let finished = false
63
+ // Gave up after the budget, but still revivable — unlike `finished`, nothing here is final.
64
+ let lost = false
65
+ let attempts = 0
66
+ let outageStartedAt: number | null = null
67
+ let outageBudget = policy.budget
68
+ let stableTimer: ReturnType<typeof setTimeout> | null = null
69
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
70
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null
71
+ // When the ping still waiting for an answer was sent, or null once anything came back.
72
+ let pingSentAt: number | null = null
73
+
74
+ let resolveReady: () => void = () => void 0
75
+ let rejectReady: (error: Error) => void = () => void 0
76
+ const ready = new Promise<void>((resolve, reject) => { resolveReady = resolve; rejectReady = reject })
77
+ let settled = false
78
+
79
+ const settle = (error?: Error) => {
80
+ if (settled) return
81
+ settled = true
82
+ if (error != null) rejectReady(error)
83
+ else resolveReady()
84
+ }
85
+
86
+ const emitSystem = async <T,>(event: SocketSystemEvent, payload: T): Promise<void> => {
87
+ const msg: EMessage<T> = { type: MessageType.System, event, payload }
88
+ model.prepare?.(msg)
89
+ await Promise.all(model.getListeners().map(async listener => {
90
+ try {
91
+ await listener(msg)
92
+ } catch (error) {
93
+ console.error('Socket system listener error:', error)
94
+ }
95
+ }))
96
+ }
97
+
98
+ const clearHeartbeat = () => {
99
+ if (heartbeatTimer != null) { clearInterval(heartbeatTimer); heartbeatTimer = null }
100
+ }
101
+
102
+ const clearTimers = () => {
103
+ if (retryTimer != null) { clearTimeout(retryTimer); retryTimer = null }
104
+ if (stableTimer != null) { clearTimeout(stableTimer); stableTimer = null }
105
+ clearHeartbeat()
106
+ }
107
+
108
+ const startHeartbeat = (socket: WebSocket) => {
109
+ pingSentAt = null
110
+ heartbeatTimer = setInterval(() => {
111
+ if (socket.readyState !== WebSocket.OPEN) return
112
+ if (pingSentAt != null && Date.now() - pingSentAt > policy.pongTimeout) {
113
+ // Silent half-open TCP: the ping this socket owes an answer to went out long enough ago
114
+ // that any live peer would have replied — force the close its own stack would eventually
115
+ // notice on its own, minutes later.
116
+ socket.close(SOCKET_HEARTBEAT_TIMEOUT_CODE)
117
+ return
118
+ }
119
+ // Measured from the PING rather than from the last frame: a hidden tab's timers are
120
+ // throttled to about one wake-up a minute, so "nothing has arrived for longer than a
121
+ // heartbeat interval" is what throttling looks like, not what a dead socket looks like —
122
+ // it used to kill a perfectly healthy backgrounded connection on every wake-up.
123
+ pingSentAt = Date.now()
124
+ socket.send(JSON.stringify({ type: 'ping' }))
125
+ }, policy.heartbeat)
126
+ }
127
+
128
+ const computeDelay = (attempt: number): number => {
129
+ const raw = Math.min(policy.maxDelay, policy.minDelay * Math.pow(policy.factor, attempt - 1))
130
+ const span = raw * policy.jitter
131
+ const jittered = raw + (Math.random() * 2 - 1) * span
132
+ return Math.max(policy.minDelay, Math.min(policy.maxDelay, Math.round(jittered)))
133
+ }
134
+
135
+ const finish = async (code: number) => {
136
+ if (finished) return
137
+ finished = true
138
+ closedByClient = true
139
+ clearTimers()
140
+ onStatus?.('lost')
141
+ await emitSystem(SocketSystemEvent.Close, { code })
142
+ settle(new SocketConnectionError('lost'))
143
+ }
144
+
145
+ // No `Close` frame here: the connection stays revivable, and `close` means gone for good.
146
+ const giveUp = async () => {
147
+ lost = true
148
+ clearTimers()
149
+ onStatus?.('lost')
150
+ await emitSystem(SocketSystemEvent.Lost, {})
151
+ settle(new SocketConnectionError('lost'))
152
+ }
153
+
154
+ const scheduleReconnect = () => {
155
+ if (outageStartedAt == null) outageStartedAt = Date.now()
156
+ if (Date.now() - outageStartedAt >= outageBudget) {
157
+ void giveUp()
158
+ return
13
159
  }
14
- if (conn.readyState === WebSocket.OPEN) {
15
- conn.send(typeof message === 'string' ? message : JSON.stringify(message))
160
+ attempts += 1
161
+ const delay = computeDelay(attempts)
162
+ onStatus?.('reconnecting')
163
+ void emitSystem(SocketSystemEvent.Reconnecting, { attempt: attempts, delay })
164
+ retryTimer = setTimeout(() => { retryTimer = null; void attemptConnect() }, delay)
165
+ }
166
+
167
+ const attemptConnect = async () => {
168
+ if (closedByClient) return
169
+ let socket: WebSocket
170
+ try {
171
+ socket = await open()
172
+ } catch {
173
+ if (retry) scheduleReconnect()
174
+ else void finish(1006)
175
+ return
176
+ }
177
+ if (closedByClient) {
178
+ // Closed while this handshake was in flight — nobody is left to own the socket.
179
+ socket.close(1000)
180
+ return
181
+ }
182
+ onOpened(socket)
183
+ }
184
+
185
+ const revive = () => {
186
+ if (finished) return
187
+ if (lost) {
188
+ lost = false
189
+ attempts = 1
190
+ outageStartedAt = Date.now()
191
+ outageBudget = policy.reviveBudget
192
+ onStatus?.('reconnecting')
193
+ void emitSystem(SocketSystemEvent.Reconnecting, { attempt: attempts, delay: 0 })
194
+ void attemptConnect()
195
+ return
196
+ }
197
+ // Still retrying: never give up sooner than a revived connection would — otherwise a retry of
198
+ // its lost siblings is undone moments later when this one's older budget runs out, even by an
199
+ // attempt already in flight — and attempt now rather than after the backoff.
200
+ if (current != null) return
201
+ const now = Date.now()
202
+ if (outageStartedAt != null && outageBudget - (now - outageStartedAt) < policy.reviveBudget) {
203
+ outageStartedAt = now
204
+ outageBudget = policy.reviveBudget
205
+ }
206
+ if (retryTimer == null) return
207
+ clearTimeout(retryTimer)
208
+ retryTimer = null
209
+ void attemptConnect()
210
+ }
211
+
212
+ const onOpened = (socket: WebSocket) => {
213
+ current = socket
214
+ const wasRetry = attempts > 0
215
+
216
+ const receiveMessage = async (event: MessageEvent) => {
217
+ // Any frame at all answers the outstanding ping — the point is liveness, not pongs.
218
+ pingSentAt = null
219
+ if (typeof event.data === 'string') {
220
+ try {
221
+ if (JSON.parse(event.data)?.type === 'pong') return
222
+ } catch {
223
+ // The shared parser below reports malformed JSON consistently.
224
+ }
225
+ }
226
+ await model.receive(event.data)
227
+ }
228
+ const messageHandler = (event: MessageEvent) => {
229
+ void receiveMessage(event).catch(error => {
230
+ console.error('WebSocket message rejected:', error)
231
+ socket.close(1008)
232
+ })
233
+ }
234
+ const errorHandler = () => {
235
+ // The close event that always follows carries the code; nothing actionable here beyond
236
+ // keeping the browser's own unhandled-error reporting quiet for it.
237
+ }
238
+ const closeHandler = async (event: CloseEvent) => {
239
+ if (current !== socket) return
240
+ socket.removeEventListener('message', messageHandler)
241
+ socket.removeEventListener('close', closeHandler)
242
+ socket.removeEventListener('error', errorHandler)
243
+ clearHeartbeat()
244
+ current = null
245
+
246
+ if (closedByClient) {
247
+ await emitSystem(SocketSystemEvent.Close, { code: event.code })
248
+ return
249
+ }
250
+ if (!retry || TERMINAL_CLOSE_CODES.has(event.code)) {
251
+ await finish(event.code)
252
+ return
253
+ }
254
+
255
+ if (model._authSequence != null) {
256
+ model._authSequence.reject(new SocketConnectionError('disconnected'))
257
+ model._authSequence = undefined
258
+ }
259
+ model.stage = AuthenticationStage.Init
260
+ await emitSystem(SocketSystemEvent.Disconnected, { code: event.code })
261
+ scheduleReconnect()
262
+ }
263
+
264
+ socket.addEventListener('message', messageHandler)
265
+ socket.addEventListener('close', closeHandler)
266
+ socket.addEventListener('error', errorHandler)
267
+ startHeartbeat(socket)
268
+
269
+ if (stableTimer != null) clearTimeout(stableTimer)
270
+ stableTimer = setTimeout(() => {
271
+ attempts = 0
272
+ outageStartedAt = null
273
+ outageBudget = policy.budget
274
+ }, policy.stableAfter)
275
+
276
+ onStatus?.('online')
277
+ settle()
278
+ if (wasRetry) void emitSystem(SocketSystemEvent.Reconnected, { attempts })
279
+ }
280
+
281
+ model.send = async message => {
282
+ if (typeof message !== 'string') model.prepare?.(message)
283
+ if (current?.readyState === WebSocket.OPEN) {
284
+ current.send(typeof message === 'string' ? message : JSON.stringify(message))
16
285
  }
17
286
  }
18
287
 
19
288
  model.close = async () => {
20
- if (conn.readyState === WebSocket.OPEN || conn.readyState === WebSocket.CONNECTING) {
21
- conn.close()
289
+ if (finished) return
290
+ finished = true
291
+ closedByClient = true
292
+ clearTimers()
293
+ if (current != null && (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) {
294
+ current.close(1000)
295
+ } else {
296
+ // No live socket to fire a close event from — mid-retry, or closed before the first open
297
+ // ever resolved — so report the same frame directly.
298
+ await emitSystem(SocketSystemEvent.Close, { code: 1000 })
22
299
  }
23
- // @TODO Make sure it trigger close observers
300
+ settle(new SocketConnectionError('closed'))
24
301
  }
25
302
 
26
303
  model.authenticate = async (_stage, _payload) => {
@@ -34,24 +311,7 @@ export const makeConnection = <C extends Config = Config, T extends Context<C> =
34
311
  return message
35
312
  }
36
313
 
37
- const messageHandler = async (message: MessageEvent) => {
38
- await model.receive(message.data)
39
- }
40
- const closeHandler = async (event: CloseEvent) => {
41
- const msg: EMessage<{ code: number }> = {
42
- type: MessageType.System,
43
- event: 'close',
44
- payload: { code: event.code }
45
- }
46
- if (model.prepare != null) {
47
- model.prepare(msg)
48
- }
49
- await Promise.all(model.getListeners().map(async listener => listener(msg)))
50
- conn.removeEventListener('message', messageHandler)
51
- conn.removeEventListener('close', closeHandler)
52
- }
53
- conn.addEventListener('message', messageHandler)
54
- conn.addEventListener('close', closeHandler)
314
+ void attemptConnect()
55
315
 
56
- return model
316
+ return { connection: model, ready, revive }
57
317
  }
@@ -0,0 +1,20 @@
1
+ import { config, makeClientContext } from '@owlmeans/client-context'
2
+ import type { Config, Context } from '../src/types.js'
3
+
4
+ /**
5
+ * A real, minimal client context — no mocking of the carrier or the transport, just enough
6
+ * config plumbing (`hasService`/`registerService`/`service`, `cfg.socket`) for `connect()` and
7
+ * `appendSocketStatus()` to work against.
8
+ *
9
+ * Left unconfigured: `hasService`/`registerService` work before `configure()`/`init()`, and
10
+ * `connect()` never calls `.service()` when nothing is registered — a test that DOES need a
11
+ * registered service's methods (`appendSocketStatus`'s `ctx.socketStatus()`) must register it
12
+ * and then run `ctx.configure(); await ctx.init()` itself, since a context only calls a
13
+ * service's own `init()` once, during that pass.
14
+ */
15
+ export const makeTestContext = (socket?: Config['socket']): Context => {
16
+ const cfg = config<Config>('client-socket-tests')
17
+ cfg.socket = socket
18
+
19
+ return makeClientContext<Config, Context>(cfg)
20
+ }