@opengeni/sdk 0.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/README.md +157 -0
- package/dist/index.d.ts +1196 -0
- package/dist/index.js +955 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/client.ts +846 -0
- package/src/errors.ts +40 -0
- package/src/index.ts +142 -0
- package/src/proxy.ts +171 -0
- package/src/sse.ts +90 -0
- package/src/stream.ts +192 -0
- package/src/types.ts +1036 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Error for a non-2xx OpenGeni API response. */
|
|
2
|
+
export class OpenGeniApiError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly body: string;
|
|
5
|
+
|
|
6
|
+
constructor(status: number, body: string) {
|
|
7
|
+
super(`OpenGeni API ${status}: ${body || "(empty body)"}`);
|
|
8
|
+
this.name = "OpenGeniApiError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.body = body;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Error for an unrecoverable event-stream condition (not a transient drop). */
|
|
15
|
+
export class OpenGeniStreamError extends Error {
|
|
16
|
+
constructor(message: string) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "OpenGeniStreamError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isAbortError(error: unknown): boolean {
|
|
23
|
+
return (
|
|
24
|
+
(error instanceof DOMException && error.name === "AbortError") ||
|
|
25
|
+
(error instanceof Error && error.name === "AbortError")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Transient conditions worth a reconnect: network-level failures (`fetch`
|
|
31
|
+
* rejects with `TypeError`) and HTTP statuses that signal a temporary server
|
|
32
|
+
* or contention condition. Auth/validation failures (401/403/404/...) are
|
|
33
|
+
* permanent and surface to the caller instead.
|
|
34
|
+
*/
|
|
35
|
+
export function isRetryableStreamError(error: unknown): boolean {
|
|
36
|
+
if (error instanceof OpenGeniApiError) {
|
|
37
|
+
return error.status === 408 || error.status === 409 || error.status === 425 || error.status === 429 || error.status >= 500;
|
|
38
|
+
}
|
|
39
|
+
return error instanceof TypeError;
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export { OpenGeniClient } from "./client";
|
|
2
|
+
export type { FetchLike, OpenGeniClientOptions, SendMessageInput, SteerMessageResult } from "./client";
|
|
3
|
+
export { OpenGeniApiError, OpenGeniStreamError, isRetryableStreamError } from "./errors";
|
|
4
|
+
export {
|
|
5
|
+
formatSseEvent,
|
|
6
|
+
proxySessionEventStream,
|
|
7
|
+
resumeSequenceFromRequest,
|
|
8
|
+
sessionEventsToSseResponse,
|
|
9
|
+
sessionEventsToSseStream,
|
|
10
|
+
} from "./proxy";
|
|
11
|
+
export type { ProxySessionEventStreamOptions, SseReStreamOptions } from "./proxy";
|
|
12
|
+
export { parseSseStream } from "./sse";
|
|
13
|
+
export type { SseMessage } from "./sse";
|
|
14
|
+
export { streamSessionEvents } from "./stream";
|
|
15
|
+
export type {
|
|
16
|
+
SessionEventStreamTransport,
|
|
17
|
+
StreamConnectionState,
|
|
18
|
+
StreamSessionEventsOptions,
|
|
19
|
+
} from "./stream";
|
|
20
|
+
export { KNOWN_PERMISSIONS, KNOWN_USAGE_EVENT_TYPES, SESSION_EVENT_TYPES } from "./types";
|
|
21
|
+
export type {
|
|
22
|
+
AccessContext,
|
|
23
|
+
AccessGrant,
|
|
24
|
+
AccountGrant,
|
|
25
|
+
AccountRole,
|
|
26
|
+
AgentMessageCompletedPayload,
|
|
27
|
+
AgentTextDeltaPayload,
|
|
28
|
+
AgentToolCallCreatedPayload,
|
|
29
|
+
AgentToolCallOutputPayload,
|
|
30
|
+
ApiKey,
|
|
31
|
+
BillingBalance,
|
|
32
|
+
BillingEntitlementsResponse,
|
|
33
|
+
BillingMode,
|
|
34
|
+
BillingSummary,
|
|
35
|
+
BillingUsageResponse,
|
|
36
|
+
CapabilityCatalogItem,
|
|
37
|
+
CapabilityCatalogResponse,
|
|
38
|
+
CapabilityInstallation,
|
|
39
|
+
CapabilityInstallationStatus,
|
|
40
|
+
CapabilityKind,
|
|
41
|
+
CapabilityPack,
|
|
42
|
+
CapabilityPackConnector,
|
|
43
|
+
CapabilityPackConnectorAuthModel,
|
|
44
|
+
CapabilityPackEnvironmentSpec,
|
|
45
|
+
CapabilityPackKnowledge,
|
|
46
|
+
CapabilityPackScheduledTaskTemplate,
|
|
47
|
+
CapabilityPackSkill,
|
|
48
|
+
CapabilityPackSkillFile,
|
|
49
|
+
CapabilityRuntime,
|
|
50
|
+
CapabilitySource,
|
|
51
|
+
CompactSessionContextResult,
|
|
52
|
+
ClientSessionEventInput,
|
|
53
|
+
CompleteFileUploadResponse,
|
|
54
|
+
CreateApiKeyRequest,
|
|
55
|
+
CreateApiKeyResponse,
|
|
56
|
+
CreateCapabilityCatalogItemRequest,
|
|
57
|
+
CreateCheckoutRequest,
|
|
58
|
+
CreateCheckoutResponse,
|
|
59
|
+
CreateDocumentBaseRequest,
|
|
60
|
+
CreateFileUploadRequest,
|
|
61
|
+
CreateFileUploadResponse,
|
|
62
|
+
CreateGitHubAppManifestRequest,
|
|
63
|
+
CreateGitHubAppManifestResponse,
|
|
64
|
+
CreateScheduledTaskRequest,
|
|
65
|
+
CreateSessionRequest,
|
|
66
|
+
CreateWorkspaceEnvironmentRequest,
|
|
67
|
+
CreateWorkspaceRequest,
|
|
68
|
+
DiscoverMcpCapabilitiesResponse,
|
|
69
|
+
Document,
|
|
70
|
+
DocumentBase,
|
|
71
|
+
DocumentSearchRequest,
|
|
72
|
+
DocumentSearchResponse,
|
|
73
|
+
DocumentSearchResult,
|
|
74
|
+
DocumentStatus,
|
|
75
|
+
EnableCapabilityRequest,
|
|
76
|
+
EnablePackRequest,
|
|
77
|
+
Entitlements,
|
|
78
|
+
EntitlementValue,
|
|
79
|
+
EntitlementsMode,
|
|
80
|
+
FileAsset,
|
|
81
|
+
FileDownloadUrlResponse,
|
|
82
|
+
FileResourceRef,
|
|
83
|
+
FileStatus,
|
|
84
|
+
FileUploadData,
|
|
85
|
+
GetPackResponse,
|
|
86
|
+
GitHubAppInfo,
|
|
87
|
+
GitHubRepositoriesResponse,
|
|
88
|
+
GitHubRepository,
|
|
89
|
+
GoalSpec,
|
|
90
|
+
KnownPermission,
|
|
91
|
+
KnownSessionEventType,
|
|
92
|
+
KnownUsageEventType,
|
|
93
|
+
ListApiKeysResponse,
|
|
94
|
+
ListPacksResponse,
|
|
95
|
+
PackInstallation,
|
|
96
|
+
PackInstallationStatus,
|
|
97
|
+
Permission,
|
|
98
|
+
ProductAccessMode,
|
|
99
|
+
ReasoningEffort,
|
|
100
|
+
RegisterCapabilityPackRequest,
|
|
101
|
+
RepositoryResourceRef,
|
|
102
|
+
ResourceRef,
|
|
103
|
+
SandboxBackend,
|
|
104
|
+
ScheduledTask,
|
|
105
|
+
ScheduledTaskAgentConfig,
|
|
106
|
+
ScheduledTaskAgentConfigInput,
|
|
107
|
+
ScheduledTaskDayOfWeek,
|
|
108
|
+
ScheduledTaskOverlapPolicy,
|
|
109
|
+
ScheduledTaskRun,
|
|
110
|
+
ScheduledTaskRunMode,
|
|
111
|
+
ScheduledTaskRunStatus,
|
|
112
|
+
ScheduledTaskScheduleSpec,
|
|
113
|
+
ScheduledTaskStatus,
|
|
114
|
+
ScheduledTaskTriggerType,
|
|
115
|
+
Session,
|
|
116
|
+
SessionEvent,
|
|
117
|
+
SessionEventType,
|
|
118
|
+
SessionGoal,
|
|
119
|
+
SessionGoalCreatedBy,
|
|
120
|
+
SessionGoalStatus,
|
|
121
|
+
SessionStatus,
|
|
122
|
+
SessionStatusChangedPayload,
|
|
123
|
+
SessionTurn,
|
|
124
|
+
SessionTurnSource,
|
|
125
|
+
SessionTurnStatus,
|
|
126
|
+
ToolRef,
|
|
127
|
+
UpdateScheduledTaskRequest,
|
|
128
|
+
UpdateSessionGoalRequest,
|
|
129
|
+
UpdateSessionTurnRequest,
|
|
130
|
+
UpdateWorkspaceEnvironmentRequest,
|
|
131
|
+
UpdateWorkspaceRequest,
|
|
132
|
+
UploadFileInput,
|
|
133
|
+
UsageEvent,
|
|
134
|
+
UsageEventType,
|
|
135
|
+
UserApprovalDecisionEventInput,
|
|
136
|
+
UserInterruptEventInput,
|
|
137
|
+
UserMessageEventInput,
|
|
138
|
+
Workspace,
|
|
139
|
+
WorkspaceEnvironment,
|
|
140
|
+
WorkspaceEnvironmentVariableMetadata,
|
|
141
|
+
WorkspaceRegisteredPack,
|
|
142
|
+
} from "./types";
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { OpenGeniClient } from "./client";
|
|
2
|
+
import type { StreamSessionEventsOptions } from "./stream";
|
|
3
|
+
import type { SessionEvent } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Proxy-through-your-own-API helpers.
|
|
7
|
+
*
|
|
8
|
+
* The intended pattern: a customer's server consumes the OpenGeni event
|
|
9
|
+
* stream with its own API key (`client.streamEvents(...)`) and re-emits it to
|
|
10
|
+
* its browser clients over its own authenticated endpoint — the OpenGeni key
|
|
11
|
+
* never reaches the browser. The re-emitted wire format is identical to
|
|
12
|
+
* OpenGeni's own SSE stream (`id: <sequence>`, `event: <type>`,
|
|
13
|
+
* `data: <event JSON>`), so the browser side can consume it with this same
|
|
14
|
+
* SDK's streaming core (or a plain `EventSource`), including resume via
|
|
15
|
+
* `?after=` / `Last-Event-ID`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Format one event exactly as OpenGeni's API emits it over SSE. */
|
|
19
|
+
export function formatSseEvent(event: SessionEvent): string {
|
|
20
|
+
return `id: ${event.sequence}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SseReStreamOptions = {
|
|
24
|
+
/**
|
|
25
|
+
* Emit `: ping` comment lines at this interval, keeping intermediaries from
|
|
26
|
+
* idling the connection out. Disabled when omitted.
|
|
27
|
+
*/
|
|
28
|
+
heartbeatMs?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Called when the downstream consumer cancels (e.g. the browser
|
|
31
|
+
* disconnected). Use it to abort the upstream OpenGeni stream — an async
|
|
32
|
+
* iterator that is mid-`await` cannot be interrupted by `return()` alone.
|
|
33
|
+
*/
|
|
34
|
+
onCancel?: () => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Re-emit a stream of session events as an SSE byte stream. Pull-based, so
|
|
39
|
+
* upstream consumption follows downstream demand; cancelling the returned
|
|
40
|
+
* stream fires `onCancel` and ends the upstream iterator.
|
|
41
|
+
*/
|
|
42
|
+
export function sessionEventsToSseStream(
|
|
43
|
+
events: AsyncIterable<SessionEvent>,
|
|
44
|
+
options: SseReStreamOptions = {},
|
|
45
|
+
): ReadableStream<Uint8Array> {
|
|
46
|
+
const encoder = new TextEncoder();
|
|
47
|
+
const iterator = events[Symbol.asyncIterator]();
|
|
48
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
|
|
51
|
+
const stopHeartbeat = (): void => {
|
|
52
|
+
if (heartbeat !== undefined) {
|
|
53
|
+
clearInterval(heartbeat);
|
|
54
|
+
heartbeat = undefined;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return new ReadableStream<Uint8Array>({
|
|
59
|
+
start: (controller) => {
|
|
60
|
+
if (options.heartbeatMs !== undefined) {
|
|
61
|
+
heartbeat = setInterval(() => {
|
|
62
|
+
try {
|
|
63
|
+
controller.enqueue(encoder.encode(": ping\n\n"));
|
|
64
|
+
} catch {
|
|
65
|
+
stopHeartbeat();
|
|
66
|
+
}
|
|
67
|
+
}, options.heartbeatMs);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
pull: async (controller) => {
|
|
71
|
+
let result: IteratorResult<SessionEvent, unknown>;
|
|
72
|
+
try {
|
|
73
|
+
result = await iterator.next();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
stopHeartbeat();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (cancelled) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (result.done) {
|
|
82
|
+
stopHeartbeat();
|
|
83
|
+
controller.close();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
controller.enqueue(encoder.encode(formatSseEvent(result.value)));
|
|
87
|
+
},
|
|
88
|
+
cancel: () => {
|
|
89
|
+
cancelled = true;
|
|
90
|
+
stopHeartbeat();
|
|
91
|
+
options.onCancel?.();
|
|
92
|
+
// Fire-and-forget: if the iterator is suspended mid-await, return()
|
|
93
|
+
// settles only after onCancel unblocks it; cancel must not hang on it.
|
|
94
|
+
void Promise.resolve(iterator.return?.(undefined)).then(
|
|
95
|
+
() => undefined,
|
|
96
|
+
() => undefined,
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Wrap an event stream in a ready-to-return SSE `Response`. */
|
|
103
|
+
export function sessionEventsToSseResponse(
|
|
104
|
+
events: AsyncIterable<SessionEvent>,
|
|
105
|
+
options: SseReStreamOptions = {},
|
|
106
|
+
): Response {
|
|
107
|
+
return new Response(sessionEventsToSseStream(events, options), {
|
|
108
|
+
headers: {
|
|
109
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
110
|
+
"Cache-Control": "no-cache, no-transform",
|
|
111
|
+
Connection: "keep-alive",
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read the resume cursor a reconnecting SSE client sent: the `after` query
|
|
118
|
+
* parameter, or the standard `Last-Event-ID` header (the re-emitted stream
|
|
119
|
+
* sets `id:` to the sequence). Returns 0 (full replay) when absent.
|
|
120
|
+
*/
|
|
121
|
+
export function resumeSequenceFromRequest(request: Request): number {
|
|
122
|
+
const url = new URL(request.url);
|
|
123
|
+
const raw = url.searchParams.get("after") ?? request.headers.get("Last-Event-ID");
|
|
124
|
+
const parsed = raw === null ? 0 : Number(raw);
|
|
125
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ProxySessionEventStreamOptions = Omit<StreamSessionEventsOptions, "after"> & {
|
|
129
|
+
/**
|
|
130
|
+
* Resume cursor. Pass a number, or the incoming browser `Request` to
|
|
131
|
+
* honor its `?after=` / `Last-Event-ID` automatically.
|
|
132
|
+
*/
|
|
133
|
+
after?: number | Request;
|
|
134
|
+
/** See {@link SseReStreamOptions.heartbeatMs}. */
|
|
135
|
+
heartbeatMs?: number;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One-call proxy: consume the OpenGeni stream server-side and return an SSE
|
|
140
|
+
* `Response` for your own browser clients. Works anywhere WHATWG `Response`
|
|
141
|
+
* is the handler return type (Hono, Next.js route handlers, Bun.serve,
|
|
142
|
+
* Cloudflare Workers, ...).
|
|
143
|
+
*
|
|
144
|
+
* The upstream OpenGeni connection is torn down when the downstream client
|
|
145
|
+
* disconnects, and also when `options.signal` (e.g. the incoming request's
|
|
146
|
+
* signal) aborts.
|
|
147
|
+
*/
|
|
148
|
+
export function proxySessionEventStream(
|
|
149
|
+
client: OpenGeniClient,
|
|
150
|
+
workspaceId: string,
|
|
151
|
+
sessionId: string,
|
|
152
|
+
options: ProxySessionEventStreamOptions = {},
|
|
153
|
+
): Response {
|
|
154
|
+
const { after, heartbeatMs, signal, ...streamOptions } = options;
|
|
155
|
+
const upstream = new AbortController();
|
|
156
|
+
if (signal?.aborted) {
|
|
157
|
+
upstream.abort();
|
|
158
|
+
} else {
|
|
159
|
+
signal?.addEventListener("abort", () => upstream.abort(), { once: true });
|
|
160
|
+
}
|
|
161
|
+
const resolvedAfter = after instanceof Request ? resumeSequenceFromRequest(after) : after ?? 0;
|
|
162
|
+
const events = client.streamEvents(workspaceId, sessionId, {
|
|
163
|
+
...streamOptions,
|
|
164
|
+
after: resolvedAfter,
|
|
165
|
+
signal: upstream.signal,
|
|
166
|
+
});
|
|
167
|
+
return sessionEventsToSseResponse(events, {
|
|
168
|
+
...(heartbeatMs !== undefined ? { heartbeatMs } : {}),
|
|
169
|
+
onCancel: () => upstream.abort(),
|
|
170
|
+
});
|
|
171
|
+
}
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal incremental Server-Sent Events parser over a byte stream.
|
|
3
|
+
*
|
|
4
|
+
* Implements the parts of the SSE wire format OpenGeni uses: `id`, `event`,
|
|
5
|
+
* and `data` fields, multi-line data, comment lines, and both LF and CRLF
|
|
6
|
+
* line endings. Messages without any `data` (comments, id-only blocks) are
|
|
7
|
+
* not emitted.
|
|
8
|
+
*/
|
|
9
|
+
export type SseMessage = {
|
|
10
|
+
id?: string;
|
|
11
|
+
event?: string;
|
|
12
|
+
data: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function* parseSseStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseMessage, void, void> {
|
|
16
|
+
const reader = stream.getReader();
|
|
17
|
+
const decoder = new TextDecoder();
|
|
18
|
+
let buffer = "";
|
|
19
|
+
let id: string | undefined;
|
|
20
|
+
let event: string | undefined;
|
|
21
|
+
let dataLines: string[] | null = null;
|
|
22
|
+
|
|
23
|
+
const dispatch = (): SseMessage | null => {
|
|
24
|
+
const message = dataLines === null
|
|
25
|
+
? null
|
|
26
|
+
: {
|
|
27
|
+
...(id !== undefined ? { id } : {}),
|
|
28
|
+
...(event !== undefined ? { event } : {}),
|
|
29
|
+
data: dataLines.join("\n"),
|
|
30
|
+
};
|
|
31
|
+
id = undefined;
|
|
32
|
+
event = undefined;
|
|
33
|
+
dataLines = null;
|
|
34
|
+
return message;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleLine = (line: string): SseMessage | null => {
|
|
38
|
+
if (line === "") {
|
|
39
|
+
return dispatch();
|
|
40
|
+
}
|
|
41
|
+
if (line.startsWith(":")) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const colon = line.indexOf(":");
|
|
45
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
46
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
47
|
+
if (value.startsWith(" ")) {
|
|
48
|
+
value = value.slice(1);
|
|
49
|
+
}
|
|
50
|
+
if (field === "data") {
|
|
51
|
+
(dataLines ??= []).push(value);
|
|
52
|
+
} else if (field === "event") {
|
|
53
|
+
event = value;
|
|
54
|
+
} else if (field === "id") {
|
|
55
|
+
id = value;
|
|
56
|
+
}
|
|
57
|
+
// Other fields (e.g. `retry`) are ignored; reconnect pacing is the
|
|
58
|
+
// streaming layer's concern.
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
while (true) {
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
buffer += decoder.decode(value, { stream: true });
|
|
69
|
+
let newline = buffer.indexOf("\n");
|
|
70
|
+
while (newline !== -1) {
|
|
71
|
+
let line = buffer.slice(0, newline);
|
|
72
|
+
buffer = buffer.slice(newline + 1);
|
|
73
|
+
if (line.endsWith("\r")) {
|
|
74
|
+
line = line.slice(0, -1);
|
|
75
|
+
}
|
|
76
|
+
const message = handleLine(line);
|
|
77
|
+
if (message) {
|
|
78
|
+
yield message;
|
|
79
|
+
}
|
|
80
|
+
newline = buffer.indexOf("\n");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Per the SSE spec, a block is only dispatched on a blank line. Anything
|
|
84
|
+
// still pending at end-of-stream came from a truncated connection and is
|
|
85
|
+
// discarded — the streaming layer replays from its cursor on reconnect.
|
|
86
|
+
} finally {
|
|
87
|
+
await reader.cancel().catch(() => {});
|
|
88
|
+
reader.releaseLock();
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { isAbortError, isRetryableStreamError, OpenGeniStreamError } from "./errors";
|
|
2
|
+
import { parseSseStream } from "./sse";
|
|
3
|
+
import type { SessionEvent } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transport boundary for the streaming core. The client implements it with
|
|
7
|
+
* `fetch`; unit tests script it directly.
|
|
8
|
+
*/
|
|
9
|
+
export type SessionEventStreamTransport = {
|
|
10
|
+
/** Open the SSE stream, replaying durable events after `after` first. */
|
|
11
|
+
openStream: (after: number, signal: AbortSignal | undefined) => Promise<ReadableStream<Uint8Array>>;
|
|
12
|
+
/** Replay durable events by sequence (`GET .../events?after=&limit=`). */
|
|
13
|
+
listEvents: (after: number, limit: number) => Promise<SessionEvent[]>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type StreamConnectionState = "connecting" | "live" | "reconnecting";
|
|
17
|
+
|
|
18
|
+
export type StreamSessionEventsOptions = {
|
|
19
|
+
/** Resume after this sequence number (exclusive). Defaults to 0 (full replay). */
|
|
20
|
+
after?: number;
|
|
21
|
+
/** Aborting ends the stream gracefully (the generator returns). */
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
/** Reconnect on transient drops. Defaults to true. */
|
|
24
|
+
reconnect?: boolean;
|
|
25
|
+
/** Initial reconnect backoff. Defaults to 500ms. */
|
|
26
|
+
reconnectDelayMs?: number;
|
|
27
|
+
/** Backoff ceiling. Defaults to 10s. */
|
|
28
|
+
maxReconnectDelayMs?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Give up after this many consecutive failed reconnect attempts (i.e. N
|
|
31
|
+
* reconnects = N+1 total open-stream calls). Defaults to unlimited.
|
|
32
|
+
*/
|
|
33
|
+
maxReconnectAttempts?: number;
|
|
34
|
+
onStateChange?: (state: StreamConnectionState) => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Stream a session's events with exactly-once, in-order delivery.
|
|
39
|
+
*
|
|
40
|
+
* Guarantees, anchored on the per-session contiguous `sequence`:
|
|
41
|
+
* - **No duplicates**: events at or below the cursor are dropped, so server
|
|
42
|
+
* replay overlap and reconnect overlap never re-yield.
|
|
43
|
+
* - **No gaps**: each reconnect resumes from the last seen sequence, and a
|
|
44
|
+
* gap observed inside one connection is backfilled from the durable replay
|
|
45
|
+
* endpoint before the newer event is yielded (events are durable before
|
|
46
|
+
* they are published live, so the backfill always finds them).
|
|
47
|
+
* - **Ordered**: sequences are yielded strictly ascending.
|
|
48
|
+
*
|
|
49
|
+
* The generator ends when `signal` aborts, when the server closes and
|
|
50
|
+
* `reconnect` is false, or with an error for non-retryable failures.
|
|
51
|
+
*/
|
|
52
|
+
export async function* streamSessionEvents(
|
|
53
|
+
transport: SessionEventStreamTransport,
|
|
54
|
+
options: StreamSessionEventsOptions = {},
|
|
55
|
+
): AsyncGenerator<SessionEvent, void, void> {
|
|
56
|
+
const signal = options.signal;
|
|
57
|
+
const reconnect = options.reconnect ?? true;
|
|
58
|
+
const baseDelayMs = options.reconnectDelayMs ?? 500;
|
|
59
|
+
const maxDelayMs = options.maxReconnectDelayMs ?? 10_000;
|
|
60
|
+
const maxAttempts = options.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
|
|
61
|
+
let cursor = options.after ?? 0;
|
|
62
|
+
let failedAttempts = 0;
|
|
63
|
+
let delayMs = baseDelayMs;
|
|
64
|
+
let everConnected = false;
|
|
65
|
+
|
|
66
|
+
while (!signal?.aborted) {
|
|
67
|
+
options.onStateChange?.(everConnected || failedAttempts > 0 ? "reconnecting" : "connecting");
|
|
68
|
+
const cursorAtOpen = cursor;
|
|
69
|
+
try {
|
|
70
|
+
const body = await transport.openStream(cursor, signal);
|
|
71
|
+
everConnected = true;
|
|
72
|
+
failedAttempts = 0;
|
|
73
|
+
delayMs = baseDelayMs;
|
|
74
|
+
options.onStateChange?.("live");
|
|
75
|
+
for await (const message of parseSseStream(body)) {
|
|
76
|
+
// Re-check after every yield resumption: an abort from the consumer
|
|
77
|
+
// must not let already-buffered events keep flowing.
|
|
78
|
+
if (signal?.aborted) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const event = parseSessionEvent(message.data);
|
|
82
|
+
if (!event || event.sequence <= cursor) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (event.sequence > cursor + 1) {
|
|
86
|
+
for await (const missed of backfillEvents(transport, cursor, event.sequence - 1)) {
|
|
87
|
+
cursor = missed.sequence;
|
|
88
|
+
yield missed;
|
|
89
|
+
if (signal?.aborted) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
cursor = event.sequence;
|
|
95
|
+
yield event;
|
|
96
|
+
}
|
|
97
|
+
if (!reconnect) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Clean server close: reconnect immediately when the connection made
|
|
101
|
+
// progress (servers legitimately cycle long SSE connections); pace
|
|
102
|
+
// empty closes so a misbehaving server is not hammered in a hot loop.
|
|
103
|
+
if (cursor === cursorAtOpen) {
|
|
104
|
+
await sleep(baseDelayMs, signal);
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (signal?.aborted || isAbortError(error)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!reconnect || !isRetryableStreamError(error)) {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
failedAttempts += 1;
|
|
115
|
+
if (failedAttempts > maxAttempts) {
|
|
116
|
+
throw new OpenGeniStreamError(
|
|
117
|
+
`event stream gave up after ${maxAttempts} consecutive failed reconnect attempts: ${error instanceof Error ? error.message : String(error)}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await sleep(delayMs, signal);
|
|
122
|
+
delayMs = Math.min(Math.max(delayMs * 2, baseDelayMs), maxDelayMs);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Yield the durable events with `fromExclusive < sequence <= toInclusive`,
|
|
128
|
+
* in order. Sequences are contiguous, so every one of them must exist in the
|
|
129
|
+
* replay endpoint; if any is missing the function throws instead of skipping
|
|
130
|
+
* it — continuing would silently break the gap-free delivery guarantee.
|
|
131
|
+
*/
|
|
132
|
+
async function* backfillEvents(
|
|
133
|
+
transport: SessionEventStreamTransport,
|
|
134
|
+
fromExclusive: number,
|
|
135
|
+
toInclusive: number,
|
|
136
|
+
): AsyncGenerator<SessionEvent, void, void> {
|
|
137
|
+
let cursor = fromExclusive;
|
|
138
|
+
while (cursor < toInclusive) {
|
|
139
|
+
const page = await transport.listEvents(cursor, Math.min(500, toInclusive - cursor));
|
|
140
|
+
const advancing = page
|
|
141
|
+
.filter((event) => event.sequence > cursor && event.sequence <= toInclusive)
|
|
142
|
+
.sort((a, b) => a.sequence - b.sequence);
|
|
143
|
+
if (advancing.length === 0) {
|
|
144
|
+
throw new OpenGeniStreamError(
|
|
145
|
+
`event replay backfill stalled: expected sequences ${cursor + 1}..${toInclusive} but the replay endpoint returned none of them`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
for (const event of advancing) {
|
|
149
|
+
if (event.sequence !== cursor + 1) {
|
|
150
|
+
throw new OpenGeniStreamError(
|
|
151
|
+
`event replay backfill is missing sequence ${cursor + 1} (replay endpoint skipped to ${event.sequence}); refusing to deliver with a gap`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
cursor = event.sequence;
|
|
155
|
+
yield event;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseSessionEvent(data: string): SessionEvent | null {
|
|
161
|
+
let parsed: unknown;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(data);
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (
|
|
168
|
+
typeof parsed !== "object" ||
|
|
169
|
+
parsed === null ||
|
|
170
|
+
typeof (parsed as { sequence?: unknown }).sequence !== "number" ||
|
|
171
|
+
typeof (parsed as { type?: unknown }).type !== "string" ||
|
|
172
|
+
typeof (parsed as { id?: unknown }).id !== "string"
|
|
173
|
+
) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return parsed as SessionEvent;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function sleep(delayMs: number, signal: AbortSignal | undefined): Promise<void> {
|
|
180
|
+
if (signal?.aborted || delayMs <= 0) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
await new Promise<void>((resolve) => {
|
|
184
|
+
const timer = setTimeout(done, delayMs);
|
|
185
|
+
function done(): void {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
signal?.removeEventListener("abort", done);
|
|
188
|
+
resolve();
|
|
189
|
+
}
|
|
190
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
191
|
+
});
|
|
192
|
+
}
|