@flink-app/streaming-plugin 0.12.1-alpha.45

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,513 @@
1
+ import { FlinkApp, FlinkContext, FlinkAuthPlugin, FlinkRequest } from "@flink-app/flink";
2
+ import { EventEmitter } from "events";
3
+ import { streamingPlugin, StreamHandler, StreamingRouteProps } from "../src";
4
+
5
+ interface TestContext extends FlinkContext {
6
+ repos: {};
7
+ plugins: {};
8
+ }
9
+
10
+ // Mock auth plugin
11
+ class MockAuthPlugin implements FlinkAuthPlugin {
12
+ public shouldAuthenticate: boolean = true;
13
+
14
+ async authenticateRequest(req: FlinkRequest, permissions: string | string[]): Promise<boolean> {
15
+ // Simulate authentication check
16
+ const token = req.headers?.authorization;
17
+
18
+ if (!token) {
19
+ return false;
20
+ }
21
+
22
+ if (token === "Bearer valid-token") {
23
+ // Set user info on request
24
+ (req as any).user = { userId: "test-user", name: "Test User" };
25
+ return this.shouldAuthenticate;
26
+ }
27
+
28
+ return false;
29
+ }
30
+
31
+ createToken(userId: string): Promise<string> {
32
+ // Mock token creation
33
+ return Promise.resolve(`mock-token-${userId}`);
34
+ }
35
+ }
36
+
37
+ // Mock response object
38
+ class MockResponse extends EventEmitter {
39
+ public headers: { [key: string]: string } = {};
40
+ public statusCode?: number;
41
+ public data: string[] = [];
42
+ public jsonData: any[] = [];
43
+ public ended = false;
44
+
45
+ setHeader(key: string, value: string) {
46
+ this.headers[key] = value;
47
+ }
48
+
49
+ status(code: number) {
50
+ this.statusCode = code;
51
+ return this;
52
+ }
53
+
54
+ json(data: any) {
55
+ this.jsonData.push(data);
56
+ return this;
57
+ }
58
+
59
+ flushHeaders() {
60
+ // No-op for mock
61
+ }
62
+
63
+ write(chunk: string) {
64
+ this.data.push(chunk);
65
+ }
66
+
67
+ end() {
68
+ this.ended = true;
69
+ this.emit("close");
70
+ }
71
+ }
72
+
73
+ // Mock request object
74
+ class MockRequest {
75
+ public query: { [key: string]: any } = {};
76
+ public params: { [key: string]: any } = {};
77
+ public body: any = {};
78
+ public headers: { [key: string]: any } = {};
79
+
80
+ on(event: string, callback: () => void) {
81
+ // No-op for basic tests
82
+ }
83
+ }
84
+
85
+ describe("StreamingPlugin", () => {
86
+ let app: FlinkApp<TestContext>;
87
+ let plugin: ReturnType<typeof streamingPlugin>;
88
+
89
+ beforeEach(async () => {
90
+ plugin = streamingPlugin({ debug: false });
91
+
92
+ app = new FlinkApp<TestContext>({
93
+ name: "Test App",
94
+ port: 3999,
95
+ plugins: [plugin],
96
+ disableHttpServer: false,
97
+ });
98
+
99
+ await app.start();
100
+ });
101
+
102
+ afterEach(async () => {
103
+ await app.stop();
104
+ });
105
+
106
+ describe("SSE Format", () => {
107
+ it("should stream data in SSE format", (done) => {
108
+ const route: StreamingRouteProps = {
109
+ path: "/test-sse",
110
+ format: "sse",
111
+ skipAutoRegister: true,
112
+ };
113
+
114
+ const handler: StreamHandler<TestContext> = async ({ stream }) => {
115
+ stream.write({ message: "Hello" });
116
+ stream.write({ message: "World" });
117
+ stream.end();
118
+ };
119
+
120
+ plugin.registerStreamHandler(handler, route);
121
+
122
+ // Simulate request
123
+ const req = new MockRequest();
124
+ const res = new MockResponse();
125
+
126
+ // Find the registered route and call it
127
+ const expressApp = app.expressApp;
128
+ if (!expressApp) {
129
+ fail("Express app not initialized");
130
+ return;
131
+ }
132
+
133
+ // Make request to the endpoint
134
+ expressApp._router.stack.forEach((layer: any) => {
135
+ if (layer.route?.path === "/test-sse") {
136
+ layer.route.stack[0].handle(req, res, () => {});
137
+ }
138
+ });
139
+
140
+ setTimeout(() => {
141
+ expect(res.headers["Content-Type"]).toBe("text/event-stream");
142
+ expect(res.headers["Cache-Control"]).toBe("no-cache");
143
+ expect(res.headers["Connection"]).toBe("keep-alive");
144
+ expect(res.data.length).toBe(2);
145
+ expect(res.data[0]).toContain('data: {"message":"Hello"}');
146
+ expect(res.data[1]).toContain('data: {"message":"World"}');
147
+ expect(res.ended).toBe(true);
148
+ done();
149
+ }, 100);
150
+ });
151
+
152
+ it("should send error events in SSE format", (done) => {
153
+ const route: StreamingRouteProps = {
154
+ path: "/test-sse-error",
155
+ format: "sse",
156
+ skipAutoRegister: true,
157
+ };
158
+
159
+ const handler: StreamHandler<TestContext> = async ({ stream }) => {
160
+ stream.error(new Error("Test error"));
161
+ stream.end();
162
+ };
163
+
164
+ plugin.registerStreamHandler(handler, route);
165
+
166
+ const req = new MockRequest();
167
+ const res = new MockResponse();
168
+
169
+ const expressApp = app.expressApp;
170
+ if (!expressApp) {
171
+ fail("Express app not initialized");
172
+ return;
173
+ }
174
+
175
+ expressApp._router.stack.forEach((layer: any) => {
176
+ if (layer.route?.path === "/test-sse-error") {
177
+ layer.route.stack[0].handle(req, res, () => {});
178
+ }
179
+ });
180
+
181
+ setTimeout(() => {
182
+ expect(res.data[0]).toContain("event: error");
183
+ expect(res.data[0]).toContain("Test error");
184
+ done();
185
+ }, 100);
186
+ });
187
+ });
188
+
189
+ describe("NDJSON Format", () => {
190
+ it("should stream data in NDJSON format", (done) => {
191
+ const route: StreamingRouteProps = {
192
+ path: "/test-ndjson",
193
+ format: "ndjson",
194
+ skipAutoRegister: true,
195
+ };
196
+
197
+ const handler: StreamHandler<TestContext> = async ({ stream }) => {
198
+ stream.write({ delta: "Hello" });
199
+ stream.write({ delta: "World", done: true });
200
+ stream.end();
201
+ };
202
+
203
+ plugin.registerStreamHandler(handler, route);
204
+
205
+ const req = new MockRequest();
206
+ const res = new MockResponse();
207
+
208
+ const expressApp = app.expressApp;
209
+ if (!expressApp) {
210
+ fail("Express app not initialized");
211
+ return;
212
+ }
213
+
214
+ expressApp._router.stack.forEach((layer: any) => {
215
+ if (layer.route?.path === "/test-ndjson") {
216
+ layer.route.stack[0].handle(req, res, () => {});
217
+ }
218
+ });
219
+
220
+ setTimeout(() => {
221
+ expect(res.headers["Content-Type"]).toBe("application/x-ndjson");
222
+ expect(res.data.length).toBe(2);
223
+ expect(res.data[0]).toBe('{"delta":"Hello"}\n');
224
+ expect(res.data[1]).toBe('{"delta":"World","done":true}\n');
225
+ expect(res.ended).toBe(true);
226
+ done();
227
+ }, 100);
228
+ });
229
+ });
230
+
231
+ describe("Stream Writer", () => {
232
+ it("should detect closed connections", (done) => {
233
+ const route: StreamingRouteProps = {
234
+ path: "/test-closed",
235
+ format: "sse",
236
+ skipAutoRegister: true,
237
+ };
238
+
239
+ const handler: StreamHandler<TestContext> = async ({ stream }) => {
240
+ expect(stream.isOpen()).toBe(true);
241
+ stream.end();
242
+ // After end, isOpen should be false (handled by plugin)
243
+ };
244
+
245
+ plugin.registerStreamHandler(handler, route);
246
+
247
+ const req = new MockRequest();
248
+ const res = new MockResponse();
249
+
250
+ const expressApp = app.expressApp;
251
+ if (!expressApp) {
252
+ fail("Express app not initialized");
253
+ return;
254
+ }
255
+
256
+ expressApp._router.stack.forEach((layer: any) => {
257
+ if (layer.route?.path === "/test-closed") {
258
+ layer.route.stack[0].handle(req, res, () => {});
259
+ }
260
+ });
261
+
262
+ setTimeout(() => {
263
+ expect(res.ended).toBe(true);
264
+ done();
265
+ }, 100);
266
+ });
267
+ });
268
+
269
+ describe("Authentication", () => {
270
+ let appWithAuth: FlinkApp<TestContext>;
271
+ let pluginWithAuth: ReturnType<typeof streamingPlugin>;
272
+ let mockAuth: MockAuthPlugin;
273
+
274
+ beforeEach(async () => {
275
+ mockAuth = new MockAuthPlugin();
276
+ pluginWithAuth = streamingPlugin({ debug: false });
277
+
278
+ appWithAuth = new FlinkApp<TestContext>({
279
+ name: "Test App With Auth",
280
+ port: 4000,
281
+ plugins: [pluginWithAuth],
282
+ auth: mockAuth,
283
+ disableHttpServer: false,
284
+ });
285
+
286
+ await appWithAuth.start();
287
+ });
288
+
289
+ afterEach(async () => {
290
+ await appWithAuth.stop();
291
+ });
292
+
293
+ it("should allow authenticated requests with valid token", (done) => {
294
+ const route: StreamingRouteProps = {
295
+ path: "/test-auth-success",
296
+ format: "sse",
297
+ skipAutoRegister: true,
298
+ permissions: ["admin"],
299
+ };
300
+
301
+ const handler: StreamHandler<TestContext> = async ({ stream, req }) => {
302
+ const user = (req as any).user;
303
+ stream.write({ message: `Hello ${user?.name}` });
304
+ stream.end();
305
+ };
306
+
307
+ pluginWithAuth.registerStreamHandler(handler, route);
308
+
309
+ const req = new MockRequest();
310
+ req.headers.authorization = "Bearer valid-token";
311
+ const res = new MockResponse();
312
+
313
+ const expressApp = appWithAuth.expressApp;
314
+ if (!expressApp) {
315
+ fail("Express app not initialized");
316
+ return;
317
+ }
318
+
319
+ expressApp._router.stack.forEach((layer: any) => {
320
+ if (layer.route?.path === "/test-auth-success") {
321
+ layer.route.stack[0].handle(req, res, () => {});
322
+ }
323
+ });
324
+
325
+ setTimeout(() => {
326
+ expect(res.statusCode).not.toBe(401);
327
+ expect(res.data.length).toBe(1);
328
+ expect(res.data[0]).toContain("Hello Test User");
329
+ expect(res.ended).toBe(true);
330
+ done();
331
+ }, 100);
332
+ });
333
+
334
+ it("should reject unauthenticated requests without token", (done) => {
335
+ const route: StreamingRouteProps = {
336
+ path: "/test-auth-fail-no-token",
337
+ format: "sse",
338
+ skipAutoRegister: true,
339
+ permissions: ["admin"],
340
+ };
341
+
342
+ const handler: StreamHandler<TestContext> = async ({ stream }) => {
343
+ stream.write({ message: "Should not see this" });
344
+ stream.end();
345
+ };
346
+
347
+ pluginWithAuth.registerStreamHandler(handler, route);
348
+
349
+ const req = new MockRequest();
350
+ // No authorization header
351
+ const res = new MockResponse();
352
+
353
+ const expressApp = appWithAuth.expressApp;
354
+ if (!expressApp) {
355
+ fail("Express app not initialized");
356
+ return;
357
+ }
358
+
359
+ expressApp._router.stack.forEach((layer: any) => {
360
+ if (layer.route?.path === "/test-auth-fail-no-token") {
361
+ layer.route.stack[0].handle(req, res, () => {});
362
+ }
363
+ });
364
+
365
+ setTimeout(() => {
366
+ expect(res.statusCode).toBe(401);
367
+ expect(res.jsonData.length).toBe(1);
368
+ expect(res.jsonData[0].error.title).toBe("Unauthorized");
369
+ expect(res.data.length).toBe(0); // Should not stream any data
370
+ done();
371
+ }, 100);
372
+ });
373
+
374
+ it("should reject requests with invalid token", (done) => {
375
+ const route: StreamingRouteProps = {
376
+ path: "/test-auth-fail-invalid-token",
377
+ format: "sse",
378
+ skipAutoRegister: true,
379
+ permissions: ["admin"],
380
+ };
381
+
382
+ const handler: StreamHandler<TestContext> = async ({ stream }) => {
383
+ stream.write({ message: "Should not see this" });
384
+ stream.end();
385
+ };
386
+
387
+ pluginWithAuth.registerStreamHandler(handler, route);
388
+
389
+ const req = new MockRequest();
390
+ req.headers.authorization = "Bearer invalid-token";
391
+ const res = new MockResponse();
392
+
393
+ const expressApp = appWithAuth.expressApp;
394
+ if (!expressApp) {
395
+ fail("Express app not initialized");
396
+ return;
397
+ }
398
+
399
+ expressApp._router.stack.forEach((layer: any) => {
400
+ if (layer.route?.path === "/test-auth-fail-invalid-token") {
401
+ layer.route.stack[0].handle(req, res, () => {});
402
+ }
403
+ });
404
+
405
+ setTimeout(() => {
406
+ expect(res.statusCode).toBe(401);
407
+ expect(res.jsonData.length).toBe(1);
408
+ expect(res.jsonData[0].error.title).toBe("Unauthorized");
409
+ expect(res.data.length).toBe(0);
410
+ done();
411
+ }, 100);
412
+ });
413
+
414
+ it("should allow streaming without permissions when no auth is required", (done) => {
415
+ const route: StreamingRouteProps = {
416
+ path: "/test-no-auth-required",
417
+ format: "sse",
418
+ skipAutoRegister: true,
419
+ // No permissions specified
420
+ };
421
+
422
+ const handler: StreamHandler<TestContext> = async ({ stream }) => {
423
+ stream.write({ message: "Public endpoint" });
424
+ stream.end();
425
+ };
426
+
427
+ pluginWithAuth.registerStreamHandler(handler, route);
428
+
429
+ const req = new MockRequest();
430
+ // No authorization header, but endpoint doesn't require it
431
+ const res = new MockResponse();
432
+
433
+ const expressApp = appWithAuth.expressApp;
434
+ if (!expressApp) {
435
+ fail("Express app not initialized");
436
+ return;
437
+ }
438
+
439
+ expressApp._router.stack.forEach((layer: any) => {
440
+ if (layer.route?.path === "/test-no-auth-required") {
441
+ layer.route.stack[0].handle(req, res, () => {});
442
+ }
443
+ });
444
+
445
+ setTimeout(() => {
446
+ expect(res.statusCode).not.toBe(401);
447
+ expect(res.data.length).toBe(1);
448
+ expect(res.data[0]).toContain("Public endpoint");
449
+ expect(res.ended).toBe(true);
450
+ done();
451
+ }, 100);
452
+ });
453
+
454
+ it("should provide access to req.user populated by auth plugin", (done) => {
455
+ const route: StreamingRouteProps = {
456
+ path: "/test-req-user-access",
457
+ format: "ndjson",
458
+ skipAutoRegister: true,
459
+ permissions: ["admin"],
460
+ };
461
+
462
+ const handler: StreamHandler<TestContext> = async ({ stream, req }) => {
463
+ // Access req.user that was set by auth plugin
464
+ const user = (req as any).user;
465
+
466
+ // Verify user object exists and has expected properties
467
+ expect(user).toBeDefined();
468
+ expect(user.userId).toBe("test-user");
469
+ expect(user.name).toBe("Test User");
470
+
471
+ // Stream user info
472
+ stream.write({
473
+ userId: user.userId,
474
+ userName: user.name,
475
+ message: "User info from req.user"
476
+ });
477
+ stream.end();
478
+ };
479
+
480
+ pluginWithAuth.registerStreamHandler(handler, route);
481
+
482
+ const req = new MockRequest();
483
+ req.headers.authorization = "Bearer valid-token";
484
+ const res = new MockResponse();
485
+
486
+ const expressApp = appWithAuth.expressApp;
487
+ if (!expressApp) {
488
+ fail("Express app not initialized");
489
+ return;
490
+ }
491
+
492
+ expressApp._router.stack.forEach((layer: any) => {
493
+ if (layer.route?.path === "/test-req-user-access") {
494
+ layer.route.stack[0].handle(req, res, () => {});
495
+ }
496
+ });
497
+
498
+ setTimeout(() => {
499
+ expect(res.statusCode).not.toBe(401);
500
+ expect(res.data.length).toBe(1);
501
+
502
+ // Parse the NDJSON line
503
+ const data = JSON.parse(res.data[0].trim());
504
+ expect(data.userId).toBe("test-user");
505
+ expect(data.userName).toBe("Test User");
506
+ expect(data.message).toBe("User info from req.user");
507
+
508
+ expect(res.ended).toBe(true);
509
+ done();
510
+ }, 100);
511
+ });
512
+ });
513
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "spec_dir": "spec",
3
+ "spec_files": ["**/*[sS]pec.ts"],
4
+ "helpers": ["helpers/**/*.ts"],
5
+ "stopSpecOnExpectationFailure": false,
6
+ "random": false
7
+ }