@kottera/tera-web 0.0.0-stage → 0.1.0-rc.10

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.
Files changed (42) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +28 -0
  3. package/README.md +56 -2
  4. package/dist/client.d.ts +223 -0
  5. package/dist/client.js +500 -0
  6. package/dist/feed/connection.d.ts +179 -0
  7. package/dist/feed/connection.js +457 -0
  8. package/dist/feed/sse.d.ts +51 -0
  9. package/dist/feed/sse.js +134 -0
  10. package/dist/generated/feed.d.ts +199 -0
  11. package/dist/generated/feed.js +16 -0
  12. package/dist/generated/kit.d.ts +374 -0
  13. package/dist/generated/kit.js +258 -0
  14. package/dist/generated/shares.d.ts +214 -0
  15. package/dist/generated/shares.js +17 -0
  16. package/dist/generated/workorder-events.d.ts +47 -0
  17. package/dist/generated/workorder-events.js +10 -0
  18. package/dist/generated/workorder.d.ts +3775 -0
  19. package/dist/generated/workorder.js +74 -0
  20. package/dist/generated/wsstream.d.ts +34 -0
  21. package/dist/generated/wsstream.js +28 -0
  22. package/dist/index.d.ts +23 -0
  23. package/dist/index.js +24 -0
  24. package/dist/phone.d.ts +11 -0
  25. package/dist/phone.js +24 -0
  26. package/dist/routes.d.ts +32 -0
  27. package/dist/routes.js +69 -0
  28. package/dist/signin.d.ts +48 -0
  29. package/dist/signin.js +74 -0
  30. package/dist/stepup.d.ts +87 -0
  31. package/dist/stepup.js +66 -0
  32. package/dist/stream/connection.d.ts +145 -0
  33. package/dist/stream/connection.js +377 -0
  34. package/dist/terminal/view.d.ts +168 -0
  35. package/dist/terminal/view.js +546 -0
  36. package/dist/terminalwire/codec.d.ts +182 -0
  37. package/dist/terminalwire/codec.js +556 -0
  38. package/dist/terminalwire/collapse.d.ts +4 -0
  39. package/dist/terminalwire/collapse.js +7 -0
  40. package/dist/unreached.d.ts +26 -0
  41. package/dist/unreached.js +30 -0
  42. package/package.json +32 -4
package/dist/client.js ADDED
@@ -0,0 +1,500 @@
1
+ /**
2
+ * The typed client over D3's routes (ADR-162 D4 items 1, 3 and 4): who is
3
+ * signed in, invoking a capability as them, opening a feed, uploading a file
4
+ * with progress, and signing out.
5
+ *
6
+ * Every type here for what the contract declares — a route's answer, the
7
+ * error envelope, the upload's answer — is the one generated into
8
+ * `./generated/` from the committed schemas; none is written by hand. A
9
+ * Work Order verb's params and result are generated there too
10
+ * (`WorkOrderGetParams`, `WorkOrderGetResult`, …), and a caller names them as
11
+ * {@link TeraClient.invoke}'s type arguments.
12
+ *
13
+ * # Two kinds of failure, kept apart
14
+ *
15
+ * - **The kit's refusal** — no verified caller, a cross-origin write, an
16
+ * unknown or unserved route — and anything else that is not the route's
17
+ * answer, is thrown as a {@link KitRequestError}, carrying the kit's
18
+ * envelope (`code`, `message`, `retryable`) as it arrived.
19
+ * - **A verb's refusal** is not a failure of the route. The verb ran and said
20
+ * no; the kit passes its verdict through with HTTP 200 (D3), and
21
+ * {@link TeraClient.invoke} answers it as that verdict, `{ status: "error",
22
+ * error }`, beside `{ status: "success", result }`.
23
+ *
24
+ * # Same origin
25
+ *
26
+ * Every request is to a path under the kit's prefix, below the app's base
27
+ * ({@link TeraClientOptions.base}), on the page's own origin,
28
+ * with `credentials: "same-origin"`; a fetch is also `mode: "same-origin"`, so
29
+ * a page that somehow aimed one elsewhere is refused by the browser rather
30
+ * than sent. The browser then marks it `Sec-Fetch-Site: same-origin`, which is
31
+ * what the kit's same-origin rule for writes admits (D3). No header here
32
+ * claims it: a page cannot set `Sec-Fetch-*` or `Origin`, and the rule rests
33
+ * on that.
34
+ *
35
+ * # A step-up
36
+ *
37
+ * Every request it makes handles a `step_up_required` refusal that names a
38
+ * page to confirm the act on (ADR-173 D11): the kit's envelope from a kit
39
+ * route or from the plugin's own route ({@link TeraClient.fetch}), and a Work
40
+ * Order verb's refusal relayed by {@link TeraClient.invoke}. It has the person
41
+ * confirm it ({@link TeraClient.confirmStepUp}) and sends the identical request
42
+ * once more, with `Tera-Step-Up: <id>`, or with `step_up` in an invoke's
43
+ * params. A step-up the person declines, lets expire or walks away from
44
+ * rejects with a {@link StepUpError}, and the request is not sent again.
45
+ *
46
+ * # Signing in again
47
+ *
48
+ * A request tera's UI proxy answers `sign_in_required` — the person's session
49
+ * on the plugin's origin is gone: signed out, lapsed, or ended with the tera
50
+ * session it was opened from — loads the
51
+ * page again ({@link TeraClient.reSignIn}), which signs the person in, and
52
+ * still rejects (or, from {@link TeraClient.fetch}, answers) as it arrived.
53
+ * The page is loaded again once per client, however many requests were
54
+ * refused together. A feed or stream the client opens does the same.
55
+ *
56
+ * It is a plain class with an injected fetch, request factory, navigation,
57
+ * window opener and timer, so its tests drive it against a fake and no
58
+ * network.
59
+ */
60
+ import { appBase, isBaseRelative, routePath, underBase } from "./routes.js";
61
+ import { FeedConnection } from "./feed/connection.js";
62
+ import { fetchOpener } from "./feed/sse.js";
63
+ import { StreamConnection } from "./stream/connection.js";
64
+ import { TerminalView } from "./terminal/view.js";
65
+ import { SIGN_IN_REQUIRED, isSignInRequired } from "./signin.js";
66
+ import { STEP_UP_HEADER, STEP_UP_POLL_MS, StepUpError, defaultOpenWindow, defaultSleep, resumedStepUp, stepUpChallengeOf, stepUpPageURL, } from "./stepup.js";
67
+ /**
68
+ * The `code` of a {@link KitRequestError} whose answer was not the kit's
69
+ * envelope — a proxy's page, a dropped body, a success that is not the
70
+ * route's JSON. It is this package's, never one the kit sends.
71
+ */
72
+ export const UNRECOGNIZED_RESPONSE = "unrecognized_response";
73
+ /**
74
+ * A kit route that did not answer as the route: the kit's own refusal, or
75
+ * something that was not the route's answer at all.
76
+ */
77
+ export class KitRequestError extends Error {
78
+ /** The HTTP status the route answered with. */
79
+ status;
80
+ /**
81
+ * The envelope's code, as it arrived — one of the kit's `KitErrorCode`s, or
82
+ * a code this package does not know, kept as it is — or
83
+ * {@link UNRECOGNIZED_RESPONSE} when there was no envelope.
84
+ */
85
+ code;
86
+ /** Whether the identical request may succeed if sent again later. Branch on this, never on a list of codes. */
87
+ retryable;
88
+ /** How long to wait before retrying, when the envelope said. */
89
+ retryAfterMs;
90
+ /** The envelope's structured detail, when it carried any. */
91
+ data;
92
+ /** The envelope as it arrived, or null when the answer carried none. */
93
+ envelope;
94
+ constructor(status, envelope, fallback) {
95
+ super(envelope?.message ?? fallback);
96
+ this.name = "KitRequestError";
97
+ this.status = status;
98
+ this.envelope = envelope;
99
+ this.code = envelope?.code ?? UNRECOGNIZED_RESPONSE;
100
+ // Without an envelope only the status speaks: a 5xx, a 408 or a 429 says "later".
101
+ this.retryable = envelope?.retryable ?? (status >= 500 || status === 408 || status === 429);
102
+ this.retryAfterMs = envelope?.retry_after_ms;
103
+ this.data = envelope?.data;
104
+ }
105
+ }
106
+ const JSON_TYPE = "application/json";
107
+ export class TeraClient {
108
+ base;
109
+ fetchFn;
110
+ uploadRequest;
111
+ navigate;
112
+ openWindow;
113
+ pageURL;
114
+ sleep;
115
+ pollMs;
116
+ /** Whether the step-up the page was loaded back with has been taken up. */
117
+ resumeTaken = false;
118
+ /** Whether this client has loaded the page again to sign in. */
119
+ signingIn = false;
120
+ constructor(options = {}) {
121
+ this.base = appBase(options.base);
122
+ this.fetchFn = options.fetch ?? ((...args) => globalThis.fetch(...args));
123
+ this.uploadRequest = options.uploadRequest ?? (() => new XMLHttpRequest());
124
+ this.navigate = options.navigate ?? ((path) => globalThis.location.assign(path));
125
+ const stepUp = options.stepUp ?? {};
126
+ this.openWindow = stepUp.openWindow ?? defaultOpenWindow;
127
+ this.pageURL = stepUp.pageURL ?? (() => globalThis.location.href);
128
+ this.sleep = stepUp.sleep ?? defaultSleep;
129
+ this.pollMs = stepUp.pollIntervalMs ?? STEP_UP_POLL_MS;
130
+ }
131
+ /** `GET /tera/v1/me`: who is signed in, and where to send them to sign out. */
132
+ async me() {
133
+ return this.json(this.route("me"), { method: "GET" });
134
+ }
135
+ /**
136
+ * `POST /tera/v1/invoke/{capability}`: invokes one capability as the
137
+ * signed-in person, with `params` as its params, and answers the verb's
138
+ * verdict. A refusal by the verb answers `{ status: "error" }`; only the
139
+ * kit's own refusal, or an answer that is not the route's, throws.
140
+ *
141
+ * const got = await client.invoke<WorkOrderGetResult, WorkOrderGetParams>("work_order.get", { id });
142
+ * if (got.status === "success") show(got.result);
143
+ */
144
+ async invoke(capability, params) {
145
+ const verdict = await this.invokeOnce(capability, params);
146
+ if (verdict.status !== "error")
147
+ return verdict;
148
+ const challenge = stepUpChallengeOf(verdict.error.code, verdict.error.data?.step_up);
149
+ if (challenge === null || !isParamsObject(params))
150
+ return verdict;
151
+ const id = await this.confirmStepUp(challenge);
152
+ return this.invokeOnce(capability, { ...(params ?? {}), step_up: id });
153
+ }
154
+ async invokeOnce(capability, params) {
155
+ const url = this.route("invoke", capability);
156
+ const reply = await this.json(url, {
157
+ method: "POST",
158
+ headers: { "Content-Type": JSON_TYPE },
159
+ body: JSON.stringify(params ?? {}),
160
+ });
161
+ if (reply.status === "success" && reply.result !== undefined) {
162
+ return { status: "success", result: reply.result.structured };
163
+ }
164
+ if (reply.status === "error" && reply.error !== undefined) {
165
+ return { status: "error", error: reply.error };
166
+ }
167
+ throw new KitRequestError(200, null, `${url} answered a reply envelope with no ${reply.status === "error" ? "error" : "result"}`);
168
+ }
169
+ /**
170
+ * A {@link FeedConnection} over `GET /tera/v1/feeds/{feed}`, read with this
171
+ * client's fetch. It is not started: call `start()` on it, and `stop()` when
172
+ * the page no longer wants it.
173
+ */
174
+ feed(options) {
175
+ return new FeedConnection({ ...options, base: this.base, open: fetchOpener(this.fetchFn), reSignIn: () => this.reSignIn() });
176
+ }
177
+ /**
178
+ * A {@link StreamConnection} to one of the kit's stream routes, its failed
179
+ * handshakes probed with this client's fetch. It opens at once: call
180
+ * `close()` on it when the page no longer wants it.
181
+ */
182
+ stream(options) {
183
+ return new StreamConnection({ ...options, base: this.base, fetch: this.fetchFn, reSignIn: () => this.reSignIn() });
184
+ }
185
+ /**
186
+ * A {@link TerminalView} of an attended session's terminal at the kit's
187
+ * terminal route, its failed handshakes probed with this client's fetch. It
188
+ * opens at once: call `detach()` on it when the pane closes.
189
+ */
190
+ terminal(options) {
191
+ return new TerminalView({ ...options, base: this.base, fetch: this.fetchFn, reSignIn: () => this.reSignIn() });
192
+ }
193
+ /**
194
+ * `POST /tera/v1/uploads`: sends one file as the signed-in person and
195
+ * answers the staged artifact, whose `handle` a Work Order's attachments
196
+ * name. Its name and description travel percent-encoded (UTF-8) in the
197
+ * route's headers, and `onProgress` reports the body as it is sent.
198
+ */
199
+ async upload(file, options = {}) {
200
+ try {
201
+ return await this.signInOnRefusal(this.uploadOnce(file, options, null));
202
+ }
203
+ catch (err) {
204
+ const challenge = err instanceof KitRequestError ? stepUpChallengeOf(err.code, err.envelope?.step_up) : null;
205
+ if (challenge === null)
206
+ throw err;
207
+ return this.signInOnRefusal(this.uploadOnce(file, options, await this.confirmStepUp(challenge)));
208
+ }
209
+ }
210
+ uploadOnce(file, options, stepUpId) {
211
+ const filename = options.filename ?? file.name ?? "";
212
+ if (filename === "") {
213
+ return Promise.reject(new TypeError("upload: a filename is required: pass a File, or options.filename"));
214
+ }
215
+ const contentType = options.contentType || file.type || "application/octet-stream";
216
+ const url = this.route("uploads");
217
+ const { signal, onProgress } = options;
218
+ if (signal?.aborted)
219
+ return Promise.reject(abortError());
220
+ return new Promise((resolve, reject) => {
221
+ const xhr = this.uploadRequest();
222
+ const onAbort = () => xhr.abort();
223
+ const settle = () => signal?.removeEventListener("abort", onAbort);
224
+ xhr.open("POST", url);
225
+ // Same origin sends the page's cookies; it never needs withCredentials.
226
+ xhr.withCredentials = false;
227
+ xhr.setRequestHeader("Content-Type", contentType);
228
+ xhr.setRequestHeader("Tera-Upload-Filename", encodeURIComponent(filename));
229
+ if (options.description !== undefined && options.description !== "") {
230
+ xhr.setRequestHeader("Tera-Upload-Description", encodeURIComponent(options.description));
231
+ }
232
+ xhr.setRequestHeader("Accept", JSON_TYPE);
233
+ if (stepUpId !== null)
234
+ xhr.setRequestHeader(STEP_UP_HEADER, stepUpId);
235
+ if (onProgress !== undefined) {
236
+ xhr.upload.onprogress = (ev) => onProgress({ loaded: ev.loaded, total: ev.lengthComputable ? ev.total : file.size });
237
+ }
238
+ xhr.onload = () => {
239
+ settle();
240
+ try {
241
+ resolve(answer(url, xhr.status, xhr.getResponseHeader("content-type") ?? "", xhr.responseText));
242
+ }
243
+ catch (err) {
244
+ reject(err);
245
+ }
246
+ };
247
+ xhr.onerror = () => {
248
+ settle();
249
+ reject(new TypeError(`${url}: the upload failed with no answer`));
250
+ };
251
+ xhr.onabort = () => {
252
+ settle();
253
+ reject(abortError());
254
+ };
255
+ signal?.addEventListener("abort", onAbort, { once: true });
256
+ try {
257
+ xhr.send(file);
258
+ }
259
+ catch (err) {
260
+ settle();
261
+ reject(err);
262
+ }
263
+ });
264
+ }
265
+ /**
266
+ * `GET /tera/v1/step-ups/{id}`: where one of the person's own step-ups is in
267
+ * its life. A step-up that is not theirs is thrown as tera's 404, as an id
268
+ * no step-up carries is.
269
+ */
270
+ async stepUp(id) {
271
+ return this.json(this.route("step-ups", id), { method: "GET" }, false);
272
+ }
273
+ /**
274
+ * A request to one of the plugin's own routes, `path` relative to the app's
275
+ * base (`reviews/7/finish`), same-origin. A `step_up_required` answer in the
276
+ * kit's envelope — what `webui.RequireStepUp` writes — is confirmed by the
277
+ * person and the request sent once more with `Tera-Step-Up`; any other
278
+ * answer, a success or a refusal, is returned as it arrived. A body sent
279
+ * again must be one that can be: a string, a `Blob`, `FormData` or a
280
+ * buffer, never a stream. A `sign_in_required` answer loads the page again
281
+ * ({@link TeraClient.reSignIn}) and is returned as it arrived.
282
+ */
283
+ async fetch(path, init = {}) {
284
+ const url = underBase(this.base, path);
285
+ const first = await this.fetchSameOrigin(url, init, null);
286
+ if (await isSignInRequired(first)) {
287
+ this.reSignIn();
288
+ return first;
289
+ }
290
+ const challenge = await stepUpRefusalOf(first);
291
+ if (challenge === null)
292
+ return first;
293
+ const second = await this.fetchSameOrigin(url, init, await this.confirmStepUp(challenge));
294
+ if (await isSignInRequired(second))
295
+ this.reSignIn();
296
+ return second;
297
+ }
298
+ /**
299
+ * Loads the page again to sign the person in to the plugin's origin: a
300
+ * top-level navigation to the page's own URL, which tera's UI proxy answers
301
+ * with its silent sign-in. The client does it itself on a
302
+ * `sign_in_required` answer; a page calls it for one it read on its own.
303
+ * It navigates once per client: a second call does nothing.
304
+ */
305
+ reSignIn() {
306
+ if (this.signingIn)
307
+ return;
308
+ this.signingIn = true;
309
+ this.navigate(this.pageURL());
310
+ }
311
+ /** Loads the page again when `request` rejects as `sign_in_required`, and rejects as it did. */
312
+ async signInOnRefusal(request) {
313
+ try {
314
+ return await request;
315
+ }
316
+ catch (err) {
317
+ if (err instanceof KitRequestError && err.status === 401 && err.code === SIGN_IN_REQUIRED)
318
+ this.reSignIn();
319
+ throw err;
320
+ }
321
+ }
322
+ /**
323
+ * Has the person confirm the step-up `challenge` names, and answers its id
324
+ * once it is satisfied, to send the act again with. It opens the step-up's
325
+ * page in a new window, or navigates to it with a `return_to` naming this
326
+ * page when the browser opens none, and reads its state every poll interval
327
+ * until it is satisfied, declined or expired, closing the window it opened.
328
+ *
329
+ * Rejects with a {@link StepUpError} when the step-up ends unconfirmed, the
330
+ * person closes its window while it still waits, or the page navigated away
331
+ * to confirm it. A page loaded back from that navigation carries the id, and
332
+ * the first step-up it is asked for resumes that one when it is satisfied.
333
+ */
334
+ async confirmStepUp(challenge) {
335
+ const resumed = this.takeResumed();
336
+ if (resumed !== null) {
337
+ const state = await this.stepUp(resumed).then((s) => s.state, () => null);
338
+ if (state === "satisfied")
339
+ return resumed;
340
+ }
341
+ const win = this.openWindow(challenge.url);
342
+ if (win === null) {
343
+ this.navigate(stepUpPageURL(challenge.url, challenge.id, this.pageURL()));
344
+ throw new StepUpError(challenge.id, "navigated", "the browser opened no window, so the page went to tera's to confirm the act");
345
+ }
346
+ let closedSeen = false;
347
+ for (;;) {
348
+ await this.sleep(this.pollMs);
349
+ let state;
350
+ try {
351
+ state = (await this.stepUp(challenge.id)).state;
352
+ }
353
+ catch (err) {
354
+ win.close();
355
+ throw err;
356
+ }
357
+ if (state === "satisfied") {
358
+ win.close();
359
+ return challenge.id;
360
+ }
361
+ if (state !== "pending") {
362
+ win.close();
363
+ throw new StepUpError(challenge.id, state, `the step-up was ${state === "spent" ? "spent by another request" : state}, and the act was not sent again`);
364
+ }
365
+ // One more read after the person closes the window, in case they
366
+ // confirmed it just before; then it is abandoned.
367
+ if (win.closed) {
368
+ if (closedSeen) {
369
+ throw new StepUpError(challenge.id, "abandoned", "the step-up's window was closed before it was confirmed");
370
+ }
371
+ closedSeen = true;
372
+ }
373
+ }
374
+ }
375
+ /** The step-up this page was loaded back with, once. */
376
+ takeResumed() {
377
+ if (this.resumeTaken)
378
+ return null;
379
+ this.resumeTaken = true;
380
+ try {
381
+ return resumedStepUp(this.pageURL());
382
+ }
383
+ catch {
384
+ return null;
385
+ }
386
+ }
387
+ fetchSameOrigin(url, init, stepUpId) {
388
+ const headers = new Headers(init.headers);
389
+ if (stepUpId !== null)
390
+ headers.set(STEP_UP_HEADER, stepUpId);
391
+ return this.fetchFn(url, { ...init, headers, credentials: "same-origin", mode: "same-origin" });
392
+ }
393
+ /**
394
+ * Signs the person out: navigates to the `signout_path` `me()` answers,
395
+ * tera's own sign-out, resolved against the app's base. A `signout_path`
396
+ * that is not a path relative to the base — a root-absolute path, or one
397
+ * naming another origin — is refused rather than followed.
398
+ */
399
+ async signOut() {
400
+ const { signout_path: path } = await this.me();
401
+ if (!isBaseRelative(path)) {
402
+ throw new KitRequestError(200, null, `${this.route("me")} answered a signout_path that is not a path relative to the app's base: ${JSON.stringify(path)}`);
403
+ }
404
+ this.navigate(underBase(this.base, path));
405
+ }
406
+ /** A kit route's path under the app's base. */
407
+ route(...segments) {
408
+ return underBase(this.base, routePath(...segments));
409
+ }
410
+ async json(url, init, stepUp = true) {
411
+ try {
412
+ return await this.signInOnRefusal(this.jsonOnce(url, init, null));
413
+ }
414
+ catch (err) {
415
+ const challenge = stepUp && err instanceof KitRequestError ? stepUpChallengeOf(err.code, err.envelope?.step_up) : null;
416
+ if (challenge === null)
417
+ throw err;
418
+ return this.signInOnRefusal(this.jsonOnce(url, init, await this.confirmStepUp(challenge)));
419
+ }
420
+ }
421
+ async jsonOnce(url, init, stepUpId) {
422
+ const response = await this.fetchFn(url, {
423
+ ...init,
424
+ headers: {
425
+ Accept: JSON_TYPE,
426
+ ...init.headers,
427
+ ...(stepUpId === null ? {} : { [STEP_UP_HEADER]: stepUpId }),
428
+ },
429
+ credentials: "same-origin",
430
+ mode: "same-origin",
431
+ cache: "no-store",
432
+ });
433
+ let text = "";
434
+ try {
435
+ text = await response.text();
436
+ }
437
+ catch {
438
+ // An unread body is read as no envelope: the status decides.
439
+ }
440
+ return answer(url, response.status, response.headers.get("content-type") ?? "", text);
441
+ }
442
+ }
443
+ /**
444
+ * Reads a route's answer: on a 2xx, the JSON body the route declares; on
445
+ * anything else, the kit's envelope as a thrown {@link KitRequestError}.
446
+ */
447
+ function answer(url, status, contentType, text) {
448
+ const isJSON = contentType.toLowerCase().split(";")[0].trim() === JSON_TYPE;
449
+ let body;
450
+ if (isJSON) {
451
+ try {
452
+ body = JSON.parse(text);
453
+ }
454
+ catch {
455
+ body = undefined;
456
+ }
457
+ }
458
+ if (status >= 200 && status < 300) {
459
+ if (typeof body === "object" && body !== null)
460
+ return body;
461
+ throw new KitRequestError(status, null, `${url} answered HTTP ${status} with a body that is not its JSON (${contentType || "no content type"})`);
462
+ }
463
+ throw new KitRequestError(status, envelopeOf(body), `${url} answered HTTP ${status} (${contentType || "no content type"})`);
464
+ }
465
+ /** Whether `params` can carry `step_up` beside what it holds: absent, or a plain object. */
466
+ function isParamsObject(params) {
467
+ return params === undefined || params === null || (typeof params === "object" && !Array.isArray(params));
468
+ }
469
+ /** The step-up a response to the plugin's own route needs confirmed, read from a copy of it. */
470
+ async function stepUpRefusalOf(response) {
471
+ if (response.status !== 403)
472
+ return null;
473
+ const type = (response.headers.get("content-type") ?? "").toLowerCase().split(";")[0].trim();
474
+ if (type !== JSON_TYPE)
475
+ return null;
476
+ let body;
477
+ try {
478
+ body = JSON.parse(await response.clone().text());
479
+ }
480
+ catch {
481
+ return null;
482
+ }
483
+ const e = envelopeOf(body);
484
+ return e === null ? null : stepUpChallengeOf(e.code, e.step_up);
485
+ }
486
+ /** The envelope's `error` member, when `body` is a kit envelope; null otherwise. */
487
+ function envelopeOf(body) {
488
+ if (typeof body !== "object" || body === null)
489
+ return null;
490
+ const error = body.error;
491
+ if (typeof error !== "object" || error === null)
492
+ return null;
493
+ const e = error;
494
+ if (typeof e.code !== "string" || typeof e.message !== "string" || typeof e.retryable !== "boolean")
495
+ return null;
496
+ return e;
497
+ }
498
+ function abortError() {
499
+ return new DOMException("the upload was aborted", "AbortError");
500
+ }
@@ -0,0 +1,179 @@
1
+ import type { FeedFrameRecord, FeedFrameStatus } from "../generated/feed.ts";
2
+ import { type FeedOpenError, type FeedOpener } from "./sse.ts";
3
+ export declare const BASE_DELAY_MS = 1000;
4
+ export declare const MAX_DELAY_MS = 30000;
5
+ /** Replays allowed in {@link REPLAY_WINDOW_MS} before a loss is surfaced. */
6
+ export declare const REPLAY_LIMIT = 3;
7
+ export declare const REPLAY_WINDOW_MS: number;
8
+ /** Added to twice the feed's heartbeat interval before a silent stream is given up on. */
9
+ export declare const HEARTBEAT_GRACE_MS = 5000;
10
+ /** The SSE event the kit closes a stream with when the person's token expires (D2 item 6). */
11
+ export declare const REAUTHENTICATE_EVENT = "reauthenticate";
12
+ /**
13
+ * One feed frame as the stream carried it, before its `type` is read. A frame
14
+ * of a known type is handed on as the type generated for it from
15
+ * `sdk/feed/schema/` ({@link FeedFrameRecord}, {@link FeedFrameStatus}); one
16
+ * of a type this package does not know is skipped (ADR-143 D13). The
17
+ * connection reads a frame's `type`, a status frame's `status` and drop
18
+ * counts, and a record's order key — and nothing else: the payload's contract
19
+ * is its publishing family's.
20
+ */
21
+ export type FeedFrame = {
22
+ readonly type: string;
23
+ readonly [field: string]: unknown;
24
+ };
25
+ /**
26
+ * Where the connection is.
27
+ *
28
+ * reconnecting a stream is being opened, or will be in `delayMs`; attempt 0
29
+ * is the first open and an immediate reopen
30
+ * replaying open, and the run's stored history is still arriving
31
+ * live open, history read: records now are live
32
+ * refused the route refused; a person has to act
33
+ * stopped nothing is wanted any more
34
+ */
35
+ export type FeedConnectionPhase = "reconnecting" | "replaying" | "live" | "refused" | "stopped";
36
+ /** A loss no replay is repairing, surfaced to the page. */
37
+ export interface FeedLoss {
38
+ /** The status value that reported it (`records_dropped`, `backfill_gap`, …). */
39
+ readonly status: string;
40
+ /**
41
+ * `replays-exhausted`: {@link REPLAY_LIMIT} replays in {@link REPLAY_WINDOW_MS}
42
+ * did not stop it. `unrepairable`: a replay cannot bring the records back.
43
+ */
44
+ readonly reason: "replays-exhausted" | "unrepairable";
45
+ }
46
+ export interface FeedConnectionState {
47
+ readonly phase: FeedConnectionPhase;
48
+ /** Consecutive failed attempts; 0 once a connection has gone live. */
49
+ readonly attempt: number;
50
+ /** How long until the scheduled reconnect, while `reconnecting`; 0 when it is under way. */
51
+ readonly delayMs: number;
52
+ /** Why the last stream ended, in the words it ended with. */
53
+ readonly lastError: string | null;
54
+ /** The refusal, while `refused`. */
55
+ readonly refusal: FeedOpenError | null;
56
+ /** A loss no replay is repairing, or null. */
57
+ readonly loss: FeedLoss | null;
58
+ }
59
+ export interface Clock {
60
+ now(): number;
61
+ setTimeout(fn: () => void, ms: number): unknown;
62
+ clearTimeout(handle: unknown): void;
63
+ /** A number in [0, 1), for the jitter. */
64
+ random(): number;
65
+ }
66
+ /** Something outside the stream saying now is a good time to reconnect. */
67
+ export type FeedSignal = "visible" | "online";
68
+ /** Subscribes to {@link FeedSignal}s; returns the unsubscribe. */
69
+ export type FeedSignals = (notify: (signal: FeedSignal) => void) => () => void;
70
+ /** The browser's signals: the document becoming visible, and the window coming online. */
71
+ export declare const browserSignals: FeedSignals;
72
+ /**
73
+ * defaultOrderKey reads the order ADR-083 D7's contract names: the id of a
74
+ * Work Order event (ADR-078 D3), monotonic across every Work Order and the
75
+ * cursor `work_order.events` replays from, carried as the record payload's
76
+ * `id`. A record without one has no key.
77
+ */
78
+ export declare function defaultOrderKey(record: FeedFrameRecord): number | undefined;
79
+ /** A {@link FeedConnectionOptions.backfill}: the run's stored history first, or the live stream alone. */
80
+ export type FeedBackfill = "all" | "none";
81
+ export interface FeedConnectionOptions {
82
+ /** The feed's name, as the plugin's manifest declares it. */
83
+ feed: string;
84
+ /** The path the app is served under on its origin, as a client's `base` option names it; `/` when absent. */
85
+ base?: string;
86
+ /** Narrows the feed to these of its topic filters (the feed's `topics` parameter). */
87
+ topics?: readonly string[];
88
+ /**
89
+ * Where each stream starts: `all` (the default) precedes the live records
90
+ * with the run's stored history, sent as the feed's `backfill=all`; `none`
91
+ * asks for no history and sends no `backfill`. The daemon serves `all` only
92
+ * to a connection whose topics name one run and refuses any other
93
+ * `backfill_scope_too_broad`, so a work-order-events feed, or a feed not
94
+ * narrowed to one Work Order, is opened with `none`.
95
+ */
96
+ backfill?: FeedBackfill;
97
+ /** Each record not already delivered, in order. */
98
+ onRecord: (record: FeedFrameRecord) => void;
99
+ /** Every status frame, as it arrives. Heartbeats are absorbed and never reach it (ADR-083 D7). */
100
+ onStatus?: (status: FeedFrameStatus) => void;
101
+ /** Every change of {@link FeedConnectionState}. */
102
+ onChange?: (state: FeedConnectionState) => void;
103
+ /**
104
+ * A record's place in the feed's order, for de-duplication across replays;
105
+ * {@link defaultOrderKey} when absent. A record with no key cannot be told
106
+ * apart from one already delivered, so it is delivered every time it
107
+ * arrives: a feed whose records carry no Work Order event id passes the key
108
+ * its own records are ordered by.
109
+ */
110
+ orderKey?: (record: FeedFrameRecord) => number | undefined;
111
+ /** Opens one stream; {@link fetchOpener} when absent. */
112
+ open?: FeedOpener;
113
+ /**
114
+ * Loads the page again when tera's UI proxy refuses the stream
115
+ * `sign_in_required`, which signs the person in; {@link reSignIn} when
116
+ * absent. A {@link TeraClient}'s feed passes the client's own.
117
+ */
118
+ reSignIn?: () => void;
119
+ clock?: Clock;
120
+ /** {@link browserSignals} when absent. */
121
+ signals?: FeedSignals;
122
+ }
123
+ export declare class FeedConnection {
124
+ private readonly options;
125
+ private readonly clock;
126
+ private readonly open;
127
+ private readonly orderKey;
128
+ private view;
129
+ private unsubscribe;
130
+ private generation;
131
+ private controller;
132
+ private retryTimer;
133
+ private silenceTimer;
134
+ private high;
135
+ private replays;
136
+ private opened;
137
+ private replaying;
138
+ private silenceMs;
139
+ private droppedSeen;
140
+ private lossPending;
141
+ private streamLoss;
142
+ constructor(options: FeedConnectionOptions);
143
+ /** Whether each stream asks for the run's stored history. */
144
+ private get backfills();
145
+ get state(): FeedConnectionState;
146
+ /** The stream's URL: the feed route, narrowed to the topics, asking for the backfill unless it is `none`. */
147
+ get url(): string;
148
+ /** Opens the first stream. Nothing delivered before is remembered. */
149
+ start(): void;
150
+ /** Ends everything; nothing this connection opened may deliver again. */
151
+ stop(): void;
152
+ /** A person asked: reopens from any ending, resetting the backoff and the replay ration. */
153
+ retry(): void;
154
+ private nudge;
155
+ private set;
156
+ private teardown;
157
+ private connect;
158
+ private event;
159
+ /**
160
+ * The person's token expired under an open stream (D7), told by a
161
+ * `reauthenticate` event or a `credential_expired` status: reconnecting
162
+ * carries a fresh assertion, so it is at once. A stream that never opened is
163
+ * not one the token ended, so it backs off rather than looping at once.
164
+ */
165
+ private expired;
166
+ private frame;
167
+ private status;
168
+ private deliver;
169
+ private droppedRose;
170
+ private unrepairable;
171
+ private lost;
172
+ private goLive;
173
+ /** One replay for a loss, within the ration; past it, the loss is surfaced. */
174
+ private replay;
175
+ /** Ends the current stream as a failure and schedules the next with backoff. */
176
+ private reopen;
177
+ private schedule;
178
+ private armSilence;
179
+ }