@owlmeans/client-socket 0.1.18-rc.3 → 0.1.18-rc.30

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/src/helper.ts CHANGED
@@ -1,57 +1,141 @@
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'
12
+ import { entrypointUrl } from '@owlmeans/client-entrypoint/utils'
13
13
  import { useEffect, useMemo } 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
+ const { connection, ready } = makeConnection({
51
+ policy, retry, open, onStatus: state => status?.report(statusId, state)
52
+ })
37
53
 
38
- resolve(makeConnection(socket, ctx))
39
- }
54
+ const baseClose = connection.close
55
+ connection.close = async () => {
56
+ await baseClose()
57
+ status?.release(statusId)
58
+ }
59
+
60
+ // A rejection here means `finish()` already ran and reported this id's terminal state —
61
+ // `'lost'` in every case, since the budget-exhausted path is the only one that rejects
62
+ // `ready`. Deliberately NOT released: there is no `Connection` to hand back for a caller to
63
+ // close, so the aggregate stays `'lost'` — the whole reason `SocketReloadDialog` exists — until
64
+ // the page reloads. Only a connection this function actually returns can be released, and only
65
+ // by the caller closing it on purpose.
66
+ await ready
67
+
68
+ return connection
69
+ }
70
+
71
+ /**
72
+ * Open a socket with no declared entrypoint behind it — the primitive `ws()` builds on, and what
73
+ * a test drives directly against a bare WebSocket server.
74
+ */
75
+ export const connect = async (
76
+ address: () => Promise<string> | string, ctx: Context, options?: ConnectOptions
77
+ ): Promise<Connection> => {
78
+ const { policy, retry } = resolvePolicy(ctx, options)
79
+ return await establish(ctx, policy, retry, async () => openRawSocket(await address()))
80
+ }
81
+
82
+ /**
83
+ * Open a socket through a declared entrypoint protocol, restoring it on its own after a network
84
+ * failure — see the client-socket skill's "Disconnects" section for the full policy. Resolves
85
+ * once the first attempt opens; rejects with `SocketConnectionError('lost')` if the retry budget
86
+ * elapses before that ever happens (or the single attempt fails, with `reconnect: false`).
87
+ */
88
+ export const ws = async (
89
+ module: ClientEntrypoint<string>, request?: AbstractRequest<{ token?: string }>, options?: WsOptions
90
+ ): Promise<Connection> => {
91
+ const ctx = assertContext<Config, Context>(module.ctx as Context, 'client-ws')
92
+ request = request ?? provideRequest(module.alias, module.path())
93
+ const { policy, retry } = resolvePolicy(ctx, options)
94
+
95
+ return await establish(ctx, policy, retry, async () => {
96
+ await options?.beforeConnect?.(request!)
97
+ const url = await entrypointUrl({ ref: module }, request)
98
+ return await openRawSocket(url)
40
99
  })
41
100
  }
42
101
 
43
- export const useWs = (module: string | ClientEntrypoint<any>, request?: Partial<AbstractRequest<any>>): Connection | null => {
102
+ /**
103
+ * Open a socket through a declared entrypoint protocol.
104
+ *
105
+ * String aliases remain an adapter input for dynamically addressed integrations, while
106
+ * application callers pass their immutable protocol declaration and receive its bound client
107
+ * entrypoint here at the transport boundary.
108
+ *
109
+ * Re-opens on its own after a network drop (see `ws()`). `null` until the first attempt opens,
110
+ * and again once the retry budget elapses without one ever opening — a screen that must tell
111
+ * those two apart reads `useSocketStatus()` (from `./status.js`) alongside this.
112
+ */
113
+ export const useWs = (
114
+ module: EntrypointReference | string, request?: Partial<AbstractRequest<any>>, options?: WsOptions
115
+ ): Connection | null => {
44
116
  const ctx = useContext()
45
117
  const mod = useMemo(
46
- () => typeof module === 'string' ? ctx.entrypoint<ClientEntrypoint>(module) : module,
118
+ () => ctx.entrypoint<ClientEntrypoint>(module),
47
119
  [module]
48
120
  )
49
- const connection = useValue<Connection>(async () => {
50
- const _request = provideRequest(mod.getAlias(), mod.getPath())
121
+ const connection = useValue<Connection | null>(async (cancel) => {
122
+ const _request = provideRequest(mod.alias, mod.path())
51
123
  Object.assign(_request, request)
52
- return await ws(mod, _request)
124
+ try {
125
+ const conn = await ws(mod, _request, options)
126
+ if (cancel?.current) {
127
+ // The component unmounted while the handshake (or its retries) were still in flight —
128
+ // there is no `useEffect` cleanup coming for this value, so close it here instead of
129
+ // leaving a live socket and its retry timers attached to nothing.
130
+ void conn.close()
131
+ return null
132
+ }
133
+ return conn
134
+ } catch {
135
+ return null
136
+ }
53
137
  }, [
54
- mod.getAlias(),
138
+ mod.alias,
55
139
  request?.query?.[AUTH_QUERY],
56
140
  request?.params ? JSON.stringify(request.params) : undefined
57
141
  ])
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,108 @@
1
+ import { createService } from '@owlmeans/context'
2
+ import { useContext } from '@owlmeans/client'
3
+ import { 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 listeners = new Set<(state: SocketConnectionState) => void>()
21
+ let current: SocketConnectionState = 'online'
22
+
23
+ const recompute = () => {
24
+ let worst: SocketConnectionState = 'online'
25
+ for (const state of states.values()) {
26
+ if (RANK[state] > RANK[worst]) worst = state
27
+ }
28
+ if (worst !== current) {
29
+ current = worst
30
+ listeners.forEach(listener => listener(current))
31
+ }
32
+ }
33
+
34
+ return createService<SocketStatusService>(alias, {
35
+ report: (id, state) => { states.set(id, state); recompute() },
36
+ release: (id) => { states.delete(id); recompute() },
37
+ state: () => current,
38
+ subscribe: listener => {
39
+ listeners.add(listener)
40
+ return () => { listeners.delete(listener) }
41
+ },
42
+ }, service => async () => { service.initialized = true })
43
+ }
44
+
45
+ /**
46
+ * Register the shared socket-status aggregator. Idempotent, and cheap enough for a host package
47
+ * (`@owlmeans/web-panel`) to call unconditionally on every context: every `ws()`/`useWs()`
48
+ * connection reports itself here if — and only if — the context carries this service, so an app
49
+ * that never calls this behaves exactly as it did before reconnect support existed.
50
+ */
51
+ export const appendSocketStatus = <C extends Config = Config, T extends Context<C> = Context<C>>(
52
+ ctx: T, alias: string = SOCKET_STATUS
53
+ ): T & SocketStatusServiceAppend => {
54
+ const _ctx = ctx as T & SocketStatusServiceAppend
55
+ if (!ctx.hasService(alias)) {
56
+ ctx.registerService(createSocketStatusService(alias))
57
+ }
58
+ _ctx.socketStatus = () => ctx.service(alias)
59
+ return _ctx
60
+ }
61
+
62
+ const readSocketStatusService = (
63
+ ctx: Context & Partial<SocketStatusServiceAppend>
64
+ ): SocketStatusService | null => {
65
+ try {
66
+ return typeof ctx.hasService === 'function' && ctx.hasService(SOCKET_STATUS)
67
+ ? ctx.socketStatus?.() ?? null : null
68
+ } catch {
69
+ // Registered but not yet run through the context's OWN `init()` — a component that renders
70
+ // as a sibling of the router (as `SocketReloadDialog` does) mounts before that finishes,
71
+ // since `context.configure().init()` runs from the router's effect, not before React's
72
+ // first commit. Treat it as "not ready yet" rather than crash the render.
73
+ return null
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The worst state across every live `ws()`/`useWs()` connection, or `'online'` if the app never
79
+ * registered the status service (`appendSocketStatus`) or has not finished initializing yet.
80
+ */
81
+ export const useSocketStatus = (): SocketConnectionState => {
82
+ const ctx = useContext() as unknown as Context & Partial<SocketStatusServiceAppend>
83
+ const [state, setState] = useState<SocketConnectionState>(() => readSocketStatusService(ctx)?.state() ?? 'online')
84
+
85
+ useEffect(() => {
86
+ let cancelled = false
87
+ let unsubscribe: (() => void) | null = null
88
+
89
+ const attach = (): boolean => {
90
+ const service = readSocketStatusService(ctx)
91
+ if (service == null) return false
92
+ setState(service.state())
93
+ unsubscribe = service.subscribe(next => { if (!cancelled) setState(next) })
94
+ return true
95
+ }
96
+
97
+ if (!attach()) {
98
+ void ctx.waitForInitialized?.().then(() => { if (!cancelled) attach() })
99
+ }
100
+
101
+ return () => {
102
+ cancelled = true
103
+ unsubscribe?.()
104
+ }
105
+ }, [ctx])
106
+
107
+ return state
108
+ }
package/src/types.ts CHANGED
@@ -1,7 +1,79 @@
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
+ /** How long a reopened socket must stay open before the attempt count and the outage clock
36
+ * reset, in ms. Default 10000. */
37
+ stableAfter: number
38
+ /** Heartbeat ping interval, in ms. Default 30000. */
39
+ heartbeat: number
40
+ /** How long to wait for ANY inbound frame after a ping before treating the socket as dead and
41
+ * forcing it closed, in ms. Default 10000. */
42
+ pongTimeout: number
43
+ }
44
+
45
+ export interface SocketClientSettings {
46
+ /** `false` disables reconnection outright — a dropped socket is reported gone and stays gone,
47
+ * the pre-reconnect-support behaviour. Anything else merges over the defaults. */
48
+ reconnect?: false | Partial<ReconnectPolicy>
49
+ }
50
+
51
+ export interface ConnectOptions {
52
+ reconnect?: false | Partial<ReconnectPolicy>
53
+ }
54
+
55
+ export interface WsOptions extends ConnectOptions {
56
+ /**
57
+ * Run before every connection attempt, the first one included, given the request that will be
58
+ * turned into the socket URL — where a caller refreshes a token or other query/param value
59
+ * that may have changed since the previous attempt.
60
+ */
61
+ beforeConnect?: (request: AbstractRequest<any>) => void | Promise<void>
62
+ }
63
+
64
+ /** The coarse health of a socket connection — the worst of everything a status service tracks. */
65
+ export type SocketConnectionState = 'online' | 'reconnecting' | 'lost'
66
+
67
+ export interface SocketStatusService extends InitializedService {
68
+ /** Record this connection's current state under `id` — call again to update it. */
69
+ report: (id: string, state: SocketConnectionState) => void
70
+ /** Stop tracking `id` (the connection closed on purpose). */
71
+ release: (id: string) => void
72
+ /** The worst state across every tracked connection; `'online'` when none are tracked. */
73
+ state: () => SocketConnectionState
74
+ subscribe: (listener: (state: SocketConnectionState) => void) => () => void
75
+ }
76
+
77
+ export interface SocketStatusServiceAppend {
78
+ socketStatus: () => SocketStatusService
79
+ }
@@ -1,26 +1,226 @@
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
+ }
40
+
41
+ /**
42
+ * Build a `Connection` whose transport survives a dropped WebSocket.
43
+ *
44
+ * The connection MODEL (`createBasicConnection()`) is created exactly once and never replaced —
45
+ * every `observe`/`listen` a caller registered keeps firing across a reconnect, because only the
46
+ * underlying `WebSocket` is swapped underneath it. A server's own subscriptions do NOT survive a
47
+ * new TCP connection, so a caller whose server sets up state only after an in-band
48
+ * `authenticated` frame (as this platform's handlers do) must replay that frame itself on
49
+ * `SocketSystemEvent.Reconnected` — this carrier only re-opens the pipe.
50
+ */
51
+ export const makeConnection = (opts: ManagedConnectionOptions): ManagedConnection => {
52
+ const { policy, retry, open, onStatus } = opts
8
53
  const model = createBasicConnection()
9
54
 
10
- model.send = async message => {
11
- if (typeof message !== 'string') {
12
- model.prepare?.(message)
55
+ let current: WebSocket | null = null
56
+ let closedByClient = false
57
+ let finished = false
58
+ let attempts = 0
59
+ let outageStartedAt: number | null = null
60
+ let stableTimer: ReturnType<typeof setTimeout> | null = null
61
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
62
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null
63
+ let lastFrameAt = Date.now()
64
+
65
+ let resolveReady: () => void = () => void 0
66
+ let rejectReady: (error: Error) => void = () => void 0
67
+ const ready = new Promise<void>((resolve, reject) => { resolveReady = resolve; rejectReady = reject })
68
+ let settled = false
69
+
70
+ const settle = (error?: Error) => {
71
+ if (settled) return
72
+ settled = true
73
+ if (error != null) rejectReady(error)
74
+ else resolveReady()
75
+ }
76
+
77
+ const emitSystem = async <T,>(event: SocketSystemEvent, payload: T): Promise<void> => {
78
+ const msg: EMessage<T> = { type: MessageType.System, event, payload }
79
+ model.prepare?.(msg)
80
+ await Promise.all(model.getListeners().map(async listener => listener(msg)))
81
+ }
82
+
83
+ const clearHeartbeat = () => {
84
+ if (heartbeatTimer != null) { clearInterval(heartbeatTimer); heartbeatTimer = null }
85
+ }
86
+
87
+ const clearTimers = () => {
88
+ if (retryTimer != null) { clearTimeout(retryTimer); retryTimer = null }
89
+ if (stableTimer != null) { clearTimeout(stableTimer); stableTimer = null }
90
+ clearHeartbeat()
91
+ }
92
+
93
+ const startHeartbeat = (socket: WebSocket) => {
94
+ lastFrameAt = Date.now()
95
+ heartbeatTimer = setInterval(() => {
96
+ if (socket.readyState !== WebSocket.OPEN) return
97
+ if (Date.now() - lastFrameAt > policy.heartbeat + policy.pongTimeout) {
98
+ // Silent half-open TCP: nothing at all has arrived since well past a heartbeat interval
99
+ // plus its grace period — force the close this socket's own stack would eventually
100
+ // notice on its own, minutes later.
101
+ socket.close(SOCKET_HEARTBEAT_TIMEOUT_CODE)
102
+ return
103
+ }
104
+ socket.send(JSON.stringify({ type: 'ping' }))
105
+ }, policy.heartbeat)
106
+ }
107
+
108
+ const computeDelay = (attempt: number): number => {
109
+ const raw = Math.min(policy.maxDelay, policy.minDelay * Math.pow(policy.factor, attempt - 1))
110
+ const span = raw * policy.jitter
111
+ const jittered = raw + (Math.random() * 2 - 1) * span
112
+ return Math.max(policy.minDelay, Math.min(policy.maxDelay, Math.round(jittered)))
113
+ }
114
+
115
+ const finish = async (code: number) => {
116
+ if (finished) return
117
+ finished = true
118
+ closedByClient = true
119
+ clearTimers()
120
+ onStatus?.('lost')
121
+ await emitSystem(SocketSystemEvent.Close, { code })
122
+ settle(new SocketConnectionError('lost'))
123
+ }
124
+
125
+ const scheduleReconnect = (code: number) => {
126
+ if (outageStartedAt == null) outageStartedAt = Date.now()
127
+ if (Date.now() - outageStartedAt >= policy.budget) {
128
+ void (async () => {
129
+ onStatus?.('lost')
130
+ await emitSystem(SocketSystemEvent.Lost, {})
131
+ await finish(code)
132
+ })()
133
+ return
13
134
  }
14
- if (conn.readyState === WebSocket.OPEN) {
15
- conn.send(typeof message === 'string' ? message : JSON.stringify(message))
135
+ attempts += 1
136
+ const delay = computeDelay(attempts)
137
+ onStatus?.('reconnecting')
138
+ void emitSystem(SocketSystemEvent.Reconnecting, { attempt: attempts, delay })
139
+ retryTimer = setTimeout(() => { retryTimer = null; void attemptConnect() }, delay)
140
+ }
141
+
142
+ const attemptConnect = async () => {
143
+ if (closedByClient) return
144
+ try {
145
+ const socket = await open()
146
+ onOpened(socket)
147
+ } catch {
148
+ if (retry) scheduleReconnect(1006)
149
+ else void finish(1006)
150
+ }
151
+ }
152
+
153
+ const onOpened = (socket: WebSocket) => {
154
+ current = socket
155
+ const wasRetry = attempts > 0
156
+
157
+ const messageHandler = async (event: MessageEvent) => {
158
+ lastFrameAt = Date.now()
159
+ await model.receive(event.data)
160
+ }
161
+ const errorHandler = () => {
162
+ // The close event that always follows carries the code; nothing actionable here beyond
163
+ // keeping the browser's own unhandled-error reporting quiet for it.
164
+ }
165
+ const closeHandler = async (event: CloseEvent) => {
166
+ if (current !== socket) return
167
+ socket.removeEventListener('message', messageHandler)
168
+ socket.removeEventListener('close', closeHandler)
169
+ socket.removeEventListener('error', errorHandler)
170
+ clearHeartbeat()
171
+ current = null
172
+
173
+ if (closedByClient) {
174
+ await emitSystem(SocketSystemEvent.Close, { code: event.code })
175
+ return
176
+ }
177
+ if (!retry || TERMINAL_CLOSE_CODES.has(event.code)) {
178
+ await finish(event.code)
179
+ return
180
+ }
181
+
182
+ if (model._authSequence != null) {
183
+ model._authSequence.reject(new SocketConnectionError('disconnected'))
184
+ model._authSequence = undefined
185
+ }
186
+ model.stage = AuthenticationStage.Init
187
+ await emitSystem(SocketSystemEvent.Disconnected, { code: event.code })
188
+ scheduleReconnect(event.code)
189
+ }
190
+
191
+ socket.addEventListener('message', messageHandler)
192
+ socket.addEventListener('close', closeHandler)
193
+ socket.addEventListener('error', errorHandler)
194
+ startHeartbeat(socket)
195
+
196
+ if (stableTimer != null) clearTimeout(stableTimer)
197
+ stableTimer = setTimeout(() => { attempts = 0; outageStartedAt = null }, policy.stableAfter)
198
+
199
+ onStatus?.('online')
200
+ settle()
201
+ if (wasRetry) void emitSystem(SocketSystemEvent.Reconnected, { attempts })
202
+ }
203
+
204
+ model.send = async message => {
205
+ if (typeof message !== 'string') model.prepare?.(message)
206
+ if (current?.readyState === WebSocket.OPEN) {
207
+ current.send(typeof message === 'string' ? message : JSON.stringify(message))
16
208
  }
17
209
  }
18
210
 
19
211
  model.close = async () => {
20
- if (conn.readyState === WebSocket.OPEN || conn.readyState === WebSocket.CONNECTING) {
21
- conn.close()
212
+ if (finished) return
213
+ finished = true
214
+ closedByClient = true
215
+ clearTimers()
216
+ if (current != null && (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) {
217
+ current.close(1000)
218
+ } else {
219
+ // No live socket to fire a close event from — mid-retry, or closed before the first open
220
+ // ever resolved — so report the same frame directly.
221
+ await emitSystem(SocketSystemEvent.Close, { code: 1000 })
22
222
  }
23
- // @TODO Make sure it trigger close observers
223
+ settle(new SocketConnectionError('closed'))
24
224
  }
25
225
 
26
226
  model.authenticate = async (_stage, _payload) => {
@@ -34,24 +234,7 @@ export const makeConnection = <C extends Config = Config, T extends Context<C> =
34
234
  return message
35
235
  }
36
236
 
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)
237
+ void attemptConnect()
55
238
 
56
- return model
239
+ return { connection: model, ready }
57
240
  }
@@ -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
+ }