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