@kyneta/sse-transport 1.1.0
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/LICENSE +21 -0
- package/README.md +334 -0
- package/dist/chunk-7D4SUZUM.js +38 -0
- package/dist/chunk-7D4SUZUM.js.map +1 -0
- package/dist/chunk-TR4Y3HFB.js +255 -0
- package/dist/chunk-TR4Y3HFB.js.map +1 -0
- package/dist/client.d.ts +144 -0
- package/dist/client.js +460 -0
- package/dist/client.js.map +1 -0
- package/dist/express.d.ts +135 -0
- package/dist/express.js +23021 -0
- package/dist/express.js.map +1 -0
- package/dist/server-transport-BrMRLsmp.d.ts +180 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +12 -0
- package/dist/server.js.map +1 -0
- package/dist/types-BTgljZGe.d.ts +83 -0
- package/package.json +60 -0
- package/src/__tests__/client-state-machine.test.ts +201 -0
- package/src/__tests__/connection.test.ts +184 -0
- package/src/__tests__/sse-handler.test.ts +145 -0
- package/src/client-state-machine.ts +69 -0
- package/src/client-transport.ts +722 -0
- package/src/client.ts +30 -0
- package/src/connection.ts +181 -0
- package/src/express-router.ts +231 -0
- package/src/express.ts +29 -0
- package/src/server-transport.ts +229 -0
- package/src/server.ts +33 -0
- package/src/sse-handler.ts +116 -0
- package/src/types.ts +108 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/connection.ts","../src/server-transport.ts"],"sourcesContent":["// connection — SseConnection for server-side peer connections.\n//\n// Wraps a TextReassembler + textCodec to provide send/receive for\n// ChannelMsg over a single SSE connection.\n//\n// Used by SseServerTransport to manage individual client connections.\n// The client adapter handles its own encoding/decoding inline since it\n// manages a single EventSource with reconnection logic.\n//\n// The sendFn receives pre-encoded text frame strings. Framework\n// integrations just wrap them in SSE syntax:\n// Express: res.write(`data: ${textFrame}\\n\\n`)\n// Hono: stream.writeSSE({ data: textFrame })\n\nimport type { Channel, ChannelMsg, PeerId } from \"@kyneta/exchange\"\nimport {\n encodeTextComplete,\n fragmentTextPayload,\n TextReassembler,\n textCodec,\n} from \"@kyneta/wire\"\n\n/**\n * Default fragment threshold in characters for outbound SSE messages.\n * 60K chars provides a safety margin below typical infrastructure limits.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 60_000\n\n/**\n * Configuration for creating an SseConnection.\n */\nexport interface SseConnectionConfig {\n /**\n * Fragment threshold in characters. Messages larger than this are fragmented.\n * Set to 0 to disable fragmentation.\n * Default: 60000 (60K chars)\n */\n fragmentThreshold?: number\n}\n\n/**\n * Represents a single SSE connection to a peer (server-side).\n *\n * Manages encoding, framing, fragmentation, and reassembly for one\n * connected client. Created by `SseServerTransport.registerConnection()`.\n *\n * The connection uses the text codec for transport — this is the natural\n * choice for SSE's text-only protocol.\n */\nexport class SseConnection {\n readonly peerId: PeerId\n readonly channelId: number\n\n #channel: Channel | null = null\n #sendFn: ((textFrame: string) => void) | null = null\n #onDisconnect: (() => void) | null = null\n\n // Fragmentation support\n readonly #fragmentThreshold: number\n\n /**\n * Text reassembler for handling fragmented POST bodies.\n * Each connection has its own reassembler to track in-flight fragment batches.\n */\n readonly reassembler: TextReassembler\n\n constructor(peerId: PeerId, channelId: number, config?: SseConnectionConfig) {\n this.peerId = peerId\n this.channelId = channelId\n this.#fragmentThreshold =\n config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n this.reassembler = new TextReassembler({\n timeoutMs: 10_000,\n onTimeout: (frameId: string) => {\n console.warn(\n `[SseConnection] Fragment batch timed out for peer ${peerId}: ${frameId}`,\n )\n },\n })\n }\n\n // ==========================================================================\n // INTERNAL API — for adapter use\n // ==========================================================================\n\n /**\n * Set the channel reference.\n * Called by the adapter when the channel is created.\n * @internal\n */\n _setChannel(channel: Channel): void {\n this.#channel = channel\n }\n\n // ==========================================================================\n // PUBLIC API\n // ==========================================================================\n\n /**\n * Set the function to call when sending messages to this peer.\n *\n * The function receives a fully encoded text frame string.\n * The framework integration just wraps it in SSE syntax:\n * - Express: `res.write(\\`data: \\${textFrame}\\\\n\\\\n\\`)`\n * - Hono: `stream.writeSSE({ data: textFrame })`\n *\n * @param sendFn Function that writes a text frame string to the SSE stream\n */\n setSendFunction(sendFn: (textFrame: string) => void): void {\n this.#sendFn = sendFn\n }\n\n /**\n * Set the function to call when this connection is disconnected.\n */\n setDisconnectHandler(handler: () => void): void {\n this.#onDisconnect = handler\n }\n\n /**\n * Send a ChannelMsg to the peer through the SSE stream.\n *\n * Encodes via textCodec → text frame → fragment if needed → sendFn().\n * Encoding and fragmentation are the connection's concern — the\n * framework integration only needs to write strings.\n */\n send(msg: ChannelMsg): void {\n if (!this.#sendFn) {\n throw new Error(\n `Cannot send message: send function not set for peer ${this.peerId}`,\n )\n }\n\n // Encode to text wire format\n const textFrame = encodeTextComplete(textCodec, msg)\n\n // Fragment large payloads\n if (\n this.#fragmentThreshold > 0 &&\n textFrame.length > this.#fragmentThreshold\n ) {\n const payload = JSON.stringify(textCodec.encode(msg))\n const fragments = fragmentTextPayload(payload, this.#fragmentThreshold)\n for (const fragment of fragments) {\n this.#sendFn(fragment)\n }\n } else {\n this.#sendFn(textFrame)\n }\n }\n\n /**\n * Receive a message from the peer and route it to the channel.\n *\n * Called by the framework integration after parsing a POST body\n * through `parseTextPostBody`.\n */\n receive(msg: ChannelMsg): void {\n if (!this.#channel) {\n throw new Error(\n `Cannot receive message: channel not set for peer ${this.peerId}`,\n )\n }\n this.#channel.onReceive(msg)\n }\n\n /**\n * Disconnect this connection.\n */\n disconnect(): void {\n this.#onDisconnect?.()\n }\n\n /**\n * Dispose of resources held by this connection.\n * Must be called when the connection is closed to prevent timer leaks.\n */\n dispose(): void {\n this.reassembler.dispose()\n }\n}\n","// server-adapter — SSE server adapter for @kyneta/exchange.\n//\n// Manages SSE connections from clients, encoding/decoding via the\n// kyneta text wire format. Framework-agnostic — works with any HTTP\n// framework through the SseConnection's setSendFunction() callback.\n//\n// Usage with Express:\n// import { SseServerTransport } from \"@kyneta/sse-network-adapter/server\"\n// import { createSseExpressRouter } from \"@kyneta/sse-network-adapter/express\"\n//\n// const serverAdapter = new SseServerTransport()\n// app.use(\"/sse\", createSseExpressRouter(serverAdapter))\n//\n// Usage with Hono:\n// import { SseServerTransport } from \"@kyneta/sse-network-adapter/server\"\n// import { parseTextPostBody } from \"@kyneta/sse-network-adapter/express\"\n//\n// const serverAdapter = new SseServerTransport()\n// // Wire up GET /events and POST /sync manually using\n// // serverAdapter.registerConnection() and parseTextPostBody()\n\nimport type { ChannelMsg, GeneratedChannel, PeerId } from \"@kyneta/exchange\"\nimport { Transport } from \"@kyneta/exchange\"\nimport {\n DEFAULT_FRAGMENT_THRESHOLD,\n SseConnection,\n type SseConnectionConfig,\n} from \"./connection.js\"\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Options for the SSE server adapter.\n */\nexport interface SseServerTransportOptions {\n /**\n * Fragment threshold in characters. Messages larger than this are fragmented\n * into multiple SSE events.\n * Set to 0 to disable fragmentation.\n * Default: 60000 (60K chars)\n */\n fragmentThreshold?: number\n}\n\n// ---------------------------------------------------------------------------\n// Peer ID generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a random peer ID for connections that don't provide one.\n */\nfunction generatePeerId(): PeerId {\n const chars = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\"\n let result = \"sse-\"\n for (let i = 0; i < 12; i++) {\n result += chars.charAt(Math.floor(Math.random() * chars.length))\n }\n return result\n}\n\n// ---------------------------------------------------------------------------\n// SseServerTransport\n// ---------------------------------------------------------------------------\n\n/**\n * SSE server network adapter.\n *\n * Framework-agnostic — works with any HTTP framework through the\n * `SseConnection.setSendFunction()` callback. Use `registerConnection()`\n * to integrate with your framework's SSE endpoint handler.\n *\n * Each client connection is tracked as an `SseConnection` keyed by peer ID.\n * The adapter creates a channel per connection and routes outbound messages\n * through the connection's send method (which encodes to text wire format\n * and calls the injected sendFn).\n *\n * The connection handshake:\n * 1. Client opens EventSource (GET /events)\n * 2. Server calls `registerConnection(peerId)` → creates channel\n * 3. Client's EventSource.onopen fires → client sends establish-request (POST)\n * 4. Server receives establish-request → Synchronizer responds with establish-response (SSE)\n *\n * The server does NOT call `establishChannel()` — it waits for the client's\n * establish-request, which arrives via POST after the EventSource is open.\n */\nexport class SseServerTransport extends Transport<PeerId> {\n #connections = new Map<PeerId, SseConnection>()\n readonly #fragmentThreshold: number\n\n constructor(options?: SseServerTransportOptions) {\n super({ transportType: \"sse-server\" })\n this.#fragmentThreshold =\n options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n }\n\n // ==========================================================================\n // Adapter abstract method implementations\n // ==========================================================================\n\n protected generate(peerId: PeerId): GeneratedChannel {\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n const connection = this.#connections.get(peerId)\n if (connection) {\n connection.send(msg)\n }\n },\n stop: () => {\n this.unregisterConnection(peerId)\n },\n }\n }\n\n async onStart(): Promise<void> {\n // Server adapter starts passively — connections arrive via registerConnection()\n }\n\n async onStop(): Promise<void> {\n // Disconnect all active connections\n for (const connection of this.#connections.values()) {\n connection.disconnect()\n }\n this.#connections.clear()\n }\n\n // ==========================================================================\n // Connection management\n // ==========================================================================\n\n /**\n * Register a new peer connection.\n *\n * Call this from your framework's SSE endpoint handler when a client\n * connects via EventSource. Returns an `SseConnection` that you wire\n * up with `setSendFunction()` and `setDisconnectHandler()`.\n *\n * @param peerId The unique identifier for the peer (from query param or header)\n * @returns An SseConnection object for managing the connection\n *\n * @example Express\n * ```typescript\n * const connection = serverAdapter.registerConnection(peerId)\n * connection.setSendFunction((textFrame) => {\n * res.write(`data: ${textFrame}\\n\\n`)\n * })\n * ```\n *\n * @example Hono\n * ```typescript\n * const connection = serverAdapter.registerConnection(peerId)\n * connection.setSendFunction((textFrame) => {\n * stream.writeSSE({ data: textFrame })\n * })\n * ```\n */\n registerConnection(peerId?: PeerId): SseConnection {\n const resolvedPeerId = peerId ?? generatePeerId()\n\n // Check for existing connection and clean it up\n const existingConnection = this.#connections.get(resolvedPeerId)\n if (existingConnection) {\n existingConnection.dispose()\n this.unregisterConnection(resolvedPeerId)\n }\n\n // Create channel for this peer\n const channel = this.addChannel(resolvedPeerId)\n\n // Create connection object with fragmentation config\n const connection = new SseConnection(resolvedPeerId, channel.channelId, {\n fragmentThreshold: this.#fragmentThreshold,\n })\n connection._setChannel(channel)\n\n // Store connection\n this.#connections.set(resolvedPeerId, connection)\n\n return connection\n }\n\n /**\n * Unregister a peer connection.\n *\n * Removes the channel, disposes the connection's reassembler,\n * and cleans up tracking state. Called automatically when the\n * client disconnects (via req.on(\"close\")) or manually.\n *\n * @param peerId The unique identifier for the peer\n */\n unregisterConnection(peerId: PeerId): void {\n const connection = this.#connections.get(peerId)\n if (connection) {\n connection.dispose()\n this.removeChannel(connection.channelId)\n this.#connections.delete(peerId)\n }\n }\n\n /**\n * Get an active connection by peer ID.\n */\n getConnection(peerId: PeerId): SseConnection | undefined {\n return this.#connections.get(peerId)\n }\n\n /**\n * Get all active connections.\n */\n getAllConnections(): SseConnection[] {\n return Array.from(this.#connections.values())\n }\n\n /**\n * Check if a peer is connected.\n */\n isConnected(peerId: PeerId): boolean {\n return this.#connections.has(peerId)\n }\n\n /**\n * Get the number of connected peers.\n */\n get connectionCount(): number {\n return this.#connections.size\n }\n}\n"],"mappings":";AAeA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAMA,IAAM,6BAA6B;AAuBnC,IAAM,gBAAN,MAAoB;AAAA,EAChB;AAAA,EACA;AAAA,EAET,WAA2B;AAAA,EAC3B,UAAgD;AAAA,EAChD,gBAAqC;AAAA;AAAA,EAG5B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA;AAAA,EAET,YAAY,QAAgB,WAAmB,QAA8B;AAC3E,SAAK,SAAS;AACd,SAAK,YAAY;AACjB,SAAK,qBACH,QAAQ,qBAAqB;AAC/B,SAAK,cAAc,IAAI,gBAAgB;AAAA,MACrC,WAAW;AAAA,MACX,WAAW,CAAC,YAAoB;AAC9B,gBAAQ;AAAA,UACN,qDAAqD,MAAM,KAAK,OAAO;AAAA,QACzE;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,YAAY,SAAwB;AAClC,SAAK,WAAW;AAAA,EAClB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBA,gBAAgB,QAA2C;AACzD,SAAK,UAAU;AAAA,EACjB;AAAA;AAAA;AAAA;AAAA,EAKA,qBAAqB,SAA2B;AAC9C,SAAK,gBAAgB;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,KAAK,KAAuB;AAC1B,QAAI,CAAC,KAAK,SAAS;AACjB,YAAM,IAAI;AAAA,QACR,uDAAuD,KAAK,MAAM;AAAA,MACpE;AAAA,IACF;AAGA,UAAM,YAAY,mBAAmB,WAAW,GAAG;AAGnD,QACE,KAAK,qBAAqB,KAC1B,UAAU,SAAS,KAAK,oBACxB;AACA,YAAM,UAAU,KAAK,UAAU,UAAU,OAAO,GAAG,CAAC;AACpD,YAAM,YAAY,oBAAoB,SAAS,KAAK,kBAAkB;AACtE,iBAAW,YAAY,WAAW;AAChC,aAAK,QAAQ,QAAQ;AAAA,MACvB;AAAA,IACF,OAAO;AACL,WAAK,QAAQ,SAAS;AAAA,IACxB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,QAAQ,KAAuB;AAC7B,QAAI,CAAC,KAAK,UAAU;AAClB,YAAM,IAAI;AAAA,QACR,oDAAoD,KAAK,MAAM;AAAA,MACjE;AAAA,IACF;AACA,SAAK,SAAS,UAAU,GAAG;AAAA,EAC7B;AAAA;AAAA;AAAA;AAAA,EAKA,aAAmB;AACjB,SAAK,gBAAgB;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,UAAgB;AACd,SAAK,YAAY,QAAQ;AAAA,EAC3B;AACF;;;AC9JA,SAAS,iBAAiB;AA+B1B,SAAS,iBAAyB;AAChC,QAAM,QAAQ;AACd,MAAI,SAAS;AACb,WAAS,IAAI,GAAG,IAAI,IAAI,KAAK;AAC3B,cAAU,MAAM,OAAO,KAAK,MAAM,KAAK,OAAO,IAAI,MAAM,MAAM,CAAC;AAAA,EACjE;AACA,SAAO;AACT;AA2BO,IAAM,qBAAN,cAAiC,UAAkB;AAAA,EACxD,eAAe,oBAAI,IAA2B;AAAA,EACrC;AAAA,EAET,YAAY,SAAqC;AAC/C,UAAM,EAAE,eAAe,aAAa,CAAC;AACrC,SAAK,qBACH,SAAS,qBAAqB;AAAA,EAClC;AAAA;AAAA;AAAA;AAAA,EAMU,SAAS,QAAkC;AACnD,WAAO;AAAA,MACL,eAAe,KAAK;AAAA,MACpB,MAAM,CAAC,QAAoB;AACzB,cAAM,aAAa,KAAK,aAAa,IAAI,MAAM;AAC/C,YAAI,YAAY;AACd,qBAAW,KAAK,GAAG;AAAA,QACrB;AAAA,MACF;AAAA,MACA,MAAM,MAAM;AACV,aAAK,qBAAqB,MAAM;AAAA,MAClC;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,UAAyB;AAAA,EAE/B;AAAA,EAEA,MAAM,SAAwB;AAE5B,eAAW,cAAc,KAAK,aAAa,OAAO,GAAG;AACnD,iBAAW,WAAW;AAAA,IACxB;AACA,SAAK,aAAa,MAAM;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgCA,mBAAmB,QAAgC;AACjD,UAAM,iBAAiB,UAAU,eAAe;AAGhD,UAAM,qBAAqB,KAAK,aAAa,IAAI,cAAc;AAC/D,QAAI,oBAAoB;AACtB,yBAAmB,QAAQ;AAC3B,WAAK,qBAAqB,cAAc;AAAA,IAC1C;AAGA,UAAM,UAAU,KAAK,WAAW,cAAc;AAG9C,UAAM,aAAa,IAAI,cAAc,gBAAgB,QAAQ,WAAW;AAAA,MACtE,mBAAmB,KAAK;AAAA,IAC1B,CAAC;AACD,eAAW,YAAY,OAAO;AAG9B,SAAK,aAAa,IAAI,gBAAgB,UAAU;AAEhD,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,qBAAqB,QAAsB;AACzC,UAAM,aAAa,KAAK,aAAa,IAAI,MAAM;AAC/C,QAAI,YAAY;AACd,iBAAW,QAAQ;AACnB,WAAK,cAAc,WAAW,SAAS;AACvC,WAAK,aAAa,OAAO,MAAM;AAAA,IACjC;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,cAAc,QAA2C;AACvD,WAAO,KAAK,aAAa,IAAI,MAAM;AAAA,EACrC;AAAA;AAAA;AAAA;AAAA,EAKA,oBAAqC;AACnC,WAAO,MAAM,KAAK,KAAK,aAAa,OAAO,CAAC;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA,EAKA,YAAY,QAAyB;AACnC,WAAO,KAAK,aAAa,IAAI,MAAM;AAAA,EACrC;AAAA;AAAA;AAAA;AAAA,EAKA,IAAI,kBAA0B;AAC5B,WAAO,KAAK,aAAa;AAAA,EAC3B;AACF;","names":[]}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { PeerId, Transport, TransitionListener, GeneratedChannel, TransportFactory, ClientStateMachine } from '@kyneta/exchange';
|
|
2
|
+
export { StateTransition, TransitionListener } from '@kyneta/exchange';
|
|
3
|
+
import { b as SseClientLifecycleEvents, c as SseClientState } from './types-BTgljZGe.js';
|
|
4
|
+
export { D as DisconnectReason } from './types-BTgljZGe.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default fragment threshold in characters.
|
|
8
|
+
* 60K chars provides a safety margin below typical 100KB body-parser limits,
|
|
9
|
+
* accounting for JSON overhead and potential base64 expansion.
|
|
10
|
+
*/
|
|
11
|
+
declare const DEFAULT_FRAGMENT_THRESHOLD = 60000;
|
|
12
|
+
/**
|
|
13
|
+
* Options for the SSE client adapter.
|
|
14
|
+
*/
|
|
15
|
+
interface SseClientOptions {
|
|
16
|
+
/** URL for POST requests (client→server). String or function of peerId. */
|
|
17
|
+
postUrl: string | ((peerId: PeerId) => string);
|
|
18
|
+
/** URL for SSE EventSource (server→client). String or function of peerId. */
|
|
19
|
+
eventSourceUrl: string | ((peerId: PeerId) => string);
|
|
20
|
+
/** Reconnection options for EventSource. */
|
|
21
|
+
reconnect?: {
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
maxAttempts?: number;
|
|
24
|
+
baseDelay?: number;
|
|
25
|
+
maxDelay?: number;
|
|
26
|
+
};
|
|
27
|
+
/** POST retry options. */
|
|
28
|
+
postRetry?: {
|
|
29
|
+
maxAttempts?: number;
|
|
30
|
+
baseDelay?: number;
|
|
31
|
+
maxDelay?: number;
|
|
32
|
+
};
|
|
33
|
+
/** Fragment threshold in characters. Default: 60000 (60K chars). */
|
|
34
|
+
fragmentThreshold?: number;
|
|
35
|
+
/** Lifecycle event callbacks. */
|
|
36
|
+
lifecycle?: SseClientLifecycleEvents;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* SSE client network adapter for @kyneta/exchange.
|
|
40
|
+
*
|
|
41
|
+
* Uses two HTTP channels:
|
|
42
|
+
* - **EventSource** (GET, long-lived) for server→client messages
|
|
43
|
+
* - **fetch POST** for client→server messages
|
|
44
|
+
*
|
|
45
|
+
* Both directions use the text wire format (`textCodec` + text framing).
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* import { createSseClient } from "@kyneta/sse-network-adapter/client"
|
|
50
|
+
*
|
|
51
|
+
* const adapter = createSseClient({
|
|
52
|
+
* postUrl: "/sync",
|
|
53
|
+
* eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,
|
|
54
|
+
* reconnect: { enabled: true },
|
|
55
|
+
* })
|
|
56
|
+
*
|
|
57
|
+
* const exchange = new Exchange({
|
|
58
|
+
* identity: { peerId: "browser-client" },
|
|
59
|
+
* transports: [adapter],
|
|
60
|
+
* })
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
declare class SseClientTransport extends Transport<void> {
|
|
64
|
+
#private;
|
|
65
|
+
constructor(options: SseClientOptions);
|
|
66
|
+
/**
|
|
67
|
+
* Get the current state of the connection.
|
|
68
|
+
*/
|
|
69
|
+
getState(): SseClientState;
|
|
70
|
+
/**
|
|
71
|
+
* Subscribe to state transitions.
|
|
72
|
+
* @returns Unsubscribe function
|
|
73
|
+
*/
|
|
74
|
+
subscribeToTransitions(listener: TransitionListener<SseClientState>): () => void;
|
|
75
|
+
/**
|
|
76
|
+
* Wait for a specific state.
|
|
77
|
+
*/
|
|
78
|
+
waitForState(predicate: (state: SseClientState) => boolean, options?: {
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
}): Promise<SseClientState>;
|
|
81
|
+
/**
|
|
82
|
+
* Wait for a specific status.
|
|
83
|
+
*/
|
|
84
|
+
waitForStatus(status: SseClientState["status"], options?: {
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
}): Promise<SseClientState>;
|
|
87
|
+
/**
|
|
88
|
+
* Check if the client is connected (EventSource open, channel established).
|
|
89
|
+
*/
|
|
90
|
+
get isConnected(): boolean;
|
|
91
|
+
protected generate(): GeneratedChannel;
|
|
92
|
+
onStart(): Promise<void>;
|
|
93
|
+
onStop(): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Create an SSE client adapter for browser-to-server connections.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* import { createSseClient } from "@kyneta/sse-network-adapter/client"
|
|
101
|
+
*
|
|
102
|
+
* const exchange = new Exchange({
|
|
103
|
+
* transports: [createSseClient({
|
|
104
|
+
* postUrl: "/sync",
|
|
105
|
+
* eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,
|
|
106
|
+
* reconnect: { enabled: true },
|
|
107
|
+
* })],
|
|
108
|
+
* })
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
declare function createSseClient(options: SseClientOptions): TransportFactory;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Observable state machine for SSE client connection lifecycle.
|
|
115
|
+
*
|
|
116
|
+
* Extends the generic `ClientStateMachine<SseClientState>` with
|
|
117
|
+
* an SSE-specific convenience helper. Unlike the WebSocket state machine,
|
|
118
|
+
* SSE has no `"ready"` state — the connection is usable as soon as
|
|
119
|
+
* `EventSource.onopen` fires.
|
|
120
|
+
*
|
|
121
|
+
* Usage:
|
|
122
|
+
* ```typescript
|
|
123
|
+
* const sm = new SseClientStateMachine()
|
|
124
|
+
*
|
|
125
|
+
* sm.subscribeToTransitions(({ from, to }) => {
|
|
126
|
+
* console.log(`${from.status} → ${to.status}`)
|
|
127
|
+
* })
|
|
128
|
+
*
|
|
129
|
+
* sm.transition({ status: "connecting", attempt: 1 })
|
|
130
|
+
* sm.transition({ status: "connected" })
|
|
131
|
+
*
|
|
132
|
+
* // Transitions are delivered asynchronously via microtask
|
|
133
|
+
* // Listener will see: disconnected → connecting, connecting → connected
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
declare class SseClientStateMachine extends ClientStateMachine<SseClientState> {
|
|
137
|
+
constructor();
|
|
138
|
+
/**
|
|
139
|
+
* Check if the client is connected (EventSource open, channel established).
|
|
140
|
+
*/
|
|
141
|
+
isConnected(): boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export { DEFAULT_FRAGMENT_THRESHOLD, SseClientLifecycleEvents, type SseClientOptions, SseClientState, SseClientStateMachine, SseClientTransport, createSseClient };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import "./chunk-7D4SUZUM.js";
|
|
2
|
+
|
|
3
|
+
// src/client-transport.ts
|
|
4
|
+
import { Transport } from "@kyneta/exchange";
|
|
5
|
+
import {
|
|
6
|
+
encodeTextComplete,
|
|
7
|
+
fragmentTextPayload,
|
|
8
|
+
TextReassembler,
|
|
9
|
+
textCodec
|
|
10
|
+
} from "@kyneta/wire";
|
|
11
|
+
|
|
12
|
+
// src/client-state-machine.ts
|
|
13
|
+
import { ClientStateMachine } from "@kyneta/exchange";
|
|
14
|
+
var SSE_VALID_TRANSITIONS = {
|
|
15
|
+
disconnected: ["connecting"],
|
|
16
|
+
connecting: ["connected", "disconnected", "reconnecting"],
|
|
17
|
+
connected: ["disconnected", "reconnecting"],
|
|
18
|
+
reconnecting: ["connecting", "disconnected"]
|
|
19
|
+
};
|
|
20
|
+
var SseClientStateMachine = class extends ClientStateMachine {
|
|
21
|
+
constructor() {
|
|
22
|
+
super({
|
|
23
|
+
initialState: { status: "disconnected" },
|
|
24
|
+
validTransitions: SSE_VALID_TRANSITIONS
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check if the client is connected (EventSource open, channel established).
|
|
29
|
+
*/
|
|
30
|
+
isConnected() {
|
|
31
|
+
return this.getStatus() === "connected";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// src/client-transport.ts
|
|
36
|
+
var DEFAULT_FRAGMENT_THRESHOLD = 6e4;
|
|
37
|
+
var DEFAULT_RECONNECT = {
|
|
38
|
+
enabled: true,
|
|
39
|
+
maxAttempts: 10,
|
|
40
|
+
baseDelay: 1e3,
|
|
41
|
+
maxDelay: 3e4
|
|
42
|
+
};
|
|
43
|
+
var DEFAULT_POST_RETRY = {
|
|
44
|
+
maxAttempts: 3,
|
|
45
|
+
baseDelay: 1e3,
|
|
46
|
+
maxDelay: 1e4
|
|
47
|
+
};
|
|
48
|
+
var SseClientTransport = class extends Transport {
|
|
49
|
+
#peerId;
|
|
50
|
+
#eventSource;
|
|
51
|
+
#serverChannel;
|
|
52
|
+
#reconnectTimer;
|
|
53
|
+
#options;
|
|
54
|
+
#shouldReconnect = true;
|
|
55
|
+
#wasConnectedBefore = false;
|
|
56
|
+
// State machine
|
|
57
|
+
#stateMachine = new SseClientStateMachine();
|
|
58
|
+
// Fragmentation
|
|
59
|
+
#fragmentThreshold;
|
|
60
|
+
// Inbound reassembly for fragmented SSE messages from server
|
|
61
|
+
#reassembler;
|
|
62
|
+
// POST retry
|
|
63
|
+
#currentRetryAbortController;
|
|
64
|
+
constructor(options) {
|
|
65
|
+
super({ transportType: "sse-client" });
|
|
66
|
+
this.#options = options;
|
|
67
|
+
this.#fragmentThreshold = options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD;
|
|
68
|
+
this.#reassembler = new TextReassembler({
|
|
69
|
+
timeoutMs: 1e4
|
|
70
|
+
});
|
|
71
|
+
this.#setupLifecycleEvents();
|
|
72
|
+
}
|
|
73
|
+
// ==========================================================================
|
|
74
|
+
// Lifecycle event forwarding
|
|
75
|
+
// ==========================================================================
|
|
76
|
+
#setupLifecycleEvents() {
|
|
77
|
+
this.#stateMachine.subscribeToTransitions((transition) => {
|
|
78
|
+
this.#options.lifecycle?.onStateChange?.(transition);
|
|
79
|
+
const { from, to } = transition;
|
|
80
|
+
if (to.status === "disconnected" && to.reason) {
|
|
81
|
+
this.#options.lifecycle?.onDisconnect?.(to.reason);
|
|
82
|
+
}
|
|
83
|
+
if (to.status === "reconnecting") {
|
|
84
|
+
this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs);
|
|
85
|
+
}
|
|
86
|
+
if (this.#wasConnectedBefore && (from.status === "reconnecting" || from.status === "connecting") && to.status === "connected") {
|
|
87
|
+
this.#options.lifecycle?.onReconnected?.();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// ==========================================================================
|
|
92
|
+
// State observation API
|
|
93
|
+
// ==========================================================================
|
|
94
|
+
/**
|
|
95
|
+
* Get the current state of the connection.
|
|
96
|
+
*/
|
|
97
|
+
getState() {
|
|
98
|
+
return this.#stateMachine.getState();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Subscribe to state transitions.
|
|
102
|
+
* @returns Unsubscribe function
|
|
103
|
+
*/
|
|
104
|
+
subscribeToTransitions(listener) {
|
|
105
|
+
return this.#stateMachine.subscribeToTransitions(listener);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Wait for a specific state.
|
|
109
|
+
*/
|
|
110
|
+
waitForState(predicate, options) {
|
|
111
|
+
return this.#stateMachine.waitForState(predicate, options);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Wait for a specific status.
|
|
115
|
+
*/
|
|
116
|
+
waitForStatus(status, options) {
|
|
117
|
+
return this.#stateMachine.waitForStatus(status, options);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Check if the client is connected (EventSource open, channel established).
|
|
121
|
+
*/
|
|
122
|
+
get isConnected() {
|
|
123
|
+
return this.#stateMachine.isConnected();
|
|
124
|
+
}
|
|
125
|
+
// ==========================================================================
|
|
126
|
+
// Adapter abstract method implementations
|
|
127
|
+
// ==========================================================================
|
|
128
|
+
generate() {
|
|
129
|
+
return {
|
|
130
|
+
transportType: this.transportType,
|
|
131
|
+
send: (msg) => {
|
|
132
|
+
if (!this.#peerId) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!this.#eventSource || this.#eventSource.readyState === 2) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const resolvedPostUrl = typeof this.#options.postUrl === "function" ? this.#options.postUrl(this.#peerId) : this.#options.postUrl;
|
|
139
|
+
const textFrame = encodeTextComplete(textCodec, msg);
|
|
140
|
+
if (this.#fragmentThreshold > 0 && textFrame.length > this.#fragmentThreshold) {
|
|
141
|
+
const payload = JSON.stringify(textCodec.encode(msg));
|
|
142
|
+
const fragments = fragmentTextPayload(
|
|
143
|
+
payload,
|
|
144
|
+
this.#fragmentThreshold
|
|
145
|
+
);
|
|
146
|
+
for (const fragment of fragments) {
|
|
147
|
+
void this.#sendTextWithRetry(resolvedPostUrl, fragment);
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
void this.#sendTextWithRetry(resolvedPostUrl, textFrame);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
stop: () => {
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async onStart() {
|
|
158
|
+
if (!this.identity) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
"Adapter not properly initialized \u2014 identity not available"
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
this.#peerId = this.identity.peerId;
|
|
164
|
+
this.#shouldReconnect = true;
|
|
165
|
+
this.#wasConnectedBefore = false;
|
|
166
|
+
this.#connect();
|
|
167
|
+
}
|
|
168
|
+
async onStop() {
|
|
169
|
+
this.#shouldReconnect = false;
|
|
170
|
+
this.#reassembler.dispose();
|
|
171
|
+
this.#currentRetryAbortController?.abort();
|
|
172
|
+
this.#currentRetryAbortController = void 0;
|
|
173
|
+
this.#disconnect({ type: "intentional" });
|
|
174
|
+
}
|
|
175
|
+
// ==========================================================================
|
|
176
|
+
// Connection management
|
|
177
|
+
// ==========================================================================
|
|
178
|
+
/**
|
|
179
|
+
* Connect to the SSE server by creating an EventSource.
|
|
180
|
+
*/
|
|
181
|
+
#connect() {
|
|
182
|
+
const currentState = this.#stateMachine.getState();
|
|
183
|
+
if (currentState.status === "connecting") {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!this.#peerId) {
|
|
187
|
+
throw new Error("Cannot connect: peerId not set");
|
|
188
|
+
}
|
|
189
|
+
const attempt = currentState.status === "reconnecting" ? currentState.attempt : 1;
|
|
190
|
+
this.#stateMachine.transition({ status: "connecting", attempt });
|
|
191
|
+
const url = typeof this.#options.eventSourceUrl === "function" ? this.#options.eventSourceUrl(this.#peerId) : this.#options.eventSourceUrl;
|
|
192
|
+
try {
|
|
193
|
+
this.#eventSource = new EventSource(url);
|
|
194
|
+
this.#eventSource.onopen = () => {
|
|
195
|
+
this.#handleOpen();
|
|
196
|
+
};
|
|
197
|
+
this.#eventSource.onmessage = (event) => {
|
|
198
|
+
this.#handleMessage(event);
|
|
199
|
+
};
|
|
200
|
+
this.#eventSource.onerror = () => {
|
|
201
|
+
this.#handleError();
|
|
202
|
+
};
|
|
203
|
+
} catch (error) {
|
|
204
|
+
this.#scheduleReconnect({
|
|
205
|
+
type: "error",
|
|
206
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Disconnect from the SSE server.
|
|
212
|
+
*/
|
|
213
|
+
#disconnect(reason) {
|
|
214
|
+
this.#clearReconnectTimer();
|
|
215
|
+
if (this.#eventSource) {
|
|
216
|
+
this.#eventSource.onopen = null;
|
|
217
|
+
this.#eventSource.onmessage = null;
|
|
218
|
+
this.#eventSource.onerror = null;
|
|
219
|
+
this.#eventSource.close();
|
|
220
|
+
this.#eventSource = void 0;
|
|
221
|
+
}
|
|
222
|
+
if (this.#serverChannel) {
|
|
223
|
+
this.removeChannel(this.#serverChannel.channelId);
|
|
224
|
+
this.#serverChannel = void 0;
|
|
225
|
+
}
|
|
226
|
+
const currentState = this.#stateMachine.getState();
|
|
227
|
+
if (currentState.status !== "disconnected") {
|
|
228
|
+
this.#stateMachine.transition({ status: "disconnected", reason });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// ==========================================================================
|
|
232
|
+
// Event handlers
|
|
233
|
+
// ==========================================================================
|
|
234
|
+
/**
|
|
235
|
+
* Handle EventSource open event.
|
|
236
|
+
*
|
|
237
|
+
* The SSE connection is usable immediately — no "ready" signal needed.
|
|
238
|
+
* Create the channel and initiate establishment.
|
|
239
|
+
*/
|
|
240
|
+
#handleOpen() {
|
|
241
|
+
const currentState = this.#stateMachine.getState();
|
|
242
|
+
if (currentState.status !== "connecting" && currentState.status !== "connected") {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (currentState.status === "connecting") {
|
|
246
|
+
this.#stateMachine.transition({ status: "connected" });
|
|
247
|
+
}
|
|
248
|
+
this.#wasConnectedBefore = true;
|
|
249
|
+
if (this.#currentRetryAbortController) {
|
|
250
|
+
this.#currentRetryAbortController.abort();
|
|
251
|
+
this.#currentRetryAbortController = void 0;
|
|
252
|
+
}
|
|
253
|
+
if (this.#serverChannel) {
|
|
254
|
+
this.removeChannel(this.#serverChannel.channelId);
|
|
255
|
+
this.#serverChannel = void 0;
|
|
256
|
+
}
|
|
257
|
+
this.#serverChannel = this.addChannel();
|
|
258
|
+
this.establishChannel(this.#serverChannel.channelId);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Handle incoming SSE message.
|
|
262
|
+
*
|
|
263
|
+
* Each SSE `data:` event contains a text wire frame string.
|
|
264
|
+
* Feed it through the TextReassembler to handle both complete
|
|
265
|
+
* and fragmented frames.
|
|
266
|
+
*/
|
|
267
|
+
#handleMessage(event) {
|
|
268
|
+
if (!this.#serverChannel) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const data = event.data;
|
|
272
|
+
if (typeof data !== "string") {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const result = this.#reassembler.receive(data);
|
|
276
|
+
if (result.status === "complete") {
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(result.frame.content.payload);
|
|
279
|
+
const messages = textCodec.decode(parsed);
|
|
280
|
+
for (const msg of messages) {
|
|
281
|
+
this.#serverChannel.onReceive(msg);
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error("Failed to decode SSE message:", error);
|
|
285
|
+
}
|
|
286
|
+
} else if (result.status === "error") {
|
|
287
|
+
console.error("SSE message reassembly error:", result.error);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Handle EventSource error.
|
|
292
|
+
*
|
|
293
|
+
* Closes the EventSource immediately and takes over reconnection
|
|
294
|
+
* via the state machine's backoff logic. This prevents the browser's
|
|
295
|
+
* built-in EventSource reconnection from running.
|
|
296
|
+
*/
|
|
297
|
+
#handleError() {
|
|
298
|
+
if (this.#eventSource) {
|
|
299
|
+
this.#eventSource.onopen = null;
|
|
300
|
+
this.#eventSource.onmessage = null;
|
|
301
|
+
this.#eventSource.onerror = null;
|
|
302
|
+
this.#eventSource.close();
|
|
303
|
+
this.#eventSource = void 0;
|
|
304
|
+
}
|
|
305
|
+
if (this.#serverChannel) {
|
|
306
|
+
this.removeChannel(this.#serverChannel.channelId);
|
|
307
|
+
this.#serverChannel = void 0;
|
|
308
|
+
}
|
|
309
|
+
this.#scheduleReconnect({
|
|
310
|
+
type: "error",
|
|
311
|
+
error: new Error("EventSource connection error")
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// ==========================================================================
|
|
315
|
+
// POST sending with retry
|
|
316
|
+
// ==========================================================================
|
|
317
|
+
/**
|
|
318
|
+
* Send a text frame via POST with retry logic.
|
|
319
|
+
*/
|
|
320
|
+
async #sendTextWithRetry(url, textFrame) {
|
|
321
|
+
let attempt = 0;
|
|
322
|
+
const postRetryOpts = {
|
|
323
|
+
...DEFAULT_POST_RETRY,
|
|
324
|
+
...this.#options.postRetry
|
|
325
|
+
};
|
|
326
|
+
const { maxAttempts, baseDelay, maxDelay } = postRetryOpts;
|
|
327
|
+
while (attempt < maxAttempts) {
|
|
328
|
+
try {
|
|
329
|
+
if (!this.#currentRetryAbortController) {
|
|
330
|
+
this.#currentRetryAbortController = new AbortController();
|
|
331
|
+
}
|
|
332
|
+
if (!this.#peerId) {
|
|
333
|
+
throw new Error("PeerId not available for retry");
|
|
334
|
+
}
|
|
335
|
+
const response = await fetch(url, {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: {
|
|
338
|
+
"Content-Type": "text/plain",
|
|
339
|
+
"X-Peer-Id": this.#peerId
|
|
340
|
+
},
|
|
341
|
+
body: textFrame,
|
|
342
|
+
signal: this.#currentRetryAbortController.signal
|
|
343
|
+
});
|
|
344
|
+
if (!response.ok) {
|
|
345
|
+
if (response.status >= 400 && response.status < 500) {
|
|
346
|
+
throw new Error(`Failed to send message: ${response.statusText}`);
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`Server error: ${response.statusText}`);
|
|
349
|
+
}
|
|
350
|
+
this.#currentRetryAbortController = void 0;
|
|
351
|
+
return;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
attempt++;
|
|
354
|
+
const err = error;
|
|
355
|
+
if (err.name === "AbortError") {
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
if (!this.#currentRetryAbortController) {
|
|
359
|
+
const abortError = new Error("Retry aborted by connection reset");
|
|
360
|
+
abortError.name = "AbortError";
|
|
361
|
+
throw abortError;
|
|
362
|
+
}
|
|
363
|
+
if (attempt >= maxAttempts) {
|
|
364
|
+
this.#currentRetryAbortController = void 0;
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
const delay = Math.min(
|
|
368
|
+
baseDelay * 2 ** (attempt - 1) + Math.random() * 100,
|
|
369
|
+
maxDelay
|
|
370
|
+
);
|
|
371
|
+
await new Promise((resolve, reject) => {
|
|
372
|
+
if (this.#currentRetryAbortController?.signal.aborted) {
|
|
373
|
+
const error2 = new Error("Retry aborted");
|
|
374
|
+
error2.name = "AbortError";
|
|
375
|
+
reject(error2);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const timer = setTimeout(() => {
|
|
379
|
+
cleanup();
|
|
380
|
+
resolve();
|
|
381
|
+
}, delay);
|
|
382
|
+
const onAbort = () => {
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
cleanup();
|
|
385
|
+
const error2 = new Error("Retry aborted");
|
|
386
|
+
error2.name = "AbortError";
|
|
387
|
+
reject(error2);
|
|
388
|
+
};
|
|
389
|
+
const cleanup = () => {
|
|
390
|
+
this.#currentRetryAbortController?.signal.removeEventListener(
|
|
391
|
+
"abort",
|
|
392
|
+
onAbort
|
|
393
|
+
);
|
|
394
|
+
};
|
|
395
|
+
this.#currentRetryAbortController?.signal.addEventListener(
|
|
396
|
+
"abort",
|
|
397
|
+
onAbort
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// ==========================================================================
|
|
404
|
+
// Reconnection
|
|
405
|
+
// ==========================================================================
|
|
406
|
+
/**
|
|
407
|
+
* Schedule a reconnection attempt or transition to disconnected.
|
|
408
|
+
*/
|
|
409
|
+
#scheduleReconnect(reason) {
|
|
410
|
+
const currentState = this.#stateMachine.getState();
|
|
411
|
+
if (currentState.status === "disconnected") {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const reconnectOpts = {
|
|
415
|
+
...DEFAULT_RECONNECT,
|
|
416
|
+
...this.#options.reconnect
|
|
417
|
+
};
|
|
418
|
+
if (!this.#shouldReconnect || !reconnectOpts.enabled) {
|
|
419
|
+
this.#stateMachine.transition({ status: "disconnected", reason });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const currentAttempt = currentState.status === "reconnecting" ? currentState.attempt : currentState.status === "connecting" ? currentState.attempt : 0;
|
|
423
|
+
if (currentAttempt >= reconnectOpts.maxAttempts) {
|
|
424
|
+
this.#stateMachine.transition({
|
|
425
|
+
status: "disconnected",
|
|
426
|
+
reason: { type: "max-retries-exceeded", attempts: currentAttempt }
|
|
427
|
+
});
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const nextAttempt = currentAttempt + 1;
|
|
431
|
+
const delay = Math.min(
|
|
432
|
+
reconnectOpts.baseDelay * 2 ** (nextAttempt - 1) + Math.random() * 1e3,
|
|
433
|
+
reconnectOpts.maxDelay
|
|
434
|
+
);
|
|
435
|
+
this.#stateMachine.transition({
|
|
436
|
+
status: "reconnecting",
|
|
437
|
+
attempt: nextAttempt,
|
|
438
|
+
nextAttemptMs: delay
|
|
439
|
+
});
|
|
440
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
441
|
+
this.#connect();
|
|
442
|
+
}, delay);
|
|
443
|
+
}
|
|
444
|
+
#clearReconnectTimer() {
|
|
445
|
+
if (this.#reconnectTimer) {
|
|
446
|
+
clearTimeout(this.#reconnectTimer);
|
|
447
|
+
this.#reconnectTimer = void 0;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
function createSseClient(options) {
|
|
452
|
+
return () => new SseClientTransport(options);
|
|
453
|
+
}
|
|
454
|
+
export {
|
|
455
|
+
DEFAULT_FRAGMENT_THRESHOLD,
|
|
456
|
+
SseClientStateMachine,
|
|
457
|
+
SseClientTransport,
|
|
458
|
+
createSseClient
|
|
459
|
+
};
|
|
460
|
+
//# sourceMappingURL=client.js.map
|