@studyportals/ws-client 0.1.1-beta.5 → 0.1.1-beta.6

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/dist/index.js CHANGED
@@ -38,7 +38,7 @@ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
38
38
  var DEFAULT_BASE_RECONNECT_DELAY_MS = 500;
39
39
  var DEFAULT_MAX_RECONNECT_DELAY_MS = 3e4;
40
40
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 3e4;
41
- var DEFAULT_HEARTBEAT_MESSAGE = { type: "ping" };
41
+ var DEFAULT_HEARTBEAT_MESSAGE = { action: "who-am-I" };
42
42
  var WebSocketManager = class _WebSocketManager {
43
43
  static instance;
44
44
  bus;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/event-bus.ts","../src/types/ws-identified.ts","../src/types/campaign-saving.ts","../src/types/server-message.ts","../src/websocket-manager.ts","../src/transports/real.transport.ts","../src/transports/mock.transport.ts"],"sourcesContent":["import mitt from 'mitt';\nimport type { Emitter } from 'mitt';\nimport type { WsEventMap } from './types';\n\n// Intersect with Record<string | symbol, unknown> to satisfy mitt's generic\n// constraint while keeping WsEventMap clean of index signatures.\ntype MittMap = WsEventMap & Record<string | symbol, unknown>;\n\nexport type EventBus = Emitter<MittMap>;\n\nexport const eventBus: EventBus = mitt<MittMap>();\n","import { z } from 'zod';\n\nexport const wsIdentifiedPayloadSchema = z.object({\n connectionId: z.string(),\n});\n\nexport type WsIdentifiedPayload = z.infer<typeof wsIdentifiedPayloadSchema>;\n","import { z } from 'zod';\n\nconst campaignSavingStartSchema = z.object({\n status: z.literal('start'),\n});\n\nconst campaignSavingSuccessSchema = z.object({\n status: z.literal('success'),\n});\n\nconst campaignSavingFailedSchema = z.object({\n status: z.literal('failed'),\n error: z.record(z.string(), z.unknown()),\n});\n\nexport const campaignSavingPayloadSchema = z.discriminatedUnion('status', [\n campaignSavingStartSchema,\n campaignSavingSuccessSchema,\n campaignSavingFailedSchema,\n]);\n\nexport type CampaignSavingPayload = z.infer<typeof campaignSavingPayloadSchema>;\n","import { z } from 'zod';\nimport type { WsEventMap } from './ws-event-map';\nimport { wsIdentifiedPayloadSchema } from './ws-identified';\nimport { campaignSavingPayloadSchema } from './campaign-saving';\n\n// ---------------------------------------------------------------------------\n// Client-only events — fired by the transport layer, never sent by the server\n// ---------------------------------------------------------------------------\n\ntype ClientOnlyEvent =\n | 'ws:connected'\n | 'ws:disconnected'\n | 'ws:error'\n | 'ws:max-retries-exceeded'\n | 'ws:unparseable';\n\n// ---------------------------------------------------------------------------\n// ServerEventKey — every WsEventMap key the server is allowed to send\n// Automatically excludes client-only events; stays in sync with WsEventMap.\n// ---------------------------------------------------------------------------\n\nexport type ServerEventKey = Exclude<keyof WsEventMap, ClientOnlyEvent>;\n\n// ---------------------------------------------------------------------------\n// ServerMessage — strict discriminated union of all valid server wire frames.\n// Use this in backend code and anywhere a server frame must be type-narrowed.\n//\n// Shape: { type: ServerEventKey; payload: WsEventMap[K] }\n// ---------------------------------------------------------------------------\n\nexport type ServerMessage = {\n [K in ServerEventKey]: { type: K; payload: WsEventMap[K] };\n}[ServerEventKey];\n\n// ---------------------------------------------------------------------------\n// serverMessageSchema — Zod discriminated union over every server event.\n// Validates that an incoming frame is both structurally and semantically valid.\n//\n// NOTE: if you extend WsEventMap via module augmentation you must also add\n// the corresponding member to this schema.\n// ---------------------------------------------------------------------------\n\nexport const serverMessageSchema = z.discriminatedUnion('type', [\n z.object({ type: z.literal('ws:identified'), payload: wsIdentifiedPayloadSchema }),\n z.object({ type: z.literal('campaign:saving'), payload: campaignSavingPayloadSchema }),\n]);\n\nexport type ServerMessageParsed = z.infer<typeof serverMessageSchema>;\n","import type { EventBus } from './event-bus';\nimport type { WebSocketManagerConfig } from './types';\nimport { serverMessageSchema } from './types';\n\nexport type { WebSocketManagerConfig } from './types';\n\nconst DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;\nconst DEFAULT_BASE_RECONNECT_DELAY_MS = 500;\nconst DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;\nconst DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;\nconst DEFAULT_HEARTBEAT_MESSAGE: Record<string, unknown> = { type: 'ping' };\n\ntype ResolvedConfig = Required<WebSocketManagerConfig>;\n\nexport class WebSocketManager {\n private static instance: WebSocketManager | undefined;\n\n private readonly bus: EventBus;\n private readonly config: ResolvedConfig;\n\n private reconnectAttempts = 0;\n private intentionalClose = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | undefined;\n private reconnectTimer: ReturnType<typeof setTimeout> | undefined;\n private connectionId: string | undefined;\n\n private constructor(bus: EventBus, config: WebSocketManagerConfig) {\n this.bus = bus;\n this.config = {\n url: config.url,\n transport: config.transport,\n maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS,\n baseReconnectDelayMs: config.baseReconnectDelayMs ?? DEFAULT_BASE_RECONNECT_DELAY_MS,\n maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS,\n heartbeatIntervalMs: config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,\n heartbeatMessage: config.heartbeatMessage ?? DEFAULT_HEARTBEAT_MESSAGE,\n };\n\n this.connect();\n }\n\n /**\n * Returns the singleton instance, creating it on first call.\n * Subsequent calls ignore `bus` and `config` — pass them only on first call.\n */\n public static getInstance(bus: EventBus, config: WebSocketManagerConfig): WebSocketManager {\n if (!WebSocketManager.instance) {\n WebSocketManager.instance = new WebSocketManager(bus, config);\n }\n return WebSocketManager.instance;\n }\n\n /**\n * Destroy the singleton.\n * Call before re-initialising in tests or when switching environments.\n */\n public static reset(): void {\n WebSocketManager.instance = undefined;\n }\n\n /** Returns the connectionId received from the last \"ws:identified\" message. */\n public getConnectionId(): string | undefined {\n return this.connectionId;\n }\n\n /** Gracefully close the connection without triggering automatic reconnect. */\n public disconnect(): void {\n this.intentionalClose = true;\n this.clearHeartbeat();\n this.clearReconnectTimer();\n this.config.transport.close(1000, 'client disconnect');\n }\n\n private connect(): void {\n const { transport, url } = this.config;\n\n transport.onopen = (): void => {\n this.reconnectAttempts = 0;\n this.intentionalClose = false;\n this.startHeartbeat();\n this.bus.emit('ws:connected', undefined);\n };\n\n transport.onmessage = (data: string): void => {\n this.handleMessage(data);\n };\n\n transport.onclose = (event: CloseEvent): void => {\n this.clearHeartbeat();\n this.connectionId = undefined;\n this.bus.emit('ws:disconnected', event);\n\n if (!this.intentionalClose) {\n this.scheduleReconnect();\n }\n };\n\n transport.onerror = (event: Event): void => {\n this.bus.emit('ws:error', event);\n };\n\n transport.connect(url);\n }\n\n private handleMessage(raw: string): void {\n let parsedJson: unknown;\n\n try {\n parsedJson = JSON.parse(raw);\n } catch {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n const result = serverMessageSchema.safeParse(parsedJson);\n if (!result.success) {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n // result.data is ServerMessageParsed — a strict discriminated union.\n // The switch is exhaustive: adding a new event to serverMessageSchema\n // without handling it here is a compile error.\n const msg = result.data;\n\n switch (msg.type) {\n case 'ws:identified':\n this.connectionId = msg.payload.connectionId;\n this.bus.emit('ws:identified', msg.payload);\n break;\n case 'campaign:saving':\n this.bus.emit('campaign:saving', msg.payload);\n break;\n default: {\n // Exhaustive guard — msg is `never` if all cases are handled.\n // If this line errors, a new ServerMessage member needs a case above.\n const _exhaustive: never = msg;\n }\n }\n }\n\n private scheduleReconnect(): void {\n if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {\n this.bus.emit('ws:max-retries-exceeded', undefined);\n return;\n }\n\n const { baseReconnectDelayMs, maxReconnectDelayMs } = this.config;\n const exponential = baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts);\n const capped = Math.min(exponential, maxReconnectDelayMs);\n // Add random jitter up to one base interval to avoid thundering herd\n const delay = capped + Math.random() * baseReconnectDelayMs;\n\n this.reconnectAttempts++;\n\n this.reconnectTimer = setTimeout(() => {\n this.connect();\n }, delay);\n }\n\n private clearReconnectTimer(): void {\n if (this.reconnectTimer !== undefined) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = undefined;\n }\n }\n\n private startHeartbeat(): void {\n this.clearHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));\n }, this.config.heartbeatIntervalMs);\n }\n\n private clearHeartbeat(): void {\n if (this.heartbeatTimer !== undefined) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = undefined;\n }\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\nexport class RealWebSocketTransport implements IWebSocketTransport {\n private socket: WebSocket | null = null;\n\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n public connect(url: string): void {\n this.socket = new WebSocket(url);\n\n this.socket.addEventListener('open', () => {\n this.onopen?.();\n });\n\n this.socket.addEventListener('message', (event: MessageEvent) => {\n if (typeof event.data === 'string') {\n this.onmessage?.(event.data);\n }\n });\n\n this.socket.addEventListener('close', (event: CloseEvent) => {\n this.onclose?.(event);\n });\n\n this.socket.addEventListener('error', (event: Event) => {\n this.onerror?.(event);\n });\n }\n\n public send(data: string): void {\n if (this.socket?.readyState === WebSocket.OPEN) {\n this.socket.send(data);\n }\n }\n\n public close(code?: number, reason?: string): void {\n this.socket?.close(code, reason);\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\n/**\n * In-memory WebSocket transport for use in unit tests and Cypress specs.\n *\n * Wire it up via WebSocketManagerConfig.transport, then drive the connection\n * state with simulateMessage() and simulateClose().\n */\nexport class MockWebSocketTransport implements IWebSocketTransport {\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n private connected = false;\n\n public connect(_url: string): void {\n this.connected = true;\n // Simulate async open so callers can register handlers before the event fires\n setTimeout(() => {\n this.onopen?.();\n }, 0);\n }\n\n /** No-op by design. Spy on this method to assert outgoing frames in tests. */\n public send(_data: string): void {\n // intentionally empty\n }\n\n public close(code = 1000, _reason?: string): void {\n if (!this.connected) return;\n this.simulateClose(code);\n }\n\n /**\n * Deliver a raw message string directly to the manager's onmessage handler.\n * Call this from tests/Cypress to simulate server-pushed frames.\n */\n public simulateMessage(data: string): void {\n this.onmessage?.(data);\n }\n\n /**\n * Fire a CloseEvent on the manager's onclose handler.\n * Call this from tests/Cypress to simulate a server-initiated disconnect.\n */\n public simulateClose(code = 1000): void {\n this.connected = false;\n const event = new CloseEvent('close', { code, wasClean: code === 1000 });\n this.onclose?.(event);\n }\n}\n"],"mappings":"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","names":["z","z","z"]}
1
+ {"version":3,"sources":["../src/event-bus.ts","../src/types/ws-identified.ts","../src/types/campaign-saving.ts","../src/types/server-message.ts","../src/websocket-manager.ts","../src/transports/real.transport.ts","../src/transports/mock.transport.ts"],"sourcesContent":["import mitt from 'mitt';\nimport type { Emitter } from 'mitt';\nimport type { WsEventMap } from './types';\n\n// Intersect with Record<string | symbol, unknown> to satisfy mitt's generic\n// constraint while keeping WsEventMap clean of index signatures.\ntype MittMap = WsEventMap & Record<string | symbol, unknown>;\n\nexport type EventBus = Emitter<MittMap>;\n\nexport const eventBus: EventBus = mitt<MittMap>();\n","import { z } from 'zod';\n\nexport const wsIdentifiedPayloadSchema = z.object({\n connectionId: z.string(),\n});\n\nexport type WsIdentifiedPayload = z.infer<typeof wsIdentifiedPayloadSchema>;\n","import { z } from 'zod';\n\nconst campaignSavingStartSchema = z.object({\n status: z.literal('start'),\n});\n\nconst campaignSavingSuccessSchema = z.object({\n status: z.literal('success'),\n});\n\nconst campaignSavingFailedSchema = z.object({\n status: z.literal('failed'),\n error: z.record(z.string(), z.unknown()),\n});\n\nexport const campaignSavingPayloadSchema = z.discriminatedUnion('status', [\n campaignSavingStartSchema,\n campaignSavingSuccessSchema,\n campaignSavingFailedSchema,\n]);\n\nexport type CampaignSavingPayload = z.infer<typeof campaignSavingPayloadSchema>;\n","import { z } from 'zod';\nimport type { WsEventMap } from './ws-event-map';\nimport { wsIdentifiedPayloadSchema } from './ws-identified';\nimport { campaignSavingPayloadSchema } from './campaign-saving';\n\n// ---------------------------------------------------------------------------\n// Client-only events — fired by the transport layer, never sent by the server\n// ---------------------------------------------------------------------------\n\ntype ClientOnlyEvent =\n | 'ws:connected'\n | 'ws:disconnected'\n | 'ws:error'\n | 'ws:max-retries-exceeded'\n | 'ws:unparseable';\n\n// ---------------------------------------------------------------------------\n// ServerEventKey — every WsEventMap key the server is allowed to send\n// Automatically excludes client-only events; stays in sync with WsEventMap.\n// ---------------------------------------------------------------------------\n\nexport type ServerEventKey = Exclude<keyof WsEventMap, ClientOnlyEvent>;\n\n// ---------------------------------------------------------------------------\n// ServerMessage — strict discriminated union of all valid server wire frames.\n// Use this in backend code and anywhere a server frame must be type-narrowed.\n//\n// Shape: { type: ServerEventKey; payload: WsEventMap[K] }\n// ---------------------------------------------------------------------------\n\nexport type ServerMessage = {\n [K in ServerEventKey]: { type: K; payload: WsEventMap[K] };\n}[ServerEventKey];\n\n// ---------------------------------------------------------------------------\n// serverMessageSchema — Zod discriminated union over every server event.\n// Validates that an incoming frame is both structurally and semantically valid.\n//\n// NOTE: if you extend WsEventMap via module augmentation you must also add\n// the corresponding member to this schema.\n// ---------------------------------------------------------------------------\n\nexport const serverMessageSchema = z.discriminatedUnion('type', [\n z.object({ type: z.literal('ws:identified'), payload: wsIdentifiedPayloadSchema }),\n z.object({ type: z.literal('campaign:saving'), payload: campaignSavingPayloadSchema }),\n]);\n\nexport type ServerMessageParsed = z.infer<typeof serverMessageSchema>;\n","import type { EventBus } from './event-bus';\nimport type { WebSocketManagerConfig } from './types';\nimport { serverMessageSchema } from './types';\n\nexport type { WebSocketManagerConfig } from './types';\n\nconst DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;\nconst DEFAULT_BASE_RECONNECT_DELAY_MS = 500;\nconst DEFAULT_MAX_RECONNECT_DELAY_MS = 30_000;\nconst DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;\nconst DEFAULT_HEARTBEAT_MESSAGE: Record<string, unknown> = { action: \"who-am-I\" };\n\ntype ResolvedConfig = Required<WebSocketManagerConfig>;\n\nexport class WebSocketManager {\n private static instance: WebSocketManager | undefined;\n\n private readonly bus: EventBus;\n private readonly config: ResolvedConfig;\n\n private reconnectAttempts = 0;\n private intentionalClose = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | undefined;\n private reconnectTimer: ReturnType<typeof setTimeout> | undefined;\n private connectionId: string | undefined;\n\n private constructor(bus: EventBus, config: WebSocketManagerConfig) {\n this.bus = bus;\n this.config = {\n url: config.url,\n transport: config.transport,\n maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS,\n baseReconnectDelayMs: config.baseReconnectDelayMs ?? DEFAULT_BASE_RECONNECT_DELAY_MS,\n maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS,\n heartbeatIntervalMs: config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,\n heartbeatMessage: config.heartbeatMessage ?? DEFAULT_HEARTBEAT_MESSAGE,\n };\n\n this.connect();\n }\n\n /**\n * Returns the singleton instance, creating it on first call.\n * Subsequent calls ignore `bus` and `config` — pass them only on first call.\n */\n public static getInstance(bus: EventBus, config: WebSocketManagerConfig): WebSocketManager {\n if (!WebSocketManager.instance) {\n WebSocketManager.instance = new WebSocketManager(bus, config);\n }\n return WebSocketManager.instance;\n }\n\n /**\n * Destroy the singleton.\n * Call before re-initialising in tests or when switching environments.\n */\n public static reset(): void {\n WebSocketManager.instance = undefined;\n }\n\n /** Returns the connectionId received from the last \"ws:identified\" message. */\n public getConnectionId(): string | undefined {\n return this.connectionId;\n }\n\n /** Gracefully close the connection without triggering automatic reconnect. */\n public disconnect(): void {\n this.intentionalClose = true;\n this.clearHeartbeat();\n this.clearReconnectTimer();\n this.config.transport.close(1000, 'client disconnect');\n }\n\n private connect(): void {\n const { transport, url } = this.config;\n\n transport.onopen = (): void => {\n this.reconnectAttempts = 0;\n this.intentionalClose = false;\n this.startHeartbeat();\n this.bus.emit('ws:connected', undefined);\n };\n\n transport.onmessage = (data: string): void => {\n this.handleMessage(data);\n };\n\n transport.onclose = (event: CloseEvent): void => {\n this.clearHeartbeat();\n this.connectionId = undefined;\n this.bus.emit('ws:disconnected', event);\n\n if (!this.intentionalClose) {\n this.scheduleReconnect();\n }\n };\n\n transport.onerror = (event: Event): void => {\n this.bus.emit('ws:error', event);\n };\n\n transport.connect(url);\n }\n\n private handleMessage(raw: string): void {\n let parsedJson: unknown;\n\n try {\n parsedJson = JSON.parse(raw);\n } catch {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n const result = serverMessageSchema.safeParse(parsedJson);\n if (!result.success) {\n this.bus.emit('ws:unparseable', raw);\n return;\n }\n\n // result.data is ServerMessageParsed — a strict discriminated union.\n // The switch is exhaustive: adding a new event to serverMessageSchema\n // without handling it here is a compile error.\n const msg = result.data;\n\n switch (msg.type) {\n case 'ws:identified':\n this.connectionId = msg.payload.connectionId;\n this.bus.emit('ws:identified', msg.payload);\n break;\n case 'campaign:saving':\n this.bus.emit('campaign:saving', msg.payload);\n break;\n default: {\n // Exhaustive guard — msg is `never` if all cases are handled.\n // If this line errors, a new ServerMessage member needs a case above.\n const _exhaustive: never = msg;\n }\n }\n }\n\n private scheduleReconnect(): void {\n if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {\n this.bus.emit('ws:max-retries-exceeded', undefined);\n return;\n }\n\n const { baseReconnectDelayMs, maxReconnectDelayMs } = this.config;\n const exponential = baseReconnectDelayMs * Math.pow(2, this.reconnectAttempts);\n const capped = Math.min(exponential, maxReconnectDelayMs);\n // Add random jitter up to one base interval to avoid thundering herd\n const delay = capped + Math.random() * baseReconnectDelayMs;\n\n this.reconnectAttempts++;\n\n this.reconnectTimer = setTimeout(() => {\n this.connect();\n }, delay);\n }\n\n private clearReconnectTimer(): void {\n if (this.reconnectTimer !== undefined) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = undefined;\n }\n }\n\n private startHeartbeat(): void {\n this.clearHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n this.config.transport.send(JSON.stringify(this.config.heartbeatMessage));\n }, this.config.heartbeatIntervalMs);\n }\n\n private clearHeartbeat(): void {\n if (this.heartbeatTimer !== undefined) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = undefined;\n }\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\nexport class RealWebSocketTransport implements IWebSocketTransport {\n private socket: WebSocket | null = null;\n\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n public connect(url: string): void {\n this.socket = new WebSocket(url);\n\n this.socket.addEventListener('open', () => {\n this.onopen?.();\n });\n\n this.socket.addEventListener('message', (event: MessageEvent) => {\n if (typeof event.data === 'string') {\n this.onmessage?.(event.data);\n }\n });\n\n this.socket.addEventListener('close', (event: CloseEvent) => {\n this.onclose?.(event);\n });\n\n this.socket.addEventListener('error', (event: Event) => {\n this.onerror?.(event);\n });\n }\n\n public send(data: string): void {\n if (this.socket?.readyState === WebSocket.OPEN) {\n this.socket.send(data);\n }\n }\n\n public close(code?: number, reason?: string): void {\n this.socket?.close(code, reason);\n }\n}\n","import type { IWebSocketTransport } from '../transport.interface';\n\n/**\n * In-memory WebSocket transport for use in unit tests and Cypress specs.\n *\n * Wire it up via WebSocketManagerConfig.transport, then drive the connection\n * state with simulateMessage() and simulateClose().\n */\nexport class MockWebSocketTransport implements IWebSocketTransport {\n public onopen: (() => void) | null = null;\n public onmessage: ((data: string) => void) | null = null;\n public onclose: ((event: CloseEvent) => void) | null = null;\n public onerror: ((event: Event) => void) | null = null;\n\n private connected = false;\n\n public connect(_url: string): void {\n this.connected = true;\n // Simulate async open so callers can register handlers before the event fires\n setTimeout(() => {\n this.onopen?.();\n }, 0);\n }\n\n /** No-op by design. Spy on this method to assert outgoing frames in tests. */\n public send(_data: string): void {\n // intentionally empty\n }\n\n public close(code = 1000, _reason?: string): void {\n if (!this.connected) return;\n this.simulateClose(code);\n }\n\n /**\n * Deliver a raw message string directly to the manager's onmessage handler.\n * Call this from tests/Cypress to simulate server-pushed frames.\n */\n public simulateMessage(data: string): void {\n this.onmessage?.(data);\n }\n\n /**\n * Fire a CloseEvent on the manager's onclose handler.\n * Call this from tests/Cypress to simulate a server-initiated disconnect.\n */\n public simulateClose(code = 1000): void {\n this.connected = false;\n const event = new CloseEvent('close', { code, wasClean: code === 1000 });\n this.onclose?.(event);\n }\n}\n"],"mappings":";AAAA,OAAO,UAAU;AAUV,IAAM,WAAqB,KAAc;;;ACVhD,SAAS,SAAS;AAEX,IAAM,4BAA4B,EAAE,OAAO;AAAA,EAChD,cAAc,EAAE,OAAO;AACzB,CAAC;;;ACJD,SAAS,KAAAA,UAAS;AAElB,IAAM,4BAA4BA,GAAE,OAAO;AAAA,EACzC,QAAQA,GAAE,QAAQ,OAAO;AAC3B,CAAC;AAED,IAAM,8BAA8BA,GAAE,OAAO;AAAA,EAC3C,QAAQA,GAAE,QAAQ,SAAS;AAC7B,CAAC;AAED,IAAM,6BAA6BA,GAAE,OAAO;AAAA,EAC1C,QAAQA,GAAE,QAAQ,QAAQ;AAAA,EAC1B,OAAOA,GAAE,OAAOA,GAAE,OAAO,GAAGA,GAAE,QAAQ,CAAC;AACzC,CAAC;AAEM,IAAM,8BAA8BA,GAAE,mBAAmB,UAAU;AAAA,EACxE;AAAA,EACA;AAAA,EACA;AACF,CAAC;;;ACnBD,SAAS,KAAAC,UAAS;AA0CX,IAAM,sBAAsBC,GAAE,mBAAmB,QAAQ;AAAA,EAC9DA,GAAE,OAAO,EAAE,MAAMA,GAAE,QAAQ,eAAe,GAAG,SAAS,0BAA0B,CAAC;AAAA,EACjFA,GAAE,OAAO,EAAE,MAAMA,GAAE,QAAQ,iBAAiB,GAAG,SAAS,4BAA4B,CAAC;AACvF,CAAC;;;ACvCD,IAAM,iCAAiC;AACvC,IAAM,kCAAkC;AACxC,IAAM,iCAAiC;AACvC,IAAM,gCAAgC;AACtC,IAAM,4BAAqD,EAAE,QAAQ,WAAW;AAIzE,IAAM,mBAAN,MAAM,kBAAiB;AAAA,EAC5B,OAAe;AAAA,EAEE;AAAA,EACA;AAAA,EAET,oBAAoB;AAAA,EACpB,mBAAmB;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EAEA,YAAY,KAAe,QAAgC;AACjE,SAAK,MAAM;AACX,SAAK,SAAS;AAAA,MACZ,KAAK,OAAO;AAAA,MACZ,WAAW,OAAO;AAAA,MAClB,sBAAsB,OAAO,wBAAwB;AAAA,MACrD,sBAAsB,OAAO,wBAAwB;AAAA,MACrD,qBAAqB,OAAO,uBAAuB;AAAA,MACnD,qBAAqB,OAAO,uBAAuB;AAAA,MACnD,kBAAkB,OAAO,oBAAoB;AAAA,IAC/C;AAEA,SAAK,QAAQ;AAAA,EACf;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAc,YAAY,KAAe,QAAkD;AACzF,QAAI,CAAC,kBAAiB,UAAU;AAC9B,wBAAiB,WAAW,IAAI,kBAAiB,KAAK,MAAM;AAAA,IAC9D;AACA,WAAO,kBAAiB;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAc,QAAc;AAC1B,sBAAiB,WAAW;AAAA,EAC9B;AAAA;AAAA,EAGO,kBAAsC;AAC3C,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGO,aAAmB;AACxB,SAAK,mBAAmB;AACxB,SAAK,eAAe;AACpB,SAAK,oBAAoB;AACzB,SAAK,OAAO,UAAU,MAAM,KAAM,mBAAmB;AAAA,EACvD;AAAA,EAEQ,UAAgB;AACtB,UAAM,EAAE,WAAW,IAAI,IAAI,KAAK;AAEhC,cAAU,SAAS,MAAY;AAC7B,WAAK,oBAAoB;AACzB,WAAK,mBAAmB;AACxB,WAAK,eAAe;AACpB,WAAK,IAAI,KAAK,gBAAgB,MAAS;AAAA,IACzC;AAEA,cAAU,YAAY,CAAC,SAAuB;AAC5C,WAAK,cAAc,IAAI;AAAA,IACzB;AAEA,cAAU,UAAU,CAAC,UAA4B;AAC/C,WAAK,eAAe;AACpB,WAAK,eAAe;AACpB,WAAK,IAAI,KAAK,mBAAmB,KAAK;AAEtC,UAAI,CAAC,KAAK,kBAAkB;AAC1B,aAAK,kBAAkB;AAAA,MACzB;AAAA,IACF;AAEA,cAAU,UAAU,CAAC,UAAuB;AAC1C,WAAK,IAAI,KAAK,YAAY,KAAK;AAAA,IACjC;AAEA,cAAU,QAAQ,GAAG;AAAA,EACvB;AAAA,EAEQ,cAAc,KAAmB;AACvC,QAAI;AAEJ,QAAI;AACF,mBAAa,KAAK,MAAM,GAAG;AAAA,IAC7B,QAAQ;AACN,WAAK,IAAI,KAAK,kBAAkB,GAAG;AACnC;AAAA,IACF;AAEA,UAAM,SAAS,oBAAoB,UAAU,UAAU;AACvD,QAAI,CAAC,OAAO,SAAS;AACnB,WAAK,IAAI,KAAK,kBAAkB,GAAG;AACnC;AAAA,IACF;AAKA,UAAM,MAAM,OAAO;AAEnB,YAAQ,IAAI,MAAM;AAAA,MAChB,KAAK;AACH,aAAK,eAAe,IAAI,QAAQ;AAChC,aAAK,IAAI,KAAK,iBAAiB,IAAI,OAAO;AAC1C;AAAA,MACF,KAAK;AACH,aAAK,IAAI,KAAK,mBAAmB,IAAI,OAAO;AAC5C;AAAA,MACF,SAAS;AAGP,cAAM,cAAqB;AAAA,MAC7B;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,oBAA0B;AAChC,QAAI,KAAK,qBAAqB,KAAK,OAAO,sBAAsB;AAC9D,WAAK,IAAI,KAAK,2BAA2B,MAAS;AAClD;AAAA,IACF;AAEA,UAAM,EAAE,sBAAsB,oBAAoB,IAAI,KAAK;AAC3D,UAAM,cAAc,uBAAuB,KAAK,IAAI,GAAG,KAAK,iBAAiB;AAC7E,UAAM,SAAS,KAAK,IAAI,aAAa,mBAAmB;AAExD,UAAM,QAAQ,SAAS,KAAK,OAAO,IAAI;AAEvC,SAAK;AAEL,SAAK,iBAAiB,WAAW,MAAM;AACrC,WAAK,QAAQ;AAAA,IACf,GAAG,KAAK;AAAA,EACV;AAAA,EAEQ,sBAA4B;AAClC,QAAI,KAAK,mBAAmB,QAAW;AACrC,mBAAa,KAAK,cAAc;AAChC,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEQ,iBAAuB;AAC7B,SAAK,eAAe;AACpB,SAAK,iBAAiB,YAAY,MAAM;AACtC,WAAK,OAAO,UAAU,KAAK,KAAK,UAAU,KAAK,OAAO,gBAAgB,CAAC;AAAA,IACzE,GAAG,KAAK,OAAO,mBAAmB;AAAA,EACpC;AAAA,EAEQ,iBAAuB;AAC7B,QAAI,KAAK,mBAAmB,QAAW;AACrC,oBAAc,KAAK,cAAc;AACjC,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AACF;;;AClLO,IAAM,yBAAN,MAA4D;AAAA,EACzD,SAA2B;AAAA,EAE5B,SAA8B;AAAA,EAC9B,YAA6C;AAAA,EAC7C,UAAgD;AAAA,EAChD,UAA2C;AAAA,EAE3C,QAAQ,KAAmB;AAChC,SAAK,SAAS,IAAI,UAAU,GAAG;AAE/B,SAAK,OAAO,iBAAiB,QAAQ,MAAM;AACzC,WAAK,SAAS;AAAA,IAChB,CAAC;AAED,SAAK,OAAO,iBAAiB,WAAW,CAAC,UAAwB;AAC/D,UAAI,OAAO,MAAM,SAAS,UAAU;AAClC,aAAK,YAAY,MAAM,IAAI;AAAA,MAC7B;AAAA,IACF,CAAC;AAED,SAAK,OAAO,iBAAiB,SAAS,CAAC,UAAsB;AAC3D,WAAK,UAAU,KAAK;AAAA,IACtB,CAAC;AAED,SAAK,OAAO,iBAAiB,SAAS,CAAC,UAAiB;AACtD,WAAK,UAAU,KAAK;AAAA,IACtB,CAAC;AAAA,EACH;AAAA,EAEO,KAAK,MAAoB;AAC9B,QAAI,KAAK,QAAQ,eAAe,UAAU,MAAM;AAC9C,WAAK,OAAO,KAAK,IAAI;AAAA,IACvB;AAAA,EACF;AAAA,EAEO,MAAM,MAAe,QAAuB;AACjD,SAAK,QAAQ,MAAM,MAAM,MAAM;AAAA,EACjC;AACF;;;ACjCO,IAAM,yBAAN,MAA4D;AAAA,EAC1D,SAA8B;AAAA,EAC9B,YAA6C;AAAA,EAC7C,UAAgD;AAAA,EAChD,UAA2C;AAAA,EAE1C,YAAY;AAAA,EAEb,QAAQ,MAAoB;AACjC,SAAK,YAAY;AAEjB,eAAW,MAAM;AACf,WAAK,SAAS;AAAA,IAChB,GAAG,CAAC;AAAA,EACN;AAAA;AAAA,EAGO,KAAK,OAAqB;AAAA,EAEjC;AAAA,EAEO,MAAM,OAAO,KAAM,SAAwB;AAChD,QAAI,CAAC,KAAK,UAAW;AACrB,SAAK,cAAc,IAAI;AAAA,EACzB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMO,gBAAgB,MAAoB;AACzC,SAAK,YAAY,IAAI;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMO,cAAc,OAAO,KAAY;AACtC,SAAK,YAAY;AACjB,UAAM,QAAQ,IAAI,WAAW,SAAS,EAAE,MAAM,UAAU,SAAS,IAAK,CAAC;AACvE,SAAK,UAAU,KAAK;AAAA,EACtB;AACF;","names":["z","z","z"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studyportals/ws-client",
3
- "version": "0.1.1-beta.5",
3
+ "version": "0.1.1-beta.6",
4
4
  "description": "WebSocket client with reconnect, heartbeat, and typed event bus",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",