@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/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
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": ["tests/**/*"],
4
+ "exclude": []
5
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ reporter: ['text', 'json', 'html'],
10
+ },
11
+ },
12
+ });