@larc-iu/plaid-client 0.2.0-alpha.1 → 0.2.0-alpha.11

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/services.js CHANGED
@@ -52,13 +52,38 @@ export function discardService(client, projectId, serviceId) {
52
52
  * @param {Object} serviceInfo - {serviceId, serviceName, description}
53
53
  * @param {function} onServiceRequest - Handler callback (data, responseHelper)
54
54
  * @param {Object} extras - Optional additional metadata
55
- * @returns {Object} ServiceRegistration with .stop(), .isRunning(), .serviceInfo
55
+ * @param {function} [onStatus] - Optional callback (event, projectId, detail) for
56
+ * connection-state transitions: 'registered', 'reconnected', 'disconnected'.
57
+ * Called once per transition, not once per retry.
58
+ * @returns {Object} ServiceRegistration with .stop(), .isRunning(), .isConnected(),
59
+ * .serviceInfo
56
60
  */
57
- export function serve(client, projectId, serviceInfo, onServiceRequest, extras = {}) {
61
+ export function serve(client, projectId, serviceInfo, onServiceRequest, extras = {}, onStatus = null) {
58
62
  const { serviceId, serviceName, description = '' } = serviceInfo;
59
63
  let connection = null;
60
64
  let isRunning = true;
61
65
  let reconnectTimer = null;
66
+ // Last state REPORTED, so an outage produces one "lost" line and one "back"
67
+ // line however many retries it took.
68
+ let isConnected = false;
69
+ let everConnected = false;
70
+
71
+ const report = (event, detail) => {
72
+ if (!onStatus) return;
73
+ try { onStatus(event, projectId, detail); } catch (_) { /* never disturb the loop */ }
74
+ };
75
+ const noteConnected = () => {
76
+ if (isConnected) return;
77
+ const first = !everConnected;
78
+ isConnected = true;
79
+ everConnected = true;
80
+ report(first ? 'registered' : 'reconnected');
81
+ };
82
+ const noteDisconnected = () => {
83
+ if (!isConnected) return;
84
+ isConnected = false;
85
+ report('disconnected');
86
+ };
62
87
 
63
88
  const reportEvent = (requestId, body) =>
64
89
  client
@@ -76,6 +101,10 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
76
101
  if (connection) connection.close();
77
102
  },
78
103
  isRunning: () => isRunning,
104
+ // Whether the request channel is open RIGHT NOW, i.e. whether the server
105
+ // currently sees this service as online. A registration that is retrying
106
+ // through a server restart is still running but not connected.
107
+ isConnected: () => isRunning && !!connection && connection.readyState === 1,
79
108
  serviceInfo: { serviceId, serviceName, description, extras },
80
109
  };
81
110
 
@@ -89,24 +118,58 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
89
118
  const qs = params.toString();
90
119
  const channelPath = `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs ? `?${qs}` : ''}`;
91
120
 
92
- // The channel only carries `connected` (ignored) and `service_request` events.
121
+ // The requests this channel is serving, by id, each flagged once a
122
+ // `service_cancel` arrives. The handler reads it as `responseHelper.cancelled`;
123
+ // whether to stop is the handler's decision (a write under way should
124
+ // finish), and the request still ends with whatever it reports.
125
+ const cancelled = new Map();
126
+
127
+ // Beyond `connected` (ignored), the channel carries `service_request` and
128
+ // `service_cancel` events.
93
129
  const onChannelEvent = (eventType, payload) => {
94
130
  if (!isRunning) return true;
95
- if (eventType !== 'service_request' || !payload) return;
131
+ if (!payload) return;
132
+ if (eventType === 'service_cancel') {
133
+ if (cancelled.has(payload.requestId)) cancelled.set(payload.requestId, true);
134
+ return;
135
+ }
136
+ if (eventType !== 'service_request') return;
96
137
  const requestId = payload.requestId;
97
138
  if (!requestId) return;
139
+ cancelled.set(requestId, false);
140
+ const finished = () => cancelled.delete(requestId);
141
+
142
+ // Every service is told who asked (`requesterId`), beside the payload
143
+ // when that is a plain object, and on the helper regardless.
144
+ const requesterId = payload.requesterId || null;
145
+ const data =
146
+ requesterId && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
147
+ ? { ...payload.data, requesterId }
148
+ : payload.data;
98
149
 
99
150
  const responseHelper = {
100
- progress: (percent, msg) =>
101
- reportEvent(requestId, { status: 'progress', progress: { percent, message: msg } }),
102
- complete: (data) =>
103
- reportEvent(requestId, { status: 'completed', data }),
104
- error: (error) =>
105
- reportEvent(requestId, { status: 'error', data: { error: error?.message || error } }),
151
+ requestId,
152
+ requesterId,
153
+ // True once the requester asked for the request to stop.
154
+ get cancelled() {
155
+ return cancelled.get(requestId) === true;
156
+ },
157
+ // Extra fields ride in the progress payload (a chat service sends the
158
+ // reply text so far as `text`).
159
+ progress: (percent, msg, extra) =>
160
+ reportEvent(requestId, { status: 'progress', progress: { percent, message: msg, ...extra } }),
161
+ complete: (data) => {
162
+ finished();
163
+ return reportEvent(requestId, { status: 'completed', data });
164
+ },
165
+ error: (error) => {
166
+ finished();
167
+ return reportEvent(requestId, { status: 'error', data: { error: error?.message || error } });
168
+ },
106
169
  };
107
170
 
108
171
  try {
109
- onServiceRequest(payload.data, responseHelper);
172
+ onServiceRequest(data, responseHelper);
110
173
  } catch (error) {
111
174
  responseHelper.error(error?.message || error);
112
175
  }
@@ -118,12 +181,17 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
118
181
  throw new Error(`Failed to start service: ${error.message}`);
119
182
  }
120
183
 
121
- // Reopen the channel if it drops (e.g. the server restarted). Reopening
122
- // re-registers the service server-side, so presence and reachability come
123
- // back together.
184
+ // Reopen the channel whenever it drops (e.g. the server restarted), for as
185
+ // long as the service runs. Reopening re-registers the service server-side,
186
+ // so presence and reachability come back together, and a server restart never
187
+ // means a service restart. A reopened channel counts as connected only once
188
+ // the server has actually answered it (readyState OPEN), not merely because
189
+ // the attempt was made.
124
190
  reconnectTimer = setInterval(() => {
125
- if (!isRunning) return;
126
- if (connection && connection.readyState === 2) { // CLOSED (dropped)
191
+ if (!isRunning || !connection) return;
192
+ if (connection.readyState === 1) { noteConnected(); return; }
193
+ if (connection.readyState === 2) { // CLOSED (dropped or failed)
194
+ noteDisconnected();
127
195
  try { connection = openChannel(); } catch (_) { /* retry next tick */ }
128
196
  }
129
197
  }, 3000);
@@ -138,15 +206,26 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
138
206
  * response (no broadcast). Rejects if no service is connected (503), if the
139
207
  * service reports an error, or on timeout.
140
208
  *
209
+ * The request outlives this call: after a timeout, an abort, or a dropped
210
+ * connection the service goes on, and `attachServiceRequest` collects the
211
+ * result given the request id, which `opts.onAccepted` receives as soon as
212
+ * the server has taken the request. Pass `opts.requestId` (a UUID you mint)
213
+ * to know the id before submitting; submitting an id that names a request
214
+ * you already made rejoins it instead of starting another.
215
+ *
141
216
  * @param {Object} client - PlaidClient instance
142
217
  * @param {string} projectId - Project UUID
143
218
  * @param {string} serviceId - Service ID to request
144
219
  * @param {any} data - Request payload
145
220
  * @param {number} [timeout=10000] - Timeout in ms
146
221
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
222
+ * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
223
+ * @param {Object} [opts]
224
+ * @param {string} [opts.requestId] - A client-minted request id (UUID)
225
+ * @param {function} [opts.onAccepted] - Called with the request id once the server has it
147
226
  * @returns {Promise<any>} The service's result
148
227
  */
149
- export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress) {
228
+ export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal, opts = {}) {
150
229
  // Propagate an open logical operation (client.beginOperation) to the
151
230
  // service: its writes then fold under the requester's audit-log entry
152
231
  // (the Python BaseService adopts the id around process_request). Only for a
@@ -157,6 +236,73 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
157
236
  group && data && typeof data === 'object' && !Array.isArray(data)
158
237
  ? { ...data, operationGroup: { id: group.id, message: group.message } }
159
238
  : data;
239
+ const qs = opts.requestId ? `?request-id=${encodeURIComponent(opts.requestId)}` : '';
240
+ return streamServiceRequest(
241
+ client,
242
+ {
243
+ url: `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs}`,
244
+ method: 'POST',
245
+ body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
246
+ onStatus: (response) =>
247
+ response.status === 503 ? new Error(`No live service '${serviceId}' on this project`) : null,
248
+ what: 'Service request',
249
+ },
250
+ timeout,
251
+ onProgress,
252
+ signal,
253
+ opts.onAccepted,
254
+ );
255
+ }
256
+
257
+ /**
258
+ * Rejoin a service request made earlier (by this user) and await its result:
259
+ * the latest progress is replayed, then the result comes, or at once if the
260
+ * request already finished. Rejects with an error whose `status` is 404 when
261
+ * the request is unknown or expired (the server keeps a finished request's
262
+ * result for a while, not forever).
263
+ *
264
+ * @param {Object} client - PlaidClient instance
265
+ * @param {string} projectId - Project UUID
266
+ * @param {string} requestId - The request id (from `onAccepted` or your own)
267
+ * @param {number} [timeout=10000] - Timeout in ms
268
+ * @param {function} [onProgress] - Called with each progress payload {percent, message}
269
+ * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
270
+ * @returns {Promise<any>} The service's result
271
+ */
272
+ export function attachServiceRequest(client, projectId, requestId, timeout = 10000, onProgress, signal) {
273
+ return streamServiceRequest(
274
+ client,
275
+ {
276
+ url: `${client.baseUrl}/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`,
277
+ method: 'GET',
278
+ what: 'Attach to service request',
279
+ },
280
+ timeout,
281
+ onProgress,
282
+ signal,
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Ask the service to stop a request made earlier (by this user). The request
288
+ * still ends with whatever the service then reports, on the stream of whoever
289
+ * is awaiting it. Rejects with 404 if unknown or expired, 409 once finished.
290
+ *
291
+ * @param {Object} client - PlaidClient instance
292
+ * @param {string} projectId - Project UUID
293
+ * @param {string} requestId - The request id
294
+ * @returns {Promise<void>}
295
+ */
296
+ export function cancelServiceRequest(client, projectId, requestId) {
297
+ return client._request('DELETE', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`);
298
+ }
299
+
300
+ /**
301
+ * Open a request stream (submit or attach) and read it to its terminal
302
+ * event: `accepted` names the request, `progress` events go to `onProgress`,
303
+ * and `result` / `error` settle the promise.
304
+ */
305
+ function streamServiceRequest(client, { url, method, body, onStatus, what }, timeout, onProgress, signal, onAccepted) {
160
306
  return new Promise((resolve, reject) => {
161
307
  const abortController = new AbortController();
162
308
  let settled = false;
@@ -168,37 +314,54 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
168
314
  fn(arg);
169
315
  };
170
316
  const timer = setTimeout(
171
- () => finish(reject, new Error(`Service request timed out after ${timeout}ms`)),
317
+ () => finish(reject, new Error(`${what} timed out after ${timeout}ms`)),
172
318
  timeout,
173
319
  );
174
320
 
321
+ // An external signal stops waiting on a long request (a UI's Stop button).
322
+ // Reject with an AbortError so a caller can tell a deliberate stop from a
323
+ // failure. The service is not told here: it goes on, and its result can
324
+ // still be collected by request id (see cancelServiceRequest to stop it).
325
+ const stop = () => {
326
+ const err = new Error('The service request was stopped');
327
+ err.name = 'AbortError';
328
+ finish(reject, err);
329
+ };
330
+ if (signal) {
331
+ if (signal.aborted) {
332
+ stop();
333
+ return;
334
+ }
335
+ signal.addEventListener('abort', stop, { once: true });
336
+ }
337
+
175
338
  (async () => {
176
339
  let response;
177
340
  try {
178
- response = await fetch(
179
- `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests`,
180
- {
181
- method: 'POST',
182
- headers: {
183
- 'Authorization': `Bearer ${client.token}`,
184
- 'Content-Type': 'application/json',
185
- 'Accept': 'text/event-stream',
186
- },
187
- body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
188
- signal: abortController.signal,
341
+ response = await fetch(url, {
342
+ method,
343
+ headers: {
344
+ 'Authorization': `Bearer ${client.token}`,
345
+ 'Content-Type': 'application/json',
346
+ 'Accept': 'text/event-stream',
189
347
  },
190
- );
348
+ ...(body === undefined ? {} : { body }),
349
+ signal: abortController.signal,
350
+ });
191
351
  } catch (error) {
192
- if (error.name !== 'AbortError') finish(reject, new Error(`Failed to submit service request: ${error.message}`));
352
+ if (error.name !== 'AbortError') finish(reject, new Error(`${what} could not be sent: ${error.message}`));
193
353
  return;
194
354
  }
195
355
 
196
- if (response.status === 503) {
197
- finish(reject, new Error(`No live service '${serviceId}' on this project`));
356
+ const special = onStatus ? onStatus(response) : null;
357
+ if (special) {
358
+ finish(reject, special);
198
359
  return;
199
360
  }
200
361
  if (!response.ok) {
201
- finish(reject, new Error(`Service request failed: HTTP ${response.status} ${response.statusText}`));
362
+ const err = new Error(`${what} failed: HTTP ${response.status} ${response.statusText}`);
363
+ err.status = response.status;
364
+ finish(reject, err);
202
365
  return;
203
366
  }
204
367
 
@@ -223,13 +386,15 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
223
386
  dataLine = line.slice(6);
224
387
  } else if (line === '' && eventType && dataLine) {
225
388
  const payload = transformResponse(JSON.parse(dataLine));
226
- if (eventType === 'progress') {
389
+ if (eventType === 'accepted') {
390
+ if (onAccepted) { try { onAccepted(payload.requestId); } catch (_) { /* ignore */ } }
391
+ } else if (eventType === 'progress') {
227
392
  if (onProgress) { try { onProgress(payload.progress); } catch (_) { /* ignore */ } }
228
393
  } else if (eventType === 'result') {
229
394
  finish(resolve, payload.data);
230
395
  return;
231
396
  } else if (eventType === 'error') {
232
- finish(reject, new Error(payload?.error || 'Service request failed'));
397
+ finish(reject, new Error(payload?.error || `${what} failed`));
233
398
  return;
234
399
  }
235
400
  eventType = '';
@@ -239,7 +404,7 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
239
404
  }
240
405
  finish(reject, new Error('Service closed the connection without a result'));
241
406
  } catch (error) {
242
- if (error.name !== 'AbortError') finish(reject, new Error(`Service request stream error: ${error.message}`));
407
+ if (error.name !== 'AbortError') finish(reject, new Error(`${what} stream error: ${error.message}`));
243
408
  }
244
409
  })();
245
410
  });
package/src/sse.js CHANGED
@@ -1,5 +1,36 @@
1
1
  import { transformResponse } from './transforms.js';
2
2
 
3
+ // A live SSE stream is never silent for long: the server sends a keepalive
4
+ // comment on service channels (every 25s) and a heartbeat event on the /listen
5
+ // bus (every 30s). So if NOTHING arrives for longer than this, the connection is
6
+ // dead: the server was killed without closing the socket, the network dropped
7
+ // it, or a reconnect caught the server mid-restart (port accepting, app not yet
8
+ // streaming). We must surface that as a drop so the registration supervisor in
9
+ // serve() can reopen it. Without the watchdog such a silent drop
10
+ // leaves `reader.read()` pending forever: readyState never reaches CLOSED, so
11
+ // nothing reconnects and the service never heals. Comfortably above both server
12
+ // cadences, so a healthy idle stream never trips it.
13
+ export const SSE_IDLE_TIMEOUT_MS = 60000;
14
+
15
+ /**
16
+ * Project a parsed SSE `data:` object onto what a listener callback sees.
17
+ *
18
+ * The envelope is API surface and gets the usual `transformResponse` recasing.
19
+ * A `message` event's `data` is NOT: it is opaque application data (whatever
20
+ * `sendMessage` was handed), so its keys must survive verbatim, exactly as
21
+ * `metadata` and `config` do elsewhere. Without this, a key written
22
+ * `case-marker` reaches a JavaScript listener as `caseMarker` and a Python
23
+ * listener as `case_marker`, so the same broadcast reads differently depending
24
+ * on the reader's language.
25
+ */
26
+ export function eventPayload(eventType, parsed) {
27
+ const payload = transformResponse(parsed);
28
+ if (eventType === 'message' && parsed && typeof parsed === 'object' && 'data' in parsed) {
29
+ payload.data = parsed.data;
30
+ }
31
+ return payload;
32
+ }
33
+
3
34
  /**
4
35
  * Create an SSE connection to the listen endpoint using fetch-based streaming.
5
36
  * Automatically handles heartbeat confirmations and event parsing.
@@ -40,6 +71,21 @@ export function createSSEConnection(client, projectId, onEvent, path) {
40
71
  }
41
72
  };
42
73
 
74
+ // Watchdog: abort the fetch if the stream goes silent for longer than the
75
+ // server's keepalive cadence, so a silently-dropped connection lands in the
76
+ // catch below (readyState CLOSED) instead of blocking on a read that will
77
+ // never resolve.
78
+ let idleTimer = null;
79
+ const clearIdleTimer = () => {
80
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
81
+ };
82
+ const armIdleTimer = () => {
83
+ clearIdleTimer();
84
+ idleTimer = setTimeout(() => {
85
+ if (!isClosed) abortController.abort(new Error('SSE stream went silent'));
86
+ }, SSE_IDLE_TIMEOUT_MS);
87
+ };
88
+
43
89
  const sseConnection = {
44
90
  readyState: 0, // CONNECTING
45
91
  close: () => {
@@ -47,6 +93,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
47
93
  isClosed = true;
48
94
  isConnected = false;
49
95
  sseConnection.readyState = 2; // CLOSED
96
+ clearIdleTimer();
50
97
  abortController.abort();
51
98
  }
52
99
  },
@@ -64,6 +111,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
64
111
  (async () => {
65
112
  try {
66
113
  const url = `${client.baseUrl}${streamPath}`;
114
+ armIdleTimer();
67
115
  const response = await fetch(url, {
68
116
  method: 'GET',
69
117
  headers: {
@@ -101,6 +149,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
101
149
  while (true) {
102
150
  const { done, value } = await reader.read();
103
151
  if (done || isClosed) break;
152
+ armIdleTimer();
104
153
 
105
154
  buffer += decoder.decode(value, { stream: true });
106
155
  const lines = buffer.split('\n');
@@ -124,7 +173,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
124
173
  sendHeartbeatConfirmation();
125
174
  } else {
126
175
  const parsedData = JSON.parse(data);
127
- const shouldStop = onEvent(eventType, transformResponse(parsedData));
176
+ const shouldStop = onEvent(eventType, eventPayload(eventType, parsedData));
128
177
  if (shouldStop === true) {
129
178
  sseConnection.close();
130
179
  return;
@@ -144,6 +193,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
144
193
  console.warn('SSE connection error:', error);
145
194
  }
146
195
  } finally {
196
+ clearIdleTimer();
147
197
  isConnected = false;
148
198
  isClosed = true;
149
199
  sseConnection.readyState = 2; // CLOSED