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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sse.js CHANGED
@@ -1,5 +1,22 @@
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
+ // 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
+
3
20
  /**
4
21
  * Project a parsed SSE `data:` object onto what a listener callback sees.
5
22
  *
@@ -30,7 +47,10 @@ export function eventPayload(eventType, parsed) {
30
47
  * /listen bus; service request channels pass their own. Only /listen emits
31
48
  * `heartbeat` events needing a POST confirmation — other streams keep
32
49
  * themselves alive with ignored SSE comments.
33
- * @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`.
34
54
  */
35
55
  export function createSSEConnection(client, projectId, onEvent, path) {
36
56
  const streamPath = path || `/api/v1/projects/${projectId}/listen`;
@@ -59,13 +79,30 @@ export function createSSEConnection(client, projectId, onEvent, path) {
59
79
  }
60
80
  };
61
81
 
82
+ // Watchdog: abort the fetch if the stream goes silent for longer than the
83
+ // server's keepalive cadence, so a silently-dropped connection lands in the
84
+ // catch below (readyState CLOSED) instead of blocking on a read that will
85
+ // never resolve.
86
+ let idleTimer = null;
87
+ const clearIdleTimer = () => {
88
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
89
+ };
90
+ const armIdleTimer = () => {
91
+ clearIdleTimer();
92
+ idleTimer = setTimeout(() => {
93
+ if (!isClosed) abortController.abort(new Error('SSE stream went silent'));
94
+ }, SSE_IDLE_TIMEOUT_MS);
95
+ };
96
+
62
97
  const sseConnection = {
63
98
  readyState: 0, // CONNECTING
99
+ error: null,
64
100
  close: () => {
65
101
  if (!isClosed) {
66
102
  isClosed = true;
67
103
  isConnected = false;
68
104
  sseConnection.readyState = 2; // CLOSED
105
+ clearIdleTimer();
69
106
  abortController.abort();
70
107
  }
71
108
  },
@@ -83,6 +120,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
83
120
  (async () => {
84
121
  try {
85
122
  const url = `${client.baseUrl}${streamPath}`;
123
+ armIdleTimer();
86
124
  const response = await fetch(url, {
87
125
  method: 'GET',
88
126
  headers: {
@@ -101,11 +139,14 @@ export function createSSEConnection(client, projectId, onEvent, path) {
101
139
  // takes over.
102
140
  console.warn('Service registration rejected (409): another instance of this service is already connected; will retry');
103
141
  }
104
- throw new Error(`HTTP ${response.status} ${response.statusText}`);
142
+ const refused = new Error(`HTTP ${response.status} ${response.statusText}`);
143
+ refused.status = response.status;
144
+ throw refused;
105
145
  }
106
146
 
107
147
  isConnected = true;
108
148
  sseConnection.readyState = 1; // OPEN
149
+ lastWarning.delete(url);
109
150
 
110
151
  const reader = response.body.getReader();
111
152
  const decoder = new TextDecoder();
@@ -120,6 +161,7 @@ export function createSSEConnection(client, projectId, onEvent, path) {
120
161
  while (true) {
121
162
  const { done, value } = await reader.read();
122
163
  if (done || isClosed) break;
164
+ armIdleTimer();
123
165
 
124
166
  buffer += decoder.decode(value, { stream: true });
125
167
  const lines = buffer.split('\n');
@@ -158,11 +200,26 @@ export function createSSEConnection(client, projectId, onEvent, path) {
158
200
  }
159
201
  }
160
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
+ }
161
211
  } catch (error) {
212
+ if (!isClosed) sseConnection.error = error;
162
213
  if (error.name !== 'AbortError') {
163
- console.warn('SSE connection error:', error);
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
+ }
164
220
  }
165
221
  } finally {
222
+ clearIdleTimer();
166
223
  isConnected = false;
167
224
  isClosed = true;
168
225
  sseConnection.readyState = 2; // CLOSED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Text edit operations: composing a stream of ops into the net change.
3
+ *
4
+ * An op is one of the shapes `PATCH /texts/:id` takes:
5
+ *
6
+ * { type: 'insert', index, value: string }
7
+ * { type: 'delete', index, value: count }
8
+ * { type: 'replace', index, length, value: string }
9
+ *
10
+ * Every index and count is in Unicode CODE POINTS (see ./codepoint.js), and
11
+ * the ops are in running coordinates: each op's `index` is in the body as the
12
+ * ops before it left it.
13
+ *
14
+ * A GAP is `{ start, end, value }` in the OLD body's coordinates: old
15
+ * [start, end) gives way to `value`. `composeTextEdits` turns any op stream
16
+ * into gaps that depend only on the net change, never on how the keystrokes
17
+ * came, which is also how the server reads an edit. The Python client's
18
+ * `plaid_client.text_edits` is the twin of this module.
19
+ */
20
+
21
+ const codePoints = (s) => [...s];
22
+
23
+ function opName(op) {
24
+ return JSON.stringify(op);
25
+ }
26
+
27
+ /**
28
+ * The op's type after checking its shape and bounds against a body of `len`
29
+ * code points, as the server checks it. Throws an Error otherwise.
30
+ */
31
+ function checkOp(op, len) {
32
+ const type = op && op.type;
33
+ const { index, value, length } = op || {};
34
+ const int = Number.isInteger;
35
+ const wellFormed =
36
+ (type === 'insert' && int(index) && typeof value === 'string') ||
37
+ (type === 'delete' && int(index) && int(value)) ||
38
+ (type === 'replace' && int(index) && int(length) && typeof value === 'string');
39
+ if (!wellFormed) {
40
+ throw new Error(
41
+ `Malformed text edit operation: ${opName(op)}. Expected {type: "insert", index: int, ` +
42
+ `value: string}, {type: "delete", index: int, value: int} or {type: "replace", ` +
43
+ `index: int, length: int, value: string}`,
44
+ );
45
+ }
46
+ const inBounds =
47
+ type === 'insert'
48
+ ? index >= 0 && index <= len
49
+ : type === 'delete'
50
+ ? index >= 0 && value >= 0 && index + value <= len
51
+ : index >= 0 && length >= 0 && index + length <= len;
52
+ if (!inBounds) {
53
+ throw new Error(
54
+ `Text edit operation out of bounds: ${opName(op)} (text length is ${len} code points)`,
55
+ );
56
+ }
57
+ return type;
58
+ }
59
+
60
+ // How many code points an op removes and what it types.
61
+ function opParts(type, op) {
62
+ if (type === 'insert') return [0, op.value];
63
+ if (type === 'delete') return [op.value, ''];
64
+ return [op.length, op.value];
65
+ }
66
+
67
+ const segLength = (seg) => (seg.old ? seg.end - seg.start : seg.chars.length);
68
+
69
+ // The segment list of the new text, cut so that a segment boundary falls at
70
+ // code point `at`. Returns the index of the first segment at or after `at`.
71
+ function cutAt(segs, at) {
72
+ let pos = 0;
73
+ for (let i = 0; i < segs.length; i += 1) {
74
+ const seg = segs[i];
75
+ const n = segLength(seg);
76
+ if (at === pos) return i;
77
+ if (at < pos + n) {
78
+ const k = at - pos;
79
+ const left = seg.old
80
+ ? { old: true, start: seg.start, end: seg.start + k }
81
+ : { old: false, chars: seg.chars.slice(0, k) };
82
+ const right = seg.old
83
+ ? { old: true, start: seg.start + k, end: seg.end }
84
+ : { old: false, chars: seg.chars.slice(k) };
85
+ segs.splice(i, 1, left, right);
86
+ return i + 1;
87
+ }
88
+ pos += n;
89
+ }
90
+ return segs.length;
91
+ }
92
+
93
+ /**
94
+ * The net change of `ops` applied in turn to `body`, as gaps in the old
95
+ * body's code points: sorted, and never touching (at least one old code point
96
+ * is kept between two gaps). Old text deleted and typed back is typed text,
97
+ * and a gap whose value is the old text it removes is dropped. Throws on a
98
+ * malformed or out-of-bounds op.
99
+ */
100
+ export function composeTextEdits(body, ops) {
101
+ const old = codePoints(body);
102
+ let segs = old.length ? [{ old: true, start: 0, end: old.length }] : [];
103
+ let len = old.length;
104
+ for (const op of ops) {
105
+ const type = checkOp(op, len);
106
+ const [del, typed] = opParts(type, op);
107
+ const chars = codePoints(typed);
108
+ const from = cutAt(segs, op.index);
109
+ const to = cutAt(segs, op.index + del);
110
+ const inserted = chars.length ? [{ old: false, chars }] : [];
111
+ segs.splice(from, to - from, ...inserted);
112
+ len += chars.length - del;
113
+ segs = mergeTyped(segs);
114
+ }
115
+
116
+ const gaps = [];
117
+ let kept = 0; // the old position the last kept run ended at
118
+ let typed = [];
119
+ const close = (next) => {
120
+ if (next > kept || typed.length) {
121
+ const value = typed.join('');
122
+ if (value !== old.slice(kept, next).join('')) gaps.push({ start: kept, end: next, value });
123
+ }
124
+ typed = [];
125
+ };
126
+ for (const seg of segs) {
127
+ if (seg.old) {
128
+ close(seg.start);
129
+ kept = seg.end;
130
+ } else {
131
+ typed.push(...seg.chars);
132
+ }
133
+ }
134
+ close(old.length);
135
+ return gaps;
136
+ }
137
+
138
+ // Neighbouring typed segments as one, and neighbouring old ranges as one.
139
+ function mergeTyped(segs) {
140
+ const out = [];
141
+ for (const seg of segs) {
142
+ const last = out[out.length - 1];
143
+ if (last && !last.old && !seg.old) {
144
+ out[out.length - 1] = { old: false, chars: last.chars.concat(seg.chars) };
145
+ } else if (last && last.old && seg.old && last.end === seg.start) {
146
+ out[out.length - 1] = { old: true, start: last.start, end: seg.end };
147
+ } else if (segLength(seg) > 0) {
148
+ out.push(seg);
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * Gaps (old-body coordinates, sorted, not overlapping) as running ops: an
156
+ * insert where a gap removes nothing, a delete where it types nothing, and a
157
+ * replace otherwise.
158
+ */
159
+ export function gapsToOps(gaps) {
160
+ const ops = [];
161
+ let shift = 0;
162
+ for (const { start, end, value } of gaps) {
163
+ const index = start + shift;
164
+ const typed = codePoints(value).length;
165
+ if (start === end) {
166
+ if (typed) ops.push({ type: 'insert', index, value });
167
+ } else if (!typed) {
168
+ ops.push({ type: 'delete', index, value: end - start });
169
+ } else {
170
+ ops.push({ type: 'replace', index, length: end - start, value });
171
+ }
172
+ shift += typed - (end - start);
173
+ }
174
+ return ops;
175
+ }
176
+
177
+ /**
178
+ * `body` with `ops` applied in turn (running coordinates, code points).
179
+ * Throws on a malformed or out-of-bounds op.
180
+ */
181
+ export function applyTextOps(body, ops) {
182
+ let chars = codePoints(body);
183
+ for (const op of ops) {
184
+ const type = checkOp(op, chars.length);
185
+ const [del, typed] = opParts(type, op);
186
+ chars = chars
187
+ .slice(0, op.index)
188
+ .concat(codePoints(typed), chars.slice(op.index + del));
189
+ }
190
+ return chars.join('');
191
+ }
package/src/transforms.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { BatchRef } from "./batchRef.js";
2
+
1
3
  /**
2
4
  * Convert kebab-case/namespaced key to camelCase.
3
5
  * 'layer-id' -> 'layerId'
@@ -27,19 +29,27 @@ export function transformKeyFromCamel(key) {
27
29
  // usual case conversion.
28
30
  const OPAQUE_KEYS = new Set(['metadata', 'config', 'bindings']);
29
31
 
32
+ // A request's `metadata` may also be a list of metadata ops (a bulk update
33
+ // entry), whose values are user data too, so it passes through verbatim.
34
+ const isOpaque = (key, value) =>
35
+ typeof value === 'object' && value !== null &&
36
+ (Array.isArray(value) ? key === 'metadata' : OPAQUE_KEYS.has(key));
37
+
30
38
  /**
31
39
  * Recursively transform request object keys from camelCase to kebab-case.
32
40
  * Preserves `metadata` and `config` contents without transformation.
33
41
  */
34
42
  export function transformRequest(obj) {
35
43
  if (obj === null || obj === undefined) return obj;
44
+ // A batch ref is taken out whole when its write is queued.
45
+ if (obj instanceof BatchRef) return obj;
36
46
  if (Array.isArray(obj)) return obj.map(item => transformRequest(item));
37
47
  if (typeof obj !== 'object') return obj;
38
48
 
39
49
  const transformed = {};
40
50
  for (const [key, value] of Object.entries(obj)) {
41
51
  const newKey = transformKeyFromCamel(key);
42
- if (OPAQUE_KEYS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) {
52
+ if (isOpaque(key, value)) {
43
53
  transformed[newKey] = value;
44
54
  } else {
45
55
  transformed[newKey] = transformRequest(value);