configurapi-handler-ws 1.0.0 → 1.1.0

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/README.md CHANGED
@@ -1,3 +1,42 @@
1
1
  # configurapi-handler-ws
2
2
 
3
3
  Configurapi request handlers for websocket
4
+
5
+ ## Usage
6
+
7
+ ### Server side:
8
+
9
+ * If you want to stream data back to the client, return a `StreamResponse` with a readable stream.
10
+ * Use pushbackFunction(postbackFunction: Function, response: PostbackResponse) to send a response to the postback endpoint. The response will be wrapped in the specified postbackFunction.
11
+
12
+ ```
13
+ const { pushbackFunction, PostbackResponse } = require("configurapi-handler-ws");
14
+
15
+ let callback = async (body:any, headers:Record<string,string>) =>
16
+ {
17
+ const isStream = body && typeof body === 'object' && typeof body.pipe === 'function';
18
+ const isBinary = typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array;
19
+
20
+ let resp = await fetch(`http://localhost:9100/@connections/${connectionId}`, {
21
+ method: 'POST',
22
+ headers,
23
+ body: isStream || isBinary ? body : JSON.stringify(body),
24
+ ...(isStream ? { duplex: 'half' } : {})
25
+ });
26
+
27
+ console.log(resp.status + ' ' + resp.statusText)
28
+ console.log(await resp.json())
29
+ }
30
+
31
+ await pushbackFunction(callback, new PostbackResponse('body', {'content-type': 'application/json'}))
32
+ ```
33
+
34
+ ### Client side:
35
+
36
+ * Use WebSocketClient to connect to a WebSocket runner, send data over the connection, and disconnect from the runner when finished.
37
+ * To pass data (such as a protocol or authentication token) when establishing the WebSocket connection, provide it as the second parameter to the constructor.
38
+
39
+ ```
40
+ const { WebSocketClient } = require("configurapi-handler-ws");
41
+ let client = new WebSocketClient(WEBSOCKET_BASE_URL, {onPush: (data:IResponse)=>console.log(data)});
42
+ ```
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "configurapi-handler-ws",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "main": "src/index",
5
5
  "files": [
6
6
  "src/*",
7
- "types.d.ts"
7
+ "src/*.d.ts"
8
8
  ],
9
- "types": "types.d.ts",
9
+ "types": "src/index.d.ts",
10
10
  "repository": {
11
11
  "type": "git",
12
12
  "url": "git+ssh://git@gitlab.com/mappies/configurapi-handler-ws.git"
@@ -20,5 +20,8 @@
20
20
  "url": "https://gitlab.com/mappies/configurapi-handler-ws/issues"
21
21
  },
22
22
  "homepage": "https://gitlab.com/mappies/configurapi-handler-ws#readme",
23
- "description": ""
23
+ "description": "",
24
+ "devDependencies": {
25
+ "@types/node": "^25.2.3"
26
+ }
24
27
  }
package/src/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./streamResponse";
2
+ export * from "./postbackFunction";
3
+ export * from "./postbackResponse";
4
+ export * from "./webSocketClient";
package/src/index.js CHANGED
@@ -1,4 +1,6 @@
1
-
2
1
  module.exports = {
3
- StreamResponse: require('./StreamResponse'),
2
+ StreamResponse: require('./streamResponse'),
3
+ pushbackFunction: require('./postbackFunction'),
4
+ PostbackResponse: require('./postbackResponse'),
5
+ WebSocketClient: require('./webSocketClient')
4
6
  };
@@ -0,0 +1,3 @@
1
+ import { PostbackResponse } from "./postbackResponse";
2
+
3
+ export function pushFunction(postBackFunction: Function, response: PostbackResponse): Promise<void>;
@@ -0,0 +1,208 @@
1
+ const { randomUUID } = require("node:crypto");
2
+ const StreamResponse = require("./streamResponse");
3
+
4
+ // tiny sleep for retry/backoff
5
+ function sleep(ms) {
6
+ return new Promise(r => setTimeout(r, ms));
7
+ }
8
+
9
+ module.exports = async function pushFunction(postBackFunction, response)
10
+ {
11
+ // Helper: postToConnection with light retry on throttling
12
+ const postJSON = async (body, headers) =>
13
+ {
14
+ try
15
+ {
16
+ if(typeof body === 'object')
17
+ {
18
+ headers['Content-Type'] = 'application/json';
19
+ }
20
+ await postBackFunction.apply(undefined, [body, headers])
21
+ }
22
+ catch(err)
23
+ {
24
+ for(let i=0; i<10; i++)
25
+ {
26
+ if (err && err.name === 'GoneException') throw err; // client disconnected
27
+ if (err && err.name === 'LimitExceededException')
28
+ {
29
+ await sleep(i*250 + Math.floor(Math.random() * 250));
30
+ try
31
+ {
32
+ await postBackFunction.apply(undefined, [body, headers])
33
+ break;
34
+ }
35
+ catch(e){ err = e};
36
+ }
37
+ else throw err;
38
+ }
39
+ }
40
+ };
41
+
42
+ await new Promise((resolve, reject)=>
43
+ {
44
+ try
45
+ {
46
+ if(response instanceof StreamResponse)
47
+ {
48
+ const baseHeaders = {
49
+ ...response.headers
50
+ };
51
+
52
+ const stream = response.body;
53
+ const streamId = baseHeaders?.['message-id'] || randomUUID();
54
+
55
+ // Guard: need a readable stream
56
+ if(!stream || typeof stream.on !== 'function')
57
+ {
58
+ return reject(new Error('StreamResponse.stream is not a readable stream.'));
59
+ }
60
+
61
+ // API Gateway WS frame hard limit is 32 KB; keep batches small.
62
+ const BATCH_BYTES = 1024;
63
+ let bucket = '';
64
+ let bucketBytes = 0;
65
+
66
+ let flushTimer = undefined;
67
+
68
+ const frames = []; //{statusCode:number, body:any, headers: Record<string, string>}[]
69
+ let sendingPromise = undefined; //Promise<void>|undefined
70
+
71
+ async function drain()// Promise<void>
72
+ {
73
+ if (sendingPromise) return sendingPromise; // wait for whichever send is active
74
+
75
+ sendingPromise = (async () =>
76
+ {
77
+ try
78
+ {
79
+ while (frames.length)
80
+ {
81
+ const resp = frames.shift();
82
+ if(resp)
83
+ {
84
+ await postJSON(resp.body, resp.headers);
85
+ }
86
+ }
87
+ }
88
+ finally
89
+ {
90
+ sendingPromise = undefined;
91
+ }
92
+ })();
93
+ }
94
+
95
+ async function enqueue(resp)
96
+ {
97
+ frames.push(resp);
98
+ // fire-and-forget; finish() will await completion
99
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
100
+ await drain();
101
+ }
102
+
103
+ // snapshot & reset BEFORE any await to avoid duplicate sends
104
+ async function flushChunkIfAny()
105
+ {
106
+ if (bucketBytes === 0) return;
107
+
108
+ const payload = bucket; // snapshot current buffer
109
+ bucket = '';
110
+ bucketBytes = 0;
111
+ const headers = { 'message-id': streamId, 'x-stream': 'chunk', 'x-stream-id': streamId, 'Content-Type': 'text/plain' };
112
+ const resp = {body: payload, statusCode: 200, headers};
113
+ await enqueue(resp);
114
+ }
115
+
116
+ function scheduleFlush(delayMs)
117
+ {
118
+ if (flushTimer) return;
119
+
120
+ flushTimer = setTimeout(() => {
121
+ flushTimer = undefined;
122
+ flushChunkIfAny()
123
+ .then(() =>
124
+ {
125
+ // re-arm only if there is still pending data *and* we didn't reject
126
+ if (bucketBytes > 0) scheduleFlush(50);
127
+ })
128
+ .catch(err =>
129
+ {
130
+ try { cleanup(); } catch {}
131
+
132
+ // Propagate to the outer promise so Lambda sees the failure
133
+ reject(err);
134
+ });
135
+ }, delayMs);
136
+ }
137
+
138
+ const onData = (chunk) =>
139
+ {
140
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
141
+ bucket += text;
142
+ bucketBytes += Buffer.byteLength(text);
143
+
144
+ if (bucketBytes >= BATCH_BYTES || text.includes('\n'))
145
+ {
146
+ scheduleFlush(0); // flush ASAP; no overlap due to queue
147
+ }
148
+ else
149
+ {
150
+ scheduleFlush(50);
151
+ }
152
+ };
153
+
154
+ // finish by flushing tail, enqueuing 'done', and awaiting queue
155
+ async function finish()
156
+ {
157
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = undefined; }
158
+
159
+ await flushChunkIfAny();
160
+
161
+ const headers = { 'message-id': streamId, 'x-stream': 'done', 'x-stream-id': streamId };
162
+
163
+ await enqueue({body: '', statusCode: 204, headers});
164
+
165
+ await drain(); // wait until all frames are sent
166
+ }
167
+
168
+ const cleanup = () =>
169
+ {
170
+ try
171
+ {
172
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = undefined; }
173
+ stream.removeListener('data', onData);
174
+ stream.removeListener('end', onEnd);
175
+ stream.removeListener('error', onErr);
176
+ } catch(_) {}
177
+ };
178
+
179
+ // use finish() so we never resolve before queue is empty
180
+ const onEnd = () => { finish().then(() => { cleanup(); resolve(undefined); }).catch(err => { cleanup(); reject(err); }); };
181
+
182
+ const onErr = (e) =>
183
+ {
184
+ cleanup();
185
+ return reject(e);
186
+ };
187
+
188
+ stream.on('data', onData);
189
+ stream.once('end', onEnd);
190
+ stream.once('error', onErr);
191
+
192
+ // Important: stop here; promise resolves on 'end' or rejects on 'error'
193
+ return;
194
+ }
195
+ else
196
+ {
197
+ // Non-stream response: send as-is
198
+ postJSON(response.body, response.headers)
199
+ .then(() => resolve(undefined))
200
+ .catch((err) => reject(err));
201
+ }
202
+ }
203
+ catch(e)
204
+ {
205
+ reject(e)
206
+ }
207
+ });
208
+ }
@@ -0,0 +1,9 @@
1
+ import { Response } from "configurapi";
2
+
3
+ export class PostbackResponse extends Response
4
+ {
5
+ body: any;
6
+ headers: Record<string, any>;
7
+
8
+ constructor(body: any, headers?: Record<string, any>);
9
+ }
@@ -0,0 +1,15 @@
1
+ const Response = require('configurapi').Response;
2
+
3
+ module.exports = class PostbackResponse extends Response
4
+ {
5
+ body;
6
+ headers;
7
+
8
+ constructor(body, headers = {})
9
+ {
10
+ this.body = body;
11
+ this.headers = headers;
12
+
13
+ super(body, 200, headers);
14
+ }
15
+ };
@@ -0,0 +1,49 @@
1
+ export interface ClientOptions
2
+ {
3
+ /** Reject if no reply arrives within this many ms (default 15000) */
4
+ replyTimeoutMs?: number;
5
+
6
+ /** Header name to carry the correlation id (default "message-id") */
7
+ idHeaderName?: string;
8
+
9
+ /** If a response arrives without an id, deliver it here (or ignore if omitted) */
10
+ onPush?: (data: {
11
+ body: any;
12
+ statusCode: number;
13
+ headers: Record<string, string>;
14
+ }) => Promise<void>;
15
+
16
+ /**
17
+ * A single string or an array of strings representing the sub-protocol(s)
18
+ * that the client would like to use, in order of preference.
19
+ * If it is omitted, an empty array is used by default, i.e., [].
20
+ */
21
+ protocols?: () => string | string[];
22
+ }
23
+
24
+ declare class WebSocketClient
25
+ {
26
+ constructor(endpoint: string, opts?: ClientOptions);
27
+
28
+ readonly isOpen: boolean;
29
+
30
+ connecting: boolean;
31
+
32
+ connect(): Promise<void>;
33
+
34
+ close(code?: number, reason?: string): Promise<void>;
35
+
36
+ /**
37
+ * Send IRequest and resolve when an IResponse arrives
38
+ * with the same messageId
39
+ */
40
+ send<T>(
41
+ req: any,
42
+ opts?: {
43
+ timeoutMs?: number;
44
+ onChunk?: (chunk: string) => Promise<void>;
45
+ onDone?: () => Promise<void>;
46
+ onError?: (e: Error) => Promise<void>;
47
+ }
48
+ ): Promise<T>;
49
+ }
@@ -0,0 +1,267 @@
1
+ const MessageIdHeaderName = 'message-id';
2
+
3
+ module.exports = class WebSocketClient
4
+ {
5
+ url;
6
+ ws;
7
+ connecting = false;
8
+ replyTimeoutMs;
9
+ protocols;
10
+ onPush;
11
+
12
+ // Correlate by messageId: id -> waiter
13
+ inflight = new Map();
14
+
15
+ constructor(endpoint, opts = {})
16
+ {
17
+ this.url = endpoint;
18
+ this.replyTimeoutMs = opts.replyTimeoutMs ?? 27000;
19
+ this.onPush = opts.onPush;
20
+ this.protocols = opts.protocols;
21
+ }
22
+
23
+ get isOpen()
24
+ {
25
+ return !!this.ws && this.ws.readyState === this.ws.OPEN;
26
+ }
27
+
28
+ async connect()
29
+ {
30
+ if (this.isOpen || this.connecting) return;
31
+ this.connecting = true;
32
+
33
+ await new Promise((resolve, reject) =>
34
+ {
35
+ const protocols = this.protocols?.();
36
+ this.ws = new WebSocket(this.url, protocols);
37
+
38
+ this.ws.addEventListener("open", () =>
39
+ {
40
+ this.connecting = false;
41
+ resolve();
42
+ });
43
+
44
+ this.ws.addEventListener("message", async (ev) =>
45
+ {
46
+ try
47
+ {
48
+ await this.handleResponse(ev.data);
49
+ }
50
+ catch(error)
51
+ {
52
+ reject(error)
53
+ }
54
+ });
55
+
56
+ this.ws.addEventListener("close", () =>
57
+ {
58
+ this.connecting = false;
59
+ // Fail all pending calls
60
+ for (const [id, waiter] of this.inflight) {
61
+ clearTimeout(waiter.timer);
62
+ waiter.reject(new Error(`WebSocket closed before response (id=${id})`));
63
+ }
64
+ this.inflight.clear();
65
+ });
66
+
67
+ this.ws.addEventListener("error", (err) =>
68
+ {
69
+ this.connecting = false;
70
+ if (!this.isOpen) reject(err);
71
+ });
72
+ });
73
+ }
74
+
75
+ async close(code, reason)
76
+ {
77
+ if (!this.ws) return;
78
+ await new Promise((resolve) =>
79
+ {
80
+ this.ws?.addEventListener("close", () => resolve(), { once: true });
81
+ try { this.ws.close(code, reason); } catch { resolve(); }
82
+ });
83
+ }
84
+
85
+ /** Send IRequest and resolve when an IResponse arrives with the same messageId */
86
+ async send(req, opts)
87
+ {
88
+ if (!this.isOpen) await this.connect();
89
+
90
+ const id = this.ensureMessageId(req);
91
+
92
+ // Set up the waiter BEFORE sending (avoid fast-response race)
93
+ const timeoutMs = opts?.timeoutMs ?? this.replyTimeoutMs;
94
+ const promise = new Promise((resolve, reject) =>
95
+ {
96
+ let timer = null;
97
+ const waiter = { resolve, reject, timer: null, onChunk: opts?.onChunk, onDone: opts?.onDone, onError: opts?.onError, timedOut: false, muted: false };
98
+ if (timeoutMs > 0)
99
+ {
100
+ timer = setTimeout(() => {
101
+ waiter.timedOut = true;
102
+
103
+ waiter.onChunk?.("\n\nI've reached my response time limit and couldn't complete my full answer. Could you let me know which part you'd like me to focus on?");
104
+ // Reject the promise so callers can show a warning, but keep waiter so 'done' can clean up
105
+ reject(new Error(`Timed out waiting for response (messageId=${id})`));
106
+ }, timeoutMs);
107
+ }
108
+ waiter.timer = timer;
109
+
110
+ this.inflight.set(id, waiter);
111
+ });
112
+
113
+ try
114
+ {
115
+ this.ws.send(JSON.stringify(req));
116
+ }
117
+ catch (e)
118
+ {
119
+ const waiter = this.inflight.get(id);
120
+ if (waiter) { clearTimeout(waiter.timer); this.inflight.delete(id); }
121
+ throw e;
122
+ }
123
+
124
+ return promise;
125
+ }
126
+
127
+ async handleResponse(text)
128
+ {
129
+ let msg;
130
+ try { msg = JSON.parse(text); } catch { msg = { headers: {}, payload: text }; }
131
+
132
+ const rawFlag = this.getHeaderCaseInsensitive(msg.headers, 'x-stream');
133
+ const streamFlag = rawFlag ? String(rawFlag).toLowerCase() : '';
134
+
135
+ if (streamFlag)
136
+ {
137
+ // correlate by message-id; fallback is applied only for CHUNK below
138
+ const id = this.getHeaderCaseInsensitive(msg.headers, MessageIdHeaderName);
139
+ const waiter = id ? this.inflight.get(id) : undefined;
140
+
141
+ if(msg?.statusCode > 299)
142
+ {
143
+ const error = new Error(`${msg.message} - (${msg.statusCode})`)
144
+
145
+ if(waiter)
146
+ {
147
+ await waiter.onError?.(error);
148
+ }
149
+ }
150
+
151
+ if (streamFlag === 'chunk')
152
+ {
153
+ if (waiter?.timedOut) return;
154
+
155
+ if (waiter)
156
+ {
157
+ await waiter.onChunk?.(msg.body ?? msg.payload ?? msg ?? '');
158
+ }
159
+ else if (this.onPush)
160
+ {
161
+ this.onPush(msg);
162
+ }
163
+ }
164
+ else if (streamFlag === 'done' || streamFlag === 'error')
165
+ {
166
+ if (!waiter)
167
+ {
168
+ if (this.onPush) this.onPush(msg);
169
+ return;
170
+ }
171
+
172
+ await waiter.onChunk?.(msg.body ?? msg.payload ?? '');
173
+ await waiter.onDone?.();
174
+
175
+ clearTimeout(waiter.timer);
176
+
177
+ if(id)
178
+ {
179
+ this.inflight.delete(id);
180
+ }
181
+
182
+ // We alaready rejected if timed out.
183
+ if(waiter.timedOut) return
184
+
185
+ if (streamFlag === 'error')
186
+ {
187
+ waiter.reject(msg);
188
+ }
189
+ else
190
+ {
191
+ waiter.resolve(msg);
192
+ }
193
+ return;
194
+ }
195
+
196
+ // Unknown x-stream value: treat as push to avoid nuking inflight state
197
+ if (this.onPush) this.onPush(msg);
198
+ return;
199
+ }
200
+
201
+ const id = this.getHeaderCaseInsensitive(msg.headers, MessageIdHeaderName);
202
+
203
+ if (msg?.statusCode && msg.statusCode !== 202 && id && this.inflight.has(id))
204
+ {
205
+ const waiter = this.inflight.get(id);
206
+
207
+ if(msg.statusCode > 299)
208
+ {
209
+ const error = new Error(`${msg.message} - (${msg.statusCode})`)
210
+ if(waiter)
211
+ {
212
+ await waiter.onError?.(error);
213
+ }
214
+ }
215
+
216
+ clearTimeout(waiter.timer);
217
+
218
+ this.inflight.delete(id);
219
+
220
+ if(waiter.timedOut) return // Already rejected
221
+ waiter.resolve(msg);
222
+ }
223
+ else
224
+ {
225
+ if(msg?.statusCode > 299)
226
+ {
227
+ const error = new Error(`${msg.message} - (${msg.statusCode})`)
228
+ throw error;
229
+ }
230
+
231
+ // No id or unknown id. Treat as push/unsolicited.
232
+ if (this.onPush) this.onPush(msg);
233
+ }
234
+ }
235
+
236
+ ensureMessageId(request)
237
+ {
238
+ if(request === undefined) return '';
239
+
240
+ if(request.headers === undefined)
241
+ {
242
+ request.headers = {}
243
+ }
244
+
245
+ let id = this.getHeaderCaseInsensitive(request.headers, MessageIdHeaderName);
246
+ if(!id)
247
+ {
248
+ id = crypto.randomUUID()
249
+
250
+ request.headers[MessageIdHeaderName] = id;
251
+ }
252
+ return id;
253
+ }
254
+
255
+ getHeaderCaseInsensitive(headers, name)
256
+ {
257
+ if (!headers || typeof headers !== "object") return undefined;
258
+ const target = name.toLowerCase();
259
+
260
+ for (const k of Object.keys(headers))
261
+ {
262
+ if (k.toLowerCase() === target) return String(headers[k]);
263
+ }
264
+
265
+ return undefined;
266
+ }
267
+ }
File without changes