@kottera/tera-web 0.1.0-rc.5 → 0.1.0-rc.7

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.
@@ -476,6 +476,21 @@ export type WorkOrderCreateParams = {
476
476
  needs_approval?: boolean;
477
477
  /** Existing Work Order to file this one beneath, making it part of that train. It must be one you can read with work_order.get; one you cannot is refused exactly as that read would refuse it. The new Work Order takes that parent's cohort, verbatim; beside it `cohort` accepts `{"new": true, "label"?: …}`, a sub-unit nested under the parent's cohort, and `{"of": "<work-order-id>"}`, joining the cohort of a Work Order in the parent's tree. */
478
478
  parent_id?: string;
479
+ /** Directories of the filing plugin's data volume this Work Order's dispatch is handed. Only a plugin may name them — as itself or acting for a person, or on create_child as the taker of its own off-tera work — and only for a type of its own vault; any other filer is refused (plugin_data_not_a_plugin). The dispatch is placed on the host holding the volume and sees the named directories and nothing else of it. Name input, output or both; neither may contain the other. */
480
+ plugin_data?: {
481
+ /** The one directory the dispatch reads, mounted read-only and told to the step in TERA_PLUGIN_DATA_INPUT. It must exist when the step starts (plugin_data_unavailable). */
482
+ input?: {
483
+ /** A clean relative path inside the plugin's data volume: no `..`, not absolute. */
484
+ dir: string;
485
+ };
486
+ /** The one directory the dispatch writes, told to the step in TERA_PLUGIN_DATA_OUTPUT. Created if absent; refused when not empty at the step's start (plugin_output_not_empty). */
487
+ output?: {
488
+ /** The most the directory may hold when the step ends: at least 1 GiB, at most the installation's hosted { step_output_limit }. Past it the step fails (plugin_output_bound_exceeded). */
489
+ bound_bytes: number;
490
+ /** A clean relative path inside the plugin's data volume: no `..`, not absolute. */
491
+ dir: string;
492
+ };
493
+ };
479
494
  /** Dispatch priority; higher runs sooner among ready Work Orders. */
480
495
  priority?: number;
481
496
  /** Typed, NON-CAUSAL relations from this Work Order to others (supersedes / duplicate_of / about / related). Each entry is a {relation, work_order_id} pair with an optional note, and `work_order_id` is a bare Work Order id — the same spelling `depends_on` and `parent_id` use, never a `vault://` ref. Set them here when the fact is known at filing time, which for the superseding case it usually is; discovered later, use work_order.relate. Three of the four gate nothing: no dispatch is blocked, delayed or cancelled by `duplicate_of`, `about` or `related`. A `supersedes` entry is the exception and it is the point of setting one here — it RETIRES the Work Order it names, in this call's own transaction: that Work Order moves to `superseded` with `superseded_by` set to this one, and can no longer be retried. The target must be in `failed:awaiting-ack`; one that is still running is refused, and the door is work_order.force_fail(id, reason) first. A terminal one is refused too — there is no retry to close — and so is a `supersedes` entry on a drafted create, since a draft may never be released. To record the claim WITHOUT moving the row, use work_order.relate. Same authorization as work_order.relate — a worker-class caller is refused, and an operator-class one needs `api/v1/workorders/relate` on top of its create grant, because the edge is the relate operation whichever call delivered it. Every target must exist, and no two entries may name the same target. Reach on the target is required for the two EVALUATIVE relations (`supersedes`, `duplicate_of`) and not for the two descriptive ones (`about`, `related`), per entry rather than per payload. */
@@ -590,6 +605,21 @@ export type WorkOrderCreateChildParams = {
590
605
  on_child_failure?: "fail-fast" | "wait-others" | "ignore";
591
606
  /** The off-tera Work Order you hold, to file this child beneath (ADR-139 D6). Honoured only for its current taker; omit it inside a dispatch, where the parent is the dispatch's own Work Order. */
592
607
  parent?: string;
608
+ /** Directories of the filing plugin's data volume this Work Order's dispatch is handed. Only a plugin may name them — as itself or acting for a person, or on create_child as the taker of its own off-tera work — and only for a type of its own vault; any other filer is refused (plugin_data_not_a_plugin). The dispatch is placed on the host holding the volume and sees the named directories and nothing else of it. Name input, output or both; neither may contain the other. */
609
+ plugin_data?: {
610
+ /** The one directory the dispatch reads, mounted read-only and told to the step in TERA_PLUGIN_DATA_INPUT. It must exist when the step starts (plugin_data_unavailable). */
611
+ input?: {
612
+ /** A clean relative path inside the plugin's data volume: no `..`, not absolute. */
613
+ dir: string;
614
+ };
615
+ /** The one directory the dispatch writes, told to the step in TERA_PLUGIN_DATA_OUTPUT. Created if absent; refused when not empty at the step's start (plugin_output_not_empty). */
616
+ output?: {
617
+ /** The most the directory may hold when the step ends: at least 1 GiB, at most the installation's hosted { step_output_limit }. Past it the step fails (plugin_output_bound_exceeded). */
618
+ bound_bytes: number;
619
+ /** A clean relative path inside the plugin's data volume: no `..`, not absolute. */
620
+ dir: string;
621
+ };
622
+ };
593
623
  /** Informational: echoed back for your own bookkeeping. What governs whether the parent waits is the wait it declares, not this field. */
594
624
  policy?: "wait-for-all" | "wait-for-any" | "fire-and-forget";
595
625
  /** Dispatch priority; higher runs sooner among ready Work Orders. */
@@ -974,6 +1004,9 @@ export type WorkOrderGetResult = {
974
1004
  at: string;
975
1005
  };
976
1006
  display_name?: string;
1007
+ work_order?: string;
1008
+ attempt?: number;
1009
+ runner?: string;
977
1010
  };
978
1011
  };
979
1012
  question?: {
@@ -994,6 +1027,7 @@ export type WorkOrderGetResult = {
994
1027
  member?: string;
995
1028
  pool?: string;
996
1029
  pool_cause?: string;
1030
+ pool_note?: string;
997
1031
  verb?: string;
998
1032
  repairable: boolean;
999
1033
  refused_at?: string;
@@ -1404,12 +1438,35 @@ export type WorkOrderGetResult = {
1404
1438
  } | null;
1405
1439
  created_by?: string;
1406
1440
  created_via?: string;
1441
+ released_via?: string;
1442
+ lent_by?: string;
1407
1443
  arrival_evidence?: {
1408
1444
  [key: string]: string;
1409
1445
  };
1410
1446
  created_at: string;
1411
1447
  updated_at: string;
1412
1448
  off_tera: boolean;
1449
+ attended?: {
1450
+ host?: string;
1451
+ personal: boolean;
1452
+ candidate?: string;
1453
+ unsandboxed: boolean;
1454
+ unsandboxed_notice?: string;
1455
+ attendee: string;
1456
+ attached: {
1457
+ views: number;
1458
+ controller?: string;
1459
+ size_setter?: string;
1460
+ since?: string;
1461
+ last_detached_at?: string;
1462
+ } | null;
1463
+ signal: {
1464
+ signal: string;
1465
+ message?: string;
1466
+ at: string;
1467
+ dropped?: number;
1468
+ } | null;
1469
+ };
1413
1470
  has_outputs?: boolean;
1414
1471
  archived: boolean;
1415
1472
  history?: {
@@ -1739,6 +1796,8 @@ export type WorkOrderInterruptResult = {
1739
1796
  * Schema: sdk/workorder/schema/v1/list.params.json
1740
1797
  */
1741
1798
  export type WorkOrderListParams = {
1799
+ /** Select only attended Work Orders — sessions a person attends in a terminal — and carry on each row the `attended` projection work_order.get answers (host, personal, candidate, unsandboxed, attendee, attached, signal), so a list of sessions needs no get per row. Absent or false is no constraint, and rows then carry no projection. Unindexed on both store backends; pair it with `states` or `parent_id`. */
1800
+ attended?: boolean;
1742
1801
  /** Select the Work Orders in this cohort — the undertaking a Work Order belongs to, read off any row's `cohort_id` (a Work Order filed alone is a cohort of one, whose id is its own). A UUID; anything else is refused rather than matched. Unlike `tag` this reads an indexed column on both store backends, so it is the filter to pass alone for 'everything in this undertaking', in every state, terminal included. Composes with the other filters as an AND. */
1743
1802
  cohort?: string;
1744
1803
  /** Select Work Orders filed with exactly this resolved capacity key. Matched verbatim against the key on the row, which is already materialized against that Work Order's own inputs — pass `prefilter/<subject id>`, not the type's `${inputs...}` template. This is how a caller asks 'is another Work Order of this tier already working my subject?' without reading inputs back out of every row. */
@@ -1816,6 +1875,27 @@ export type WorkOrderListResult = {
1816
1875
  at: string;
1817
1876
  };
1818
1877
  };
1878
+ attended?: {
1879
+ host?: string;
1880
+ personal: boolean;
1881
+ candidate?: string;
1882
+ unsandboxed: boolean;
1883
+ unsandboxed_notice?: string;
1884
+ attendee: string;
1885
+ attached: {
1886
+ views: number;
1887
+ controller?: string;
1888
+ size_setter?: string;
1889
+ since?: string;
1890
+ last_detached_at?: string;
1891
+ } | null;
1892
+ signal: {
1893
+ signal: string;
1894
+ message?: string;
1895
+ at: string;
1896
+ dropped?: number;
1897
+ } | null;
1898
+ };
1819
1899
  }[] | null;
1820
1900
  };
1821
1901
  /**
@@ -3115,6 +3195,7 @@ export type WorkOrderTreeResult = {
3115
3195
  member?: string;
3116
3196
  pool?: string;
3117
3197
  pool_cause?: string;
3198
+ pool_note?: string;
3118
3199
  verb?: string;
3119
3200
  repairable: boolean;
3120
3201
  refused_at?: string;
@@ -3345,6 +3426,7 @@ export type WorkOrderTreeResult = {
3345
3426
  member?: string;
3346
3427
  pool?: string;
3347
3428
  pool_cause?: string;
3429
+ pool_note?: string;
3348
3430
  verb?: string;
3349
3431
  repairable: boolean;
3350
3432
  refused_at?: string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The close vocabulary of a tera stream (sdk/wsstream.CloseCodes).
3
+ *
4
+ * The WebSocket close codes a tera stream is closed with. Each states only what the hop that closed decided, and a relay passes a close on as it arrived. The reason beside a code is a code of at most 64 bytes of [a-z0-9_.:-], or "closed". A code is never renamed or given a new meaning; a client that meets a code it does not know treats it as retry_later (4001).
5
+ *
6
+ * Schema: sdk/wsstream/schema/v1/close-code.json
7
+ */
8
+ export type StreamCloseCode = 1000 | 1001 | 1009 | 4000 | 4001 | 4003;
9
+ /**
10
+ * Each member of StreamCloseCode, by name.
11
+ *
12
+ * Schema: sdk/wsstream/schema/v1/close-code.json
13
+ */
14
+ export declare const STREAM_CLOSE_CODES: {
15
+ /** What the client does: what the protocol above said last; otherwise nothing. */
16
+ readonly ended: 1000;
17
+ /** What the client does: reconnects after backoff: the peer is stopping or stopped answering pings. */
18
+ readonly going_away: 1001;
19
+ /** What the client does: nothing: a message broke the route's bound, a client defect. */
20
+ readonly too_big: 1009;
21
+ /** What the client does: reconnects at once with the same identity: a token renewal. */
22
+ readonly reconnect: 4000;
23
+ /** What the client does: reconnects after backoff: the far side is unreachable or not ready. */
24
+ readonly retry_later: 4001;
25
+ /** What the client does: nothing until something changes; the reason is the error code. */
26
+ readonly refused: 4003;
27
+ };
28
+ /**
29
+ * Every type this module declares, mapped to the repository path of the schema
30
+ * it was generated from.
31
+ */
32
+ export declare const STREAM_SCHEMAS: {
33
+ readonly StreamCloseCode: "sdk/wsstream/schema/v1/close-code.json";
34
+ };
@@ -0,0 +1,28 @@
1
+ // Code generated by cmd/internal/genwebtypes from sdk/wsstream/schema/v1/; DO NOT EDIT.
2
+ // Regenerate with `make web-types` (ADR-162 D4 item 1).
3
+ /**
4
+ * Each member of StreamCloseCode, by name.
5
+ *
6
+ * Schema: sdk/wsstream/schema/v1/close-code.json
7
+ */
8
+ export const STREAM_CLOSE_CODES = {
9
+ /** What the client does: what the protocol above said last; otherwise nothing. */
10
+ ended: 1000,
11
+ /** What the client does: reconnects after backoff: the peer is stopping or stopped answering pings. */
12
+ going_away: 1001,
13
+ /** What the client does: nothing: a message broke the route's bound, a client defect. */
14
+ too_big: 1009,
15
+ /** What the client does: reconnects at once with the same identity: a token renewal. */
16
+ reconnect: 4000,
17
+ /** What the client does: reconnects after backoff: the far side is unreachable or not ready. */
18
+ retry_later: 4001,
19
+ /** What the client does: nothing until something changes; the reason is the error code. */
20
+ refused: 4003,
21
+ };
22
+ /**
23
+ * Every type this module declares, mapped to the repository path of the schema
24
+ * it was generated from.
25
+ */
26
+ export const STREAM_SCHEMAS = {
27
+ StreamCloseCode: "sdk/wsstream/schema/v1/close-code.json",
28
+ };
package/dist/index.d.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  export { ROOT_BASE, ROUTE_PREFIX, appBase, routePath, underBase } from "./routes.ts";
2
2
  export { phoneURL } from "./phone.ts";
3
- export { TeraClient, KitRequestError, UNRECOGNIZED_RESPONSE, type FeedOptions, type InvokeVerdict, type KitErrorBody, type TeraClientOptions, type UploadOptions, type UploadProgress, type UploadRequest, type VerbRefusal, } from "./client.ts";
3
+ export { SIGN_IN_REQUIRED, isSignInRequired, isSignInRequiredAnswer, reSignIn, type ReSignInOptions } from "./signin.ts";
4
+ export { PLUGIN_BUSY, PLUGIN_STARTING, PLUGIN_RUNNER_UNREACHABLE, PLUGIN_NOT_ANSWERING, PLUGIN_UNREACHED_CODES, } from "./unreached.ts";
5
+ export { TeraClient, KitRequestError, UNRECOGNIZED_RESPONSE, type FeedOptions, type StreamOptions, type TerminalOptions, type InvokeVerdict, type KitErrorBody, type TeraClientOptions, type UploadOptions, type UploadProgress, type UploadRequest, type VerbRefusal, } from "./client.ts";
4
6
  export { STEP_UP_HEADER, STEP_UP_POLL_MS, STEP_UP_REQUIRED, STEP_UP_RESUME_PARAM, StepUpError, resumedStepUp, stepUpChallengeOf, stepUpPageURL, type StepUpChallenge, type StepUpEnd, type StepUpOptions, type StepUpWindow, } from "./stepup.ts";
5
7
  export { BASE_DELAY_MS, MAX_DELAY_MS, REPLAY_LIMIT, REPLAY_WINDOW_MS, HEARTBEAT_GRACE_MS, REAUTHENTICATE_EVENT, FeedConnection, browserSignals, defaultOrderKey, type Clock, type FeedConnectionOptions, type FeedConnectionPhase, type FeedConnectionState, type FeedFrame, type FeedLoss, type FeedSignal, type FeedSignals, } from "./feed/connection.ts";
8
+ export { IMMEDIATE_LIMIT, IMMEDIATE_WINDOW_MS, MAX_BUFFERED_BYTES, SEND_BUFFER_EXCEEDED, STABLE_OPEN_MS, StreamConnection, webSocketURL, type NormalCloseNext, type StreamConnectionOptions, type StreamConnectionState, type WebSocketLike, } from "./stream/connection.ts";
9
+ export { ACK_EVERY, INPUT_GRANT_TIMEOUT, INPUT_GRANT_TIMEOUT_MS, INPUT_WINDOW, MAX_QUEUED_INPUT, MAX_SIGNALS_PER_GRANT, RESIZE_DEBOUNCE_MS, TerminalView, newViewId, nextAfterClose, type TerminalRenderer, type TerminalStatus, type TerminalViewOptions, } from "./terminal/view.ts";
6
10
  export { FeedOpenError, SSEParser, fetchOpener, isRefusal, type FeedOpener, type SSEEvent } from "./feed/sse.ts";
11
+ export * as terminalwire from "./terminalwire/codec.ts";
7
12
  export type * from "./generated/feed.ts";
8
13
  export type * from "./generated/kit.ts";
9
14
  export type * from "./generated/shares.ts";
10
15
  export type * from "./generated/workorder.ts";
11
16
  export type * from "./generated/workorder-events.ts";
17
+ export type * from "./generated/wsstream.ts";
12
18
  export { FEED_SCHEMAS } from "./generated/feed.ts";
13
19
  export { KIT_ROUTE_PREFIX, KIT_ROUTE_TABLE, KIT_PHONE_PATH, KIT_SCHEMAS, KIT_SIGNOUT_PATH } from "./generated/kit.ts";
14
20
  export { SHARES_SCHEMAS } from "./generated/shares.ts";
15
21
  export { WORK_ORDER_SCHEMAS } from "./generated/workorder.ts";
16
22
  export { WORK_ORDER_EVENT_SCHEMAS } from "./generated/workorder-events.ts";
23
+ export { STREAM_CLOSE_CODES, STREAM_SCHEMAS } from "./generated/wsstream.ts";
package/dist/index.js CHANGED
@@ -4,12 +4,21 @@
4
4
  // because the framework is the author's.
5
5
  export { ROOT_BASE, ROUTE_PREFIX, appBase, routePath, underBase } from "./routes.js";
6
6
  export { phoneURL } from "./phone.js";
7
+ export { SIGN_IN_REQUIRED, isSignInRequired, isSignInRequiredAnswer, reSignIn } from "./signin.js";
8
+ export { PLUGIN_BUSY, PLUGIN_STARTING, PLUGIN_RUNNER_UNREACHABLE, PLUGIN_NOT_ANSWERING, PLUGIN_UNREACHED_CODES, } from "./unreached.js";
7
9
  export { TeraClient, KitRequestError, UNRECOGNIZED_RESPONSE, } from "./client.js";
8
10
  export { STEP_UP_HEADER, STEP_UP_POLL_MS, STEP_UP_REQUIRED, STEP_UP_RESUME_PARAM, StepUpError, resumedStepUp, stepUpChallengeOf, stepUpPageURL, } from "./stepup.js";
9
11
  export { BASE_DELAY_MS, MAX_DELAY_MS, REPLAY_LIMIT, REPLAY_WINDOW_MS, HEARTBEAT_GRACE_MS, REAUTHENTICATE_EVENT, FeedConnection, browserSignals, defaultOrderKey, } from "./feed/connection.js";
12
+ export { IMMEDIATE_LIMIT, IMMEDIATE_WINDOW_MS, MAX_BUFFERED_BYTES, SEND_BUFFER_EXCEEDED, STABLE_OPEN_MS, StreamConnection, webSocketURL, } from "./stream/connection.js";
13
+ export { ACK_EVERY, INPUT_GRANT_TIMEOUT, INPUT_GRANT_TIMEOUT_MS, INPUT_WINDOW, MAX_QUEUED_INPUT, MAX_SIGNALS_PER_GRANT, RESIZE_DEBOUNCE_MS, TerminalView, newViewId, nextAfterClose, } from "./terminal/view.js";
10
14
  export { FeedOpenError, SSEParser, fetchOpener, isRefusal } from "./feed/sse.js";
15
+ // The frames a view of an attended session's terminal speaks (ADR-180 D4):
16
+ // github.com/kottera/sdk/terminalwire's codec in the browser, under one name,
17
+ // so its frame and vocabulary names claim nothing at the package's root.
18
+ export * as terminalwire from "./terminalwire/codec.js";
11
19
  export { FEED_SCHEMAS } from "./generated/feed.js";
12
20
  export { KIT_ROUTE_PREFIX, KIT_ROUTE_TABLE, KIT_PHONE_PATH, KIT_SCHEMAS, KIT_SIGNOUT_PATH } from "./generated/kit.js";
13
21
  export { SHARES_SCHEMAS } from "./generated/shares.js";
14
22
  export { WORK_ORDER_SCHEMAS } from "./generated/workorder.js";
15
23
  export { WORK_ORDER_EVENT_SCHEMAS } from "./generated/workorder-events.js";
24
+ export { STREAM_CLOSE_CODES, STREAM_SCHEMAS } from "./generated/wsstream.js";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Signing in again after tera forgets the page's session.
3
+ *
4
+ * tera's UI proxy holds a plugin origin's sessions in memory, so a daemon
5
+ * restart forgets every one. Only a page load can sign the person in again —
6
+ * silently, while their tera session lives — so the proxy answers anything
7
+ * else that arrives with no live session, a script's `fetch` among them, 401
8
+ * in the kit's envelope with code {@link SIGN_IN_REQUIRED}. The page recovers
9
+ * by loading itself again ({@link reSignIn}).
10
+ *
11
+ * That cannot loop: a page load never receives this answer, it starts the
12
+ * sign-in. A person whose tera session is gone too lands on tera's sign-in
13
+ * page, which is where they have to be.
14
+ *
15
+ * It is not the kit's `unauthenticated`, which says the request did reach the
16
+ * plugin, with no verified caller; loading the page again does not fix that.
17
+ *
18
+ * {@link TeraClient} and {@link FeedConnection} react on their own. A page
19
+ * reading one of the plugin's routes with its own `fetch` applies
20
+ * {@link isSignInRequired} to the response:
21
+ *
22
+ * const res = await fetch("api/boards");
23
+ * if (await isSignInRequired(res)) return reSignIn();
24
+ */
25
+ /** The code of the proxy's answer to a request that needs the page loaded again to sign in. */
26
+ export declare const SIGN_IN_REQUIRED = "sign_in_required";
27
+ export interface ReSignInOptions {
28
+ /** Sends the page to a URL; `location.assign` when absent. */
29
+ navigate?: (url: string) => void;
30
+ /** The page's own URL, which is loaded again; `location.href` when absent. */
31
+ pageURL?: () => string;
32
+ }
33
+ /**
34
+ * Whether an answer is the proxy's `sign_in_required`: HTTP 401, a JSON body,
35
+ * and the kit's envelope carrying that code. Any other 401 — the kit's own
36
+ * `unauthenticated`, another code, a body that is not JSON — is not.
37
+ */
38
+ export declare function isSignInRequiredAnswer(status: number, contentType: string, body: string): boolean;
39
+ /**
40
+ * Whether `res` is the proxy's `sign_in_required` answer. It reads a copy of
41
+ * the body, so `res` can still be read by the caller.
42
+ */
43
+ export declare function isSignInRequired(res: Response): Promise<boolean>;
44
+ /**
45
+ * Loads the page again, a top-level navigation to its own URL, which signs
46
+ * the person in to the plugin's origin and returns them where they were.
47
+ */
48
+ export declare function reSignIn(options?: ReSignInOptions): void;
package/dist/signin.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Signing in again after tera forgets the page's session.
3
+ *
4
+ * tera's UI proxy holds a plugin origin's sessions in memory, so a daemon
5
+ * restart forgets every one. Only a page load can sign the person in again —
6
+ * silently, while their tera session lives — so the proxy answers anything
7
+ * else that arrives with no live session, a script's `fetch` among them, 401
8
+ * in the kit's envelope with code {@link SIGN_IN_REQUIRED}. The page recovers
9
+ * by loading itself again ({@link reSignIn}).
10
+ *
11
+ * That cannot loop: a page load never receives this answer, it starts the
12
+ * sign-in. A person whose tera session is gone too lands on tera's sign-in
13
+ * page, which is where they have to be.
14
+ *
15
+ * It is not the kit's `unauthenticated`, which says the request did reach the
16
+ * plugin, with no verified caller; loading the page again does not fix that.
17
+ *
18
+ * {@link TeraClient} and {@link FeedConnection} react on their own. A page
19
+ * reading one of the plugin's routes with its own `fetch` applies
20
+ * {@link isSignInRequired} to the response:
21
+ *
22
+ * const res = await fetch("api/boards");
23
+ * if (await isSignInRequired(res)) return reSignIn();
24
+ */
25
+ /** The code of the proxy's answer to a request that needs the page loaded again to sign in. */
26
+ export const SIGN_IN_REQUIRED = "sign_in_required";
27
+ const JSON_TYPE = "application/json";
28
+ /**
29
+ * Whether an answer is the proxy's `sign_in_required`: HTTP 401, a JSON body,
30
+ * and the kit's envelope carrying that code. Any other 401 — the kit's own
31
+ * `unauthenticated`, another code, a body that is not JSON — is not.
32
+ */
33
+ export function isSignInRequiredAnswer(status, contentType, body) {
34
+ if (status !== 401)
35
+ return false;
36
+ if (contentType.toLowerCase().split(";")[0].trim() !== JSON_TYPE)
37
+ return false;
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(body);
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ if (typeof parsed !== "object" || parsed === null)
46
+ return false;
47
+ const error = parsed.error;
48
+ return typeof error === "object" && error !== null && error.code === SIGN_IN_REQUIRED;
49
+ }
50
+ /**
51
+ * Whether `res` is the proxy's `sign_in_required` answer. It reads a copy of
52
+ * the body, so `res` can still be read by the caller.
53
+ */
54
+ export async function isSignInRequired(res) {
55
+ if (res.status !== 401)
56
+ return false;
57
+ let text;
58
+ try {
59
+ text = await res.clone().text();
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ return isSignInRequiredAnswer(res.status, res.headers.get("content-type") ?? "", text);
65
+ }
66
+ /**
67
+ * Loads the page again, a top-level navigation to its own URL, which signs
68
+ * the person in to the plugin's origin and returns them where they were.
69
+ */
70
+ export function reSignIn(options = {}) {
71
+ const navigate = options.navigate ?? ((url) => globalThis.location.assign(url));
72
+ const pageURL = options.pageURL ?? (() => globalThis.location.href);
73
+ navigate(pageURL());
74
+ }
@@ -0,0 +1,145 @@
1
+ import { type Clock, type FeedSignals } from "../feed/connection.ts";
2
+ /** Immediate reconnects allowed in {@link IMMEDIATE_WINDOW_MS} before the next is taken with backoff. */
3
+ export declare const IMMEDIATE_LIMIT = 3;
4
+ export declare const IMMEDIATE_WINDOW_MS = 30000;
5
+ /** How long a socket stays open before the backoff resets. */
6
+ export declare const STABLE_OPEN_MS = 10000;
7
+ /** The most a page lets wait unsent before it treats the socket as a defect (ADR-181 D6 item 5). */
8
+ export declare const MAX_BUFFERED_BYTES: number;
9
+ /** The close reason the connection sends when it closes a socket for {@link MAX_BUFFERED_BYTES}. */
10
+ export declare const SEND_BUFFER_EXCEEDED = "send_buffer_exceeded";
11
+ /**
12
+ * The part of the browser's `WebSocket` the connection uses; a real one
13
+ * satisfies it.
14
+ */
15
+ export interface WebSocketLike {
16
+ binaryType: string;
17
+ readonly bufferedAmount: number;
18
+ onopen: ((ev: unknown) => void) | null;
19
+ onmessage: ((ev: {
20
+ data: unknown;
21
+ }) => void) | null;
22
+ onclose: ((ev: {
23
+ code: number;
24
+ reason: string;
25
+ }) => void) | null;
26
+ onerror: ((ev: unknown) => void) | null;
27
+ send(data: ArrayBufferView | ArrayBuffer): void;
28
+ close(code?: number, reason?: string): void;
29
+ }
30
+ /**
31
+ * Where the connection is.
32
+ *
33
+ * connecting a socket is being opened; attempt 0 is the first, and an
34
+ * immediate reconnect keeps the attempt it had
35
+ * open the socket is open; messages flow
36
+ * reconnecting the socket ended and the next opens in `delayMs`
37
+ * ended nothing will be opened again; `code` is the close that ended
38
+ * it, and `reason` its reason, or what the probe answered
39
+ */
40
+ export type StreamConnectionState = {
41
+ readonly phase: "connecting";
42
+ readonly attempt: number;
43
+ } | {
44
+ readonly phase: "open";
45
+ } | {
46
+ readonly phase: "reconnecting";
47
+ readonly attempt: number;
48
+ readonly delayMs: number;
49
+ readonly reason: string;
50
+ } | {
51
+ readonly phase: "ended";
52
+ readonly code: number;
53
+ readonly reason: string;
54
+ };
55
+ /** What a normal closure (1000) of the current socket does, as the protocol above decided. */
56
+ export type NormalCloseNext = "reconnect" | "backoff" | "end";
57
+ export interface StreamConnectionOptions {
58
+ /** The stream route's path, built with {@link routePath}. */
59
+ path: string;
60
+ /** The one subprotocol the route speaks. */
61
+ subprotocol: string;
62
+ /** Read at every connect, so a resume offset in it is current. */
63
+ query?: () => URLSearchParams;
64
+ /** Each message, whole. A text message arrives as its UTF-8 bytes. */
65
+ onMessage: (data: ArrayBuffer) => void;
66
+ /** Every change of {@link StreamConnectionState}. */
67
+ onState?: (state: StreamConnectionState) => void;
68
+ /** The path the app is served under on its origin, as a client's `base` option names it; `/` when absent. */
69
+ base?: string;
70
+ /** Opens one socket; the browser's `WebSocket` when absent. */
71
+ socket?: (url: string, protocol: string) => WebSocketLike;
72
+ /** Sends the handshake's probe; the global `fetch` when absent. */
73
+ fetch?: typeof fetch;
74
+ /**
75
+ * Loads the page again when the probe answers `sign_in_required`;
76
+ * {@link reSignIn} when absent. A {@link TeraClient}'s stream passes the
77
+ * client's own, so the page is loaded once per client.
78
+ */
79
+ reSignIn?: () => void;
80
+ clock?: Clock;
81
+ /** The window's `online` event when absent; only `online` is acted on. */
82
+ signals?: FeedSignals;
83
+ }
84
+ /**
85
+ * webSocketURL resolves a path on the page's origin to the `ws:` or `wss:` URL
86
+ * a `WebSocket` is opened with. With no page URL — outside a browser — the
87
+ * path is answered as it is.
88
+ */
89
+ export declare function webSocketURL(path: string, pageURL: string | undefined): string;
90
+ export declare class StreamConnection {
91
+ private readonly options;
92
+ private readonly clock;
93
+ private readonly openSocket;
94
+ private view;
95
+ private unsubscribe;
96
+ private generation;
97
+ private ws;
98
+ private opened;
99
+ private retryTimer;
100
+ private stableTimer;
101
+ private attempt;
102
+ private immediate;
103
+ private normalNext;
104
+ /** Opens the first socket. */
105
+ constructor(options: StreamConnectionOptions);
106
+ get state(): StreamConnectionState;
107
+ /** The socket's URL as of now: the route under the app's base, with the query read afresh. */
108
+ get url(): string;
109
+ /** What the open socket has not yet handed to the network; 0 with none open. */
110
+ get bufferedAmount(): number;
111
+ /**
112
+ * Sends one message, answering whether it was handed to an open socket. A
113
+ * send that leaves more than {@link MAX_BUFFERED_BYTES} unsent closes the
114
+ * socket and reconnects with backoff.
115
+ */
116
+ send(message: ArrayBufferView): boolean;
117
+ /**
118
+ * Says what the normal closure (1000) that follows does: the protocol above
119
+ * read why the far side is ending, which the close code does not carry
120
+ * (ADR-181 D9 item 2). It holds for the current socket only; each new socket
121
+ * starts at `"end"`.
122
+ */
123
+ onNormalClose(next: NormalCloseNext): void;
124
+ /**
125
+ * Gives up on the open socket: closes it 1000 with `reason` and reconnects
126
+ * at once, within the ration. It is how the protocol above acts on a
127
+ * connection it finds dead, which a page cannot ping. With no socket open
128
+ * it does nothing.
129
+ */
130
+ reconnect(reason: string): void;
131
+ /** The page's own end: closes the socket 1000, and nothing is opened again. */
132
+ close(): void;
133
+ private set;
134
+ private teardown;
135
+ private connect;
136
+ private closed;
137
+ /** Settles a failed handshake with one same-origin request to the kit's `me` route. */
138
+ private probe;
139
+ /** Reconnects at once, within the ration; past it, with backoff. */
140
+ private reconnectNow;
141
+ private backoff;
142
+ private end;
143
+ /** Closes a socket the connection is giving up on, and stops listening to it. */
144
+ private drop;
145
+ }