@kottera/tera-web 0.1.0-rc.6 → 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.
@@ -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
+ }
@@ -0,0 +1,377 @@
1
+ /**
2
+ * The stream connection: one WebSocket to a kit stream route (ADR-181 D7),
3
+ * held open for a page and reopened by the close code the far side chose
4
+ * (ADR-181 D9, D11 item 1). It is general: it carries whole binary messages
5
+ * and names no protocol above it — a terminal view tells it, through
6
+ * {@link StreamConnection.onNormalClose}, what a normal closure means.
7
+ *
8
+ * # What ends a socket, and what happens next
9
+ *
10
+ * | Ending | Next |
11
+ * |--------------------------------------------|--------------------------------------------|
12
+ * | 1000 `ended` | what {@link StreamConnection.onNormalClose} said last; otherwise end |
13
+ * | 1001 `going_away` | reconnect with backoff |
14
+ * | 1009 `too_big` | end: a message broke the route's bound |
15
+ * | 4000 `reconnect` | reconnect at once, rationed (see Ration) |
16
+ * | 4001 `retry_later` | reconnect with backoff |
17
+ * | 4003 `refused` | end; the reason is the refusal's code |
18
+ * | any other code (1006: the network, a | reconnect with backoff, as `retry_later` |
19
+ * | peer that stopped answering pings) | (ADR-181 D15 item 2) |
20
+ * | the handshake failed | one probe decides (see The handshake) |
21
+ * | more than {@link MAX_BUFFERED_BYTES} unsent | close and reconnect with backoff |
22
+ * | {@link StreamConnection.close} | end: the page's own, 1000 |
23
+ * | {@link StreamConnection.reconnect} | reconnect at once, rationed |
24
+ *
25
+ * # The handshake
26
+ *
27
+ * A browser tells a script nothing about a failed handshake — no status, no
28
+ * body, code 1006 — so the connection asks once, with a `GET` of the kit's
29
+ * `me` route on the same origin, which tera's UI proxy answers as it answered
30
+ * the upgrade:
31
+ *
32
+ * - `sign_in_required`: the person's session on the plugin's origin is gone.
33
+ * The page is loaded again ({@link StreamConnectionOptions.reSignIn}), which
34
+ * signs them in, and the connection ends.
35
+ * - A success: the session is live and the stream route refused or failed
36
+ * for a reason of its own, so the connection reconnects with backoff.
37
+ * - No answer, a 5xx, a 408 or a 429: tera or the network is not there yet —
38
+ * a daemon restarting — so it reconnects with backoff as well.
39
+ * - Anything else is a refusal, and the connection ends with its code.
40
+ *
41
+ * # Backoff, and the ration
42
+ *
43
+ * FeedConnection's constants: {@link BASE_DELAY_MS} doubling to
44
+ * {@link MAX_DELAY_MS}, each delay scaled by a jitter in [0.5, 1]. It resets
45
+ * once a socket has stayed open {@link STABLE_OPEN_MS}, not on a mere open, so
46
+ * a route that accepts and then closes every socket `retry_later` still backs
47
+ * off. A scheduled reconnect is taken at once when the network returns.
48
+ *
49
+ * An immediate reconnect — a `reconnect` close, or `"reconnect"` given to
50
+ * {@link StreamConnection.onNormalClose} — is rationed to
51
+ * {@link IMMEDIATE_LIMIT} in {@link IMMEDIATE_WINDOW_MS}; past it the next is
52
+ * taken with backoff, so a peer that closes `reconnect` in a loop cannot spin
53
+ * the page (ADR-181 D9 item 3).
54
+ *
55
+ * # Sending
56
+ *
57
+ * A `WebSocket`'s `send` never blocks: what the network has not taken waits in
58
+ * the page as `bufferedAmount`. The protocol above holds what it sends to its
59
+ * own window, so more than {@link MAX_BUFFERED_BYTES} waiting is a defect, and
60
+ * the connection closes the socket and reconnects rather than let it grow
61
+ * (ADR-181 D6 item 5). A message sent while no socket is open is not sent:
62
+ * {@link StreamConnection.send} answers false, and the protocol above resends
63
+ * from its own offsets once it is open again.
64
+ *
65
+ * It is a plain class with an injected socket, clock, fetch and signals, so its
66
+ * tests drive it with fake timers and a scripted socket and no network.
67
+ */
68
+ import { routePath, underBase, ROOT_BASE } from "../routes.js";
69
+ import { BASE_DELAY_MS, MAX_DELAY_MS } from "../feed/connection.js";
70
+ import { SIGN_IN_REQUIRED, isSignInRequiredAnswer, reSignIn } from "../signin.js";
71
+ import { STREAM_CLOSE_CODES } from "../generated/wsstream.js";
72
+ /** Immediate reconnects allowed in {@link IMMEDIATE_WINDOW_MS} before the next is taken with backoff. */
73
+ export const IMMEDIATE_LIMIT = 3;
74
+ export const IMMEDIATE_WINDOW_MS = 30_000;
75
+ /** How long a socket stays open before the backoff resets. */
76
+ export const STABLE_OPEN_MS = 10_000;
77
+ /** The most a page lets wait unsent before it treats the socket as a defect (ADR-181 D6 item 5). */
78
+ export const MAX_BUFFERED_BYTES = 1 << 20;
79
+ /** The close reason the connection sends when it closes a socket for {@link MAX_BUFFERED_BYTES}. */
80
+ export const SEND_BUFFER_EXCEEDED = "send_buffer_exceeded";
81
+ /** The code a browser reports for a socket that ended with no close frame: a failed handshake, a dropped network. */
82
+ const ABNORMAL_CLOSURE = 1006;
83
+ const realClock = {
84
+ now: () => Date.now(),
85
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
86
+ clearTimeout: (handle) => clearTimeout(handle),
87
+ random: () => Math.random(),
88
+ };
89
+ /** The browser's `online` event; the connection reconnects on nothing else from outside. */
90
+ const onlineSignal = (notify) => {
91
+ const win = globalThis;
92
+ const onOnline = () => notify("online");
93
+ win.addEventListener?.("online", onOnline);
94
+ return () => win.removeEventListener?.("online", onOnline);
95
+ };
96
+ const browserSocket = (url, protocol) => new WebSocket(url, protocol);
97
+ /**
98
+ * webSocketURL resolves a path on the page's origin to the `ws:` or `wss:` URL
99
+ * a `WebSocket` is opened with. With no page URL — outside a browser — the
100
+ * path is answered as it is.
101
+ */
102
+ export function webSocketURL(path, pageURL) {
103
+ if (pageURL === undefined)
104
+ return path;
105
+ const u = new URL(path, pageURL);
106
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
107
+ return u.href;
108
+ }
109
+ /** The name of each close code the connection knows, for a state's reason. */
110
+ const CLOSE_NAMES = new Map(Object.entries(STREAM_CLOSE_CODES).map(([name, code]) => [code, name]));
111
+ const encoder = new TextEncoder();
112
+ export class StreamConnection {
113
+ options;
114
+ clock;
115
+ openSocket;
116
+ view = { phase: "connecting", attempt: 0 };
117
+ unsubscribe;
118
+ // Every socket, timer and probe belongs to one generation; a callback from
119
+ // an older one writes nothing.
120
+ generation = 0;
121
+ ws = null;
122
+ opened = false;
123
+ retryTimer = null;
124
+ stableTimer = null;
125
+ attempt = 0;
126
+ immediate = [];
127
+ normalNext = "end";
128
+ /** Opens the first socket. */
129
+ constructor(options) {
130
+ this.options = options;
131
+ this.clock = options.clock ?? realClock;
132
+ this.openSocket = options.socket ?? browserSocket;
133
+ this.unsubscribe = (options.signals ?? onlineSignal)((signal) => {
134
+ if (signal === "online" && this.retryTimer !== null)
135
+ this.connect();
136
+ });
137
+ this.connect();
138
+ }
139
+ get state() {
140
+ return this.view;
141
+ }
142
+ /** The socket's URL as of now: the route under the app's base, with the query read afresh. */
143
+ get url() {
144
+ const path = underBase(this.options.base ?? ROOT_BASE, this.options.path);
145
+ const query = this.options.query?.().toString() ?? "";
146
+ return webSocketURL(query === "" ? path : path + "?" + query, globalThis.location?.href);
147
+ }
148
+ /** What the open socket has not yet handed to the network; 0 with none open. */
149
+ get bufferedAmount() {
150
+ return this.opened ? (this.ws?.bufferedAmount ?? 0) : 0;
151
+ }
152
+ /**
153
+ * Sends one message, answering whether it was handed to an open socket. A
154
+ * send that leaves more than {@link MAX_BUFFERED_BYTES} unsent closes the
155
+ * socket and reconnects with backoff.
156
+ */
157
+ send(message) {
158
+ const ws = this.ws;
159
+ if (!this.opened || ws === null)
160
+ return false;
161
+ ws.send(message);
162
+ if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
163
+ this.drop(ws, 1000, SEND_BUFFER_EXCEEDED);
164
+ this.backoff(`more than ${MAX_BUFFERED_BYTES} bytes waited unsent`);
165
+ }
166
+ return true;
167
+ }
168
+ /**
169
+ * Says what the normal closure (1000) that follows does: the protocol above
170
+ * read why the far side is ending, which the close code does not carry
171
+ * (ADR-181 D9 item 2). It holds for the current socket only; each new socket
172
+ * starts at `"end"`.
173
+ */
174
+ onNormalClose(next) {
175
+ this.normalNext = next;
176
+ }
177
+ /**
178
+ * Gives up on the open socket: closes it 1000 with `reason` and reconnects
179
+ * at once, within the ration. It is how the protocol above acts on a
180
+ * connection it finds dead, which a page cannot ping. With no socket open
181
+ * it does nothing.
182
+ */
183
+ reconnect(reason) {
184
+ const ws = this.ws;
185
+ if (!this.opened || ws === null)
186
+ return;
187
+ this.drop(ws, STREAM_CLOSE_CODES.ended, reason);
188
+ this.reconnectNow(reason);
189
+ }
190
+ /** The page's own end: closes the socket 1000, and nothing is opened again. */
191
+ close() {
192
+ if (this.view.phase === "ended")
193
+ return;
194
+ const ws = this.ws;
195
+ this.end(STREAM_CLOSE_CODES.ended, "closed");
196
+ if (ws !== null)
197
+ ws.close(STREAM_CLOSE_CODES.ended, "closed");
198
+ }
199
+ set(state) {
200
+ this.view = state;
201
+ this.options.onState?.(state);
202
+ }
203
+ teardown() {
204
+ this.generation += 1;
205
+ const ws = this.ws;
206
+ this.ws = null;
207
+ this.opened = false;
208
+ if (ws !== null) {
209
+ ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
210
+ }
211
+ if (this.retryTimer !== null)
212
+ this.clock.clearTimeout(this.retryTimer);
213
+ if (this.stableTimer !== null)
214
+ this.clock.clearTimeout(this.stableTimer);
215
+ this.retryTimer = null;
216
+ this.stableTimer = null;
217
+ }
218
+ connect() {
219
+ this.teardown();
220
+ const mine = this.generation;
221
+ this.normalNext = "end";
222
+ this.set({ phase: "connecting", attempt: this.attempt });
223
+ let ws;
224
+ try {
225
+ ws = this.openSocket(this.url, this.options.subprotocol);
226
+ }
227
+ catch (err) {
228
+ // The browser refused to open one at all: a malformed URL or a CSP that
229
+ // does not admit it. Neither changes on its own.
230
+ this.end(ABNORMAL_CLOSURE, err instanceof Error ? err.message : String(err));
231
+ return;
232
+ }
233
+ this.ws = ws;
234
+ ws.binaryType = "arraybuffer";
235
+ ws.onopen = () => {
236
+ if (mine !== this.generation)
237
+ return;
238
+ this.opened = true;
239
+ this.stableTimer = this.clock.setTimeout(() => {
240
+ this.stableTimer = null;
241
+ if (mine === this.generation)
242
+ this.attempt = 0;
243
+ }, STABLE_OPEN_MS);
244
+ this.set({ phase: "open" });
245
+ };
246
+ ws.onmessage = (ev) => {
247
+ if (mine !== this.generation)
248
+ return;
249
+ const data = ev.data;
250
+ if (data instanceof ArrayBuffer)
251
+ this.options.onMessage(data);
252
+ else if (typeof data === "string")
253
+ this.options.onMessage(encoder.encode(data).buffer);
254
+ };
255
+ ws.onclose = (ev) => {
256
+ if (mine === this.generation)
257
+ this.closed(ev.code, ev.reason);
258
+ };
259
+ }
260
+ closed(code, reason) {
261
+ const why = reason !== "" ? reason : (CLOSE_NAMES.get(code) ?? `closed ${code}`);
262
+ if (!this.opened) {
263
+ this.teardown();
264
+ void this.probe(this.generation, code);
265
+ return;
266
+ }
267
+ switch (code) {
268
+ case STREAM_CLOSE_CODES.ended:
269
+ switch (this.normalNext) {
270
+ case "reconnect":
271
+ this.reconnectNow(why);
272
+ return;
273
+ case "backoff":
274
+ this.backoff(why);
275
+ return;
276
+ case "end":
277
+ this.end(code, reason);
278
+ return;
279
+ }
280
+ return;
281
+ case STREAM_CLOSE_CODES.reconnect:
282
+ this.reconnectNow(why);
283
+ return;
284
+ case STREAM_CLOSE_CODES.too_big:
285
+ case STREAM_CLOSE_CODES.refused:
286
+ this.end(code, reason);
287
+ return;
288
+ }
289
+ // going_away, retry_later, and every code the vocabulary does not name
290
+ // (D15 item 2), a dropped network's 1006 among them.
291
+ this.backoff(why);
292
+ }
293
+ /** Settles a failed handshake with one same-origin request to the kit's `me` route. */
294
+ async probe(mine, code) {
295
+ let status;
296
+ let contentType;
297
+ let body;
298
+ try {
299
+ const fetchFn = this.options.fetch ?? globalThis.fetch.bind(globalThis);
300
+ const res = await fetchFn(underBase(this.options.base ?? ROOT_BASE, routePath("me")), {
301
+ method: "GET",
302
+ credentials: "same-origin",
303
+ headers: { accept: "application/json" },
304
+ });
305
+ status = res.status;
306
+ contentType = res.headers.get("content-type") ?? "";
307
+ body = await res.text();
308
+ }
309
+ catch (err) {
310
+ if (mine === this.generation)
311
+ this.backoff(`the handshake failed and its probe had no answer: ${err instanceof Error ? err.message : String(err)}`);
312
+ return;
313
+ }
314
+ if (mine !== this.generation)
315
+ return;
316
+ if (isSignInRequiredAnswer(status, contentType, body)) {
317
+ this.end(code, SIGN_IN_REQUIRED);
318
+ // The person's session on the plugin's origin is gone; only a page load
319
+ // brings it back, and that ends this connection with the page.
320
+ (this.options.reSignIn ?? reSignIn)();
321
+ return;
322
+ }
323
+ if ((status >= 200 && status < 300) || status >= 500 || status === 408 || status === 429) {
324
+ this.backoff(`the handshake failed; its probe answered ${status}`);
325
+ return;
326
+ }
327
+ this.end(code, refusalCode(contentType, body) ?? `http_${status}`);
328
+ }
329
+ /** Reconnects at once, within the ration; past it, with backoff. */
330
+ reconnectNow(why) {
331
+ const now = this.clock.now();
332
+ this.immediate = this.immediate.filter((at) => now - at < IMMEDIATE_WINDOW_MS);
333
+ if (this.immediate.length >= IMMEDIATE_LIMIT) {
334
+ this.backoff(`${why} (immediate reconnects rationed)`);
335
+ return;
336
+ }
337
+ this.immediate.push(now);
338
+ this.teardown();
339
+ this.set({ phase: "reconnecting", attempt: this.attempt, delayMs: 0, reason: why });
340
+ this.connect();
341
+ }
342
+ backoff(why) {
343
+ this.teardown();
344
+ this.attempt += 1;
345
+ const exp = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (this.attempt - 1));
346
+ const delayMs = Math.round(exp * (0.5 + this.clock.random() * 0.5));
347
+ this.set({ phase: "reconnecting", attempt: this.attempt, delayMs, reason: why });
348
+ this.retryTimer = this.clock.setTimeout(() => {
349
+ this.retryTimer = null;
350
+ this.connect();
351
+ }, delayMs);
352
+ }
353
+ end(code, reason) {
354
+ this.teardown();
355
+ this.unsubscribe?.();
356
+ this.unsubscribe = null;
357
+ this.set({ phase: "ended", code, reason });
358
+ }
359
+ /** Closes a socket the connection is giving up on, and stops listening to it. */
360
+ drop(ws, code, reason) {
361
+ this.teardown();
362
+ ws.close(code, reason);
363
+ }
364
+ }
365
+ /** The kit's envelope's `error.code`, when the body is one. */
366
+ function refusalCode(contentType, body) {
367
+ if (contentType.toLowerCase().split(";")[0].trim() !== "application/json")
368
+ return undefined;
369
+ try {
370
+ const parsed = JSON.parse(body);
371
+ const code = parsed?.error?.code;
372
+ return typeof code === "string" && code !== "" ? code : undefined;
373
+ }
374
+ catch {
375
+ return undefined;
376
+ }
377
+ }
@@ -0,0 +1,168 @@
1
+ import type { Clock, FeedSignals } from "../feed/connection.ts";
2
+ import { type NormalCloseNext, type StreamConnectionOptions, type StreamConnectionState } from "../stream/connection.ts";
3
+ import { CloseReason, Role, type Size } from "../terminalwire/codec.ts";
4
+ /** The `INPUT` a view sends beyond what the minder has granted back (ADR-180 D6 item 2). */
5
+ export declare const INPUT_WINDOW: number;
6
+ /** The parsed bytes that make one `ACK`; a renderer with nothing left to parse acknowledges sooner. */
7
+ export declare const ACK_EVERY: number;
8
+ /** How long a grant may stay outstanding before the view treats its socket as dead. */
9
+ export declare const INPUT_GRANT_TIMEOUT_MS = 5000;
10
+ /** The close reason the view gives up on a socket with after {@link INPUT_GRANT_TIMEOUT_MS}. */
11
+ export declare const INPUT_GRANT_TIMEOUT = "input_grant_timeout";
12
+ /** How long `resize` waits for the next before the size is sent. */
13
+ export declare const RESIZE_DEBOUNCE_MS = 100;
14
+ /** The most input the view queues for credit; a paste past it is dropped. */
15
+ export declare const MAX_QUEUED_INPUT: number;
16
+ /**
17
+ * The `SIGNAL`s sent between two grants: half of the 8 KiB control allowance
18
+ * outside the input window (ADR-180 D6 item 3), at five bytes a frame.
19
+ */
20
+ export declare const MAX_SIGNALS_PER_GRANT: number;
21
+ /**
22
+ * What draws the terminal. xterm.js's `Terminal` satisfies it as it is; the
23
+ * package bundles no emulator (ADR-181 D11 item 4).
24
+ */
25
+ export interface TerminalRenderer {
26
+ /**
27
+ * Queues `data` to be drawn and calls `parsed` once it has been. `data` is
28
+ * a view of the message it arrived in, and the view never writes to it.
29
+ */
30
+ write(data: Uint8Array, parsed: () => void): void;
31
+ }
32
+ /** What the view knows of its session. */
33
+ export interface TerminalStatus {
34
+ /** The view's id: a UUIDv7 minted when the view was made, kept across its reconnects. */
35
+ readonly viewId: string;
36
+ readonly connection: StreamConnectionState;
37
+ /** Whether the current socket's `WELCOME` has arrived; input is sent only then. */
38
+ readonly welcomed: boolean;
39
+ /** This view's part, as the minder last said: `viewer` until it says otherwise. */
40
+ readonly role: Role;
41
+ /** Whether this view's `RESIZE` sizes the pty. */
42
+ readonly setsSize: boolean;
43
+ readonly views: number;
44
+ /** The pty's size, which a view that does not set the size renders at. */
45
+ readonly size: Size;
46
+ /** The controlling view's id; empty when none controls. */
47
+ readonly controller: string;
48
+ /** The id of the view that sets the size. */
49
+ readonly sizeSetter: string;
50
+ /** A handover is waiting for the session to go quiet. */
51
+ readonly handoverPending: boolean;
52
+ /** The process's exit, once it has exited. */
53
+ readonly exit: {
54
+ readonly status: number;
55
+ readonly signal: string;
56
+ } | null;
57
+ /** History is being drawn, and input is dropped. */
58
+ readonly restoring: boolean;
59
+ /** The reason of the last `CLOSE` frame, until the next `WELCOME`. */
60
+ readonly closeReason: CloseReason | null;
61
+ /** Input bytes the view dropped: typed while restoring or after the end, past the queue's bound, or past the `SIGNAL` allowance. */
62
+ readonly dropped: number;
63
+ }
64
+ export interface TerminalViewOptions {
65
+ /** The attended session's Work Order. */
66
+ workOrder: string;
67
+ /** Whether the view asks to type when it attaches. */
68
+ want: "control" | "view";
69
+ renderer: TerminalRenderer;
70
+ /** The view's own size in cells, each from 1 to 65535. */
71
+ cols: number;
72
+ rows: number;
73
+ /** Every change of {@link TerminalStatus}. */
74
+ onStatus?: (status: TerminalStatus) => void;
75
+ /** The app's base, as a client's `base` option names it; `/` when absent. */
76
+ base?: string;
77
+ socket?: StreamConnectionOptions["socket"];
78
+ clock?: Clock;
79
+ /** The handshake probe's fetch, as {@link StreamConnectionOptions.fetch}. */
80
+ fetch?: typeof fetch;
81
+ /** As {@link StreamConnectionOptions.reSignIn}. */
82
+ reSignIn?: () => void;
83
+ signals?: FeedSignals;
84
+ }
85
+ /** A canonical lower-case UUIDv7 (RFC 9562) at `ms`, which the kit's terminal route requires of a view id. */
86
+ export declare function newViewId(ms: number): string;
87
+ /** What the normal closure after a `CLOSE` frame of `reason` does (ADR-181 D11 item 2). */
88
+ export declare function nextAfterClose(reason: CloseReason): NormalCloseNext;
89
+ export declare class TerminalView {
90
+ readonly viewId: string;
91
+ private readonly renderer;
92
+ private readonly clock;
93
+ private readonly onStatus;
94
+ private readonly conn;
95
+ private want;
96
+ private local;
97
+ private sent;
98
+ private gen;
99
+ private connection;
100
+ private welcomed;
101
+ private ended;
102
+ private role;
103
+ private setsSize;
104
+ private views;
105
+ private size;
106
+ private controller;
107
+ private sizeSetter;
108
+ private handoverPending;
109
+ private exit;
110
+ private closeReason;
111
+ private dropped;
112
+ private takeSent;
113
+ private hasOffset;
114
+ private base;
115
+ private handed;
116
+ private unacked;
117
+ private pending;
118
+ private restoring;
119
+ private writeSeq;
120
+ private parsedSeq;
121
+ private replayEndSeq;
122
+ private readonly queue;
123
+ private queueHead;
124
+ private queued;
125
+ private inFlight;
126
+ private signalsSinceGrant;
127
+ private grantTimer;
128
+ private resizeTimer;
129
+ /** Opens the view's socket at once. */
130
+ constructor(options: TerminalViewOptions);
131
+ get status(): TerminalStatus;
132
+ /**
133
+ * Sends what the person typed: what xterm.js's `onData` hands the page, a
134
+ * string sent as its UTF-8 bytes. A `Uint8Array` is copied, so the caller
135
+ * may reuse it.
136
+ */
137
+ input(data: string | Uint8Array): void;
138
+ /** The view's own size changed. It reaches the pty only while this view sets the size. */
139
+ resize(cols: number, rows: number): void;
140
+ /** Asks for control now, rather than at the next keystroke. */
141
+ takeControl(): void;
142
+ /** The page's own end: tells the minder the view detached, and closes the socket. */
143
+ detach(): void;
144
+ private get ready();
145
+ private emit;
146
+ private query;
147
+ private connectionState;
148
+ private message;
149
+ private apply;
150
+ private welcome;
151
+ private statusFrame;
152
+ private data;
153
+ private parsed;
154
+ private startRestoring;
155
+ /** Ends the restoring once the renderer has parsed every write before REPLAY_END. */
156
+ private restored;
157
+ private granted;
158
+ /** Sends queued input as far as the window allows. */
159
+ private pump;
160
+ private signal;
161
+ private sendTake;
162
+ private drop;
163
+ private discardQueue;
164
+ private armGrantTimer;
165
+ private clearGrantTimer;
166
+ private scheduleResize;
167
+ private clearResizeTimer;
168
+ }