@bagelink/sdk 1.7.101 → 1.8.3

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.
@@ -0,0 +1,247 @@
1
+ /* eslint-disable ts/no-unnecessary-condition */
2
+ /* eslint-disable no-await-in-loop */
3
+ /* eslint-disable ts/strict-boolean-expressions */
4
+ /* eslint-disable ts/use-unknown-in-catch-callback-variable */
5
+ /**
6
+ * SSE (Server-Sent Events) Client Utilities
7
+ * Provides utilities for consuming Server-Sent Events streams
8
+ */
9
+
10
+ import type { StreamEventMap } from './StreamController'
11
+ import { StreamController } from './StreamController'
12
+
13
+ export { type SSEEvent, StreamController, type StreamEventMap } from './StreamController'
14
+
15
+ export interface SSEStreamOptions {
16
+ /** Additional headers to send with the request */
17
+ headers?: Record<string, string>
18
+ /** Whether to include credentials (cookies) with the request */
19
+ withCredentials?: boolean
20
+ }
21
+
22
+ /**
23
+ * Creates an SSE stream consumer with elegant event-based API
24
+ * @param url - The SSE endpoint URL
25
+ * @param options - Stream options
26
+ * @returns StreamController for chainable event handling
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * const stream = createSSEStream(url)
31
+ * .on('token', data => console.log(data))
32
+ * .on('done', () => console.log('Complete!'))
33
+ * .on('error', err => console.error(err))
34
+ *
35
+ * // Close when needed
36
+ * stream.close()
37
+ * ```
38
+ */
39
+ export function createSSEStream<TEventMap extends StreamEventMap = StreamEventMap>(
40
+ url: string,
41
+ options: SSEStreamOptions = {}
42
+ ): StreamController<TEventMap> {
43
+ const { headers = {}, withCredentials = true } = options
44
+
45
+ return new StreamController((onEvent, onError, onComplete) => {
46
+ const controller = new AbortController()
47
+ const { signal } = controller
48
+
49
+ // Start the fetch request
50
+ fetch(url, {
51
+ method: 'GET',
52
+ headers: {
53
+ Accept: 'text/event-stream',
54
+ ...headers,
55
+ },
56
+ credentials: withCredentials ? 'include' : 'same-origin',
57
+ signal,
58
+ })
59
+ .then(async (response) => {
60
+ if (!response.ok) {
61
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
62
+ }
63
+
64
+ if (!response.body) {
65
+ throw new Error('Response body is null')
66
+ }
67
+
68
+ const reader = response.body.getReader()
69
+ const decoder = new TextDecoder()
70
+ let buffer = ''
71
+
72
+ try {
73
+ while (true) {
74
+ const { done, value } = await reader.read()
75
+
76
+ if (done) {
77
+ onComplete()
78
+ break
79
+ }
80
+
81
+ // Decode the chunk and add to buffer
82
+ buffer += decoder.decode(value, { stream: true })
83
+
84
+ // Process complete events in the buffer
85
+ const lines = buffer.split('\n')
86
+ buffer = lines.pop() || '' // Keep incomplete line in buffer
87
+
88
+ let eventType = ''
89
+ let eventData = ''
90
+
91
+ for (const line of lines) {
92
+ if (line.startsWith('event:')) {
93
+ eventType = line.slice(6).trim()
94
+ } else if (line.startsWith('data:')) {
95
+ eventData = line.slice(5).trim()
96
+ } else if (line === '' && eventData) {
97
+ // Empty line indicates end of event
98
+ try {
99
+ const parsedData = JSON.parse(eventData)
100
+ onEvent({
101
+ type: eventType || 'message',
102
+ data: parsedData,
103
+ raw: eventData,
104
+ })
105
+ } catch {
106
+ // If not JSON, pass as string
107
+ onEvent({
108
+ type: eventType || 'message',
109
+ data: eventData,
110
+ raw: eventData,
111
+ })
112
+ }
113
+ eventType = ''
114
+ eventData = ''
115
+ }
116
+ }
117
+ }
118
+ } catch (error) {
119
+ if (error instanceof Error && error.name !== 'AbortError') {
120
+ onError(error)
121
+ }
122
+ }
123
+ })
124
+ .catch((error) => {
125
+ if (error.name !== 'AbortError') {
126
+ onError(error)
127
+ }
128
+ })
129
+
130
+ // Return cleanup function
131
+ return () => { controller.abort() }
132
+ })
133
+ }
134
+
135
+ /**
136
+ * Creates an SSE stream consumer for POST requests with elegant event-based API
137
+ * @param url - The SSE endpoint URL
138
+ * @param body - Request body
139
+ * @param options - Stream options
140
+ * @returns StreamController for chainable event handling
141
+ *
142
+ * @example
143
+ * ```typescript
144
+ * const stream = createSSEStreamPost(url, { message: 'Hello' })
145
+ * .on('token', data => console.log(data))
146
+ * .on('done', () => console.log('Complete!'))
147
+ * .on('error', err => console.error(err))
148
+ * ```
149
+ */
150
+ export function createSSEStreamPost<TEventMap extends StreamEventMap = StreamEventMap, TBody = any>(
151
+ url: string,
152
+ body: TBody,
153
+ options: SSEStreamOptions = {}
154
+ ): StreamController<TEventMap> {
155
+ const { headers = {}, withCredentials = true } = options
156
+
157
+ return new StreamController((onEvent, onError, onComplete) => {
158
+ const controller = new AbortController()
159
+ const { signal } = controller
160
+
161
+ // Start the fetch request
162
+ fetch(url, {
163
+ method: 'POST',
164
+ headers: {
165
+ 'Accept': 'text/event-stream',
166
+ 'Content-Type': 'application/json',
167
+ ...headers,
168
+ },
169
+ credentials: withCredentials ? 'include' : 'same-origin',
170
+ body: JSON.stringify(body),
171
+ signal,
172
+ })
173
+ .then(async (response) => {
174
+ if (!response.ok) {
175
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
176
+ }
177
+
178
+ if (!response.body) {
179
+ throw new Error('Response body is null')
180
+ }
181
+
182
+ const reader = response.body.getReader()
183
+ const decoder = new TextDecoder()
184
+ let buffer = ''
185
+
186
+ try {
187
+ while (true) {
188
+ const { done, value } = await reader.read()
189
+
190
+ if (done) {
191
+ onComplete()
192
+ break
193
+ }
194
+
195
+ // Decode the chunk and add to buffer
196
+ buffer += decoder.decode(value, { stream: true })
197
+
198
+ // Process complete events in the buffer
199
+ const lines = buffer.split('\n')
200
+ buffer = lines.pop() || '' // Keep incomplete line in buffer
201
+
202
+ let eventType = ''
203
+ let eventData = ''
204
+
205
+ for (const line of lines) {
206
+ if (line.startsWith('event:')) {
207
+ eventType = line.slice(6).trim()
208
+ } else if (line.startsWith('data:')) {
209
+ eventData = line.slice(5).trim()
210
+ } else if (line === '' && eventData) {
211
+ // Empty line indicates end of event
212
+ try {
213
+ const parsedData = JSON.parse(eventData)
214
+ onEvent({
215
+ type: eventType || 'message',
216
+ data: parsedData,
217
+ raw: eventData,
218
+ })
219
+ } catch {
220
+ // If not JSON, pass as string
221
+ onEvent({
222
+ type: eventType || 'message',
223
+ data: eventData,
224
+ raw: eventData,
225
+ })
226
+ }
227
+ eventType = ''
228
+ eventData = ''
229
+ }
230
+ }
231
+ }
232
+ } catch (error) {
233
+ if (error instanceof Error && error.name !== 'AbortError') {
234
+ onError(error)
235
+ }
236
+ }
237
+ })
238
+ .catch((error) => {
239
+ if (error.name !== 'AbortError') {
240
+ onError(error)
241
+ }
242
+ })
243
+
244
+ // Return cleanup function
245
+ return () => { controller.abort() }
246
+ })
247
+ }
@@ -0,0 +1,207 @@
1
+ import type { OperationObject as OpenAPIOperation } from './types'
2
+
3
+ /**
4
+ * Detects if an operation is an SSE (Server-Sent Events) stream endpoint
5
+ *
6
+ * Primary detection: Checks for text/event-stream content type in responses
7
+ * Fallback: Only if no content type is specified, check for explicit SSE keywords
8
+ *
9
+ * @param operation - The OpenAPI operation object
10
+ * @returns Whether the operation is an SSE stream
11
+ */
12
+ export function isSSEStream(operation: OpenAPIOperation): boolean {
13
+ const responses = operation.responses || {}
14
+
15
+ // Check all response codes (200, 201, etc.)
16
+ for (const [statusCode, response] of Object.entries(responses)) {
17
+ // Only check successful responses (2xx)
18
+ if (!statusCode.startsWith('2')) continue
19
+
20
+ // Skip reference objects for now
21
+ if ('$ref' in response) continue
22
+
23
+ const content = (response as any).content || {}
24
+ const contentTypes = Object.keys(content)
25
+
26
+ // If response has content types defined
27
+ if (contentTypes.length > 0) {
28
+ // It's a stream if text/event-stream is present
29
+ // (even if application/json is also present as a fallback)
30
+ if ('text/event-stream' in content) {
31
+ return true
32
+ }
33
+ }
34
+ }
35
+
36
+ // If we checked responses and found only application/json (no text/event-stream), it's not a stream
37
+ const hasDefinedContentTypes = Object.entries(responses).some(([statusCode, response]) => {
38
+ if (!statusCode.startsWith('2')) return false
39
+ if ('$ref' in response) return false
40
+ const content = (response as any).content || {}
41
+ return Object.keys(content).length > 0
42
+ })
43
+
44
+ if (hasDefinedContentTypes) {
45
+ return false
46
+ }
47
+
48
+ // Fallback: If no content types are defined at all, check description
49
+ // (Some OpenAPI specs might not properly define response content types)
50
+ const description = operation.description?.toLowerCase() || ''
51
+ const summary = operation.summary?.toLowerCase() || ''
52
+
53
+ const hasExplicitSSEMention
54
+ = /\bsse\b/.test(description)
55
+ || /server-sent events/i.test(description)
56
+ || /text\/event-stream/i.test(description)
57
+ || /\bsse\b/.test(summary)
58
+
59
+ return hasExplicitSSEMention
60
+ }
61
+
62
+ /**
63
+ * Information about an SSE event type including its fields
64
+ */
65
+ export interface SSEEventTypeInfo {
66
+ /** Event type name */
67
+ name: string
68
+ /** Event description */
69
+ description?: string
70
+ /** Fields in the event data */
71
+ fields?: Array<{
72
+ name: string
73
+ description?: string
74
+ type?: string
75
+ }>
76
+ }
77
+
78
+ /**
79
+ * Extracts SSE event types from operation description and examples
80
+ *
81
+ * Supports multiple documentation formats:
82
+ * 1. Markdown format: "1. **event_name**: Description"
83
+ * 2. Legacy format: type: "event_name"
84
+ * 3. SSE examples: event: event_name
85
+ *
86
+ * @param operation - The OpenAPI operation object
87
+ * @returns Array of event types or undefined
88
+ */
89
+ export function extractSSEEventTypes(operation: OpenAPIOperation): string[] | undefined {
90
+ const description = operation.description || ''
91
+ const types = new Set<string>()
92
+
93
+ // Method 1: Extract from markdown numbered lists with bold event names
94
+ // Pattern: "1. **event_name**: Description"
95
+ const markdownMatches = description.matchAll(/^\d+\.\s+\*\*([a-z_]+)\*\*/gm)
96
+ for (const match of markdownMatches) {
97
+ types.add(match[1])
98
+ }
99
+
100
+ // Method 2: Legacy format - type: "event_name"
101
+ const typeMatches = description.matchAll(/type:\s*"([^"]+)"/g)
102
+ for (const match of typeMatches) {
103
+ types.add(match[1])
104
+ }
105
+
106
+ // Method 3: Extract from SSE examples in response content
107
+ const responses = operation.responses || {}
108
+ for (const response of Object.values(responses)) {
109
+ if ('content' in response) {
110
+ const content = (response as any).content || {}
111
+ const eventStream = content['text/event-stream']
112
+ if (eventStream?.example) {
113
+ // Parse SSE format: event: event_name
114
+ const exampleMatches = eventStream.example.matchAll(/^event:\s*([a-z_]+)/gm)
115
+ for (const match of exampleMatches) {
116
+ types.add(match[1])
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ return types.size > 0 ? Array.from(types).sort() : undefined
123
+ }
124
+
125
+ /**
126
+ * Extracts detailed SSE event information including field definitions
127
+ *
128
+ * Parses markdown documentation format:
129
+ * ```
130
+ * 1. **event_name**: Event description
131
+ * - `field_name`: Field description
132
+ * - `another_field`: Another description
133
+ * ```
134
+ *
135
+ * @param operation - The OpenAPI operation object
136
+ * @returns Array of event type information with fields
137
+ */
138
+ export function extractSSEEventInfo(operation: OpenAPIOperation): SSEEventTypeInfo[] | undefined {
139
+ const description = operation.description || ''
140
+ const eventInfos: SSEEventTypeInfo[] = []
141
+
142
+ // Split description into sections by numbered events
143
+ const eventSections = description.split(/(?=^\d+\.\s+\*\*)/m)
144
+
145
+ for (const section of eventSections) {
146
+ // Extract event name and description
147
+ const eventMatch = section.match(/^\d+\.\s+\*\*([a-z_]+)\*\*:\s*([^\n]+)/m)
148
+ if (!eventMatch) continue
149
+
150
+ const [, eventName, eventDescription] = eventMatch
151
+
152
+ // Extract fields from the section
153
+ const fields: Array<{ name: string, description?: string, type?: string }> = []
154
+ const fieldMatches = section.matchAll(/^\s+[-*]\s+`([^`]+)`:\s*([^\n]+)/gm)
155
+
156
+ for (const fieldMatch of fieldMatches) {
157
+ const [, fieldName, fieldDescription] = fieldMatch
158
+ fields.push({
159
+ name: fieldName,
160
+ description: fieldDescription.trim(),
161
+ })
162
+ }
163
+
164
+ eventInfos.push({
165
+ name: eventName,
166
+ description: eventDescription,
167
+ fields: fields.length > 0 ? fields : undefined,
168
+ })
169
+ }
170
+
171
+ // If no markdown events found, try to extract from examples
172
+ if (eventInfos.length === 0) {
173
+ const responses = operation.responses || {}
174
+ for (const response of Object.values(responses)) {
175
+ if ('content' in response) {
176
+ const content = (response as any).content || {}
177
+ const eventStream = content['text/event-stream']
178
+ if (eventStream?.example) {
179
+ // Parse example JSON to extract field names
180
+ const exampleMatches = eventStream.example.matchAll(/data:\s*(\{[^}]+\})/g)
181
+ const seenEvents = new Set<string>()
182
+
183
+ for (const match of exampleMatches) {
184
+ try {
185
+ const data = JSON.parse(match[1])
186
+ if (data.type && !seenEvents.has(data.type)) {
187
+ seenEvents.add(data.type)
188
+ const fields = Object.keys(data)
189
+ .filter(key => key !== 'type' && key !== 'sequence')
190
+ .map(key => ({ name: key }))
191
+
192
+ eventInfos.push({
193
+ name: data.type,
194
+ fields: fields.length > 0 ? fields : undefined,
195
+ })
196
+ }
197
+ } catch {
198
+ // Skip invalid JSON
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ return eventInfos.length > 0 ? eventInfos : undefined
207
+ }