@bagelink/sdk 1.20.52 → 1.20.56
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/package.json +1 -1
- package/src/index.js +715 -0
- package/src/openAPITools/StreamController.js +62 -0
- package/src/openAPITools/functionGenerator.js +979 -0
- package/src/openAPITools/index.js +40 -0
- package/src/openAPITools/moduleGenerator.js +125 -0
- package/src/openAPITools/streamClient.js +276 -0
- package/src/openAPITools/streamDetector.js +204 -0
- package/src/openAPITools/typeGenerator.js +48 -0
- package/src/openAPITools/types/index.js +19 -0
- package/src/openAPITools/types/utils.js +30 -0
- package/src/openAPITools/utils.js +282 -0
- package/src/unwrap.js +30 -0
- package/src/utils.js +58 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { generateFunctions } from './functionGenerator';
|
|
3
|
+
import { generateTypes } from './typeGenerator';
|
|
4
|
+
const basicAuthHeader = { Authorization: 'Basic YmFnZWxfdXNlcm5hbWU6Tm90U2VjdXJlQGJhZ2Vs' };
|
|
5
|
+
export default async (openApiUrl, baseUrl) => {
|
|
6
|
+
try {
|
|
7
|
+
console.log(`Fetching OpenAPI spec from: ${openApiUrl}`);
|
|
8
|
+
const { data: openApi } = await axios.get(openApiUrl, { headers: basicAuthHeader });
|
|
9
|
+
const schemas = openApi.components?.schemas;
|
|
10
|
+
if (!schemas) {
|
|
11
|
+
throw new Error('No schemas found in OpenAPI document');
|
|
12
|
+
}
|
|
13
|
+
const types = generateTypes(schemas);
|
|
14
|
+
// Generate Functions
|
|
15
|
+
const { paths } = openApi;
|
|
16
|
+
if (!paths) {
|
|
17
|
+
throw new Error('No paths found in OpenAPI document');
|
|
18
|
+
}
|
|
19
|
+
const code = generateFunctions(paths, baseUrl); // TODO baseURL should not be set here, but should be instatiated in runtime somehow
|
|
20
|
+
return { types, code };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.response) {
|
|
24
|
+
// Server responded with error status
|
|
25
|
+
throw new Error(`HTTP ${error.response.status}: ${error.response.statusText} - Failed to fetch OpenAPI spec from ${openApiUrl}`);
|
|
26
|
+
}
|
|
27
|
+
else if (error.request) {
|
|
28
|
+
// Request was made but no response received
|
|
29
|
+
throw new Error(`Unable to connect to ${openApiUrl}. Is the server running and accessible?`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
// Something else went wrong
|
|
33
|
+
throw new Error(`Error fetching OpenAPI spec: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
export * from './streamClient';
|
|
38
|
+
export * from './streamDetector';
|
|
39
|
+
export * from './moduleGenerator';
|
|
40
|
+
export * from './types';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { generateModuleFunctions } from './functionGenerator';
|
|
2
|
+
import { generateTypes } from './typeGenerator';
|
|
3
|
+
import { formatType } from './utils';
|
|
4
|
+
const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
5
|
+
function isReference(value) {
|
|
6
|
+
return Boolean(value && typeof value === 'object' && '$ref' in value);
|
|
7
|
+
}
|
|
8
|
+
function operationOwners(path, operation, manifest) {
|
|
9
|
+
return manifest.modules.filter(module => module.pathPrefixes?.some(prefix => path === prefix || path.startsWith(`${prefix}/`))
|
|
10
|
+
|| operation.tags?.some(tag => module.tags?.includes(tag)));
|
|
11
|
+
}
|
|
12
|
+
function collectSchemaRefs(value, refs) {
|
|
13
|
+
if (!value || typeof value !== 'object')
|
|
14
|
+
return;
|
|
15
|
+
if (isReference(value) && value.$ref.startsWith('#/components/schemas/')) {
|
|
16
|
+
refs.add(value.$ref.slice('#/components/schemas/'.length));
|
|
17
|
+
}
|
|
18
|
+
for (const child of Object.values(value))
|
|
19
|
+
collectSchemaRefs(child, refs);
|
|
20
|
+
}
|
|
21
|
+
function schemaClosure(openApi, seed) {
|
|
22
|
+
const schemas = openApi.components?.schemas ?? {};
|
|
23
|
+
const result = new Set(seed);
|
|
24
|
+
const pending = [...result];
|
|
25
|
+
while (pending.length) {
|
|
26
|
+
const name = pending.pop();
|
|
27
|
+
const schema = schemas[name];
|
|
28
|
+
if (!schema)
|
|
29
|
+
continue;
|
|
30
|
+
const refs = new Set();
|
|
31
|
+
collectSchemaRefs(schema, refs);
|
|
32
|
+
for (const ref of refs) {
|
|
33
|
+
if (!result.has(ref)) {
|
|
34
|
+
result.add(ref);
|
|
35
|
+
pending.push(ref);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return [...result].sort();
|
|
40
|
+
}
|
|
41
|
+
function moduleFactoryName(name) {
|
|
42
|
+
return `create${name
|
|
43
|
+
.split('-')
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map(part => part[0].toUpperCase() + part.slice(1))
|
|
46
|
+
.join('')}Api`;
|
|
47
|
+
}
|
|
48
|
+
export function validateOwnershipManifest(openApi, manifest) {
|
|
49
|
+
const issues = [];
|
|
50
|
+
if (manifest.version !== 1) {
|
|
51
|
+
issues.push({ kind: 'invalid-manifest', message: `Unsupported manifest version: ${manifest.version}` });
|
|
52
|
+
}
|
|
53
|
+
const names = manifest.modules.map(module => module.name);
|
|
54
|
+
for (const name of new Set(names.filter((name, index) => names.indexOf(name) !== index))) {
|
|
55
|
+
issues.push({ kind: 'invalid-manifest', message: `Duplicate module name: ${name}` });
|
|
56
|
+
}
|
|
57
|
+
const schemas = openApi.components?.schemas ?? {};
|
|
58
|
+
for (const module of manifest.modules) {
|
|
59
|
+
if (!module.name || !/^[a-z][a-z0-9-]*$/.test(module.name)) {
|
|
60
|
+
issues.push({ kind: 'invalid-manifest', message: `Invalid module name: ${module.name || '(empty)'}` });
|
|
61
|
+
}
|
|
62
|
+
if (!(module.tags?.length || module.pathPrefixes?.length)) {
|
|
63
|
+
issues.push({ kind: 'invalid-manifest', message: `Module ${module.name} must own tags or pathPrefixes` });
|
|
64
|
+
}
|
|
65
|
+
for (const schema of module.schemas ?? []) {
|
|
66
|
+
if (!schemas[schema])
|
|
67
|
+
issues.push({ kind: 'unknown-schema', message: `Module ${module.name} owns unknown schema ${schema}` });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const [path, item] of Object.entries(openApi.paths ?? {})) {
|
|
71
|
+
for (const method of METHODS) {
|
|
72
|
+
const operation = item[method];
|
|
73
|
+
if (!operation)
|
|
74
|
+
continue;
|
|
75
|
+
const owners = operationOwners(path, operation, manifest);
|
|
76
|
+
if (owners.length === 0) {
|
|
77
|
+
issues.push({ kind: 'unowned-operation', message: `${method.toUpperCase()} ${path} is not owned` });
|
|
78
|
+
}
|
|
79
|
+
else if (owners.length > 1) {
|
|
80
|
+
issues.push({
|
|
81
|
+
kind: 'multiply-owned-operation',
|
|
82
|
+
message: `${method.toUpperCase()} ${path} is owned by ${owners.map(owner => owner.name).join(', ')}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return issues;
|
|
88
|
+
}
|
|
89
|
+
export function generateModules(openApi, manifest, baseUrl) {
|
|
90
|
+
const issues = validateOwnershipManifest(openApi, manifest);
|
|
91
|
+
if (issues.length)
|
|
92
|
+
throw new Error(issues.map(issue => issue.message).join('\n'));
|
|
93
|
+
const schemas = openApi.components?.schemas ?? {};
|
|
94
|
+
return [...manifest.modules]
|
|
95
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
96
|
+
.map((module) => {
|
|
97
|
+
const paths = {};
|
|
98
|
+
const refs = new Set(module.schemas ?? []);
|
|
99
|
+
for (const [path, item] of Object.entries(openApi.paths ?? {})) {
|
|
100
|
+
const ownedItem = {};
|
|
101
|
+
for (const method of METHODS) {
|
|
102
|
+
const operation = item[method];
|
|
103
|
+
if (operation && operationOwners(path, operation, manifest)[0]?.name === module.name) {
|
|
104
|
+
;
|
|
105
|
+
ownedItem[method] = operation;
|
|
106
|
+
collectSchemaRefs(operation, refs);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (Object.keys(ownedItem).length) {
|
|
110
|
+
if (item.parameters)
|
|
111
|
+
ownedItem.parameters = item.parameters;
|
|
112
|
+
paths[path] = ownedItem;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const ownedSchemas = schemaClosure(openApi, refs);
|
|
116
|
+
const subset = Object.fromEntries(ownedSchemas.map(name => [name, schemas[name]]).filter((entry) => Boolean(entry[1])));
|
|
117
|
+
return {
|
|
118
|
+
name: module.name,
|
|
119
|
+
paths: Object.keys(paths).sort(),
|
|
120
|
+
schemas: ownedSchemas.map(formatType),
|
|
121
|
+
code: generateModuleFunctions(paths, baseUrl, moduleFactoryName(module.name)),
|
|
122
|
+
types: generateTypes(subset),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE (Server-Sent Events) Client Utilities
|
|
3
|
+
* Simple, reliable SSE streaming without event loss
|
|
4
|
+
*/
|
|
5
|
+
import { StreamController } from './StreamController';
|
|
6
|
+
export { StreamController } from './StreamController';
|
|
7
|
+
async function resolveHeaders(headers) {
|
|
8
|
+
return typeof headers === 'function' ? await headers() : (headers ?? {});
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Creates an SSE stream consumer with elegant event-based API
|
|
12
|
+
* @param url - The SSE endpoint URL
|
|
13
|
+
* @param options - Stream options
|
|
14
|
+
* @returns StreamController for chainable event handling
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const stream = createSSEStream(url)
|
|
19
|
+
* .on('token', data => console.log(data))
|
|
20
|
+
* .on('done', () => console.log('Complete!'))
|
|
21
|
+
* .on('error', err => console.error(err))
|
|
22
|
+
*
|
|
23
|
+
* // Close when needed
|
|
24
|
+
* stream.close()
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function createSSEStream(url, options = {}) {
|
|
28
|
+
const { headers, withCredentials = true, lastEventId: initialLastEventId, maxReconnects = 5, reconnectDelayMs = 1000, } = options;
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const stream = new StreamController(() => { controller.abort(); });
|
|
31
|
+
let lastEventId = initialLastEventId;
|
|
32
|
+
let reconnectAttempts = 0;
|
|
33
|
+
async function connect() {
|
|
34
|
+
if (controller.signal.aborted)
|
|
35
|
+
return;
|
|
36
|
+
const resolvedHeaders = await resolveHeaders(headers);
|
|
37
|
+
const requestHeaders = {
|
|
38
|
+
'Accept': 'text/event-stream',
|
|
39
|
+
'Cache-Control': 'no-cache',
|
|
40
|
+
...resolvedHeaders,
|
|
41
|
+
};
|
|
42
|
+
if (lastEventId !== undefined) {
|
|
43
|
+
requestHeaders['Last-Event-ID'] = lastEventId;
|
|
44
|
+
}
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await fetch(url, {
|
|
48
|
+
method: 'GET',
|
|
49
|
+
headers: requestHeaders,
|
|
50
|
+
credentials: withCredentials ? 'include' : 'same-origin',
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
keepalive: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error?.name === 'AbortError')
|
|
57
|
+
return;
|
|
58
|
+
return handleDisconnect(error);
|
|
59
|
+
}
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
stream.emit('error', new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!response.body) {
|
|
65
|
+
stream.emit('error', new Error('Response body is null'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const reader = response.body.getReader();
|
|
69
|
+
const decoder = new TextDecoder();
|
|
70
|
+
let buffer = '';
|
|
71
|
+
let terminalEventReceived = false;
|
|
72
|
+
// SSE parser state MUST live across read() chunks: a single event's
|
|
73
|
+
// `event:`/`data:` lines and its terminating blank line can be split over
|
|
74
|
+
// multiple network chunks (very common for large payloads such as big
|
|
75
|
+
// tool_result blobs). Re-initialising these per chunk would drop or
|
|
76
|
+
// mis-attribute those events, making large results appear to only "arrive"
|
|
77
|
+
// once the whole response flushes at the end.
|
|
78
|
+
let currentEvent = 'message';
|
|
79
|
+
let currentData = '';
|
|
80
|
+
let currentId;
|
|
81
|
+
try {
|
|
82
|
+
while (true) {
|
|
83
|
+
const { done, value } = await reader.read();
|
|
84
|
+
if (done) {
|
|
85
|
+
if (!controller.signal.aborted && !terminalEventReceived) {
|
|
86
|
+
return handleDisconnect(new Error('Stream ended before a terminal event'));
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
buffer += decoder.decode(value, { stream: true });
|
|
91
|
+
const lines = buffer.split('\n');
|
|
92
|
+
buffer = lines.pop() || '';
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (line.startsWith('id:')) {
|
|
95
|
+
currentId = line.slice(3).trim();
|
|
96
|
+
}
|
|
97
|
+
else if (line.startsWith('event:')) {
|
|
98
|
+
currentEvent = line.slice(6).trim();
|
|
99
|
+
}
|
|
100
|
+
else if (line.startsWith('data:')) {
|
|
101
|
+
// Accumulate multi-line data fields per the SSE spec instead of
|
|
102
|
+
// overwriting, so multi-line `data:` payloads survive intact.
|
|
103
|
+
currentData = currentData ? `${currentData}\n${line.slice(5).trim()}` : line.slice(5).trim();
|
|
104
|
+
}
|
|
105
|
+
else if (line === '' && currentData) {
|
|
106
|
+
// Track last seen ID for reconnect
|
|
107
|
+
if (currentId !== undefined) {
|
|
108
|
+
if (currentId !== lastEventId)
|
|
109
|
+
reconnectAttempts = 0;
|
|
110
|
+
lastEventId = currentId;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(currentData);
|
|
114
|
+
stream.emit(currentEvent, parsed);
|
|
115
|
+
// Auto-close stream on terminal events
|
|
116
|
+
if (currentEvent === 'done' || currentEvent === 'error') {
|
|
117
|
+
terminalEventReceived = true;
|
|
118
|
+
stream.emit('complete', {});
|
|
119
|
+
controller.abort();
|
|
120
|
+
try {
|
|
121
|
+
await reader.cancel();
|
|
122
|
+
}
|
|
123
|
+
catch { /* already closed */ }
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
stream.emit(currentEvent, currentData);
|
|
129
|
+
}
|
|
130
|
+
currentEvent = 'message';
|
|
131
|
+
currentData = '';
|
|
132
|
+
currentId = undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error?.name !== 'AbortError') {
|
|
139
|
+
return handleDisconnect(error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
try {
|
|
144
|
+
reader.releaseLock();
|
|
145
|
+
}
|
|
146
|
+
catch { /* ignore */ }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function handleDisconnect(error) {
|
|
150
|
+
if (controller.signal.aborted)
|
|
151
|
+
return;
|
|
152
|
+
if (reconnectAttempts >= maxReconnects) {
|
|
153
|
+
const msg = error?.message || 'Stream connection lost';
|
|
154
|
+
stream.emit('error', new Error(`${msg} (gave up after ${maxReconnects} reconnects)`));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
reconnectAttempts++;
|
|
158
|
+
const delay = reconnectDelayMs * Math.pow(2, reconnectAttempts - 1); // exponential backoff
|
|
159
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
160
|
+
if (!controller.signal.aborted)
|
|
161
|
+
await connect();
|
|
162
|
+
}
|
|
163
|
+
connect().catch(() => { });
|
|
164
|
+
return stream;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Creates an SSE stream consumer for POST requests with elegant event-based API
|
|
168
|
+
* @param url - The SSE endpoint URL
|
|
169
|
+
* @param body - Request body
|
|
170
|
+
* @param options - Stream options
|
|
171
|
+
* @returns StreamController for chainable event handling
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* ```typescript
|
|
175
|
+
* const stream = createSSEStreamPost(url, { message: 'Hello' })
|
|
176
|
+
* .on('token', data => console.log(data))
|
|
177
|
+
* .on('done', () => console.log('Complete!'))
|
|
178
|
+
* .on('error', err => console.error(err))
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
export function createSSEStreamPost(url, body, options = {}) {
|
|
182
|
+
const { headers, withCredentials = true } = options;
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const stream = new StreamController(() => { controller.abort(); });
|
|
185
|
+
// Resolve headers inside the request so async auth providers work for SSE too.
|
|
186
|
+
resolveHeaders(headers)
|
|
187
|
+
.then(resolvedHeaders => fetch(url, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: {
|
|
190
|
+
'Accept': 'text/event-stream',
|
|
191
|
+
'Content-Type': 'application/json',
|
|
192
|
+
'Cache-Control': 'no-cache',
|
|
193
|
+
...resolvedHeaders,
|
|
194
|
+
},
|
|
195
|
+
credentials: withCredentials ? 'include' : 'same-origin',
|
|
196
|
+
body: JSON.stringify(body),
|
|
197
|
+
signal: controller.signal,
|
|
198
|
+
// Disable keepalive for streaming
|
|
199
|
+
keepalive: false,
|
|
200
|
+
}))
|
|
201
|
+
.then(async (response) => {
|
|
202
|
+
if (!response.ok) {
|
|
203
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
204
|
+
}
|
|
205
|
+
if (!response.body) {
|
|
206
|
+
throw new Error('Response body is null');
|
|
207
|
+
}
|
|
208
|
+
const reader = response.body.getReader();
|
|
209
|
+
const decoder = new TextDecoder();
|
|
210
|
+
let buffer = '';
|
|
211
|
+
// Parser state must persist across chunks — a single SSE event (esp. a
|
|
212
|
+
// large tool_result) can be split over multiple network reads.
|
|
213
|
+
let currentEvent = 'message';
|
|
214
|
+
let currentData = '';
|
|
215
|
+
try {
|
|
216
|
+
while (true) {
|
|
217
|
+
const { done, value } = await reader.read();
|
|
218
|
+
if (done) {
|
|
219
|
+
stream.emit('done', {});
|
|
220
|
+
stream.emit('complete', {});
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
// Decode the chunk and add to buffer
|
|
224
|
+
buffer += decoder.decode(value, { stream: true });
|
|
225
|
+
// Process complete events in the buffer
|
|
226
|
+
const lines = buffer.split('\n');
|
|
227
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
228
|
+
for (const line of lines) {
|
|
229
|
+
if (line.startsWith('event:')) {
|
|
230
|
+
currentEvent = line.slice(6).trim();
|
|
231
|
+
}
|
|
232
|
+
else if (line.startsWith('data:')) {
|
|
233
|
+
currentData = currentData ? `${currentData}\n${line.slice(5).trim()}` : line.slice(5).trim();
|
|
234
|
+
}
|
|
235
|
+
else if (line === '' && currentData) {
|
|
236
|
+
// Empty line indicates end of event - emit immediately
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(currentData);
|
|
239
|
+
stream.emit(currentEvent, parsed);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// If not valid JSON, emit as string
|
|
243
|
+
stream.emit(currentEvent, currentData);
|
|
244
|
+
}
|
|
245
|
+
// Reset for next event
|
|
246
|
+
currentEvent = 'message';
|
|
247
|
+
currentData = '';
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
// Handle all stream errors (network errors, protocol errors, etc.)
|
|
254
|
+
if (error?.name !== 'AbortError') {
|
|
255
|
+
const errorMessage = error?.message || 'Stream connection error';
|
|
256
|
+
stream.emit('error', new Error(errorMessage));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
// Clean up reader
|
|
261
|
+
try {
|
|
262
|
+
reader.releaseLock();
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Ignore cleanup errors
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
.catch((error) => {
|
|
270
|
+
if (error?.name !== 'AbortError') {
|
|
271
|
+
const errorMessage = error?.message || 'Failed to establish stream connection';
|
|
272
|
+
stream.emit('error', new Error(errorMessage));
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
return stream;
|
|
276
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects if an operation is an SSE (Server-Sent Events) stream endpoint
|
|
3
|
+
*
|
|
4
|
+
* Primary detection: Checks for text/event-stream content type in responses
|
|
5
|
+
* Fallback: Only if no content type is specified, check for explicit SSE keywords
|
|
6
|
+
*
|
|
7
|
+
* @param operation - The OpenAPI operation object
|
|
8
|
+
* @returns Whether the operation is an SSE stream
|
|
9
|
+
*/
|
|
10
|
+
export function isSSEStream(operation) {
|
|
11
|
+
const responses = operation.responses || {};
|
|
12
|
+
// Check all response codes (200, 201, etc.)
|
|
13
|
+
for (const [statusCode, response] of Object.entries(responses)) {
|
|
14
|
+
// Only check successful responses (2xx)
|
|
15
|
+
if (!statusCode.startsWith('2'))
|
|
16
|
+
continue;
|
|
17
|
+
// Skip reference objects for now
|
|
18
|
+
if ('$ref' in response)
|
|
19
|
+
continue;
|
|
20
|
+
const content = response.content || {};
|
|
21
|
+
const contentTypes = Object.keys(content);
|
|
22
|
+
// If response has content types defined
|
|
23
|
+
if (contentTypes.length > 0) {
|
|
24
|
+
// It's a stream if text/event-stream is present
|
|
25
|
+
// (even if application/json is also present as a fallback)
|
|
26
|
+
if ('text/event-stream' in content) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// If we checked responses and found only application/json (no text/event-stream), it's not a stream
|
|
32
|
+
const hasDefinedContentTypes = Object.entries(responses).some(([statusCode, response]) => {
|
|
33
|
+
if (!statusCode.startsWith('2'))
|
|
34
|
+
return false;
|
|
35
|
+
if ('$ref' in response)
|
|
36
|
+
return false;
|
|
37
|
+
const content = response.content || {};
|
|
38
|
+
return Object.keys(content).length > 0;
|
|
39
|
+
});
|
|
40
|
+
if (hasDefinedContentTypes) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
// Fallback: If no content types are defined at all, check description
|
|
44
|
+
// (Some OpenAPI specs might not properly define response content types)
|
|
45
|
+
const description = operation.description?.toLowerCase() || '';
|
|
46
|
+
const summary = operation.summary?.toLowerCase() || '';
|
|
47
|
+
const hasExplicitSSEMention = /\bsse\b/.test(description)
|
|
48
|
+
|| /server-sent events/i.test(description)
|
|
49
|
+
|| /text\/event-stream/i.test(description)
|
|
50
|
+
|| /\bsse\b/.test(summary);
|
|
51
|
+
return hasExplicitSSEMention;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Extracts SSE event types from operation description and examples
|
|
55
|
+
*
|
|
56
|
+
* Supports multiple documentation formats:
|
|
57
|
+
* 1. Markdown format: "1. **event_name**: Description"
|
|
58
|
+
* 2. Legacy format: type: "event_name"
|
|
59
|
+
* 3. SSE examples: event: event_name
|
|
60
|
+
*
|
|
61
|
+
* @param operation - The OpenAPI operation object
|
|
62
|
+
* @returns Array of event types or undefined
|
|
63
|
+
*/
|
|
64
|
+
export function extractSSEEventTypes(operation) {
|
|
65
|
+
const description = operation.description || '';
|
|
66
|
+
const types = new Set();
|
|
67
|
+
// Method 1: Extract from markdown numbered lists with bold event names
|
|
68
|
+
// Pattern: "1. **event_name**: Description"
|
|
69
|
+
const markdownMatches = description.matchAll(/^\d+\.\s+\*\*([a-z_]+)\*\*/gm);
|
|
70
|
+
for (const match of markdownMatches) {
|
|
71
|
+
types.add(match[1]);
|
|
72
|
+
}
|
|
73
|
+
// Method 2: Legacy format - type: "event_name"
|
|
74
|
+
const typeMatches = description.matchAll(/type:\s*"([^"]+)"/g);
|
|
75
|
+
for (const match of typeMatches) {
|
|
76
|
+
types.add(match[1]);
|
|
77
|
+
}
|
|
78
|
+
// Method 3: Extract from SSE examples in response content
|
|
79
|
+
const responses = operation.responses || {};
|
|
80
|
+
for (const response of Object.values(responses)) {
|
|
81
|
+
if ('content' in response) {
|
|
82
|
+
const content = response.content || {};
|
|
83
|
+
const eventStream = content['text/event-stream'];
|
|
84
|
+
if (eventStream?.example) {
|
|
85
|
+
// Parse SSE format: event: event_name
|
|
86
|
+
const exampleMatches = eventStream.example.matchAll(/^event:\s*([a-z_]+)/gm);
|
|
87
|
+
for (const match of exampleMatches) {
|
|
88
|
+
types.add(match[1]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return types.size > 0 ? Array.from(types).sort() : undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Extracts typed schema refs from the x-event-schemas OpenAPI extension.
|
|
97
|
+
*
|
|
98
|
+
* Server sets this via sse_openapi_response(event_types={"created": MyModel}).
|
|
99
|
+
* Returns a map of { event_name: TypeScript type name } derived from the $ref,
|
|
100
|
+
* e.g. { "connection_created": "ConferenceConnectionOut" }.
|
|
101
|
+
*
|
|
102
|
+
* @param operation - The OpenAPI operation object
|
|
103
|
+
* @returns Map of event name → TS type name, or undefined if not present
|
|
104
|
+
*/
|
|
105
|
+
export function extractSSEEventSchemas(operation) {
|
|
106
|
+
const responses = operation.responses || {};
|
|
107
|
+
for (const [statusCode, response] of Object.entries(responses)) {
|
|
108
|
+
if (!statusCode.startsWith('2'))
|
|
109
|
+
continue;
|
|
110
|
+
if ('$ref' in response)
|
|
111
|
+
continue;
|
|
112
|
+
const eventStream = response.content?.['text/event-stream'];
|
|
113
|
+
const schemas = eventStream?.['x-event-schemas'];
|
|
114
|
+
if (schemas && Object.keys(schemas).length > 0) {
|
|
115
|
+
// Convert $ref paths like "#/components/schemas/MyModel" → "MyModel"
|
|
116
|
+
return Object.fromEntries(Object.entries(schemas).map(([event, ref]) => [
|
|
117
|
+
event,
|
|
118
|
+
ref.replace(/^#\/components\/schemas\//, ''),
|
|
119
|
+
]));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Extracts detailed SSE event information including field definitions
|
|
126
|
+
*
|
|
127
|
+
* Parses markdown documentation format:
|
|
128
|
+
* ```
|
|
129
|
+
* 1. **event_name**: Event description
|
|
130
|
+
* - `field_name`: Field description
|
|
131
|
+
* - `another_field`: Another description
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
134
|
+
* @param operation - The OpenAPI operation object
|
|
135
|
+
* @returns Array of event type information with fields
|
|
136
|
+
*/
|
|
137
|
+
export function extractSSEEventInfo(operation) {
|
|
138
|
+
const description = operation.description || '';
|
|
139
|
+
const eventInfos = [];
|
|
140
|
+
// Split description into sections by numbered events
|
|
141
|
+
const eventSections = description.split(/(?=^\d+\.\s+\*\*)/m);
|
|
142
|
+
for (const section of eventSections) {
|
|
143
|
+
// Extract event name and description
|
|
144
|
+
const eventMatch = section.match(/^\d+\.\s+\*\*([a-z_]+)\*\*:\s*([^\n]+)/m);
|
|
145
|
+
if (!eventMatch)
|
|
146
|
+
continue;
|
|
147
|
+
const [, eventName, eventDescription] = eventMatch;
|
|
148
|
+
// Extract fields from the section
|
|
149
|
+
const fields = [];
|
|
150
|
+
const fieldMatches = section.matchAll(/^\s+[-*]\s+`([^`]+)`:\s*([^\n]+)/gm);
|
|
151
|
+
for (const fieldMatch of fieldMatches) {
|
|
152
|
+
let [, fieldName, fieldDescription] = fieldMatch;
|
|
153
|
+
// Handle nested field names like "error.code" → extract just "code"
|
|
154
|
+
// This handles documentation that shows nested object paths
|
|
155
|
+
if (fieldName.includes('.')) {
|
|
156
|
+
const parts = fieldName.split('.');
|
|
157
|
+
fieldName = parts[parts.length - 1]; // Take the last part
|
|
158
|
+
}
|
|
159
|
+
fields.push({
|
|
160
|
+
name: fieldName,
|
|
161
|
+
description: fieldDescription.trim(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
eventInfos.push({
|
|
165
|
+
name: eventName,
|
|
166
|
+
description: eventDescription,
|
|
167
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// If no markdown events found, try to extract from examples
|
|
171
|
+
if (eventInfos.length === 0) {
|
|
172
|
+
const responses = operation.responses || {};
|
|
173
|
+
for (const response of Object.values(responses)) {
|
|
174
|
+
if ('content' in response) {
|
|
175
|
+
const content = response.content || {};
|
|
176
|
+
const eventStream = content['text/event-stream'];
|
|
177
|
+
if (eventStream?.example) {
|
|
178
|
+
// Parse example JSON to extract field names
|
|
179
|
+
const exampleMatches = eventStream.example.matchAll(/data:\s*(\{[^}]+\})/g);
|
|
180
|
+
const seenEvents = new Set();
|
|
181
|
+
for (const match of exampleMatches) {
|
|
182
|
+
try {
|
|
183
|
+
const data = JSON.parse(match[1]);
|
|
184
|
+
if (data.type && !seenEvents.has(data.type)) {
|
|
185
|
+
seenEvents.add(data.type);
|
|
186
|
+
const fields = Object.keys(data)
|
|
187
|
+
.filter(key => key !== 'type' && key !== 'sequence')
|
|
188
|
+
.map(key => ({ name: key }));
|
|
189
|
+
eventInfos.push({
|
|
190
|
+
name: data.type,
|
|
191
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Skip invalid JSON
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return eventInfos.length > 0 ? eventInfos : undefined;
|
|
204
|
+
}
|