@owlmeans/client-socket 0.1.18-rc.5 → 0.1.18-rc.50

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