@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/src/server.ts ADDED
@@ -0,0 +1,33 @@
1
+ // server — barrel export for @kyneta/sse-network-adapter/server.
2
+ //
3
+ // This is the server-side entry point. It exports everything needed
4
+ // to create an SSE server adapter with any framework.
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Server adapter
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export {
11
+ SseServerTransport,
12
+ type SseServerTransportOptions,
13
+ } from "./server-transport.js"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Connection
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export {
20
+ DEFAULT_FRAGMENT_THRESHOLD,
21
+ SseConnection,
22
+ type SseConnectionConfig,
23
+ } from "./connection.js"
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Shared types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export type {
30
+ DisconnectReason,
31
+ SseConnectionHandle,
32
+ SseConnectionResult,
33
+ } from "./types.js"
@@ -0,0 +1,116 @@
1
+ // sse-handler — framework-agnostic SSE POST handler (Functional Core).
2
+ //
3
+ // This module provides pure functions for handling text POST requests
4
+ // in SSE adapters. Framework-specific adapters (Express, Hono, etc.)
5
+ // use these functions and handle the HTTP-specific concerns.
6
+ //
7
+ // Design: Functional Core / Imperative Shell
8
+ // - This module parses and decodes, returning a result describing what to do
9
+ // - Framework adapters execute side effects (delivering messages, sending responses)
10
+ //
11
+ // The POST body is a text wire frame string (JSON array with "0c"/"0f" prefix).
12
+ // Decoding is two-step:
13
+ // 1. TextReassembler.receive(body) → Frame<string>
14
+ // 2. JSON.parse(frame.content.payload) → textCodec.decode(parsed) → ChannelMsg[]
15
+
16
+ import type { ChannelMsg } from "@kyneta/exchange"
17
+ import { type TextReassembler, textCodec } from "@kyneta/wire"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Result types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Response to send back to the client after processing a POST.
25
+ */
26
+ export interface SsePostResponse {
27
+ status: 200 | 202 | 400
28
+ body: { ok: true } | { pending: true } | { error: string }
29
+ }
30
+
31
+ /**
32
+ * Result of parsing a text POST body.
33
+ *
34
+ * Discriminated union describing what happened:
35
+ * - "messages": Complete message(s) decoded, ready to deliver
36
+ * - "pending": Fragment received, waiting for more
37
+ * - "error": Decode/reassembly error
38
+ */
39
+ export type SsePostResult =
40
+ | { type: "messages"; messages: ChannelMsg[]; response: SsePostResponse }
41
+ | { type: "pending"; response: SsePostResponse }
42
+ | { type: "error"; response: SsePostResponse }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // parseTextPostBody
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Parse a text POST body through the reassembler.
50
+ *
51
+ * This is the functional core of POST handling. It:
52
+ * 1. Passes the body through the reassembler (handles fragmentation)
53
+ * 2. If complete, decodes the text frame payload to ChannelMsg(s)
54
+ * 3. Returns a result describing what happened
55
+ *
56
+ * The caller (framework adapter) executes side effects based on the result.
57
+ *
58
+ * @param reassembler - The connection's text fragment reassembler
59
+ * @param body - Text wire frame string (JSON array with "0c"/"0f" prefix)
60
+ * @returns Result describing what to do
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * // In Express router (imperative shell)
65
+ * const result = parseTextPostBody(connection.reassembler, req.body)
66
+ *
67
+ * if (result.type === "messages") {
68
+ * for (const msg of result.messages) {
69
+ * connection.receive(msg)
70
+ * }
71
+ * }
72
+ *
73
+ * res.status(result.response.status).json(result.response.body)
74
+ * ```
75
+ */
76
+ export function parseTextPostBody(
77
+ reassembler: TextReassembler,
78
+ body: string,
79
+ ): SsePostResult {
80
+ const result = reassembler.receive(body)
81
+
82
+ if (result.status === "complete") {
83
+ try {
84
+ // Two-step decode:
85
+ // 1. Frame<string> → extract payload string
86
+ // 2. JSON.parse → textCodec.decode → ChannelMsg[]
87
+ const parsed = JSON.parse(result.frame.content.payload)
88
+ const messages = textCodec.decode(parsed)
89
+ return {
90
+ type: "messages",
91
+ messages,
92
+ response: { status: 200, body: { ok: true } },
93
+ }
94
+ } catch (err) {
95
+ const errorMessage = err instanceof Error ? err.message : "decode_failed"
96
+ return {
97
+ type: "error",
98
+ response: { status: 400, body: { error: errorMessage } },
99
+ }
100
+ }
101
+ } else if (result.status === "pending") {
102
+ return {
103
+ type: "pending",
104
+ response: { status: 202, body: { pending: true } },
105
+ }
106
+ } else {
107
+ // result.status === "error"
108
+ return {
109
+ type: "error",
110
+ response: {
111
+ status: 400,
112
+ body: { error: result.error.type },
113
+ },
114
+ }
115
+ }
116
+ }
package/src/types.ts ADDED
@@ -0,0 +1,108 @@
1
+ // types — SSE-specific types for @kyneta/sse-network-adapter.
2
+ //
3
+ // SSE has a simpler lifecycle than WebSocket — no "ready" state because
4
+ // there's no transport-level handshake. The connection is usable as soon
5
+ // as EventSource.onopen fires.
6
+ //
7
+ // DisconnectReason is SSE-specific: no "closed" variant (SSE doesn't have
8
+ // close codes) and no "not-started" variant (no "ready" gate).
9
+
10
+ import type { PeerId } from "@kyneta/exchange"
11
+
12
+ // Re-export specialized transition types from generic state machine
13
+ export type { StateTransition, TransitionListener } from "@kyneta/exchange"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Disconnect reason
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Discriminated union describing why an SSE connection was lost.
21
+ *
22
+ * Unlike WebSocket's DisconnectReason, SSE does not have:
23
+ * - `{ type: "closed"; code; reason }` — SSE has no close codes
24
+ * - `{ type: "not-started" }` — SSE has no "ready" gate
25
+ */
26
+ export type DisconnectReason =
27
+ | { type: "intentional" }
28
+ | { type: "error"; error: Error }
29
+ | { type: "max-retries-exceeded"; attempts: number }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Connection state (for client adapter observability)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * All possible states of the SSE client.
37
+ *
38
+ * State machine transitions (4 states, no "ready"):
39
+ * ```
40
+ * disconnected → connecting → connected
41
+ * ↓ ↓
42
+ * reconnecting ← ─ ─┘
43
+ * ↓
44
+ * connecting (retry)
45
+ * ↓
46
+ * disconnected (max retries)
47
+ * ```
48
+ */
49
+ export type SseClientState =
50
+ | { status: "disconnected"; reason?: DisconnectReason }
51
+ | { status: "connecting"; attempt: number }
52
+ | { status: "connected" }
53
+ | { status: "reconnecting"; attempt: number; nextAttemptMs: number }
54
+
55
+ /**
56
+ * A state transition event for SSE client states.
57
+ * Specialized from the generic `StateTransition<S>`.
58
+ */
59
+ export type { StateTransition as SseClientStateTransition } from "@kyneta/exchange"
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Connection handle — used by server adapter
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Handle for an active SSE connection (server-side).
67
+ */
68
+ export interface SseConnectionHandle {
69
+ /** The peer ID for this connection. */
70
+ readonly peerId: PeerId
71
+ /** The channel ID for this connection. */
72
+ readonly channelId: number
73
+ /** Disconnect this connection. */
74
+ disconnect(): void
75
+ }
76
+
77
+ /**
78
+ * Result of registering an SSE connection on the server.
79
+ */
80
+ export interface SseConnectionResult {
81
+ /** The connection handle for managing this peer. */
82
+ connection: SseConnectionHandle
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Client options
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Lifecycle event callbacks for the SSE client.
91
+ */
92
+ export interface SseClientLifecycleEvents {
93
+ /** Called on every state transition (delivered async via microtask). */
94
+ onStateChange?: (transition: {
95
+ from: SseClientState
96
+ to: SseClientState
97
+ timestamp: number
98
+ }) => void
99
+
100
+ /** Called when the connection is lost. */
101
+ onDisconnect?: (reason: DisconnectReason) => void
102
+
103
+ /** Called when a reconnection attempt is scheduled. */
104
+ onReconnecting?: (attempt: number, nextAttemptMs: number) => void
105
+
106
+ /** Called when reconnection succeeds after a previous connection. */
107
+ onReconnected?: () => void
108
+ }