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

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.
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * {
18
18
  * schemaVersion: 1,
19
- * tasks: ["tokenize"], // controlled vocab; REPLACES tok:/asr: id prefixes
19
+ * tasks: ["tokenize"], // controlled vocab (TASKS)
20
20
  * summary: "## markdown …", // rich human description
21
21
  * parameters: [ // ordered; rendered into a form
22
22
  * { key, label, type, description?, default?, required?,
@@ -63,8 +63,8 @@ export const TASKS = Object.freeze({
63
63
  *
64
64
  * Apart from `analyze` on purpose. A task is the CONTRACT a spot offers a
65
65
  * service against, and these two share almost none of it: an analyze
66
- * request names the three token layers plus the language, metalanguage and
67
- * orthography, and writes morphemes and glosses. Both claimed `analyze` for
66
+ * request names the three token layers plus the language and metalanguage,
67
+ * and writes morphemes and glosses. Both claimed `analyze` for
68
68
  * a while, so every app offering an analyze spot offered the other app's
69
69
  * services, which could not answer the request they would have been given.
70
70
  */
@@ -93,32 +93,14 @@ export const TASKS = Object.freeze({
93
93
  });
94
94
 
95
95
  /**
96
- * Legacy id-prefix → task map, for services that have not yet migrated to a
97
- * declared `tasks` array. Drop once all services advertise `tasks`.
98
- */
99
- const LEGACY_TASK_PREFIXES = Object.freeze({
100
- [TASKS.TOKENIZE]: "tok:",
101
- [TASKS.TRANSCRIBE]: "asr:",
102
- });
103
-
104
- /**
105
- * Does `service` serve `task`? Prefers the declared `extras.tasks` array; falls
106
- * back to the legacy id-prefix convention for un-migrated services.
96
+ * Does `service` serve `task`, as its declared `extras.tasks` says?
107
97
  * @param {{serviceId?: string, extras?: {tasks?: string[]}}} service
108
98
  * @param {string} task one of TASKS
109
99
  * @returns {boolean}
110
100
  */
111
101
  export function servesTask(service, task) {
112
102
  const declared = service?.extras?.tasks;
113
- if (Array.isArray(declared) && declared.length) {
114
- return declared.includes(task);
115
- }
116
- const prefix = LEGACY_TASK_PREFIXES[task];
117
- return (
118
- !!prefix &&
119
- typeof service?.serviceId === "string" &&
120
- service.serviceId.startsWith(prefix)
121
- );
103
+ return Array.isArray(declared) && declared.includes(task);
122
104
  }
123
105
 
124
106
  /**
package/src/services.js CHANGED
@@ -434,14 +434,18 @@ export function serve(client, projectId, serviceInfo, onServiceRequest, extras =
434
434
  * you already made rejoins it instead of starting another.
435
435
  *
436
436
  * Errors that leave the request alive carry `pending: true`: a timeout, a
437
- * stop, a dropped connection. An error without it is the end of the request.
437
+ * stop, a dropped connection, a proxy's 502 or 504. An error without it is
438
+ * the end of the request. With `opts.requestId`, a request whose answer went
439
+ * missing (a dropped connection, a 502 or 504) is rejoined by that id a few
440
+ * times before the error is given.
438
441
  *
439
442
  * @param {Object} client - PlaidClient instance
440
443
  * @param {string} projectId - Project UUID
441
444
  * @param {string} serviceId - Service ID to request
442
445
  * @param {any} data - Request payload
443
446
  * @param {number} [timeout=10000] - How long the service may say NOTHING, in ms.
444
- * Every event it sends starts the clock again, so this does not cap a long run
447
+ * Every event it sends starts the clock again, so this does not cap a long run.
448
+ * 0, a negative number or null: no limit (wait until `signal` stops it)
445
449
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
446
450
  * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
447
451
  * @param {Object} [opts]
@@ -485,14 +489,21 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
485
489
  if (opts.requestId) query.set('request-id', opts.requestId);
486
490
  if (opts.projectIds?.length) query.set('project-ids', opts.projectIds.join(','));
487
491
  const qs = String(query) ? `?${query}` : '';
488
- return streamServiceRequest(
492
+ const submit = () => streamServiceRequest(
489
493
  client,
490
494
  {
491
495
  url: `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs}`,
492
496
  method: 'POST',
493
497
  body: JSON.stringify(payload === undefined ? null : transformRequest(payload)),
498
+ // `notLive` (with `serviceId`) lets an app say it in the service's own
499
+ // name rather than its id.
494
500
  onStatus: (response) =>
495
- response.status === 503 ? new Error(`No live service '${serviceId}' on this project`) : null,
501
+ response.status === 503
502
+ ? Object.assign(new Error(`No live service '${serviceId}' on this project`), {
503
+ notLive: true,
504
+ serviceId,
505
+ })
506
+ : null,
496
507
  what: 'Service request',
497
508
  },
498
509
  timeout,
@@ -500,6 +511,14 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
500
511
  signal,
501
512
  opts.onAccepted,
502
513
  );
514
+ // A request whose id is known is rejoined when its answer goes missing, so
515
+ // a run the service took and finished is not reported failed.
516
+ if (!opts.requestId) return submit();
517
+ return rejoinLost(
518
+ submit,
519
+ () => attachServiceRequest(client, projectId, opts.requestId, timeout, onProgress, signal),
520
+ signal,
521
+ );
503
522
  }
504
523
 
505
524
  /**
@@ -515,6 +534,7 @@ export function requestService(client, projectId, serviceId, data, timeout = 100
515
534
  * @param {string} projectId - Project UUID
516
535
  * @param {string} requestId - The request id (from `onAccepted` or your own)
517
536
  * @param {number} [timeout=10000] - How long the service may say NOTHING, in ms
537
+ * (0, a negative number or null: no limit)
518
538
  * @param {function} [onProgress] - Called with each progress payload {percent, message}
519
539
  * @param {AbortSignal} [signal] - Abort to stop waiting; rejects with an AbortError
520
540
  * @returns {Promise<any>} The service's result
@@ -612,6 +632,62 @@ export function cancelServiceRequest(client, projectId, requestId) {
612
632
  */
613
633
  const stillRunning = (err) => Object.assign(err, { pending: true });
614
634
 
635
+ /**
636
+ * A still-running error whose cause is that the ANSWER went missing: the
637
+ * request could not be sent or read to its end, or a proxy answered for the
638
+ * server (502, 504). Not a timeout or a stop, which are the caller's own
639
+ * choice to stop waiting. A submit whose answer was lost rejoins its request
640
+ * (`rejoinLost`).
641
+ */
642
+ const lostAnswer = (err) => Object.assign(stillRunning(err), { lostAnswer: true });
643
+
644
+ /** How long to wait before each attempt to rejoin a request whose answer was lost. */
645
+ const REJOIN_DELAYS_MS = [1000, 3000, 9000];
646
+
647
+ const delay = (ms, signal) =>
648
+ new Promise((resolve) => {
649
+ if (signal?.aborted) return resolve();
650
+ const timer = setTimeout(resolve, ms);
651
+ signal?.addEventListener(
652
+ 'abort',
653
+ () => {
654
+ clearTimeout(timer);
655
+ resolve();
656
+ },
657
+ { once: true },
658
+ );
659
+ });
660
+
661
+ /**
662
+ * `submit()` again, by attaching to the request `requestId` names, while its
663
+ * answer is the one that went missing. The request was taken, or never
664
+ * reached the server: attaching follows a taken one to its end, and a 404
665
+ * says the server never had it, so the first error stands. Each attempt
666
+ * whose answer goes missing too waits a little longer before the next.
667
+ */
668
+ async function rejoinLost(submit, attach, signal) {
669
+ let first;
670
+ try {
671
+ return await submit();
672
+ } catch (err) {
673
+ if (!err?.lostAnswer) throw err;
674
+ first = err;
675
+ }
676
+ let last = first;
677
+ for (const ms of REJOIN_DELAYS_MS) {
678
+ await delay(ms, signal);
679
+ if (signal?.aborted) throw last;
680
+ try {
681
+ return await attach();
682
+ } catch (err) {
683
+ if (err?.status === 404) throw first;
684
+ if (!err?.lostAnswer) throw err;
685
+ last = err;
686
+ }
687
+ }
688
+ throw last;
689
+ }
690
+
615
691
  /**
616
692
  * Open a request stream (submit or attach) and read it to its terminal
617
693
  * event: `accepted` names the request, `progress` events go to `onProgress`,
@@ -635,9 +711,11 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
635
711
  // deadline on the whole run it killed working transcriptions at the
636
712
  // five-minute default and left their writes to land on a document the page
637
713
  // had already handed back to the user as editable.
714
+ // 0, a negative number or null is no limit, as for every other timeout.
638
715
  let timer;
639
716
  const waitAgain = () => {
640
717
  clearTimeout(timer);
718
+ if (!(timeout > 0)) return;
641
719
  timer = setTimeout(
642
720
  () =>
643
721
  finish(reject, stillRunning(new Error(`${what} timed out after ${timeout}ms of silence`))),
@@ -679,7 +757,7 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
679
757
  } catch (error) {
680
758
  // The POST may or may not have reached the server. Treat it as alive:
681
759
  // rejoining a request that was never made simply 404s.
682
- if (error.name !== 'AbortError') finish(reject, stillRunning(new Error(`${what} could not be sent: ${error.message}`)));
760
+ if (error.name !== 'AbortError') finish(reject, lostAnswer(new Error(`${what} could not be sent: ${error.message}`)));
683
761
  return;
684
762
  }
685
763
 
@@ -691,7 +769,9 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
691
769
  if (!response.ok) {
692
770
  const err = new Error(`${what} failed: HTTP ${response.status} ${response.statusText}`);
693
771
  err.status = response.status;
694
- finish(reject, err);
772
+ // A proxy's 502 or 504 is no answer from the server: the request may
773
+ // have been taken, and the service may be running it.
774
+ finish(reject, response.status === 502 || response.status === 504 ? lostAnswer(err) : err);
695
775
  return;
696
776
  }
697
777
 
@@ -733,9 +813,9 @@ function streamServiceRequest(client, { url, method, body, onStatus, what }, tim
733
813
  }
734
814
  }
735
815
  }
736
- finish(reject, stillRunning(new Error('Service closed the connection without a result')));
816
+ finish(reject, lostAnswer(new Error('Service closed the connection without a result')));
737
817
  } catch (error) {
738
- if (error.name !== 'AbortError') finish(reject, stillRunning(new Error(`${what} stream error: ${error.message}`)));
818
+ if (error.name !== 'AbortError') finish(reject, lostAnswer(new Error(`${what} stream error: ${error.message}`)));
739
819
  }
740
820
  })();
741
821
  });
package/src/textEdits.js CHANGED
@@ -20,6 +20,22 @@
20
20
 
21
21
  const codePoints = (s) => [...s];
22
22
 
23
+ // The last body split into code points, kept: an edit log composes the same
24
+ // long body once for each of its kept states, and splitting it each time made
25
+ // a save on a long text block the page (H31-TEXT-1). Frozen, as every caller
26
+ // shares it.
27
+ let lastBody = null;
28
+ let lastPoints = Object.freeze([]);
29
+
30
+ /** The code points of `body`, as a frozen array shared with other callers. */
31
+ export function bodyCodePoints(body) {
32
+ if (body !== lastBody) {
33
+ lastPoints = Object.freeze(codePoints(body));
34
+ lastBody = body;
35
+ }
36
+ return lastPoints;
37
+ }
38
+
23
39
  function opName(op) {
24
40
  return JSON.stringify(op);
25
41
  }
@@ -98,7 +114,7 @@ function cutAt(segs, at) {
98
114
  * malformed or out-of-bounds op.
99
115
  */
100
116
  export function composeTextEdits(body, ops) {
101
- const old = codePoints(body);
117
+ const old = bodyCodePoints(body);
102
118
  let segs = old.length ? [{ old: true, start: 0, end: old.length }] : [];
103
119
  let len = old.length;
104
120
  for (const op of ops) {
@@ -128,7 +144,7 @@ export function composeTextEdits(body, ops) {
128
144
  close(seg.start);
129
145
  kept = seg.end;
130
146
  } else {
131
- typed.push(...seg.chars);
147
+ for (const c of seg.chars) typed.push(c);
132
148
  }
133
149
  }
134
150
  close(old.length);