@larc-iu/plaid-client 0.2.0-alpha.2 → 0.2.0-alpha.21

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
@@ -10,12 +10,130 @@
10
10
  */
11
11
  import { transformRequest, transformResponse } from './transforms.js';
12
12
 
13
+ // An absolute URL anywhere in an error message, with the phrase that introduces
14
+ // it (`... at http://host/api/v1/spans`). Client and transport errors name the
15
+ // endpoint they called, which is the service operator's business and not the
16
+ // requester's.
17
+ const URL_IN_TEXT = /(?:\s+(?:at|for url:?))?\s*\b[a-zA-Z][\w+.-]*:\/\/\S+/g;
18
+
19
+ /**
20
+ * What a requester is told when an error carries no message of its own.
21
+ * Naming the JS class instead would say nothing they can act on.
22
+ */
23
+ export const UNKNOWN_FAILURE = 'The service could not finish this request.';
24
+
25
+ const redact = (text, secrets) => {
26
+ let out = text;
27
+ for (const secret of secrets || []) {
28
+ if (secret && String(secret).length >= 8) out = out.split(String(secret)).join('[redacted]');
29
+ }
30
+ return out.trim();
31
+ };
32
+
33
+ /** A write sent to the server whose answer never came back. */
34
+ export const UNKNOWN_OUTCOME =
35
+ 'The Plaid server did not answer. This change may or may not have been saved.';
36
+
37
+ /** The server was never reached, so nothing was sent. */
38
+ export const UNREACHABLE = 'The Plaid server could not be reached.';
39
+
40
+ // A few reads travel as a POST, and saved nothing either way (the Python twin
41
+ // and plaid-ui's errors.js keep the same list).
42
+ const READ_POSTS = /\/api\/v1\/(?:query|login|invites\/lookup)(?:[?#]|$)/;
43
+
44
+ // Node's fetch (undici) names a connection that never opened by its code: a
45
+ // refused port, a name that did not resolve, a connect timeout. Nothing was
46
+ // sent then. A browser's fetch says only "Failed to fetch", which cannot be
47
+ // told apart from a reset, so it counts as sent.
48
+ const NEVER_CONNECTED = new Set([
49
+ 'ECONNREFUSED',
50
+ 'ENOTFOUND',
51
+ 'EAI_AGAIN',
52
+ 'EHOSTUNREACH',
53
+ 'ENETUNREACH',
54
+ 'UND_ERR_CONNECT_TIMEOUT',
55
+ ]);
56
+
57
+ const neverConnected = (error) => {
58
+ const seen = new Set();
59
+ let cause = error && error.originalError;
60
+ while (cause && typeof cause === 'object' && !seen.has(cause)) {
61
+ seen.add(cause);
62
+ if (NEVER_CONNECTED.has(cause.code)) return true;
63
+ cause = cause.cause;
64
+ }
65
+ return false;
66
+ };
67
+
68
+ const networkFailure = (error) => {
69
+ const method = String(error.method || '').toUpperCase();
70
+ if (neverConnected(error)) return UNREACHABLE;
71
+ if (method && method !== 'GET' && method !== 'HEAD' && !READ_POSTS.test(String(error.url || ''))) {
72
+ return UNKNOWN_OUTCOME;
73
+ }
74
+ return 'The Plaid server did not answer.';
75
+ };
76
+
77
+ /**
78
+ * One line about a failure that is safe to show the person who asked.
79
+ *
80
+ * Strips absolute URLs (so an internal host never reaches a requester's screen)
81
+ * and any `secrets` given (a model provider's API key can come back inside its
82
+ * own error text). The whole error still goes to the operator's console: this
83
+ * is the requester's half only. The Python twin is `requester_message`.
84
+ *
85
+ * Only a connection that never opened is "could not be reached". A write whose
86
+ * answer never came (a reset, a timeout) may have been saved, and says so
87
+ * (`UNKNOWN_OUTCOME`).
88
+ *
89
+ * @param {any} error
90
+ * @param {string[]} [secrets]
91
+ * @returns {string}
92
+ */
93
+ export function requesterMessage(error, secrets = []) {
94
+ if (error && typeof error.status === 'number') {
95
+ if (!error.status) return networkFailure(error);
96
+ let text = String(error.message || '');
97
+ if (error.url) text = text.split(` at ${error.url}`).join('').split(error.url).join('');
98
+ text = text.trim().replace(/[\s,:;]+$/, '');
99
+ return redact(text, secrets) || `HTTP ${error.status}`;
100
+ }
101
+ // An Error is read by its message and nothing else: `String(new TypeError(''))`
102
+ // is 'TypeError', which names the class to the requester and tells them
103
+ // nothing they can act on.
104
+ const raw =
105
+ error && typeof error === 'object' && 'message' in error
106
+ ? String(error.message ?? '')
107
+ : String(error ?? '');
108
+ const text = raw.replace(URL_IN_TEXT, '').trim().replace(/[\s,:;]+$/, '');
109
+ return redact(text, secrets) || UNKNOWN_FAILURE;
110
+ }
111
+
112
+ /**
113
+ * What the requester is told when a request fails, named by the service.
114
+ *
115
+ * Every path that reports a failed request goes through here, so a raw message
116
+ * cannot reach a requester from one of them. (Python's twin additionally lets a
117
+ * `ValueError` through unprefixed, as an authored refusal; JS has no
118
+ * distinguished refusal type, so every failure is named by the service.)
119
+ *
120
+ * @param {any} error
121
+ * @param {string} [serviceName]
122
+ * @param {string[]} [secrets]
123
+ * @returns {string}
124
+ */
125
+ export function serviceErrorMessage(error, serviceName = '', secrets = []) {
126
+ const text = requesterMessage(error, secrets);
127
+ return serviceName ? `${serviceName}: ${text}` : text;
128
+ }
129
+
13
130
  /**
14
131
  * Discover the services seen on a project — a synchronous GET. Returns every
15
132
  * service ever registered on the project: currently connected ones carry
16
133
  * `online: true`; previously-seen offline ones carry `online: false` plus a
17
134
  * `lastSeenAt` stamp. Callers that need a service they can actually submit
18
- * work to should filter on `online`.
135
+ * work to should filter on `online`. Goes over the wire even while a batch is
136
+ * open on the client.
19
137
  *
20
138
  * @param {Object} client - PlaidClient instance
21
139
  * @param {string} projectId - Project UUID
@@ -36,9 +154,45 @@ export function discoverServices(client, projectId) {
36
154
  * @returns {Promise<void>}
37
155
  */
38
156
  export function discardService(client, projectId, serviceId) {
39
- return client._request('DELETE', `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}`);
157
+ // outOfBand: the registry is not project data (see the note at the top of
158
+ // http.js).
159
+ return client._request('DELETE', `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}`, {
160
+ outOfBand: true,
161
+ });
162
+ }
163
+
164
+ /**
165
+ * POST a progress, result or error event for an in-flight request; the server
166
+ * relays it to the waiting requester. The Python twin is `_report_event`.
167
+ *
168
+ * outOfBand: these are signals to whoever is waiting, not writes to the
169
+ * project (see the note at the top of http.js). A service may report them on
170
+ * the batch it is filling, where queueing them would hold every one back until
171
+ * submit, deliver none at all if the batch aborts, and take slots in the
172
+ * batch's results. The requester would hear nothing and wait out its idle
173
+ * timeout on work that had already finished.
174
+ *
175
+ * @param {Object} client - PlaidClient instance
176
+ * @param {string} projectId - Project UUID
177
+ * @param {string} requestId - The request id
178
+ * @param {Object} body - The event payload
179
+ * @returns {Promise<any>}
180
+ */
181
+ export function reportRequestEvent(client, projectId, requestId, body) {
182
+ return client._request(
183
+ 'POST',
184
+ `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}/events`,
185
+ { body, outOfBand: true },
186
+ );
40
187
  }
41
188
 
189
+ /**
190
+ * HTTP statuses a refused service channel answers with that retrying cannot
191
+ * fix: a bad or revoked token, no write access to the project, a project that
192
+ * does not exist. The same set as the Python client's.
193
+ */
194
+ const PERMANENT_STATUSES = new Set([400, 401, 403, 404, 405, 422]);
195
+
42
196
  /**
43
197
  * Register a service and handle incoming work requests.
44
198
  *
@@ -52,21 +206,46 @@ export function discardService(client, projectId, serviceId) {
52
206
  * @param {Object} serviceInfo - {serviceId, serviceName, description}
53
207
  * @param {function} onServiceRequest - Handler callback (data, responseHelper)
54
208
  * @param {Object} extras - Optional additional metadata
55
- * @returns {Object} ServiceRegistration with .stop(), .isRunning(), .serviceInfo
209
+ * @param {function} [onStatus] - Optional callback (event, projectId, detail) for
210
+ * connection-state transitions: 'registered', 'reconnected', 'disconnected',
211
+ * and 'stopped' when the server refuses the channel for a reason retrying
212
+ * cannot fix (see PERMANENT_STATUSES), which ends the registration.
213
+ * Called once per transition, not once per retry.
214
+ * @returns {Object} ServiceRegistration with .stop(), .isRunning(), .isConnected(),
215
+ * .serviceInfo
56
216
  */
57
- export function serve(client, projectId, serviceInfo, onServiceRequest, extras = {}) {
217
+ export function serve(client, projectId, serviceInfo, onServiceRequest, extras = {}, onStatus = null) {
58
218
  const { serviceId, serviceName, description = '' } = serviceInfo;
59
219
  let connection = null;
60
220
  let isRunning = true;
61
221
  let reconnectTimer = null;
222
+ // Last state REPORTED, so an outage produces one "lost" line and one "back"
223
+ // line however many retries it took.
224
+ let isConnected = false;
225
+ let everConnected = false;
226
+
227
+ const report = (event, detail) => {
228
+ if (!onStatus) return;
229
+ try { onStatus(event, projectId, detail); } catch (_) { /* never disturb the loop */ }
230
+ };
231
+ const noteConnected = () => {
232
+ if (isConnected) return;
233
+ const first = !everConnected;
234
+ isConnected = true;
235
+ everConnected = true;
236
+ report(first ? 'registered' : 'reconnected');
237
+ };
238
+ const noteDisconnected = () => {
239
+ if (!isConnected) return;
240
+ isConnected = false;
241
+ report('disconnected');
242
+ };
62
243
 
63
244
  const reportEvent = (requestId, body) =>
64
- client
65
- ._request('POST', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}/events`, { body })
66
- .catch((error) => {
67
- // 404 just means the requester already went away; nothing to do.
68
- console.warn('Failed to report request event:', error.message || error);
69
- });
245
+ reportRequestEvent(client, projectId, requestId, body).catch((error) => {
246
+ // 404 just means the requester already went away; nothing to do.
247
+ console.warn('Failed to report request event:', error.message || error);
248
+ });
70
249
 
71
250
  const serviceRegistration = {
72
251
  stop: () => {
@@ -76,6 +255,10 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
76
255
  if (connection) connection.close();
77
256
  },
78
257
  isRunning: () => isRunning,
258
+ // Whether the request channel is open RIGHT NOW, i.e. whether the server
259
+ // currently sees this service as online. A registration that is retrying
260
+ // through a server restart is still running but not connected.
261
+ isConnected: () => isRunning && !!connection && connection.readyState === 1,
79
262
  serviceInfo: { serviceId, serviceName, description, extras },
80
263
  };
81
264
 
@@ -89,26 +272,113 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
89
272
  const qs = params.toString();
90
273
  const channelPath = `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs ? `?${qs}` : ''}`;
91
274
 
92
- // The channel only carries `connected` (ignored) and `service_request` events.
275
+ // The requests this channel is serving, by id, each flagged once a
276
+ // `service_cancel` arrives. The handler reads it as `responseHelper.cancelled`;
277
+ // whether to stop is the handler's decision (a write under way should
278
+ // finish), and the request still ends with whatever it reports.
279
+ const cancelled = new Map();
280
+
281
+ // Beyond `connected` (ignored), the channel carries `service_request` and
282
+ // `service_cancel` events.
93
283
  const onChannelEvent = (eventType, payload) => {
94
284
  if (!isRunning) return true;
95
- if (eventType !== 'service_request' || !payload) return;
285
+ if (!payload) return;
286
+ if (eventType === 'service_cancel') {
287
+ if (cancelled.has(payload.requestId)) cancelled.set(payload.requestId, true);
288
+ return;
289
+ }
290
+ if (eventType !== 'service_request') return;
96
291
  const requestId = payload.requestId;
97
292
  if (!requestId) return;
293
+ cancelled.set(requestId, false);
294
+ const finished = () => cancelled.delete(requestId);
98
295
 
296
+ // Every service is told who asked (`requesterId`), beside the payload
297
+ // when that is a plain object, and on the helper regardless. A delegating
298
+ // service (extras `delegation: true`) also gets a short-lived token for the
299
+ // requesting user (`delegatedToken`) and the projects that token reaches
300
+ // (`delegatedProjects`, the request's own first), as the Python client
301
+ // hands them over.
302
+ const requesterId = payload.requesterId || null;
303
+ const extra = {
304
+ ...(requesterId ? { requesterId } : {}),
305
+ ...(payload.delegatedToken ? { delegatedToken: payload.delegatedToken } : {}),
306
+ ...(payload.delegatedProjects ? { delegatedProjects: payload.delegatedProjects } : {}),
307
+ };
308
+ const data =
309
+ Object.keys(extra).length && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
310
+ ? { ...payload.data, ...extra }
311
+ : payload.data;
312
+
313
+ const scope = createCancelScope(() => cancelled.get(requestId) === true);
99
314
  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 } }),
315
+ requestId,
316
+ requesterId,
317
+ // True once the requester asked for the request to stop.
318
+ get cancelled() {
319
+ return scope.cancelled;
320
+ },
321
+ raiseIfCancelled: () => scope.raiseIfCancelled(),
322
+ critical: (fn) => scope.critical(fn),
323
+ // Extra fields ride in the progress payload (a chat service sends the
324
+ // reply text so far as `text`).
325
+ //
326
+ // This is a CANCELLATION CHECKPOINT: it throws ServiceCancelled if the
327
+ // requester has stopped the request, so a service that reports progress
328
+ // through its work is cancellable without doing anything else. Use
329
+ // `critical()` around writes to hold it off.
330
+ progress: (percent, msg, extra) => {
331
+ scope.raiseIfCancelled();
332
+ return reportEvent(requestId, {
333
+ status: 'progress',
334
+ progress: { percent, message: msg, ...extra },
335
+ });
336
+ },
337
+ complete: (data) => {
338
+ finished();
339
+ return reportEvent(requestId, { status: 'completed', data });
340
+ },
341
+ // Asked to stop, and it did. Reported as a result carrying
342
+ // `stopped: true`, so a client can tell "you stopped it" from "it broke".
343
+ stopped: (data) => {
344
+ finished();
345
+ return reportEvent(requestId, {
346
+ status: 'completed',
347
+ data: { ...(data || {}), stopped: true },
348
+ });
349
+ },
350
+ // Every failure a requester is shown goes out through here, whoever
351
+ // reports it, so the scrub lives here rather than only in `settle`: a
352
+ // service that catches its own error and calls `helper.error(err)`
353
+ // would otherwise put the endpoint the client called on the requester's
354
+ // screen. Already-sanitized text passes through unchanged.
355
+ error: (error) => {
356
+ finished();
357
+ return reportEvent(requestId, {
358
+ status: 'error',
359
+ data: { error: requesterMessage(error) },
360
+ });
361
+ },
106
362
  };
107
363
 
364
+ // A handler may be async, so a rejected promise has to be caught too, or a
365
+ // cancelled async service would report a failure instead of a stop.
366
+ const settle = (error) => {
367
+ if (error instanceof ServiceCancelled || error?.name === 'ServiceCancelled') {
368
+ responseHelper.stopped();
369
+ } else {
370
+ // What reaches the requester is the sanitized half: the raw message
371
+ // names the endpoint the client called, which is the operator's
372
+ // business. The console keeps the whole error.
373
+ console.error(`Service ${serviceId} failed request ${requestId}:`, error);
374
+ responseHelper.error(serviceErrorMessage(error, serviceName));
375
+ }
376
+ };
108
377
  try {
109
- onServiceRequest(payload.data, responseHelper);
378
+ const maybe = onServiceRequest(data, responseHelper);
379
+ if (maybe && typeof maybe.catch === 'function') maybe.catch(settle);
110
380
  } catch (error) {
111
- responseHelper.error(error?.message || error);
381
+ settle(error);
112
382
  }
113
383
  };
114
384
  const openChannel = () => client.messages.listen(projectId, onChannelEvent, channelPath);
@@ -118,12 +388,30 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
118
388
  throw new Error(`Failed to start service: ${error.message}`);
119
389
  }
120
390
 
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.
391
+ // Reopen the channel whenever it drops (e.g. the server restarted), for as
392
+ // long as the service runs. Reopening re-registers the service server-side,
393
+ // so presence and reachability come back together, and a server restart never
394
+ // means a service restart. A reopened channel counts as connected only once
395
+ // the server has actually answered it (readyState OPEN), not merely because
396
+ // the attempt was made.
397
+ //
398
+ // A channel the server REFUSED for a reason retrying cannot fix (the token
399
+ // was revoked, the user lost write access, the project is gone) ends the
400
+ // registration instead: the server closes a channel whose opener lost the
401
+ // right to it, and reopening it every few seconds forever would only hide
402
+ // that from the operator.
124
403
  reconnectTimer = setInterval(() => {
125
- if (!isRunning) return;
126
- if (connection && connection.readyState === 2) { // CLOSED (dropped)
404
+ if (!isRunning || !connection) return;
405
+ if (connection.readyState === 1) { noteConnected(); return; }
406
+ if (connection.readyState === 2) { // CLOSED (dropped or failed)
407
+ const status = connection.error?.status;
408
+ if (PERMANENT_STATUSES.has(status)) {
409
+ noteDisconnected();
410
+ serviceRegistration.stop();
411
+ report('stopped', `HTTP ${status}`);
412
+ return;
413
+ }
414
+ noteDisconnected();
127
415
  try { connection = openChannel(); } catch (_) { /* retry next tick */ }
128
416
  }
129
417
  }, 3000);
@@ -136,27 +424,267 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
136
424
  *
137
425
  * Streams the service's progress + result back over a single server-mediated
138
426
  * response (no broadcast). Rejects if no service is connected (503), if the
139
- * service reports an error, or on timeout.
427
+ * service reports an error, or after `timeout` of SILENCE.
428
+ *
429
+ * The request outlives this call: after a timeout, an abort, or a dropped
430
+ * connection the service goes on, and `attachServiceRequest` collects the
431
+ * result given the request id, which `opts.onAccepted` receives as soon as
432
+ * the server has taken the request. Pass `opts.requestId` (a UUID you mint)
433
+ * to know the id before submitting; submitting an id that names a request
434
+ * you already made rejoins it instead of starting another.
435
+ *
436
+ * Errors that leave the request alive carry `pending: true`: a timeout, a
437
+ * stop, a dropped connection, a proxy's 502 or 504. An error without it is
438
+ * the end of the request. With `opts.requestId`, a request whose answer went
439
+ * missing (a dropped connection, a 502 or 504) is rejoined by that id a few
440
+ * times before the error is given.
140
441
  *
141
442
  * @param {Object} client - PlaidClient instance
142
443
  * @param {string} projectId - Project UUID
143
444
  * @param {string} serviceId - Service ID to request
144
445
  * @param {any} data - Request payload
145
- * @param {number} [timeout=10000] - Timeout in ms
446
+ * @param {number} [timeout=10000] - How long the service may say NOTHING, in ms.
447
+ * Every event it sends starts the clock again, so this does not cap a long run
146
448
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
449
+ * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
450
+ * @param {Object} [opts]
451
+ * @param {string} [opts.requestId] - A client-minted request id (UUID)
452
+ * @param {function} [opts.onAccepted] - Called with the request id once the server has it
453
+ * @param {string[]} [opts.projectIds] - Other projects the request is about, beside
454
+ * `projectId`. A delegating service's token is scoped to `projectId` and to those of
455
+ * these the requester can read, and reaches nothing else
456
+ * @param {boolean} [opts.noOperation] - Carry no open operation: the service's writes
457
+ * are a group of their own, not part of whatever operation this client has open
147
458
  * @returns {Promise<any>} The service's result
148
459
  */
149
- export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress) {
460
+ export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal, opts = {}) {
150
461
  // Propagate an open logical operation (client.beginOperation) to the
151
462
  // service: its writes then fold under the requester's audit-log entry
152
463
  // (the Python BaseService adopts the id around process_request). Only for a
153
464
  // plain-object payload — that's the only shape the service param schema
154
465
  // delivers anyway.
155
- const group = client.operationGroup;
466
+ // Its kind and reference go too, so a service that writes before the
467
+ // requester does still records them.
468
+ //
469
+ // `opts.noOperation` sends none: the service starts a group of its own. For
470
+ // a request that is its own action whatever else is on the wire, such as
471
+ // approving an assistant's plan or starting a service run. The client holds
472
+ // one open operation, so without it an edit still saving at that moment
473
+ // would take in every write the request makes, under the edit's kind.
474
+ const group = opts.noOperation ? null : client.operationGroup;
156
475
  const payload =
157
476
  group && data && typeof data === 'object' && !Array.isArray(data)
158
- ? { ...data, operationGroup: { id: group.id, message: group.message } }
477
+ ? {
478
+ ...data,
479
+ operationGroup: {
480
+ id: group.id,
481
+ message: group.message,
482
+ ...(group.kind ? { kind: group.kind } : {}),
483
+ ...(group.ref ? { ref: group.ref } : {}),
484
+ },
485
+ }
159
486
  : data;
487
+ const query = new URLSearchParams();
488
+ if (opts.requestId) query.set('request-id', opts.requestId);
489
+ if (opts.projectIds?.length) query.set('project-ids', opts.projectIds.join(','));
490
+ const qs = String(query) ? `?${query}` : '';
491
+ const submit = () => streamServiceRequest(
492
+ client,
493
+ {
494
+ url: `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs}`,
495
+ method: 'POST',
496
+ body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
497
+ onStatus: (response) =>
498
+ response.status === 503 ? new Error(`No live service '${serviceId}' on this project`) : null,
499
+ what: 'Service request',
500
+ },
501
+ timeout,
502
+ onProgress,
503
+ signal,
504
+ opts.onAccepted,
505
+ );
506
+ // A request whose id is known is rejoined when its answer goes missing, so
507
+ // a run the service took and finished is not reported failed.
508
+ if (!opts.requestId) return submit();
509
+ return rejoinLost(
510
+ submit,
511
+ () => attachServiceRequest(client, projectId, opts.requestId, timeout, onProgress, signal),
512
+ signal,
513
+ );
514
+ }
515
+
516
+ /**
517
+ * Rejoin a service request made earlier (by this user) and await its result:
518
+ * the latest progress is replayed, then the result comes, or at once if the
519
+ * request already finished. Rejects with an error whose `status` is 404 when
520
+ * the request is unknown or expired (the server keeps a finished request's
521
+ * result for a while, not forever). As with `requestService`, `timeout` is
522
+ * how long the service may be silent, and an error carrying `pending: true`
523
+ * means the request is still there to rejoin again.
524
+ *
525
+ * @param {Object} client - PlaidClient instance
526
+ * @param {string} projectId - Project UUID
527
+ * @param {string} requestId - The request id (from `onAccepted` or your own)
528
+ * @param {number} [timeout=10000] - How long the service may say NOTHING, in ms
529
+ * @param {function} [onProgress] - Called with each progress payload {percent, message}
530
+ * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
531
+ * @returns {Promise<any>} The service's result
532
+ */
533
+ export function attachServiceRequest(client, projectId, requestId, timeout = 10000, onProgress, signal) {
534
+ return streamServiceRequest(
535
+ client,
536
+ {
537
+ url: `${client.baseUrl}/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`,
538
+ method: 'GET',
539
+ what: 'Attach to service request',
540
+ },
541
+ timeout,
542
+ onProgress,
543
+ signal,
544
+ );
545
+ }
546
+
547
+ /**
548
+ * Thrown inside a handler when the requester has asked it to stop.
549
+ *
550
+ * Cooperative cancellation: nothing interrupts a handler, so the request ends
551
+ * at the next point the handler looks. `responseHelper.progress` is that
552
+ * point, which is why a long service that already reports progress needs no
553
+ * changes at all. `serve` catches this and ends the request as STOPPED rather
554
+ * than failed.
555
+ */
556
+ export class ServiceCancelled extends Error {
557
+ constructor(message = 'The requester stopped this request') {
558
+ super(message);
559
+ this.name = 'ServiceCancelled';
560
+ }
561
+ }
562
+
563
+ /**
564
+ * The cancellation half of a responseHelper: a flag, a depth counter, and one
565
+ * rule about when a checkpoint may raise. Split from the transport so it can
566
+ * be reasoned about on its own. Mirrors `CancelScope` in the Python client.
567
+ */
568
+ export function createCancelScope(isCancelled) {
569
+ // Depth of nested `critical()` blocks; while non-zero, a checkpoint will
570
+ // not throw.
571
+ let criticalDepth = 0;
572
+ return {
573
+ get cancelled() {
574
+ return !!isCancelled();
575
+ },
576
+ /** Stop here if the requester has asked the request to stop. */
577
+ raiseIfCancelled() {
578
+ if (isCancelled() && criticalDepth === 0) throw new ServiceCancelled();
579
+ },
580
+ /**
581
+ * Run `fn` as a stretch that must finish once begun, usually the writes.
582
+ * Checkpoints inside do not throw, so a half-written document is never
583
+ * left behind; cancellation takes effect at the first checkpoint after.
584
+ */
585
+ async critical(fn) {
586
+ criticalDepth++;
587
+ try {
588
+ return await fn();
589
+ } finally {
590
+ criticalDepth--;
591
+ }
592
+ },
593
+ };
594
+ }
595
+
596
+ /**
597
+ * Ask the service to stop a request made earlier (by this user). The request
598
+ * still ends with whatever the service then reports, on the stream of whoever
599
+ * is awaiting it. Rejects with 404 if unknown or expired, 409 once finished.
600
+ *
601
+ * outOfBand: a DELETE, but not a write. It signals a running service and
602
+ * changes no project data (see the note at the top of http.js).
603
+ *
604
+ * @param {Object} client - PlaidClient instance
605
+ * @param {string} projectId - Project UUID
606
+ * @param {string} requestId - The request id
607
+ * @returns {Promise<void>}
608
+ */
609
+ export function cancelServiceRequest(client, projectId, requestId) {
610
+ return client._request(
611
+ 'DELETE',
612
+ `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`,
613
+ { outOfBand: true },
614
+ );
615
+ }
616
+
617
+ /**
618
+ * Mark an error as one that leaves the request ALIVE on the server: a timeout,
619
+ * a stop, a dropped connection. The service goes on working and its result
620
+ * still waits under the request id, so a caller holding that id can rejoin it
621
+ * with `attachServiceRequest`, and must not forget the id the way it forgets
622
+ * a request that really ended. An error without `pending` is terminal.
623
+ */
624
+ const stillRunning = (err) => Object.assign(err, { pending: true });
625
+
626
+ /**
627
+ * A still-running error whose cause is that the ANSWER went missing: the
628
+ * request could not be sent or read to its end, or a proxy answered for the
629
+ * server (502, 504). Not a timeout or a stop, which are the caller's own
630
+ * choice to stop waiting. A submit whose answer was lost rejoins its request
631
+ * (`rejoinLost`).
632
+ */
633
+ const lostAnswer = (err) => Object.assign(stillRunning(err), { lostAnswer: true });
634
+
635
+ /** How long to wait before each attempt to rejoin a request whose answer was lost. */
636
+ const REJOIN_DELAYS_MS = [1000, 3000, 9000];
637
+
638
+ const delay = (ms, signal) =>
639
+ new Promise((resolve) => {
640
+ if (signal?.aborted) return resolve();
641
+ const timer = setTimeout(resolve, ms);
642
+ signal?.addEventListener(
643
+ 'abort',
644
+ () => {
645
+ clearTimeout(timer);
646
+ resolve();
647
+ },
648
+ { once: true },
649
+ );
650
+ });
651
+
652
+ /**
653
+ * `submit()` again, by attaching to the request `requestId` names, while its
654
+ * answer is the one that went missing. The request was taken, or never
655
+ * reached the server: attaching follows a taken one to its end, and a 404
656
+ * says the server never had it, so the first error stands. Each attempt
657
+ * whose answer goes missing too waits a little longer before the next.
658
+ */
659
+ async function rejoinLost(submit, attach, signal) {
660
+ let first;
661
+ try {
662
+ return await submit();
663
+ } catch (err) {
664
+ if (!err?.lostAnswer) throw err;
665
+ first = err;
666
+ }
667
+ let last = first;
668
+ for (const ms of REJOIN_DELAYS_MS) {
669
+ await delay(ms, signal);
670
+ if (signal?.aborted) throw last;
671
+ try {
672
+ return await attach();
673
+ } catch (err) {
674
+ if (err?.status === 404) throw first;
675
+ if (!err?.lostAnswer) throw err;
676
+ last = err;
677
+ }
678
+ }
679
+ throw last;
680
+ }
681
+
682
+ /**
683
+ * Open a request stream (submit or attach) and read it to its terminal
684
+ * event: `accepted` names the request, `progress` events go to `onProgress`,
685
+ * and `result` / `error` settle the promise.
686
+ */
687
+ function streamServiceRequest(client, { url, method, body, onStatus, what }, timeout, onProgress, signal, onAccepted) {
160
688
  return new Promise((resolve, reject) => {
161
689
  const abortController = new AbortController();
162
690
  let settled = false;
@@ -167,38 +695,72 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
167
695
  abortController.abort();
168
696
  fn(arg);
169
697
  };
170
- const timer = setTimeout(
171
- () => finish(reject, new Error(`Service request timed out after ${timeout}ms`)),
172
- timeout,
173
- );
698
+
699
+ // `timeout` is IDLE time, meaning how long the service may say nothing. It
700
+ // is not a deadline on the run: every event from the server restarts it,
701
+ // because a service that is reporting its progress is not hung. As a
702
+ // deadline on the whole run it killed working transcriptions at the
703
+ // five-minute default and left their writes to land on a document the page
704
+ // had already handed back to the user as editable.
705
+ let timer;
706
+ const waitAgain = () => {
707
+ clearTimeout(timer);
708
+ timer = setTimeout(
709
+ () =>
710
+ finish(reject, stillRunning(new Error(`${what} timed out after ${timeout}ms of silence`))),
711
+ timeout,
712
+ );
713
+ };
714
+ waitAgain();
715
+
716
+ // An external signal stops waiting on a long request (a UI's Stop button).
717
+ // Reject with an AbortError so a caller can tell a deliberate stop from a
718
+ // failure. The service is not told here: it goes on, and its result can
719
+ // still be collected by request id (see cancelServiceRequest to stop it).
720
+ const stop = () => {
721
+ const err = new Error('The service request was stopped');
722
+ err.name = 'AbortError';
723
+ finish(reject, stillRunning(err));
724
+ };
725
+ if (signal) {
726
+ if (signal.aborted) {
727
+ stop();
728
+ return;
729
+ }
730
+ signal.addEventListener('abort', stop, { once: true });
731
+ }
174
732
 
175
733
  (async () => {
176
734
  let response;
177
735
  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,
736
+ response = await fetch(url, {
737
+ method,
738
+ headers: {
739
+ 'Authorization': `Bearer ${client.token}`,
740
+ 'Content-Type': 'application/json',
741
+ 'Accept': 'text/event-stream',
189
742
  },
190
- );
743
+ ...(body === undefined ? {} : { body }),
744
+ signal: abortController.signal,
745
+ });
191
746
  } catch (error) {
192
- if (error.name !== 'AbortError') finish(reject, new Error(`Failed to submit service request: ${error.message}`));
747
+ // The POST may or may not have reached the server. Treat it as alive:
748
+ // rejoining a request that was never made simply 404s.
749
+ if (error.name !== 'AbortError') finish(reject, lostAnswer(new Error(`${what} could not be sent: ${error.message}`)));
193
750
  return;
194
751
  }
195
752
 
196
- if (response.status === 503) {
197
- finish(reject, new Error(`No live service '${serviceId}' on this project`));
753
+ const special = onStatus ? onStatus(response) : null;
754
+ if (special) {
755
+ finish(reject, special);
198
756
  return;
199
757
  }
200
758
  if (!response.ok) {
201
- finish(reject, new Error(`Service request failed: HTTP ${response.status} ${response.statusText}`));
759
+ const err = new Error(`${what} failed: HTTP ${response.status} ${response.statusText}`);
760
+ err.status = response.status;
761
+ // A proxy's 502 or 504 is no answer from the server: the request may
762
+ // have been taken, and the service may be running it.
763
+ finish(reject, response.status === 502 || response.status === 504 ? lostAnswer(err) : err);
202
764
  return;
203
765
  }
204
766
 
@@ -223,13 +785,16 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
223
785
  dataLine = line.slice(6);
224
786
  } else if (line === '' && eventType && dataLine) {
225
787
  const payload = transformResponse(JSON.parse(dataLine));
226
- if (eventType === 'progress') {
788
+ waitAgain(); // it spoke, so it is not hung
789
+ if (eventType === 'accepted') {
790
+ if (onAccepted) { try { onAccepted(payload.requestId); } catch (_) { /* ignore */ } }
791
+ } else if (eventType === 'progress') {
227
792
  if (onProgress) { try { onProgress(payload.progress); } catch (_) { /* ignore */ } }
228
793
  } else if (eventType === 'result') {
229
794
  finish(resolve, payload.data);
230
795
  return;
231
796
  } else if (eventType === 'error') {
232
- finish(reject, new Error(payload?.error || 'Service request failed'));
797
+ finish(reject, new Error(payload?.error || `${what} failed`));
233
798
  return;
234
799
  }
235
800
  eventType = '';
@@ -237,9 +802,9 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
237
802
  }
238
803
  }
239
804
  }
240
- finish(reject, new Error('Service closed the connection without a result'));
805
+ finish(reject, lostAnswer(new Error('Service closed the connection without a result')));
241
806
  } catch (error) {
242
- if (error.name !== 'AbortError') finish(reject, new Error(`Service request stream error: ${error.message}`));
807
+ if (error.name !== 'AbortError') finish(reject, lostAnswer(new Error(`${what} stream error: ${error.message}`)));
243
808
  }
244
809
  })();
245
810
  });