@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,281 @@
1
+ import { Request, Response } from "express";
2
+ import { FlinkApp, FlinkContext, FlinkPlugin, HttpMethod, log } from "@flink-app/flink";
3
+ import {
4
+ StreamFormat,
5
+ StreamHandler,
6
+ StreamHandlerModule,
7
+ StreamingPluginOptions,
8
+ StreamingRouteProps,
9
+ StreamWriter,
10
+ } from "./types";
11
+
12
+ /**
13
+ * Creates a stream writer for the given format
14
+ */
15
+ function createStreamWriter<T>(
16
+ res: Response,
17
+ format: StreamFormat,
18
+ debug: boolean
19
+ ): StreamWriter<T> {
20
+ let closed = false;
21
+
22
+ res.on("close", () => {
23
+ closed = true;
24
+ if (debug) {
25
+ log.debug("[StreamingPlugin] Client closed connection");
26
+ }
27
+ });
28
+
29
+ return {
30
+ write(data: T): void {
31
+ if (closed) {
32
+ if (debug) {
33
+ log.warn("[StreamingPlugin] Attempted write to closed stream");
34
+ }
35
+ return;
36
+ }
37
+
38
+ try {
39
+ if (format === "sse") {
40
+ // SSE format: data: {json}\n\n
41
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
42
+ } else if (format === "ndjson") {
43
+ // NDJSON format: {json}\n
44
+ res.write(`${JSON.stringify(data)}\n`);
45
+ }
46
+ } catch (err) {
47
+ log.error("[StreamingPlugin] Error writing to stream:", err);
48
+ closed = true;
49
+ }
50
+ },
51
+
52
+ error(error: Error | string): void {
53
+ if (closed) return;
54
+
55
+ const errorMessage = typeof error === "string" ? error : error.message;
56
+
57
+ try {
58
+ if (format === "sse") {
59
+ // SSE error event
60
+ res.write(`event: error\ndata: ${JSON.stringify({ message: errorMessage })}\n\n`);
61
+ } else if (format === "ndjson") {
62
+ // NDJSON error object
63
+ res.write(`${JSON.stringify({ error: errorMessage })}\n`);
64
+ }
65
+ } catch (err) {
66
+ log.error("[StreamingPlugin] Error writing error to stream:", err);
67
+ closed = true;
68
+ }
69
+ },
70
+
71
+ end(): void {
72
+ if (closed) return;
73
+ closed = true;
74
+ res.end();
75
+ },
76
+
77
+ isOpen(): boolean {
78
+ return !closed;
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Streaming plugin for Flink Framework
85
+ * Provides SSE and NDJSON streaming support
86
+ */
87
+ export class StreamingPlugin implements FlinkPlugin {
88
+ public id = "streaming";
89
+ private app?: FlinkApp<any>;
90
+ private options: Required<StreamingPluginOptions>;
91
+
92
+ constructor(options: StreamingPluginOptions = {}) {
93
+ this.options = {
94
+ defaultFormat: options.defaultFormat || "sse",
95
+ debug: options.debug || false,
96
+ };
97
+ }
98
+
99
+ async init(app: FlinkApp<any>): Promise<void> {
100
+ this.app = app;
101
+
102
+ if (this.options.debug) {
103
+ log.info(`[StreamingPlugin] Initialized with default format: ${this.options.defaultFormat}`);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Register a streaming handler from a module (preferred DX)
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * import * as GetChatStream from "./handlers/streaming/GetChatStream";
113
+ *
114
+ * // After app.start()
115
+ * streaming.registerStreamHandler(GetChatStream);
116
+ * ```
117
+ */
118
+ public registerStreamHandler<Ctx extends FlinkContext, T = any>(
119
+ handlerModule: StreamHandlerModule<Ctx, T>
120
+ ): void;
121
+
122
+ /**
123
+ * Register a streaming handler with explicit handler and route props
124
+ *
125
+ * @example
126
+ * ```typescript
127
+ * streaming.registerStreamHandler(
128
+ * async ({ stream }) => {
129
+ * stream.write({ message: "Hello" });
130
+ * stream.end();
131
+ * },
132
+ * { path: "/stream", skipAutoRegister: true }
133
+ * );
134
+ * ```
135
+ */
136
+ public registerStreamHandler<Ctx extends FlinkContext, T = any>(
137
+ handler: StreamHandler<Ctx, T>,
138
+ routeProps: StreamingRouteProps
139
+ ): void;
140
+
141
+ // Implementation
142
+ public registerStreamHandler<Ctx extends FlinkContext, T = any>(
143
+ handlerOrModule: StreamHandler<Ctx, T> | StreamHandlerModule<Ctx, T>,
144
+ routeProps?: StreamingRouteProps
145
+ ): void {
146
+ if (!this.app) {
147
+ throw new Error("[StreamingPlugin] Plugin not initialized - call app.start() first");
148
+ }
149
+
150
+ if (!this.app.expressApp) {
151
+ throw new Error("[StreamingPlugin] Express app not available");
152
+ }
153
+
154
+ // Determine if we received a module or explicit handler+props
155
+ let handler: StreamHandler<Ctx, T>;
156
+ let props: StreamingRouteProps;
157
+
158
+ if (typeof handlerOrModule === "function") {
159
+ // Explicit handler function + route props
160
+ if (!routeProps) {
161
+ throw new Error("[StreamingPlugin] Route props are required when registering with explicit handler function");
162
+ }
163
+ handler = handlerOrModule;
164
+ props = routeProps;
165
+ } else {
166
+ // Handler module with default export and Route
167
+ const module = handlerOrModule as StreamHandlerModule<Ctx, T>;
168
+ if (!module.default) {
169
+ throw new Error("[StreamingPlugin] Handler module must have a default export");
170
+ }
171
+ if (!module.Route) {
172
+ throw new Error("[StreamingPlugin] Handler module must export Route configuration");
173
+ }
174
+ handler = module.default;
175
+ props = module.Route;
176
+ }
177
+
178
+ const method = props.method || HttpMethod.get;
179
+ const format = props.format || this.options.defaultFormat;
180
+ const methodAndRoute = `${method.toUpperCase()} ${props.path}`;
181
+
182
+ // Register Express route
183
+ this.app.expressApp[method](props.path, async (req: any, res: any) => {
184
+ // Check authentication if permissions are required
185
+ if (props.permissions) {
186
+ if (!this.app!.auth) {
187
+ log.error(`[StreamingPlugin] ${methodAndRoute} requires authentication but no auth plugin is configured`);
188
+ res.status(500).json({
189
+ status: 500,
190
+ error: {
191
+ title: "Internal Server Error",
192
+ detail: "Authentication not configured",
193
+ },
194
+ });
195
+ return;
196
+ }
197
+
198
+ try {
199
+ const authenticated = await this.app!.auth.authenticateRequest(req as any, props.permissions);
200
+ if (!authenticated) {
201
+ res.status(401).json({
202
+ status: 401,
203
+ error: {
204
+ title: "Unauthorized",
205
+ detail: "Authentication required",
206
+ },
207
+ });
208
+ return;
209
+ }
210
+ } catch (err) {
211
+ log.error(`[StreamingPlugin] ${methodAndRoute} authentication error:`, err);
212
+ res.status(401).json({
213
+ status: 401,
214
+ error: {
215
+ title: "Unauthorized",
216
+ detail: "Authentication failed",
217
+ },
218
+ });
219
+ return;
220
+ }
221
+ }
222
+
223
+ // Set headers based on format
224
+ if (format === "sse") {
225
+ res.setHeader("Content-Type", "text/event-stream");
226
+ res.setHeader("Cache-Control", "no-cache");
227
+ res.setHeader("Connection", "keep-alive");
228
+ res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
229
+ } else if (format === "ndjson") {
230
+ res.setHeader("Content-Type", "application/x-ndjson");
231
+ res.setHeader("Cache-Control", "no-cache");
232
+ res.setHeader("X-Accel-Buffering", "no");
233
+ }
234
+
235
+ // Flush headers immediately
236
+ res.flushHeaders();
237
+
238
+ // Create stream writer
239
+ const streamWriter = createStreamWriter<T>(res, format, this.options.debug);
240
+
241
+ try {
242
+ if (this.options.debug) {
243
+ log.debug(`[StreamingPlugin] ${methodAndRoute} - Stream started`);
244
+ }
245
+
246
+ // Call the handler
247
+ await handler({
248
+ req: req as any,
249
+ ctx: this.app!.ctx,
250
+ stream: streamWriter,
251
+ origin: props.origin,
252
+ });
253
+
254
+ if (this.options.debug) {
255
+ log.debug(`[StreamingPlugin] ${methodAndRoute} - Handler completed`);
256
+ }
257
+ } catch (err) {
258
+ log.error(`[StreamingPlugin] ${methodAndRoute} - Handler error:`, err);
259
+
260
+ // Send error to client if stream still open
261
+ if (streamWriter.isOpen()) {
262
+ streamWriter.error(err as Error);
263
+ }
264
+ } finally {
265
+ // Ensure stream is closed
266
+ if (streamWriter.isOpen()) {
267
+ streamWriter.end();
268
+ }
269
+ }
270
+ });
271
+
272
+ log.info(`[StreamingPlugin] Registered streaming route ${methodAndRoute} (${format})`);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Factory function to create streaming plugin
278
+ */
279
+ export function streamingPlugin(options?: StreamingPluginOptions): StreamingPlugin {
280
+ return new StreamingPlugin(options);
281
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Main plugin export
2
+ export { StreamingPlugin, streamingPlugin } from "./StreamingPlugin";
3
+
4
+ // Type exports
5
+ export type {
6
+ StreamFormat,
7
+ StreamWriter,
8
+ StreamHandlerProps,
9
+ StreamHandler,
10
+ StreamHandlerModule,
11
+ StreamingRouteProps,
12
+ StreamingPluginOptions,
13
+ } from "./types";
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { FlinkContext, FlinkRequest, RouteProps, HttpMethod } from "@flink-app/flink";
2
+
3
+ /**
4
+ * Streaming format types
5
+ */
6
+ export type StreamFormat = "sse" | "ndjson";
7
+
8
+ /**
9
+ * Stream writer interface for sending data to clients
10
+ */
11
+ export interface StreamWriter<T = any> {
12
+ /**
13
+ * Write data to the stream
14
+ */
15
+ write(data: T): void;
16
+
17
+ /**
18
+ * Send an error event to the stream
19
+ */
20
+ error(error: Error | string): void;
21
+
22
+ /**
23
+ * End the stream
24
+ */
25
+ end(): void;
26
+
27
+ /**
28
+ * Check if the connection is still open
29
+ */
30
+ isOpen(): boolean;
31
+ }
32
+
33
+ /**
34
+ * Props passed to streaming handlers
35
+ */
36
+ export interface StreamHandlerProps<Ctx extends FlinkContext, T = any> {
37
+ req: FlinkRequest;
38
+ ctx: Ctx;
39
+ stream: StreamWriter<T>;
40
+ origin?: string;
41
+ }
42
+
43
+ /**
44
+ * Streaming handler function signature
45
+ */
46
+ export type StreamHandler<Ctx extends FlinkContext, T = any> = (
47
+ props: StreamHandlerProps<Ctx, T>
48
+ ) => Promise<void> | void;
49
+
50
+ /**
51
+ * Route configuration for streaming endpoints
52
+ */
53
+ export interface StreamingRouteProps extends Omit<RouteProps, "skipAutoRegister"> {
54
+ /**
55
+ * HTTP path for the streaming endpoint
56
+ */
57
+ path: string;
58
+
59
+ /**
60
+ * HTTP method (defaults to GET)
61
+ */
62
+ method?: HttpMethod;
63
+
64
+ /**
65
+ * Streaming format to use
66
+ * - 'sse': Server-Sent Events (text/event-stream)
67
+ * - 'ndjson': Newline-Delimited JSON (application/x-ndjson)
68
+ * @default 'sse'
69
+ */
70
+ format?: StreamFormat;
71
+
72
+ /**
73
+ * Must be true for streaming handlers (prevents auto-registration)
74
+ */
75
+ skipAutoRegister: true;
76
+ }
77
+
78
+ /**
79
+ * Options for creating the streaming plugin
80
+ */
81
+ export interface StreamingPluginOptions {
82
+ /**
83
+ * Default format if not specified in route props
84
+ * @default 'sse'
85
+ */
86
+ defaultFormat?: StreamFormat;
87
+
88
+ /**
89
+ * Enable debug logging
90
+ * @default false
91
+ */
92
+ debug?: boolean;
93
+ }
94
+
95
+ /**
96
+ * Handler module type (namespace import with default export and Route)
97
+ */
98
+ export interface StreamHandlerModule<Ctx extends FlinkContext = any, T = any> {
99
+ default: StreamHandler<Ctx, T>;
100
+ Route: StreamingRouteProps;
101
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es5",
4
+ "lib": ["esnext", "es2016"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "esModuleInterop": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "strict": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "module": "commonjs",
12
+ "moduleResolution": "node",
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "noEmit": false,
16
+ "declaration": true,
17
+ "declarationMap": true,
18
+ "sourceMap": true,
19
+ "experimentalDecorators": true,
20
+ "outDir": "./dist",
21
+ "rootDir": "./src"
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["node_modules", "dist", "spec"]
25
+ }