@larc-iu/plaid-client 0.2.0-alpha.16 → 0.2.0-alpha.18
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 +409 -65
- package/package.json +1 -1
- package/src/created.js +29 -0
- package/src/documentLock.js +14 -9
- package/src/events.js +256 -0
- package/src/http.js +168 -56
- package/src/index.js +495 -208
- package/src/metadataOps.js +117 -0
- package/src/provenance.js +7 -17
- package/src/requester.js +56 -0
- package/src/services.js +62 -7
- package/src/sse.js +29 -3
- package/src/transforms.js +7 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata ops: the body of every metadata PATCH, and the `metadata` of a
|
|
3
|
+
* bulk update entry. An op is `{op: 'set', path, value}` or
|
|
4
|
+
* `{op: 'delete', path}`, where `path` is a non-empty array of keys into the
|
|
5
|
+
* nested metadata, the first a top-level key. See the manual, "Metadata".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { PLAID_NAMESPACE } from './roles.js';
|
|
9
|
+
import { PROVENANCE_KEYS } from './provenance.js';
|
|
10
|
+
|
|
11
|
+
const RESERVED_KEYS = new Set([PLAID_NAMESPACE, ...PROVENANCE_KEYS]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True for a top-level metadata key Plaid keeps for itself: the shared `plaid`
|
|
15
|
+
* namespace (settings every app reads, such as the text direction) and the
|
|
16
|
+
* provenance keys. Neither is ever a user's metadata field, so an app neither
|
|
17
|
+
* lists it as one nor writes a field's value to it.
|
|
18
|
+
* @param {string} key
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export const isReservedMetadataKey = (key) => RESERVED_KEYS.has(key);
|
|
22
|
+
|
|
23
|
+
// The longest top-level key the server takes, in UTF-16 code units (Java's
|
|
24
|
+
// String length, which is what it counts).
|
|
25
|
+
const MAX_KEY_LENGTH = 200;
|
|
26
|
+
|
|
27
|
+
// Java's Character.isWhitespace, which the server's blank check uses: the
|
|
28
|
+
// Unicode space separators except the three no-break spaces, and the ASCII
|
|
29
|
+
// whitespace controls.
|
|
30
|
+
const isJavaWhitespace = (c) =>
|
|
31
|
+
(/[\p{Zs}\p{Zl}\p{Zp}]/u.test(c) && c !== '\u00A0' && c !== '\u2007' && c !== '\u202F') ||
|
|
32
|
+
/[\t\n\u000B\f\r\u001C-\u001F]/.test(c);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when the server accepts `k` as a top-level metadata key: not blank,
|
|
36
|
+
* at most 200 UTF-16 code units, and no ASCII control character.
|
|
37
|
+
* @param {string} k
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
const validMetadataKey = (k) =>
|
|
41
|
+
typeof k === 'string' &&
|
|
42
|
+
k.length <= MAX_KEY_LENGTH &&
|
|
43
|
+
!/[\u0000-\u001F\u007F]/.test(k) &&
|
|
44
|
+
![...k].every(isJavaWhitespace);
|
|
45
|
+
|
|
46
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The ops that set each top-level key of `fragment`, deleting a key whose
|
|
50
|
+
* value is null. This is how a provenance fragment (verifyOnEdit,
|
|
51
|
+
* contributeOnEdit, ...) is sent as a patch.
|
|
52
|
+
* @param {Object|null|undefined} fragment
|
|
53
|
+
* @returns {Array<{op: string, path: string[], value?: any}>}
|
|
54
|
+
*/
|
|
55
|
+
export const metadataOps = (fragment) =>
|
|
56
|
+
Object.entries(fragment || {}).map(([k, v]) =>
|
|
57
|
+
v === null ? { op: 'delete', path: [k] } : { op: 'set', path: [k], value: v },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const applyOp = (node, path, depth, o) => {
|
|
61
|
+
const [k, ...more] = path.slice(depth);
|
|
62
|
+
if (more.length === 0) {
|
|
63
|
+
const out = { ...node };
|
|
64
|
+
if (o.op === 'set') out[k] = o.value;
|
|
65
|
+
else delete out[k];
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
const child = Object.prototype.hasOwnProperty.call(node, k) ? node[k] : undefined;
|
|
69
|
+
if (child === undefined) {
|
|
70
|
+
return o.op === 'set' ? { ...node, [k]: applyOp({}, path, depth + 1, o) } : node;
|
|
71
|
+
}
|
|
72
|
+
if (!isObject(child)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Metadata path ${JSON.stringify(path.slice(0, depth + 1))} holds a value that is not an object`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { ...node, [k]: applyOp(child, path, depth + 1, o) };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Apply ops to a local copy of an entity's metadata the way the server does,
|
|
82
|
+
* for an optimistic update. Returns a new object and never mutates its input.
|
|
83
|
+
* Throws where the server would refuse (a path through a non-object, or a
|
|
84
|
+
* first key that is blank, over 200 characters or holds a control character).
|
|
85
|
+
* @param {Object|null|undefined} metadata
|
|
86
|
+
* @param {Array<{op: string, path: string[], value?: any}>} ops
|
|
87
|
+
* @returns {Object}
|
|
88
|
+
*/
|
|
89
|
+
export const applyMetadataOps = (metadata, ops) =>
|
|
90
|
+
(ops || []).reduce((m, o) => {
|
|
91
|
+
if (!Array.isArray(o.path) || o.path.length === 0) {
|
|
92
|
+
throw new Error('A metadata op needs a non-empty path');
|
|
93
|
+
}
|
|
94
|
+
if (!validMetadataKey(o.path[0])) {
|
|
95
|
+
throw new Error('Invalid metadata key');
|
|
96
|
+
}
|
|
97
|
+
if (o.op !== 'set' && o.op !== 'delete') {
|
|
98
|
+
throw new Error(`Unknown metadata op '${o.op}': expected set or delete`);
|
|
99
|
+
}
|
|
100
|
+
if (o.op === 'set' && !('value' in o)) {
|
|
101
|
+
throw new Error('A set op needs a value');
|
|
102
|
+
}
|
|
103
|
+
return applyOp(m, o.path, 0, o);
|
|
104
|
+
}, { ...(metadata || {}) });
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Merge a top-level fragment into a local copy, a null value deleting the
|
|
108
|
+
* key: applyMetadataOps over metadataOps(fragment), so the same result as
|
|
109
|
+
* sending those ops, refused where the server would refuse them. How an app
|
|
110
|
+
* mirrors a provenance stamp (verifyOnEdit, contributeOnEdit, ...) on the row
|
|
111
|
+
* it shows. Returns a new object.
|
|
112
|
+
* @param {Object|null|undefined} metadata
|
|
113
|
+
* @param {Object|null|undefined} fragment
|
|
114
|
+
* @returns {Object}
|
|
115
|
+
*/
|
|
116
|
+
export const mergeMetadata = (metadata, fragment) =>
|
|
117
|
+
applyMetadataOps(metadata, metadataOps(fragment));
|
package/src/provenance.js
CHANGED
|
@@ -78,7 +78,11 @@
|
|
|
78
78
|
* - valueProbs / formProbs: a top-k {label: probability} map over the
|
|
79
79
|
* alternatives the producer considered, the chosen label included
|
|
80
80
|
* (provProb, when given, is that label's entry duplicated flat).
|
|
81
|
-
* - model
|
|
81
|
+
* - model: what made the prediction (a model name, or a tool and its
|
|
82
|
+
* release such as 'stanza==1.11.0'), and version: which version of the
|
|
83
|
+
* writer ran ('<release>+<8 hex>', see the manual, "Provenance"). Every
|
|
84
|
+
* machine writer records both, one of rules alone only version.
|
|
85
|
+
* - anything else (language, adapter, raw scores).
|
|
82
86
|
*/
|
|
83
87
|
|
|
84
88
|
/** The flat metadata keys of the provenance convention. */
|
|
@@ -228,8 +232,8 @@ export const verifyOnEdit = (metadata) => (needsReview(metadata) ? PROV_CONFIRME
|
|
|
228
232
|
/**
|
|
229
233
|
* The metadata fragment a CONTRIBUTOR's edit of this entity should merge in
|
|
230
234
|
* (write-contract rule 3): the contributed stamp, plus provConfirmed: null
|
|
231
|
-
* so an earlier confirmation is dropped (
|
|
232
|
-
*
|
|
235
|
+
* so an earlier confirmation is dropped (send it with metadataOps, which
|
|
236
|
+
* turns a null into a delete, or apply it locally with mergeMetadata). Prediction extras a machine
|
|
233
237
|
* recorded stay, as history.
|
|
234
238
|
* @param {Object|null|undefined} metadata - unused today; the fragment is
|
|
235
239
|
* the same whatever the entity was, and the parameter keeps the shape of
|
|
@@ -242,20 +246,6 @@ export const contributeOnEdit = (metadata, userId) => ({
|
|
|
242
246
|
[PROV.confirmedKey]: null,
|
|
243
247
|
});
|
|
244
248
|
|
|
245
|
-
/**
|
|
246
|
-
* Merge a metadata fragment the way the server's PATCH does — a null value
|
|
247
|
-
* deletes the key — for callers that keep a local copy or send a full
|
|
248
|
-
* replacement (setMetadata). Returns a new object.
|
|
249
|
-
*/
|
|
250
|
-
export const mergeMetadata = (metadata, fragment) => {
|
|
251
|
-
const out = { ...(metadata || {}) };
|
|
252
|
-
for (const [k, v] of Object.entries(fragment || {})) {
|
|
253
|
-
if (v === null) delete out[k];
|
|
254
|
-
else out[k] = v;
|
|
255
|
-
}
|
|
256
|
-
return out;
|
|
257
|
-
};
|
|
258
|
-
|
|
259
249
|
/** Canonical provSource for a service: 'service:<serviceId>'. */
|
|
260
250
|
export const serviceSource = (serviceId) => `service:${serviceId}`;
|
|
261
251
|
|
package/src/requester.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Who asked for a service run, named in what the run leaves behind.
|
|
2
|
+
//
|
|
3
|
+
// A plain service writes with its own token, so the History entry of a run
|
|
4
|
+
// reads "by <operator> (via <token>)" and the person who pressed the button is
|
|
5
|
+
// recorded nowhere. Core tells every service who asked (`requesterId` in the
|
|
6
|
+
// request data), and every app's services put that person in the History
|
|
7
|
+
// label and in what they store:
|
|
8
|
+
//
|
|
9
|
+
// AnCast adjudication against lunch, requested by second
|
|
10
|
+
//
|
|
11
|
+
// This is the JS twin of `plaid_client.workflows.requester`, for a service
|
|
12
|
+
// authored with `serve`. The wording and the stored key are the same.
|
|
13
|
+
//
|
|
14
|
+
// The name is the requester's display name, which any signed-in user may
|
|
15
|
+
// read. When it cannot be read the id stands in for it, and with no requester
|
|
16
|
+
// at all the label is left as it was and nothing is stored.
|
|
17
|
+
|
|
18
|
+
/** The key a run's stored record names its requester under. */
|
|
19
|
+
export const REQUESTED_BY = "requestedBy";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The person who asked for one run: `id` is their account (their email),
|
|
23
|
+
* `name` how they are shown. Both are null when nobody asked.
|
|
24
|
+
*/
|
|
25
|
+
export function makeRequester(id = null, name = null) {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
name,
|
|
29
|
+
/** A History label with the requester named, or `text` when nobody asked. */
|
|
30
|
+
label: (text) => (name ? `${text}, requested by ${name}` : text),
|
|
31
|
+
/** `{ id, name }` for a stored report, or null when nobody asked. */
|
|
32
|
+
record: () => (id ? { id, name } : null),
|
|
33
|
+
/** A machine stamp's `provDetail` with the requester's id added. */
|
|
34
|
+
detail: (detail = {}) => ({ ...(detail || {}), ...(id ? { [REQUESTED_BY]: id } : {}) }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The requester of one request, from the `requesterId` core put in its data,
|
|
40
|
+
* with their display name read through `client`.
|
|
41
|
+
* @param {object} client - A PlaidClient (only `users.get` is used)
|
|
42
|
+
* @param {object} data - The request data a `serve` handler receives
|
|
43
|
+
*/
|
|
44
|
+
export async function requesterOf(client, data) {
|
|
45
|
+
const id = data && typeof data === "object" ? data.requesterId || null : null;
|
|
46
|
+
if (!id) return makeRequester();
|
|
47
|
+
let name = null;
|
|
48
|
+
try {
|
|
49
|
+
name = (await client.users.get(id))?.displayName || null;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// The id is always there to fall back on, so a failed read costs the
|
|
52
|
+
// label nothing but the nicer name.
|
|
53
|
+
console.warn(`Could not read the requester ${id}: ${error?.message || error}`);
|
|
54
|
+
}
|
|
55
|
+
return makeRequester(id, name || id);
|
|
56
|
+
}
|
package/src/services.js
CHANGED
|
@@ -138,6 +138,13 @@ export function reportRequestEvent(client, projectId, requestId, body) {
|
|
|
138
138
|
);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* HTTP statuses a refused service channel answers with that retrying cannot
|
|
143
|
+
* fix: a bad or revoked token, no write access to the project, a project that
|
|
144
|
+
* does not exist. The same set as the Python client's.
|
|
145
|
+
*/
|
|
146
|
+
const PERMANENT_STATUSES = new Set([400, 401, 403, 404, 405, 422]);
|
|
147
|
+
|
|
141
148
|
/**
|
|
142
149
|
* Register a service and handle incoming work requests.
|
|
143
150
|
*
|
|
@@ -152,7 +159,9 @@ export function reportRequestEvent(client, projectId, requestId, body) {
|
|
|
152
159
|
* @param {function} onServiceRequest - Handler callback (data, responseHelper)
|
|
153
160
|
* @param {Object} extras - Optional additional metadata
|
|
154
161
|
* @param {function} [onStatus] - Optional callback (event, projectId, detail) for
|
|
155
|
-
* connection-state transitions: 'registered', 'reconnected', 'disconnected'
|
|
162
|
+
* connection-state transitions: 'registered', 'reconnected', 'disconnected',
|
|
163
|
+
* and 'stopped' when the server refuses the channel for a reason retrying
|
|
164
|
+
* cannot fix (see PERMANENT_STATUSES), which ends the registration.
|
|
156
165
|
* Called once per transition, not once per retry.
|
|
157
166
|
* @returns {Object} ServiceRegistration with .stop(), .isRunning(), .isConnected(),
|
|
158
167
|
* .serviceInfo
|
|
@@ -237,11 +246,20 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
237
246
|
const finished = () => cancelled.delete(requestId);
|
|
238
247
|
|
|
239
248
|
// Every service is told who asked (`requesterId`), beside the payload
|
|
240
|
-
// when that is a plain object, and on the helper regardless.
|
|
249
|
+
// when that is a plain object, and on the helper regardless. A delegating
|
|
250
|
+
// service (extras `delegation: true`) also gets a short-lived token for the
|
|
251
|
+
// requesting user (`delegatedToken`) and the projects that token reaches
|
|
252
|
+
// (`delegatedProjects`, the request's own first), as the Python client
|
|
253
|
+
// hands them over.
|
|
241
254
|
const requesterId = payload.requesterId || null;
|
|
255
|
+
const extra = {
|
|
256
|
+
...(requesterId ? { requesterId } : {}),
|
|
257
|
+
...(payload.delegatedToken ? { delegatedToken: payload.delegatedToken } : {}),
|
|
258
|
+
...(payload.delegatedProjects ? { delegatedProjects: payload.delegatedProjects } : {}),
|
|
259
|
+
};
|
|
242
260
|
const data =
|
|
243
|
-
|
|
244
|
-
? { ...payload.data,
|
|
261
|
+
Object.keys(extra).length && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
|
|
262
|
+
? { ...payload.data, ...extra }
|
|
245
263
|
: payload.data;
|
|
246
264
|
|
|
247
265
|
const scope = createCancelScope(() => cancelled.get(requestId) === true);
|
|
@@ -328,10 +346,23 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
328
346
|
// means a service restart. A reopened channel counts as connected only once
|
|
329
347
|
// the server has actually answered it (readyState OPEN), not merely because
|
|
330
348
|
// the attempt was made.
|
|
349
|
+
//
|
|
350
|
+
// A channel the server REFUSED for a reason retrying cannot fix (the token
|
|
351
|
+
// was revoked, the user lost write access, the project is gone) ends the
|
|
352
|
+
// registration instead: the server closes a channel whose opener lost the
|
|
353
|
+
// right to it, and reopening it every few seconds forever would only hide
|
|
354
|
+
// that from the operator.
|
|
331
355
|
reconnectTimer = setInterval(() => {
|
|
332
356
|
if (!isRunning || !connection) return;
|
|
333
357
|
if (connection.readyState === 1) { noteConnected(); return; }
|
|
334
358
|
if (connection.readyState === 2) { // CLOSED (dropped or failed)
|
|
359
|
+
const status = connection.error?.status;
|
|
360
|
+
if (PERMANENT_STATUSES.has(status)) {
|
|
361
|
+
noteDisconnected();
|
|
362
|
+
serviceRegistration.stop();
|
|
363
|
+
report('stopped', `HTTP ${status}`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
335
366
|
noteDisconnected();
|
|
336
367
|
try { connection = openChannel(); } catch (_) { /* retry next tick */ }
|
|
337
368
|
}
|
|
@@ -368,6 +399,11 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
|
|
|
368
399
|
* @param {Object} [opts]
|
|
369
400
|
* @param {string} [opts.requestId] - A client-minted request id (UUID)
|
|
370
401
|
* @param {function} [opts.onAccepted] - Called with the request id once the server has it
|
|
402
|
+
* @param {string[]} [opts.projectIds] - Other projects the request is about, beside
|
|
403
|
+
* `projectId`. A delegating service's token is scoped to `projectId` and to those of
|
|
404
|
+
* these the requester can read, and reaches nothing else
|
|
405
|
+
* @param {boolean} [opts.noOperation] - Carry no open operation: the service's writes
|
|
406
|
+
* are a group of their own, not part of whatever operation this client has open
|
|
371
407
|
* @returns {Promise<any>} The service's result
|
|
372
408
|
*/
|
|
373
409
|
export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress, signal, opts = {}) {
|
|
@@ -376,12 +412,31 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
|
|
|
376
412
|
// (the Python BaseService adopts the id around process_request). Only for a
|
|
377
413
|
// plain-object payload — that's the only shape the service param schema
|
|
378
414
|
// delivers anyway.
|
|
379
|
-
|
|
415
|
+
// Its kind and reference go too, so a service that writes before the
|
|
416
|
+
// requester does still records them.
|
|
417
|
+
//
|
|
418
|
+
// `opts.noOperation` sends none: the service starts a group of its own. For
|
|
419
|
+
// a request that is its own action whatever else is on the wire, such as
|
|
420
|
+
// approving an assistant's plan or starting a service run. The client holds
|
|
421
|
+
// one open operation, so without it an edit still saving at that moment
|
|
422
|
+
// would take in every write the request makes, under the edit's kind.
|
|
423
|
+
const group = opts.noOperation ? null : client.operationGroup;
|
|
380
424
|
const payload =
|
|
381
425
|
group && data && typeof data === 'object' && !Array.isArray(data)
|
|
382
|
-
? {
|
|
426
|
+
? {
|
|
427
|
+
...data,
|
|
428
|
+
operationGroup: {
|
|
429
|
+
id: group.id,
|
|
430
|
+
message: group.message,
|
|
431
|
+
...(group.kind ? { kind: group.kind } : {}),
|
|
432
|
+
...(group.ref ? { ref: group.ref } : {}),
|
|
433
|
+
},
|
|
434
|
+
}
|
|
383
435
|
: data;
|
|
384
|
-
const
|
|
436
|
+
const query = new URLSearchParams();
|
|
437
|
+
if (opts.requestId) query.set('request-id', opts.requestId);
|
|
438
|
+
if (opts.projectIds?.length) query.set('project-ids', opts.projectIds.join(','));
|
|
439
|
+
const qs = String(query) ? `?${query}` : '';
|
|
385
440
|
return streamServiceRequest(
|
|
386
441
|
client,
|
|
387
442
|
{
|
package/src/sse.js
CHANGED
|
@@ -12,6 +12,11 @@ import { transformResponse } from './transforms.js';
|
|
|
12
12
|
// cadences, so a healthy idle stream never trips it.
|
|
13
13
|
export const SSE_IDLE_TIMEOUT_MS = 60000;
|
|
14
14
|
|
|
15
|
+
// The last failure warned about per stream URL, so a stream retried every few
|
|
16
|
+
// seconds through an outage warns once per distinct failure rather than once
|
|
17
|
+
// per attempt. Cleared when the stream opens. Mirrors the Python client.
|
|
18
|
+
const lastWarning = new Map();
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
21
|
* Project a parsed SSE `data:` object onto what a listener callback sees.
|
|
17
22
|
*
|
|
@@ -42,7 +47,10 @@ export function eventPayload(eventType, parsed) {
|
|
|
42
47
|
* /listen bus; service request channels pass their own. Only /listen emits
|
|
43
48
|
* `heartbeat` events needing a POST confirmation — other streams keep
|
|
44
49
|
* themselves alive with ignored SSE comments.
|
|
45
|
-
* @returns {Object} SSE connection with .close(), .getStats(), .readyState
|
|
50
|
+
* @returns {Object} SSE connection with .close(), .getStats(), .readyState,
|
|
51
|
+
* and .error: once CLOSED, why (null when this side closed it). A refused
|
|
52
|
+
* stream's error carries the HTTP `status`, and a stream the server ended
|
|
53
|
+
* carries an error named `StreamClosed`.
|
|
46
54
|
*/
|
|
47
55
|
export function createSSEConnection(client, projectId, onEvent, path) {
|
|
48
56
|
const streamPath = path || `/api/v1/projects/${projectId}/listen`;
|
|
@@ -88,6 +96,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
88
96
|
|
|
89
97
|
const sseConnection = {
|
|
90
98
|
readyState: 0, // CONNECTING
|
|
99
|
+
error: null,
|
|
91
100
|
close: () => {
|
|
92
101
|
if (!isClosed) {
|
|
93
102
|
isClosed = true;
|
|
@@ -130,11 +139,14 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
130
139
|
// takes over.
|
|
131
140
|
console.warn('Service registration rejected (409): another instance of this service is already connected; will retry');
|
|
132
141
|
}
|
|
133
|
-
|
|
142
|
+
const refused = new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
143
|
+
refused.status = response.status;
|
|
144
|
+
throw refused;
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
isConnected = true;
|
|
137
148
|
sseConnection.readyState = 1; // OPEN
|
|
149
|
+
lastWarning.delete(url);
|
|
138
150
|
|
|
139
151
|
const reader = response.body.getReader();
|
|
140
152
|
const decoder = new TextDecoder();
|
|
@@ -188,9 +200,23 @@ export function createSSEConnection(client, projectId, onEvent, path) {
|
|
|
188
200
|
}
|
|
189
201
|
}
|
|
190
202
|
}
|
|
203
|
+
if (!isClosed) {
|
|
204
|
+
// The server ended the stream, for instance because the opener lost
|
|
205
|
+
// the right to it. Say so, so a caller can tell it from a close of
|
|
206
|
+
// its own.
|
|
207
|
+
sseConnection.error = Object.assign(new Error('The server closed the stream'), {
|
|
208
|
+
name: 'StreamClosed',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
191
211
|
} catch (error) {
|
|
212
|
+
if (!isClosed) sseConnection.error = error;
|
|
192
213
|
if (error.name !== 'AbortError') {
|
|
193
|
-
|
|
214
|
+
const url = `${client.baseUrl}${streamPath}`;
|
|
215
|
+
const message = String(error?.message || error);
|
|
216
|
+
if (lastWarning.get(url) !== message) {
|
|
217
|
+
lastWarning.set(url, message);
|
|
218
|
+
console.warn('SSE connection error:', error);
|
|
219
|
+
}
|
|
194
220
|
}
|
|
195
221
|
} finally {
|
|
196
222
|
clearIdleTimer();
|
package/src/transforms.js
CHANGED
|
@@ -27,6 +27,12 @@ export function transformKeyFromCamel(key) {
|
|
|
27
27
|
// usual case conversion.
|
|
28
28
|
const OPAQUE_KEYS = new Set(['metadata', 'config', 'bindings']);
|
|
29
29
|
|
|
30
|
+
// A request's `metadata` may also be a list of metadata ops (a bulk update
|
|
31
|
+
// entry), whose values are user data too, so it passes through verbatim.
|
|
32
|
+
const isOpaque = (key, value) =>
|
|
33
|
+
typeof value === 'object' && value !== null &&
|
|
34
|
+
(Array.isArray(value) ? key === 'metadata' : OPAQUE_KEYS.has(key));
|
|
35
|
+
|
|
30
36
|
/**
|
|
31
37
|
* Recursively transform request object keys from camelCase to kebab-case.
|
|
32
38
|
* Preserves `metadata` and `config` contents without transformation.
|
|
@@ -39,7 +45,7 @@ export function transformRequest(obj) {
|
|
|
39
45
|
const transformed = {};
|
|
40
46
|
for (const [key, value] of Object.entries(obj)) {
|
|
41
47
|
const newKey = transformKeyFromCamel(key);
|
|
42
|
-
if (
|
|
48
|
+
if (isOpaque(key, value)) {
|
|
43
49
|
transformed[newKey] = value;
|
|
44
50
|
} else {
|
|
45
51
|
transformed[newKey] = transformRequest(value);
|