@larc-iu/plaid-client 0.2.0-alpha.1 → 0.2.0-alpha.12
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/index.d.ts +800 -107
- package/package.json +1 -1
- package/src/http.js +254 -35
- package/src/index.js +1604 -368
- package/src/provenance.js +278 -27
- package/src/roles.js +9 -0
- package/src/serviceSchema.js +61 -31
- package/src/services.js +321 -44
- package/src/sse.js +51 -1
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
|
-
* @
|
|
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,26 +118,92 @@ 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
|
|
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 (
|
|
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);
|
|
98
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;
|
|
149
|
+
|
|
150
|
+
const scope = createCancelScope(() => cancelled.get(requestId) === true);
|
|
99
151
|
const responseHelper = {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
152
|
+
requestId,
|
|
153
|
+
requesterId,
|
|
154
|
+
// True once the requester asked for the request to stop.
|
|
155
|
+
get cancelled() {
|
|
156
|
+
return scope.cancelled;
|
|
157
|
+
},
|
|
158
|
+
raiseIfCancelled: () => scope.raiseIfCancelled(),
|
|
159
|
+
critical: (fn) => scope.critical(fn),
|
|
160
|
+
// Extra fields ride in the progress payload (a chat service sends the
|
|
161
|
+
// reply text so far as `text`).
|
|
162
|
+
//
|
|
163
|
+
// This is a CANCELLATION CHECKPOINT: it throws ServiceCancelled if the
|
|
164
|
+
// requester has stopped the request, so a service that reports progress
|
|
165
|
+
// through its work is cancellable without doing anything else. Use
|
|
166
|
+
// `critical()` around writes to hold it off.
|
|
167
|
+
progress: (percent, msg, extra) => {
|
|
168
|
+
scope.raiseIfCancelled();
|
|
169
|
+
return reportEvent(requestId, {
|
|
170
|
+
status: 'progress',
|
|
171
|
+
progress: { percent, message: msg, ...extra },
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
complete: (data) => {
|
|
175
|
+
finished();
|
|
176
|
+
return reportEvent(requestId, { status: 'completed', data });
|
|
177
|
+
},
|
|
178
|
+
// Asked to stop, and it did. Reported as a result carrying
|
|
179
|
+
// `stopped: true`, so a client can tell "you stopped it" from "it broke".
|
|
180
|
+
stopped: (data) => {
|
|
181
|
+
finished();
|
|
182
|
+
return reportEvent(requestId, {
|
|
183
|
+
status: 'completed',
|
|
184
|
+
data: { ...(data || {}), stopped: true },
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
error: (error) => {
|
|
188
|
+
finished();
|
|
189
|
+
return reportEvent(requestId, { status: 'error', data: { error: error?.message || error } });
|
|
190
|
+
},
|
|
106
191
|
};
|
|
107
192
|
|
|
193
|
+
// A handler may be async, so a rejected promise has to be caught too, or a
|
|
194
|
+
// cancelled async service would report a failure instead of a stop.
|
|
195
|
+
const settle = (error) => {
|
|
196
|
+
if (error instanceof ServiceCancelled || error?.name === 'ServiceCancelled') {
|
|
197
|
+
responseHelper.stopped();
|
|
198
|
+
} else {
|
|
199
|
+
responseHelper.error(error?.message || error);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
108
202
|
try {
|
|
109
|
-
onServiceRequest(
|
|
203
|
+
const maybe = onServiceRequest(data, responseHelper);
|
|
204
|
+
if (maybe && typeof maybe.catch === 'function') maybe.catch(settle);
|
|
110
205
|
} catch (error) {
|
|
111
|
-
|
|
206
|
+
settle(error);
|
|
112
207
|
}
|
|
113
208
|
};
|
|
114
209
|
const openChannel = () => client.messages.listen(projectId, onChannelEvent, channelPath);
|
|
@@ -118,12 +213,17 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
118
213
|
throw new Error(`Failed to start service: ${error.message}`);
|
|
119
214
|
}
|
|
120
215
|
|
|
121
|
-
// Reopen the channel
|
|
122
|
-
// re-registers the service server-side,
|
|
123
|
-
// back together
|
|
216
|
+
// Reopen the channel whenever it drops (e.g. the server restarted), for as
|
|
217
|
+
// long as the service runs. Reopening re-registers the service server-side,
|
|
218
|
+
// so presence and reachability come back together, and a server restart never
|
|
219
|
+
// means a service restart. A reopened channel counts as connected only once
|
|
220
|
+
// the server has actually answered it (readyState OPEN), not merely because
|
|
221
|
+
// the attempt was made.
|
|
124
222
|
reconnectTimer = setInterval(() => {
|
|
125
|
-
if (!isRunning) return;
|
|
126
|
-
if (connection
|
|
223
|
+
if (!isRunning || !connection) return;
|
|
224
|
+
if (connection.readyState === 1) { noteConnected(); return; }
|
|
225
|
+
if (connection.readyState === 2) { // CLOSED (dropped or failed)
|
|
226
|
+
noteDisconnected();
|
|
127
227
|
try { connection = openChannel(); } catch (_) { /* retry next tick */ }
|
|
128
228
|
}
|
|
129
229
|
}, 3000);
|
|
@@ -136,17 +236,32 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
136
236
|
*
|
|
137
237
|
* Streams the service's progress + result back over a single server-mediated
|
|
138
238
|
* response (no broadcast). Rejects if no service is connected (503), if the
|
|
139
|
-
* service reports an error, or
|
|
239
|
+
* service reports an error, or after `timeout` of SILENCE.
|
|
240
|
+
*
|
|
241
|
+
* The request outlives this call: after a timeout, an abort, or a dropped
|
|
242
|
+
* connection the service goes on, and `attachServiceRequest` collects the
|
|
243
|
+
* result given the request id, which `opts.onAccepted` receives as soon as
|
|
244
|
+
* the server has taken the request. Pass `opts.requestId` (a UUID you mint)
|
|
245
|
+
* to know the id before submitting; submitting an id that names a request
|
|
246
|
+
* you already made rejoins it instead of starting another.
|
|
247
|
+
*
|
|
248
|
+
* Errors that leave the request alive carry `pending: true`: a timeout, a
|
|
249
|
+
* stop, a dropped connection. An error without it is the end of the request.
|
|
140
250
|
*
|
|
141
251
|
* @param {Object} client - PlaidClient instance
|
|
142
252
|
* @param {string} projectId - Project UUID
|
|
143
253
|
* @param {string} serviceId - Service ID to request
|
|
144
254
|
* @param {any} data - Request payload
|
|
145
|
-
* @param {number} [timeout=10000] -
|
|
255
|
+
* @param {number} [timeout=10000] - How long the service may say NOTHING, in ms.
|
|
256
|
+
* Every event it sends starts the clock again, so this does not cap a long run
|
|
146
257
|
* @param {function} [onProgress] - Called with each progress payload {percent, message}
|
|
258
|
+
* @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
|
|
259
|
+
* @param {Object} [opts]
|
|
260
|
+
* @param {string} [opts.requestId] - A client-minted request id (UUID)
|
|
261
|
+
* @param {function} [opts.onAccepted] - Called with the request id once the server has it
|
|
147
262
|
* @returns {Promise<any>} The service's result
|
|
148
263
|
*/
|
|
149
|
-
export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress) {
|
|
264
|
+
export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal, opts = {}) {
|
|
150
265
|
// Propagate an open logical operation (client.beginOperation) to the
|
|
151
266
|
// service: its writes then fold under the requester's audit-log entry
|
|
152
267
|
// (the Python BaseService adopts the id around process_request). Only for a
|
|
@@ -157,6 +272,133 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
157
272
|
group && data && typeof data === 'object' && !Array.isArray(data)
|
|
158
273
|
? { ...data, operationGroup: { id: group.id, message: group.message } }
|
|
159
274
|
: data;
|
|
275
|
+
const qs = opts.requestId ? `?request-id=${encodeURIComponent(opts.requestId)}` : '';
|
|
276
|
+
return streamServiceRequest(
|
|
277
|
+
client,
|
|
278
|
+
{
|
|
279
|
+
url: `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs}`,
|
|
280
|
+
method: 'POST',
|
|
281
|
+
body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
|
|
282
|
+
onStatus: (response) =>
|
|
283
|
+
response.status === 503 ? new Error(`No live service '${serviceId}' on this project`) : null,
|
|
284
|
+
what: 'Service request',
|
|
285
|
+
},
|
|
286
|
+
timeout,
|
|
287
|
+
onProgress,
|
|
288
|
+
signal,
|
|
289
|
+
opts.onAccepted,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Rejoin a service request made earlier (by this user) and await its result:
|
|
295
|
+
* the latest progress is replayed, then the result comes, or at once if the
|
|
296
|
+
* request already finished. Rejects with an error whose `status` is 404 when
|
|
297
|
+
* the request is unknown or expired (the server keeps a finished request's
|
|
298
|
+
* result for a while, not forever). As with `requestService`, `timeout` is
|
|
299
|
+
* how long the service may be silent, and an error carrying `pending: true`
|
|
300
|
+
* means the request is still there to rejoin again.
|
|
301
|
+
*
|
|
302
|
+
* @param {Object} client - PlaidClient instance
|
|
303
|
+
* @param {string} projectId - Project UUID
|
|
304
|
+
* @param {string} requestId - The request id (from `onAccepted` or your own)
|
|
305
|
+
* @param {number} [timeout=10000] - How long the service may say NOTHING, in ms
|
|
306
|
+
* @param {function} [onProgress] - Called with each progress payload {percent, message}
|
|
307
|
+
* @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
|
|
308
|
+
* @returns {Promise<any>} The service's result
|
|
309
|
+
*/
|
|
310
|
+
export function attachServiceRequest(client, projectId, requestId, timeout = 10000, onProgress, signal) {
|
|
311
|
+
return streamServiceRequest(
|
|
312
|
+
client,
|
|
313
|
+
{
|
|
314
|
+
url: `${client.baseUrl}/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`,
|
|
315
|
+
method: 'GET',
|
|
316
|
+
what: 'Attach to service request',
|
|
317
|
+
},
|
|
318
|
+
timeout,
|
|
319
|
+
onProgress,
|
|
320
|
+
signal,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Ask the service to stop a request made earlier (by this user). The request
|
|
326
|
+
* still ends with whatever the service then reports, on the stream of whoever
|
|
327
|
+
* is awaiting it. Rejects with 404 if unknown or expired, 409 once finished.
|
|
328
|
+
*
|
|
329
|
+
* @param {Object} client - PlaidClient instance
|
|
330
|
+
* @param {string} projectId - Project UUID
|
|
331
|
+
* @param {string} requestId - The request id
|
|
332
|
+
* @returns {Promise<void>}
|
|
333
|
+
*/
|
|
334
|
+
/**
|
|
335
|
+
* Thrown inside a handler when the requester has asked it to stop.
|
|
336
|
+
*
|
|
337
|
+
* Cooperative cancellation: nothing interrupts a handler, so the request ends
|
|
338
|
+
* at the next point the handler looks. `responseHelper.progress` is that
|
|
339
|
+
* point, which is why a long service that already reports progress needs no
|
|
340
|
+
* changes at all. `serve` catches this and ends the request as STOPPED rather
|
|
341
|
+
* than failed.
|
|
342
|
+
*/
|
|
343
|
+
export class ServiceCancelled extends Error {
|
|
344
|
+
constructor(message = 'The requester stopped this request') {
|
|
345
|
+
super(message);
|
|
346
|
+
this.name = 'ServiceCancelled';
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The cancellation half of a responseHelper: a flag, a depth counter, and one
|
|
352
|
+
* rule about when a checkpoint may raise. Split from the transport so it can
|
|
353
|
+
* be reasoned about on its own. Mirrors `CancelScope` in the Python client.
|
|
354
|
+
*/
|
|
355
|
+
export function createCancelScope(isCancelled) {
|
|
356
|
+
// Depth of nested `critical()` blocks; while non-zero, a checkpoint will
|
|
357
|
+
// not throw.
|
|
358
|
+
let criticalDepth = 0;
|
|
359
|
+
return {
|
|
360
|
+
get cancelled() {
|
|
361
|
+
return !!isCancelled();
|
|
362
|
+
},
|
|
363
|
+
/** Stop here if the requester has asked the request to stop. */
|
|
364
|
+
raiseIfCancelled() {
|
|
365
|
+
if (isCancelled() && criticalDepth === 0) throw new ServiceCancelled();
|
|
366
|
+
},
|
|
367
|
+
/**
|
|
368
|
+
* Run `fn` as a stretch that must finish once begun, usually the writes.
|
|
369
|
+
* Checkpoints inside do not throw, so a half-written document is never
|
|
370
|
+
* left behind; cancellation takes effect at the first checkpoint after.
|
|
371
|
+
*/
|
|
372
|
+
async critical(fn) {
|
|
373
|
+
criticalDepth++;
|
|
374
|
+
try {
|
|
375
|
+
return await fn();
|
|
376
|
+
} finally {
|
|
377
|
+
criticalDepth--;
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function cancelServiceRequest(client, projectId, requestId) {
|
|
384
|
+
return client._request('DELETE', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Mark an error as one that leaves the request ALIVE on the server: a timeout,
|
|
389
|
+
* a stop, a dropped connection. The service goes on working and its result
|
|
390
|
+
* still waits under the request id, so a caller holding that id can rejoin it
|
|
391
|
+
* with `attachServiceRequest`, and must not forget the id the way it forgets
|
|
392
|
+
* a request that really ended. An error without `pending` is terminal.
|
|
393
|
+
*/
|
|
394
|
+
const stillRunning = (err) => Object.assign(err, { pending: true });
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Open a request stream (submit or attach) and read it to its terminal
|
|
398
|
+
* event: `accepted` names the request, `progress` events go to `onProgress`,
|
|
399
|
+
* and `result` / `error` settle the promise.
|
|
400
|
+
*/
|
|
401
|
+
function streamServiceRequest(client, { url, method, body, onStatus, what }, timeout, onProgress, signal, onAccepted) {
|
|
160
402
|
return new Promise((resolve, reject) => {
|
|
161
403
|
const abortController = new AbortController();
|
|
162
404
|
let settled = false;
|
|
@@ -167,38 +409,70 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
167
409
|
abortController.abort();
|
|
168
410
|
fn(arg);
|
|
169
411
|
};
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
412
|
+
|
|
413
|
+
// `timeout` is IDLE time, meaning how long the service may say nothing. It
|
|
414
|
+
// is not a deadline on the run: every event from the server restarts it,
|
|
415
|
+
// because a service that is reporting its progress is not hung. As a
|
|
416
|
+
// deadline on the whole run it killed working transcriptions at the
|
|
417
|
+
// five-minute default and left their writes to land on a document the page
|
|
418
|
+
// had already handed back to the user as editable.
|
|
419
|
+
let timer;
|
|
420
|
+
const waitAgain = () => {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
timer = setTimeout(
|
|
423
|
+
() =>
|
|
424
|
+
finish(reject, stillRunning(new Error(`${what} timed out after ${timeout}ms of silence`))),
|
|
425
|
+
timeout,
|
|
426
|
+
);
|
|
427
|
+
};
|
|
428
|
+
waitAgain();
|
|
429
|
+
|
|
430
|
+
// An external signal stops waiting on a long request (a UI's Stop button).
|
|
431
|
+
// Reject with an AbortError so a caller can tell a deliberate stop from a
|
|
432
|
+
// failure. The service is not told here: it goes on, and its result can
|
|
433
|
+
// still be collected by request id (see cancelServiceRequest to stop it).
|
|
434
|
+
const stop = () => {
|
|
435
|
+
const err = new Error('The service request was stopped');
|
|
436
|
+
err.name = 'AbortError';
|
|
437
|
+
finish(reject, stillRunning(err));
|
|
438
|
+
};
|
|
439
|
+
if (signal) {
|
|
440
|
+
if (signal.aborted) {
|
|
441
|
+
stop();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
445
|
+
}
|
|
174
446
|
|
|
175
447
|
(async () => {
|
|
176
448
|
let response;
|
|
177
449
|
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,
|
|
450
|
+
response = await fetch(url, {
|
|
451
|
+
method,
|
|
452
|
+
headers: {
|
|
453
|
+
'Authorization': `Bearer ${client.token}`,
|
|
454
|
+
'Content-Type': 'application/json',
|
|
455
|
+
'Accept': 'text/event-stream',
|
|
189
456
|
},
|
|
190
|
-
|
|
457
|
+
...(body === undefined ? {} : { body }),
|
|
458
|
+
signal: abortController.signal,
|
|
459
|
+
});
|
|
191
460
|
} catch (error) {
|
|
192
|
-
|
|
461
|
+
// The POST may or may not have reached the server. Treat it as alive:
|
|
462
|
+
// rejoining a request that was never made simply 404s.
|
|
463
|
+
if (error.name !== 'AbortError') finish(reject, stillRunning(new Error(`${what} could not be sent: ${error.message}`)));
|
|
193
464
|
return;
|
|
194
465
|
}
|
|
195
466
|
|
|
196
|
-
|
|
197
|
-
|
|
467
|
+
const special = onStatus ? onStatus(response) : null;
|
|
468
|
+
if (special) {
|
|
469
|
+
finish(reject, special);
|
|
198
470
|
return;
|
|
199
471
|
}
|
|
200
472
|
if (!response.ok) {
|
|
201
|
-
|
|
473
|
+
const err = new Error(`${what} failed: HTTP ${response.status} ${response.statusText}`);
|
|
474
|
+
err.status = response.status;
|
|
475
|
+
finish(reject, err);
|
|
202
476
|
return;
|
|
203
477
|
}
|
|
204
478
|
|
|
@@ -223,13 +497,16 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
223
497
|
dataLine = line.slice(6);
|
|
224
498
|
} else if (line === '' && eventType && dataLine) {
|
|
225
499
|
const payload = transformResponse(JSON.parse(dataLine));
|
|
226
|
-
|
|
500
|
+
waitAgain(); // it spoke, so it is not hung
|
|
501
|
+
if (eventType === 'accepted') {
|
|
502
|
+
if (onAccepted) { try { onAccepted(payload.requestId); } catch (_) { /* ignore */ } }
|
|
503
|
+
} else if (eventType === 'progress') {
|
|
227
504
|
if (onProgress) { try { onProgress(payload.progress); } catch (_) { /* ignore */ } }
|
|
228
505
|
} else if (eventType === 'result') {
|
|
229
506
|
finish(resolve, payload.data);
|
|
230
507
|
return;
|
|
231
508
|
} else if (eventType === 'error') {
|
|
232
|
-
finish(reject, new Error(payload?.error ||
|
|
509
|
+
finish(reject, new Error(payload?.error || `${what} failed`));
|
|
233
510
|
return;
|
|
234
511
|
}
|
|
235
512
|
eventType = '';
|
|
@@ -237,9 +514,9 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
237
514
|
}
|
|
238
515
|
}
|
|
239
516
|
}
|
|
240
|
-
finish(reject, new Error('Service closed the connection without a result'));
|
|
517
|
+
finish(reject, stillRunning(new Error('Service closed the connection without a result')));
|
|
241
518
|
} catch (error) {
|
|
242
|
-
if (error.name !== 'AbortError') finish(reject, new Error(
|
|
519
|
+
if (error.name !== 'AbortError') finish(reject, stillRunning(new Error(`${what} stream error: ${error.message}`)));
|
|
243
520
|
}
|
|
244
521
|
})();
|
|
245
522
|
});
|
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,
|
|
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
|