@apiquest/plugin-sse 1.0.2
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.txt +661 -0
- package/README.md +160 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +341 -0
- package/dist/index.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/esbuild.config.js +34 -0
- package/package.json +62 -0
- package/rollup.config.js +31 -0
- package/src/index.ts +412 -0
- package/tsconfig.json +20 -0
- package/tsconfig.test.json +5 -0
- package/vitest.config.ts +12 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import type { IProtocolPlugin, Request, ExecutionContext, ProtocolResponse, ValidationResult, ValidationError, RuntimeOptions, ILogger } from '@apiquest/types';
|
|
2
|
+
|
|
3
|
+
// Helper functions for string validation
|
|
4
|
+
function isNullOrEmpty(value: string | null | undefined): boolean {
|
|
5
|
+
return value === null || value === undefined || value === '';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isNullOrWhitespace(value: string | null | undefined): boolean {
|
|
9
|
+
return value === null || value === undefined || value.trim() === '';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SSEMessage {
|
|
13
|
+
data: string;
|
|
14
|
+
event?: string;
|
|
15
|
+
id?: string;
|
|
16
|
+
retry?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const ssePlugin: IProtocolPlugin = {
|
|
20
|
+
name: 'SSE Client',
|
|
21
|
+
version: '1.0.0',
|
|
22
|
+
description: 'Server-Sent Events (SSE) protocol support',
|
|
23
|
+
|
|
24
|
+
// What protocols this plugin provides
|
|
25
|
+
protocols: ['sse'],
|
|
26
|
+
|
|
27
|
+
// Supported authentication types
|
|
28
|
+
supportedAuthTypes: ['bearer', 'basic', 'apikey', 'none'],
|
|
29
|
+
|
|
30
|
+
// Accept additional auth plugins beyond the listed types
|
|
31
|
+
strictAuthList: false,
|
|
32
|
+
|
|
33
|
+
// Data schema for SSE requests
|
|
34
|
+
dataSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
required: ['url'],
|
|
37
|
+
properties: {
|
|
38
|
+
url: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: 'SSE endpoint URL'
|
|
41
|
+
},
|
|
42
|
+
timeout: {
|
|
43
|
+
type: 'number',
|
|
44
|
+
description: 'Connection timeout in milliseconds',
|
|
45
|
+
default: 30000
|
|
46
|
+
},
|
|
47
|
+
headers: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
description: 'HTTP headers',
|
|
50
|
+
additionalProperties: { type: 'string' }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Options schema for runtime configuration
|
|
56
|
+
optionsSchema: {
|
|
57
|
+
timeout: {
|
|
58
|
+
type: 'number',
|
|
59
|
+
default: 30000,
|
|
60
|
+
description: 'Request timeout in milliseconds'
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// Plugin events
|
|
65
|
+
events: [
|
|
66
|
+
{
|
|
67
|
+
name: 'onMessage',
|
|
68
|
+
description: 'Fired when an SSE message is received',
|
|
69
|
+
canHaveTests: true,
|
|
70
|
+
required: false
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'onError',
|
|
74
|
+
description: 'Fired when an error occurs during streaming',
|
|
75
|
+
canHaveTests: false,
|
|
76
|
+
required: false
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'onComplete',
|
|
80
|
+
description: 'Fired when the SSE stream completes',
|
|
81
|
+
canHaveTests: true,
|
|
82
|
+
required: false
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
async execute(request: Request, context: ExecutionContext, options: RuntimeOptions, emitEvent?: (eventName: string, eventData: unknown) => Promise<void>, logger?: ILogger): Promise<ProtocolResponse> {
|
|
87
|
+
const startTime = Date.now();
|
|
88
|
+
const url = String(request.data.url ?? '');
|
|
89
|
+
|
|
90
|
+
if (isNullOrWhitespace(url)) {
|
|
91
|
+
logger?.error('SSE request missing URL');
|
|
92
|
+
throw new Error('URL is required for SSE requests');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const headers: Record<string, string> = typeof request.data.headers === 'object' && request.data.headers !== null
|
|
96
|
+
? Object.fromEntries(
|
|
97
|
+
Object.entries(request.data.headers as Record<string, unknown>).map(([k, v]) => [k, String(v)])
|
|
98
|
+
)
|
|
99
|
+
: {};
|
|
100
|
+
|
|
101
|
+
const sseOptions: Record<string, unknown> = (options.plugins?.sse as Record<string, unknown> | null | undefined) ?? {};
|
|
102
|
+
const sseTimeout = typeof sseOptions.timeout === 'number' ? sseOptions.timeout : null;
|
|
103
|
+
const timeout = (typeof request.data.timeout === 'number' ? request.data.timeout : null) ?? options.timeout?.request ?? sseTimeout ?? 30000;
|
|
104
|
+
|
|
105
|
+
logger?.debug('SSE request starting', { url, timeout });
|
|
106
|
+
|
|
107
|
+
const messages: SSEMessage[] = [];
|
|
108
|
+
let messageCount = 0;
|
|
109
|
+
|
|
110
|
+
return new Promise<ProtocolResponse>((resolve, reject) => {
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
|
|
113
|
+
const timeoutId = setTimeout(() => {
|
|
114
|
+
controller.abort();
|
|
115
|
+
logger?.debug('SSE connection timeout', { url, messageCount, duration: Date.now() - startTime });
|
|
116
|
+
|
|
117
|
+
resolve({
|
|
118
|
+
status: 200,
|
|
119
|
+
statusText: 'Stream Complete (Timeout)',
|
|
120
|
+
body: JSON.stringify({ messages, count: messageCount }),
|
|
121
|
+
headers: {},
|
|
122
|
+
duration: Date.now() - startTime,
|
|
123
|
+
messageCount,
|
|
124
|
+
messages
|
|
125
|
+
} as ProtocolResponse & { messageCount: number; messages: SSEMessage[] });
|
|
126
|
+
}, timeout);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// Use fetch with streaming which is more universally supported in Node.js
|
|
130
|
+
const signal = context.abortSignal ?? controller.signal;
|
|
131
|
+
|
|
132
|
+
// Handle abort signal
|
|
133
|
+
if (context.abortSignal !== undefined && context.abortSignal !== null) {
|
|
134
|
+
context.abortSignal.addEventListener('abort', () => {
|
|
135
|
+
controller.abort();
|
|
136
|
+
clearTimeout(timeoutId);
|
|
137
|
+
logger?.debug('SSE request aborted', { url, messageCount, duration: Date.now() - startTime });
|
|
138
|
+
resolve({
|
|
139
|
+
status: 0,
|
|
140
|
+
statusText: 'Aborted',
|
|
141
|
+
body: JSON.stringify({ messages, count: messageCount }),
|
|
142
|
+
headers: {},
|
|
143
|
+
duration: Date.now() - startTime,
|
|
144
|
+
error: 'Request aborted',
|
|
145
|
+
messageCount,
|
|
146
|
+
messages
|
|
147
|
+
} as ProtocolResponse & { messageCount: number; messages: SSEMessage[] });
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Use fetch with streaming
|
|
152
|
+
fetch(url, {
|
|
153
|
+
method: 'GET',
|
|
154
|
+
headers: {
|
|
155
|
+
...headers,
|
|
156
|
+
'Accept': 'text/event-stream',
|
|
157
|
+
'Cache-Control': 'no-cache'
|
|
158
|
+
},
|
|
159
|
+
signal
|
|
160
|
+
}).then(async (response) => {
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
clearTimeout(timeoutId);
|
|
163
|
+
logger?.warn('SSE connection failed', { status: response.status, statusText: response.statusText });
|
|
164
|
+
resolve({
|
|
165
|
+
status: response.status,
|
|
166
|
+
statusText: response.statusText,
|
|
167
|
+
body: await response.text(),
|
|
168
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
169
|
+
duration: Date.now() - startTime,
|
|
170
|
+
messageCount: 0,
|
|
171
|
+
messages: []
|
|
172
|
+
} as ProtocolResponse & { messageCount: number; messages: SSEMessage[] });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (response.body === null || response.body === undefined) {
|
|
177
|
+
clearTimeout(timeoutId);
|
|
178
|
+
logger?.error('SSE response has no body');
|
|
179
|
+
resolve({
|
|
180
|
+
status: response.status,
|
|
181
|
+
statusText: 'No Body',
|
|
182
|
+
body: '',
|
|
183
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
184
|
+
duration: Date.now() - startTime,
|
|
185
|
+
messageCount: 0,
|
|
186
|
+
messages: []
|
|
187
|
+
} as ProtocolResponse & { messageCount: number; messages: SSEMessage[] });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const reader = response.body.getReader();
|
|
192
|
+
const decoder = new TextDecoder();
|
|
193
|
+
let buffer = '';
|
|
194
|
+
|
|
195
|
+
// Temporary message fields that get compiled when we hit an empty line
|
|
196
|
+
let currentEventType: string | undefined;
|
|
197
|
+
let currentEventId: string | undefined;
|
|
198
|
+
let currentRetry: number | undefined;
|
|
199
|
+
const currentDataLines: string[] = [];
|
|
200
|
+
|
|
201
|
+
const dispatchCurrentMessage = async (): Promise<void> => {
|
|
202
|
+
if (currentDataLines.length > 0) {
|
|
203
|
+
const message: SSEMessage = {
|
|
204
|
+
data: currentDataLines.join('\n'),
|
|
205
|
+
event: currentEventType,
|
|
206
|
+
id: currentEventId,
|
|
207
|
+
retry: currentRetry
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
messages.push(message);
|
|
211
|
+
messageCount++;
|
|
212
|
+
|
|
213
|
+
logger?.trace('SSE message received', { messageCount, data: message.data.slice(0, 100) });
|
|
214
|
+
|
|
215
|
+
// Emit onMessage event
|
|
216
|
+
if (emitEvent !== undefined && emitEvent !== null) {
|
|
217
|
+
try {
|
|
218
|
+
await emitEvent('onMessage', {
|
|
219
|
+
index: messageCount,
|
|
220
|
+
data: message
|
|
221
|
+
});
|
|
222
|
+
} catch (err) {
|
|
223
|
+
logger?.error('SSE onMessage event error', { error: err instanceof Error ? err.message : String(err) });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Reset temporary fields
|
|
228
|
+
currentEventType = undefined;
|
|
229
|
+
currentEventId = undefined;
|
|
230
|
+
currentRetry = undefined;
|
|
231
|
+
currentDataLines.length = 0;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const processLine = async (line: string): Promise<void> => {
|
|
236
|
+
// Empty line signals the end of an event
|
|
237
|
+
if (line === '') {
|
|
238
|
+
await dispatchCurrentMessage();
|
|
239
|
+
} else if (line.startsWith('data:')) {
|
|
240
|
+
// Can have multiple data lines
|
|
241
|
+
const data = line.slice(5);
|
|
242
|
+
// Trim only the leading space if present (per spec)
|
|
243
|
+
currentDataLines.push(data.startsWith(' ') ? data.slice(1) : data);
|
|
244
|
+
} else if (line.startsWith('event:')) {
|
|
245
|
+
currentEventType = line.slice(6).trim();
|
|
246
|
+
} else if (line.startsWith('id:')) {
|
|
247
|
+
currentEventId = line.slice(3).trim();
|
|
248
|
+
} else if (line.startsWith('retry:')) {
|
|
249
|
+
const retryValue = parseInt(line.slice(6).trim(), 10);
|
|
250
|
+
if (!isNaN(retryValue)) {
|
|
251
|
+
currentRetry = retryValue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Lines starting with ':' are comments and should be ignored
|
|
255
|
+
// Other lines are ignored as well per spec
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
while (true) {
|
|
260
|
+
const result = await reader.read();
|
|
261
|
+
const done = result.done;
|
|
262
|
+
const value: Uint8Array | undefined = result.value as Uint8Array | undefined;
|
|
263
|
+
|
|
264
|
+
if (done) {
|
|
265
|
+
// Process remaining buffer
|
|
266
|
+
const lines = buffer.split('\n');
|
|
267
|
+
for (const line of lines) {
|
|
268
|
+
await processLine(line);
|
|
269
|
+
}
|
|
270
|
+
// Flush any pending message
|
|
271
|
+
await dispatchCurrentMessage();
|
|
272
|
+
|
|
273
|
+
clearTimeout(timeoutId);
|
|
274
|
+
logger?.debug('SSE stream complete', { messageCount, duration: Date.now() - startTime });
|
|
275
|
+
|
|
276
|
+
// Emit onComplete event
|
|
277
|
+
if (emitEvent !== undefined && emitEvent !== null) {
|
|
278
|
+
try {
|
|
279
|
+
await emitEvent('onComplete', { messageCount, messages });
|
|
280
|
+
} catch (err) {
|
|
281
|
+
logger?.error('SSE onComplete event error', { error: err instanceof Error ? err.message : String(err) });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
resolve({
|
|
286
|
+
status: response.status,
|
|
287
|
+
statusText: 'Stream Complete',
|
|
288
|
+
body: JSON.stringify({ messages, count: messageCount }),
|
|
289
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
290
|
+
duration: Date.now() - startTime,
|
|
291
|
+
messageCount,
|
|
292
|
+
messages
|
|
293
|
+
} as ProtocolResponse & { messageCount: number; messages: SSEMessage[] });
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Decode chunk and add to buffer
|
|
298
|
+
if (value !== undefined) {
|
|
299
|
+
buffer += decoder.decode(value, { stream: true });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Process complete lines
|
|
303
|
+
const lines = buffer.split('\n');
|
|
304
|
+
buffer = lines.pop() ?? ''; // Keep incomplete line in buffer
|
|
305
|
+
|
|
306
|
+
for (const line of lines) {
|
|
307
|
+
await processLine(line);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} catch (err) {
|
|
311
|
+
clearTimeout(timeoutId);
|
|
312
|
+
|
|
313
|
+
// Check if it was aborted
|
|
314
|
+
if (signal.aborted) {
|
|
315
|
+
logger?.debug('SSE stream aborted', { messageCount, duration: Date.now() - startTime });
|
|
316
|
+
resolve({
|
|
317
|
+
status: 0,
|
|
318
|
+
statusText: 'Aborted',
|
|
319
|
+
body: JSON.stringify({ messages, count: messageCount }),
|
|
320
|
+
headers: {},
|
|
321
|
+
duration: Date.now() - startTime,
|
|
322
|
+
error: 'Request aborted',
|
|
323
|
+
messageCount,
|
|
324
|
+
messages
|
|
325
|
+
} as ProtocolResponse & { messageCount: number; messages: SSEMessage[] });
|
|
326
|
+
} else {
|
|
327
|
+
logger?.error('SSE stream error', { error: err instanceof Error ? err.message : String(err) });
|
|
328
|
+
|
|
329
|
+
// Emit onError event
|
|
330
|
+
if (emitEvent !== undefined && emitEvent !== null) {
|
|
331
|
+
try {
|
|
332
|
+
await emitEvent('onError', { error: err instanceof Error ? err.message : String(err) });
|
|
333
|
+
} catch (emitErr) {
|
|
334
|
+
logger?.error('SSE onError event error', { error: emitErr instanceof Error ? emitErr.message : String(emitErr) });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
resolve({
|
|
339
|
+
status: 0,
|
|
340
|
+
statusText: 'Stream Error',
|
|
341
|
+
body: JSON.stringify({ messages, count: messageCount }),
|
|
342
|
+
headers: {},
|
|
343
|
+
duration: Date.now() - startTime,
|
|
344
|
+
error: err instanceof Error ? err.message : String(err),
|
|
345
|
+
messageCount,
|
|
346
|
+
messages
|
|
347
|
+
} as ProtocolResponse & { messageCount: number; messages: SSEMessage[] });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}).catch((err) => {
|
|
351
|
+
clearTimeout(timeoutId);
|
|
352
|
+
logger?.error('SSE fetch error', { error: err instanceof Error ? err.message : String(err) });
|
|
353
|
+
|
|
354
|
+
// Emit onError event
|
|
355
|
+
if (emitEvent !== undefined && emitEvent !== null) {
|
|
356
|
+
emitEvent('onError', { error: err instanceof Error ? err.message : String(err) }).catch((emitErr) => {
|
|
357
|
+
logger?.error('SSE onError event error', { error: emitErr instanceof Error ? emitErr.message : String(emitErr) });
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
resolve({
|
|
362
|
+
status: 0,
|
|
363
|
+
statusText: 'Connection Error',
|
|
364
|
+
body: '',
|
|
365
|
+
headers: {},
|
|
366
|
+
duration: Date.now() - startTime,
|
|
367
|
+
error: err instanceof Error ? err.message : String(err),
|
|
368
|
+
messageCount: 0,
|
|
369
|
+
messages: []
|
|
370
|
+
} as ProtocolResponse & { messageCount: number; messages: SSEMessage[] });
|
|
371
|
+
});
|
|
372
|
+
} catch (err) {
|
|
373
|
+
clearTimeout(timeoutId);
|
|
374
|
+
logger?.error('SSE unexpected error', { error: err instanceof Error ? err.message : String(err) });
|
|
375
|
+
resolve({
|
|
376
|
+
status: 0,
|
|
377
|
+
statusText: 'Error',
|
|
378
|
+
body: '',
|
|
379
|
+
headers: {},
|
|
380
|
+
duration: Date.now() - startTime,
|
|
381
|
+
error: err instanceof Error ? err.message : String(err),
|
|
382
|
+
messageCount: 0,
|
|
383
|
+
messages: []
|
|
384
|
+
} as ProtocolResponse & { messageCount: number; messages: SSEMessage[] });
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
validate(request: Request, options: RuntimeOptions): ValidationResult {
|
|
390
|
+
const errors: ValidationError[] = [];
|
|
391
|
+
|
|
392
|
+
// Check URL
|
|
393
|
+
if (typeof request.data.url !== 'string' || isNullOrWhitespace(request.data.url)) {
|
|
394
|
+
errors.push({
|
|
395
|
+
message: 'URL is required',
|
|
396
|
+
location: '',
|
|
397
|
+
source: 'protocol'
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (errors.length > 0) {
|
|
402
|
+
return {
|
|
403
|
+
valid: false,
|
|
404
|
+
errors
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { valid: true };
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
export default ssePlugin;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"allowSyntheticDefaultImports": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
20
|
+
}
|