@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.
- package/LICENSE +21 -0
- package/README.md +476 -0
- package/dist/StreamingPlugin.d.ts +45 -0
- package/dist/StreamingPlugin.d.ts.map +1 -0
- package/dist/StreamingPlugin.js +276 -0
- package/dist/StreamingPlugin.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/examples/authenticated-stream.ts +88 -0
- package/examples/client-ndjson.html +157 -0
- package/examples/client-sse.html +202 -0
- package/package.json +41 -0
- package/spec/StreamingPlugin.spec.ts +513 -0
- package/spec/support/jasmine.json +7 -0
- package/src/StreamingPlugin.ts +281 -0
- package/src/index.ts +13 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +25 -0
|
@@ -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
|
+
}
|