@apollo-deploy/tesseract 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/LICENSE +21 -0
- package/README.md +235 -0
- package/dist/adapters/types.d.ts +15 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/adapters/typescript/index.d.ts +11 -0
- package/dist/adapters/typescript/index.d.ts.map +1 -0
- package/dist/adapters/typescript/index.js +112 -0
- package/dist/adapters/typescript/index.js.map +1 -0
- package/dist/adapters/typescript/model-emitter.d.ts +13 -0
- package/dist/adapters/typescript/model-emitter.d.ts.map +1 -0
- package/dist/adapters/typescript/model-emitter.js +415 -0
- package/dist/adapters/typescript/model-emitter.js.map +1 -0
- package/dist/adapters/typescript/shared.d.ts +26 -0
- package/dist/adapters/typescript/shared.d.ts.map +1 -0
- package/dist/adapters/typescript/shared.js +65 -0
- package/dist/adapters/typescript/shared.js.map +1 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +53 -0
- package/dist/cli.js.map +1 -0
- package/dist/helpers/handlebars.d.ts +15 -0
- package/dist/helpers/handlebars.d.ts.map +1 -0
- package/dist/helpers/handlebars.js +172 -0
- package/dist/helpers/handlebars.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/pipeline/enrich.d.ts +7 -0
- package/dist/pipeline/enrich.d.ts.map +1 -0
- package/dist/pipeline/enrich.js +277 -0
- package/dist/pipeline/enrich.js.map +1 -0
- package/dist/pipeline/intake.d.ts +11 -0
- package/dist/pipeline/intake.d.ts.map +1 -0
- package/dist/pipeline/intake.js +508 -0
- package/dist/pipeline/intake.js.map +1 -0
- package/dist/pipeline/write.d.ts +15 -0
- package/dist/pipeline/write.d.ts.map +1 -0
- package/dist/pipeline/write.js +33 -0
- package/dist/pipeline/write.js.map +1 -0
- package/dist/types/config.d.ts +47 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +24 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/ir.d.ts +172 -0
- package/dist/types/ir.d.ts.map +1 -0
- package/dist/types/ir.js +7 -0
- package/dist/types/ir.js.map +1 -0
- package/dist/types/manifest.d.ts +112 -0
- package/dist/types/manifest.d.ts.map +1 -0
- package/dist/types/manifest.js +32 -0
- package/dist/types/manifest.js.map +1 -0
- package/dist/utils/format.d.ts +5 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/format.js +21 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/naming.d.ts +26 -0
- package/dist/utils/naming.d.ts.map +1 -0
- package/dist/utils/naming.js +270 -0
- package/dist/utils/naming.js.map +1 -0
- package/package.json +58 -0
- package/templates/typescript/client.hbs +145 -0
- package/templates/typescript/domain-index.hbs +10 -0
- package/templates/typescript/domain.hbs +127 -0
- package/templates/typescript/index.hbs +64 -0
- package/templates/typescript/package-json.hbs +29 -0
- package/templates/typescript/readme.hbs +78 -0
- package/templates/typescript/transport-sse.hbs +377 -0
- package/templates/typescript/transport.hbs +662 -0
- package/templates/typescript/tsconfig.hbs +18 -0
- package/templates/typescript/types-common.hbs +98 -0
- package/templates/typescript/types-errors.hbs +191 -0
- package/templates/typescript/types.hbs +33 -0
- package/templates/typescript/utils-index.hbs +7 -0
- package/templates/typescript/utils-query.hbs +29 -0
- package/templates/typescript/webhook-handler.hbs +447 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated
|
|
3
|
+
* This file was automatically generated by sdk-forge.
|
|
4
|
+
* DO NOT MODIFY THIS FILE DIRECTLY.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Client
|
|
8
|
+
export { create{{meta.clientName}}Client } from './src/client.js';
|
|
9
|
+
export type { {{meta.clientName}}Client, {{meta.clientName}}ClientConfig } from './src/client.js';
|
|
10
|
+
|
|
11
|
+
// Error class
|
|
12
|
+
export { SDKError } from './src/types/errors.js';
|
|
13
|
+
|
|
14
|
+
// Transport
|
|
15
|
+
export { createTransport, executeRequest } from './src/transport/axios.js';
|
|
16
|
+
export type {
|
|
17
|
+
RetryConfig,
|
|
18
|
+
TransportConfig,
|
|
19
|
+
ResolvedTransportConfig,
|
|
20
|
+
SDKTransport,
|
|
21
|
+
SDKPlugin,
|
|
22
|
+
RequestOptions,
|
|
23
|
+
RequestMeta,
|
|
24
|
+
ResponseMeta,
|
|
25
|
+
ErrorMeta,
|
|
26
|
+
} from './src/transport/axios.js';
|
|
27
|
+
|
|
28
|
+
{{#if hasSSE}}
|
|
29
|
+
// SSE (Server-Sent Events)
|
|
30
|
+
export { createSSEStream, parseSSEChunk, parseSSEEventData } from './src/transport/sse.js';
|
|
31
|
+
export type { SSEEvent, SSEOptions, SSEReconnectConfig } from './src/transport/sse.js';
|
|
32
|
+
{{/if}}
|
|
33
|
+
|
|
34
|
+
// Common types
|
|
35
|
+
export type {
|
|
36
|
+
PaginationQuery,
|
|
37
|
+
PageMeta,
|
|
38
|
+
Page,
|
|
39
|
+
SuccessResponse,
|
|
40
|
+
MessageResponse,
|
|
41
|
+
ApiErrorEnvelope,
|
|
42
|
+
} from './src/types/common.js';
|
|
43
|
+
|
|
44
|
+
// Domain API types
|
|
45
|
+
{{#each groups}}
|
|
46
|
+
export type { {{interfaceName}} } from './src/domain/{{fileName}}.js';
|
|
47
|
+
{{/each}}
|
|
48
|
+
|
|
49
|
+
{{#if hasWebhooks}}
|
|
50
|
+
// Webhooks
|
|
51
|
+
export { createWebhookRegistry } from './src/webhooks/handler.js';
|
|
52
|
+
export type {
|
|
53
|
+
WebhookEventMap,
|
|
54
|
+
WebhookEventName,
|
|
55
|
+
WebhookHandler,
|
|
56
|
+
WebhookMeta,
|
|
57
|
+
WebhookVerifyOptions,
|
|
58
|
+
WebhookRegistry,
|
|
59
|
+
WebhookRegistryConfig,
|
|
60
|
+
} from './src/webhooks/handler.js';
|
|
61
|
+
{{/if}}
|
|
62
|
+
|
|
63
|
+
// Types
|
|
64
|
+
export * from './src/types/index.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{packageName}}",
|
|
3
|
+
"version": "{{version}}",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "{{#if description}}{{description}}{{else}}TypeScript SDK for {{title}}{{/if}}",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"prepublishOnly": "tsc"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"axios": "1.14.0"{{#each externalPackages}},
|
|
20
|
+
"{{name}}": "{{version}}"{{/each}}
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.5.0"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# {{title}} SDK
|
|
2
|
+
|
|
3
|
+
{{#if description}}
|
|
4
|
+
{{description}}
|
|
5
|
+
|
|
6
|
+
{{/if}}
|
|
7
|
+
> This SDK was automatically generated by [sdk-forge](https://github.com/sdk-forge).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install {{packageName}}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { create{{clientName}}Client } from '{{packageName}}';
|
|
19
|
+
|
|
20
|
+
const client = create{{clientName}}Client({
|
|
21
|
+
baseUrl: '{{baseUrl}}',
|
|
22
|
+
{{#each securitySchemes}}
|
|
23
|
+
{{configKey}}: 'your-{{kebabCase name}}-credential',
|
|
24
|
+
{{/each}}
|
|
25
|
+
});
|
|
26
|
+
const api = client.{{groups.[0].name}};
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Effect Runtime
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { create{{clientName}}Client, Effect } from '{{packageName}}';
|
|
33
|
+
|
|
34
|
+
const client = create{{clientName}}Client();
|
|
35
|
+
|
|
36
|
+
const program = client.{{groups.[0].name}}.{{groups.[0].operations.[0].name}}Effect(/* params */);
|
|
37
|
+
const result = await Effect.runPromise(program);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## API Reference
|
|
41
|
+
|
|
42
|
+
{{#each groups}}
|
|
43
|
+
### {{interfaceName}}
|
|
44
|
+
|
|
45
|
+
{{#if description}}
|
|
46
|
+
{{description}}
|
|
47
|
+
|
|
48
|
+
{{/if}}
|
|
49
|
+
| Method | HTTP | Path | Description |
|
|
50
|
+
|--------|------|------|-------------|
|
|
51
|
+
{{#each operations}}
|
|
52
|
+
| `{{name}}` | {{httpMethod}} | `{{path}}` | {{#if description}}{{description}}{{else}}-{{/if}} |
|
|
53
|
+
{{/each}}
|
|
54
|
+
|
|
55
|
+
{{/each}}
|
|
56
|
+
## Types
|
|
57
|
+
|
|
58
|
+
{{#each schemas}}
|
|
59
|
+
{{#if isEnum}}
|
|
60
|
+
- `{{name}}` — enum: {{#each enumValues}}`{{this}}`{{#unless @last}}, {{/unless}}{{/each}}
|
|
61
|
+
{{else}}
|
|
62
|
+
- `{{name}}`{{#if description}} — {{description}}{{/if}}
|
|
63
|
+
{{/if}}
|
|
64
|
+
{{/each}}
|
|
65
|
+
|
|
66
|
+
## Error Handling
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { SDKError } from '{{packageName}}';
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const result = await client.someApi.someMethod();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof SDKError) {
|
|
75
|
+
console.error(`Error ${error.status}: ${error.message} (${error.code})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @generated
|
|
3
|
+
* This file was automatically generated by sdk-forge.
|
|
4
|
+
* DO NOT MODIFY THIS FILE DIRECTLY.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
8
|
+
|
|
9
|
+
// ── SSE Types ────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A parsed Server-Sent Event with typed data payload.
|
|
13
|
+
*/
|
|
14
|
+
export interface SSEEvent<T = unknown> {
|
|
15
|
+
/** Event type (from the `event:` field, defaults to "message") */
|
|
16
|
+
type: string;
|
|
17
|
+
/** Parsed JSON data payload */
|
|
18
|
+
data: T;
|
|
19
|
+
/** Last event ID (from the `id:` field) */
|
|
20
|
+
id?: string;
|
|
21
|
+
/** Retry interval in milliseconds (from the `retry:` field) */
|
|
22
|
+
retry?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options for SSE stream subscriptions.
|
|
27
|
+
*/
|
|
28
|
+
export interface SSEOptions {
|
|
29
|
+
/** AbortSignal for cooperative cancellation */
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
/** Per-request timeout for the initial connection in milliseconds */
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
/** Additional headers merged into the request */
|
|
34
|
+
headers?: Record<string, string>;
|
|
35
|
+
/** Custom event types to listen for (defaults to all events) */
|
|
36
|
+
eventTypes?: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Called when the connection encounters a retryable error.
|
|
39
|
+
* Return `false` to stop reconnection attempts.
|
|
40
|
+
*/
|
|
41
|
+
onRetry?: (attempt: number, error: unknown) => boolean | void;
|
|
42
|
+
/**
|
|
43
|
+
* Called when the SSE connection is established successfully.
|
|
44
|
+
* Useful for resetting UI state or logging.
|
|
45
|
+
*/
|
|
46
|
+
onOpen?: (response: Response) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Called when the SSE connection is closed (cleanly or after max retries).
|
|
49
|
+
*/
|
|
50
|
+
onClose?: () => void;
|
|
51
|
+
/**
|
|
52
|
+
* Custom data parser. By default, attempts JSON.parse and falls back to raw string.
|
|
53
|
+
* Useful when the server sends non-JSON payloads or when you need custom deserialization.
|
|
54
|
+
*/
|
|
55
|
+
parseData?: <R>(raw: string, eventType: string) => R;
|
|
56
|
+
/**
|
|
57
|
+
* Maximum buffer size in bytes before discarding incomplete data.
|
|
58
|
+
* Protects against memory exhaustion from malformed streams. Default: 5MB.
|
|
59
|
+
*/
|
|
60
|
+
maxBufferSize?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Heartbeat timeout in milliseconds. If no data is received within this period,
|
|
63
|
+
* the connection is considered dead and will be reconnected.
|
|
64
|
+
* Set to 0 to disable. Default: 0 (disabled).
|
|
65
|
+
*/
|
|
66
|
+
heartbeatTimeoutMs?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Whether to automatically reconnect when the stream ends cleanly (server closes connection).
|
|
69
|
+
* Default: false (only reconnects on errors).
|
|
70
|
+
*/
|
|
71
|
+
reconnectOnComplete?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Configuration for SSE reconnection behavior.
|
|
76
|
+
*/
|
|
77
|
+
export interface SSEReconnectConfig {
|
|
78
|
+
/** Maximum number of reconnection attempts (default: 5) */
|
|
79
|
+
maxAttempts: number;
|
|
80
|
+
/** Base delay between reconnection attempts in ms (default: 1000) */
|
|
81
|
+
baseDelayMs: number;
|
|
82
|
+
/** Maximum delay between reconnection attempts in ms (default: 30000) */
|
|
83
|
+
maxDelayMs: number;
|
|
84
|
+
/** Whether to apply jitter to reconnection delays (default: true) */
|
|
85
|
+
jitter?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Fetch request init with headers narrowed to a plain string record.
|
|
90
|
+
* Avoids type conflicts from the broader `HeadersInit` union in `RequestInit`.
|
|
91
|
+
*/
|
|
92
|
+
export type SSERequestInit = Omit<RequestInit, 'headers'> & {
|
|
93
|
+
headers?: Record<string, string>;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ── SSE Parser ───────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse a raw SSE text chunk into individual events.
|
|
100
|
+
* Handles multi-line `data:` fields, all standard SSE fields,
|
|
101
|
+
* and both `\n` and `\r\n` line endings per the SSE specification.
|
|
102
|
+
*/
|
|
103
|
+
export function parseSSEChunk(chunk: string): SSEEvent<string>[] {
|
|
104
|
+
const events: SSEEvent<string>[] = [];
|
|
105
|
+
// Normalize \r\n and \r to \n for consistent parsing (SSE spec allows all three)
|
|
106
|
+
const normalized = chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
107
|
+
const blocks = normalized.split(/\n{2,}/);
|
|
108
|
+
|
|
109
|
+
for (const block of blocks) {
|
|
110
|
+
const trimmed = block.trim();
|
|
111
|
+
if (!trimmed) continue;
|
|
112
|
+
// Skip blocks that are entirely comments
|
|
113
|
+
if (trimmed.split('\n').every((line) => line.startsWith(':'))) continue;
|
|
114
|
+
|
|
115
|
+
let type = 'message';
|
|
116
|
+
let data = '';
|
|
117
|
+
let id: string | undefined;
|
|
118
|
+
let retry: number | undefined;
|
|
119
|
+
|
|
120
|
+
for (const line of trimmed.split('\n')) {
|
|
121
|
+
// Comment lines
|
|
122
|
+
if (line.startsWith(':')) continue;
|
|
123
|
+
|
|
124
|
+
const colonIdx = line.indexOf(':');
|
|
125
|
+
let field: string;
|
|
126
|
+
let value: string;
|
|
127
|
+
|
|
128
|
+
if (colonIdx === -1) {
|
|
129
|
+
// Field-only lines (no colon) — per spec, the value is empty string
|
|
130
|
+
field = line;
|
|
131
|
+
value = '';
|
|
132
|
+
} else {
|
|
133
|
+
field = line.slice(0, colonIdx);
|
|
134
|
+
// Per SSE spec: if there's a space after the colon, strip exactly one space
|
|
135
|
+
const rawValue = line.slice(colonIdx + 1);
|
|
136
|
+
value = rawValue.startsWith(' ') ? rawValue.slice(1) : rawValue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
switch (field) {
|
|
140
|
+
case 'event':
|
|
141
|
+
type = value;
|
|
142
|
+
break;
|
|
143
|
+
case 'data':
|
|
144
|
+
data += (data ? '\n' : '') + value;
|
|
145
|
+
break;
|
|
146
|
+
case 'id':
|
|
147
|
+
// Per spec: id field must not contain null characters
|
|
148
|
+
if (!value.includes('\0')) {
|
|
149
|
+
id = value;
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case 'retry': {
|
|
153
|
+
const parsed = Number.parseInt(value, 10);
|
|
154
|
+
if (Number.isFinite(parsed) && parsed >= 0) retry = parsed;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
// Unknown fields are ignored per spec
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (data) {
|
|
162
|
+
const event: SSEEvent<string> = { type, data };
|
|
163
|
+
if (id !== undefined) event.id = id;
|
|
164
|
+
if (retry !== undefined) event.retry = retry;
|
|
165
|
+
events.push(event);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return events;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Parse and type-cast an SSE event's data field from JSON.
|
|
174
|
+
* Falls back to the raw string value if JSON parsing fails.
|
|
175
|
+
*/
|
|
176
|
+
export function parseSSEEventData<T>(event: SSEEvent<string>, parseData?: <R>(raw: string, type: string) => R): SSEEvent<T> {
|
|
177
|
+
if (parseData) {
|
|
178
|
+
return { ...event, data: parseData<T>(event.data, event.type) };
|
|
179
|
+
}
|
|
180
|
+
let parsed: T;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(event.data) as T;
|
|
183
|
+
} catch {
|
|
184
|
+
parsed = event.data as unknown as T;
|
|
185
|
+
}
|
|
186
|
+
return { ...event, data: parsed };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── SSE Stream Factory ──────────────────────────────
|
|
190
|
+
|
|
191
|
+
const DEFAULT_RECONNECT: SSEReconnectConfig = {
|
|
192
|
+
maxAttempts: 5,
|
|
193
|
+
baseDelayMs: 1000,
|
|
194
|
+
maxDelayMs: 30_000,
|
|
195
|
+
jitter: true,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const DEFAULT_MAX_BUFFER = 5 * 1024 * 1024; // 5MB
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create an async iterable that reads from an SSE endpoint using fetch.
|
|
202
|
+
* Automatically handles reconnection with exponential backoff.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* const stream = createSSEStream<MyEvent>(url, { method: 'GET' }, undefined, {
|
|
207
|
+
* signal: controller.signal,
|
|
208
|
+
* eventTypes: ['update', 'complete'],
|
|
209
|
+
* heartbeatTimeoutMs: 30000,
|
|
210
|
+
* onOpen: () => console.log('Connected'),
|
|
211
|
+
* onClose: () => console.log('Disconnected'),
|
|
212
|
+
* });
|
|
213
|
+
*
|
|
214
|
+
* for await (const event of stream) {
|
|
215
|
+
* console.log(event.type, event.data);
|
|
216
|
+
* }
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
export async function* createSSEStream<T>(
|
|
220
|
+
url: string,
|
|
221
|
+
init: SSERequestInit,
|
|
222
|
+
reconnect?: Partial<SSEReconnectConfig>,
|
|
223
|
+
options?: SSEOptions,
|
|
224
|
+
): AsyncIterable<SSEEvent<T>> {
|
|
225
|
+
const rc: SSEReconnectConfig = { ...DEFAULT_RECONNECT, ...reconnect };
|
|
226
|
+
const maxBuffer = options?.maxBufferSize ?? DEFAULT_MAX_BUFFER;
|
|
227
|
+
let lastEventId: string | undefined;
|
|
228
|
+
let attempt = 0;
|
|
229
|
+
let serverRetryMs: number | undefined;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
while (true) {
|
|
233
|
+
const headers: Record<string, string> = {
|
|
234
|
+
Accept: 'text/event-stream',
|
|
235
|
+
'Cache-Control': 'no-cache',
|
|
236
|
+
...(init.headers ?? {}),
|
|
237
|
+
...(options?.headers ?? {}),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (lastEventId) {
|
|
241
|
+
headers['Last-Event-ID'] = lastEventId;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const controller = new AbortController();
|
|
245
|
+
const externalSignal = options?.signal;
|
|
246
|
+
|
|
247
|
+
if (externalSignal?.aborted) return;
|
|
248
|
+
|
|
249
|
+
// Link external signal to internal controller
|
|
250
|
+
const onAbort = () => controller.abort();
|
|
251
|
+
externalSignal?.addEventListener('abort', onAbort, { once: true });
|
|
252
|
+
|
|
253
|
+
let connectionTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
254
|
+
if (options?.timeoutMs) {
|
|
255
|
+
connectionTimeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const response = await fetch(url, {
|
|
260
|
+
...init,
|
|
261
|
+
headers,
|
|
262
|
+
signal: controller.signal,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (connectionTimeoutId) {
|
|
266
|
+
clearTimeout(connectionTimeoutId);
|
|
267
|
+
connectionTimeoutId = undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
throw new Error(`SSE connection failed: ${response.status} ${response.statusText}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!response.body) {
|
|
275
|
+
throw new Error('SSE response has no body');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Connection established successfully
|
|
279
|
+
attempt = 0;
|
|
280
|
+
options?.onOpen?.(response);
|
|
281
|
+
|
|
282
|
+
const reader = response.body.getReader();
|
|
283
|
+
const decoder = new TextDecoder();
|
|
284
|
+
let buffer = '';
|
|
285
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
286
|
+
|
|
287
|
+
const resetHeartbeat = () => {
|
|
288
|
+
if (heartbeatTimer) clearTimeout(heartbeatTimer);
|
|
289
|
+
if (options?.heartbeatTimeoutMs && options.heartbeatTimeoutMs > 0) {
|
|
290
|
+
heartbeatTimer = setTimeout(() => {
|
|
291
|
+
reader.cancel().catch(() => {});
|
|
292
|
+
}, options.heartbeatTimeoutMs);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
resetHeartbeat();
|
|
298
|
+
|
|
299
|
+
while (true) {
|
|
300
|
+
if (externalSignal?.aborted) return;
|
|
301
|
+
|
|
302
|
+
const { done, value } = await reader.read();
|
|
303
|
+
if (done) break;
|
|
304
|
+
|
|
305
|
+
resetHeartbeat();
|
|
306
|
+
|
|
307
|
+
buffer += decoder.decode(value, { stream: true });
|
|
308
|
+
|
|
309
|
+
// Guard against memory exhaustion from malformed streams
|
|
310
|
+
if (buffer.length > maxBuffer) {
|
|
311
|
+
// Discard everything before the last double newline, or the whole buffer
|
|
312
|
+
const lastBreak = buffer.lastIndexOf('\n\n');
|
|
313
|
+
if (lastBreak !== -1) {
|
|
314
|
+
buffer = buffer.slice(lastBreak + 2);
|
|
315
|
+
} else {
|
|
316
|
+
buffer = '';
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Process complete events (separated by double newlines)
|
|
321
|
+
// Handle both \n\n and \r\n\r\n
|
|
322
|
+
const lastDoubleNewline = Math.max(
|
|
323
|
+
buffer.lastIndexOf('\n\n'),
|
|
324
|
+
buffer.lastIndexOf('\r\n\r\n'),
|
|
325
|
+
);
|
|
326
|
+
if (lastDoubleNewline === -1) continue;
|
|
327
|
+
|
|
328
|
+
const breakLen = buffer[lastDoubleNewline] === '\r' ? 4 : 2;
|
|
329
|
+
const complete = buffer.slice(0, lastDoubleNewline + breakLen);
|
|
330
|
+
buffer = buffer.slice(lastDoubleNewline + breakLen);
|
|
331
|
+
|
|
332
|
+
const rawEvents = parseSSEChunk(complete);
|
|
333
|
+
for (const rawEvent of rawEvents) {
|
|
334
|
+
if (rawEvent.id !== undefined) lastEventId = rawEvent.id;
|
|
335
|
+
if (rawEvent.retry !== undefined) serverRetryMs = rawEvent.retry;
|
|
336
|
+
|
|
337
|
+
if (options?.eventTypes && !options.eventTypes.includes(rawEvent.type)) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
yield parseSSEEventData<T>(rawEvent, options?.parseData);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} finally {
|
|
345
|
+
if (heartbeatTimer) clearTimeout(heartbeatTimer);
|
|
346
|
+
reader.releaseLock();
|
|
347
|
+
externalSignal?.removeEventListener('abort', onAbort);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Stream ended cleanly
|
|
351
|
+
if (!options?.reconnectOnComplete) return;
|
|
352
|
+
// Fall through to reconnection logic below
|
|
353
|
+
} catch (error: unknown) {
|
|
354
|
+
if (connectionTimeoutId) clearTimeout(connectionTimeoutId);
|
|
355
|
+
externalSignal?.removeEventListener('abort', onAbort);
|
|
356
|
+
|
|
357
|
+
if (externalSignal?.aborted) return;
|
|
358
|
+
|
|
359
|
+
attempt++;
|
|
360
|
+
if (attempt > rc.maxAttempts) throw error;
|
|
361
|
+
|
|
362
|
+
const shouldRetry = options?.onRetry?.(attempt, error);
|
|
363
|
+
if (shouldRetry === false) throw error;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Reconnection delay
|
|
367
|
+
const baseDelay = serverRetryMs ?? rc.baseDelayMs * Math.pow(2, attempt - 1);
|
|
368
|
+
let delayMs = Math.min(baseDelay, rc.maxDelayMs);
|
|
369
|
+
if (rc.jitter !== false) {
|
|
370
|
+
delayMs = Math.floor(Math.random() * (delayMs + 1));
|
|
371
|
+
}
|
|
372
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
373
|
+
}
|
|
374
|
+
} finally {
|
|
375
|
+
options?.onClose?.();
|
|
376
|
+
}
|
|
377
|
+
}
|