@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
@@ -0,0 +1,556 @@
1
+ // The terminalwire codec (ADR-180 D4; ADR-181 D11 item 3): the frames a view
2
+ // of an attended session's terminal speaks, read and written in the browser.
3
+ // It is github.com/kottera/sdk/terminalwire's, and the committed corpus
4
+ // sdk/testdata/terminalwire-corpus.json holds the two to one wire.
5
+ //
6
+ // A frame is a 4-byte header — one byte of type, three of payload length,
7
+ // little-endian — and its payload; one frame rides in one binary WebSocket
8
+ // message. Control bodies are CBOR from a closed subset this file reads and
9
+ // writes by hand, so the package keeps no runtime dependency: one map of
10
+ // definite length, text keys, and values that are unsigned integers, UTF-8
11
+ // text or booleans. An offset is at most MAX_OFFSET (2^53-1), so it is a
12
+ // number; an encoder refuses an offset past it, or a string with a lone
13
+ // surrogate, with a RangeError rather than write a body its decoder refuses.
14
+ // A field this version does not know is skipped whatever well-formed item it
15
+ // is; a frame type this version does not know is ignored (`isKnownFrameType`).
16
+ // The protocol only grows: types and fields are append-only.
17
+ //
18
+ // Decoding copies nothing: a frame's payload is a view of the message's
19
+ // buffer, so a DATA payload goes to the renderer as it arrived.
20
+ import { collapseCloseReason, collapseRole } from "./collapse.js";
21
+ /** The protocol version this codec speaks. */
22
+ export const VERSION = 1;
23
+ /** The WebSocket subprotocol a view negotiates. */
24
+ export const SUBPROTOCOL = "tera.terminalwire.v1";
25
+ export const HEADER_SIZE = 4;
26
+ /** The bound on a DATA or INPUT payload: the runner link's stream frame. */
27
+ export const MAX_DATA_PAYLOAD = 16 << 10;
28
+ /** The bound on a CBOR control body. */
29
+ export const MAX_CONTROL_PAYLOAD = 4 << 10;
30
+ /** The bound on any frame's payload, a type this version does not know included. */
31
+ export const MAX_PAYLOAD = MAX_DATA_PAYLOAD;
32
+ /** The largest frame, and so the largest message, on the wire. */
33
+ export const MAX_FRAME_BYTES = HEADER_SIZE + MAX_PAYLOAD;
34
+ /**
35
+ * The bound on an output offset, 2^53-1: every offset the wire admits is a
36
+ * number held exactly. An encoder refuses one above it and a decoder reads a
37
+ * body carrying one as malformed, as the Go codec does.
38
+ */
39
+ export const MAX_OFFSET = Number.MAX_SAFE_INTEGER;
40
+ /**
41
+ * The frame types of version 1. Zero is no frame; a value is never reassigned.
42
+ * SIGNAL carries one keystroke's character outside the input window; its
43
+ * contract is sdk/terminalwire's `TypeSignal` (`encodeSignal`).
44
+ */
45
+ export const FrameType = {
46
+ Welcome: 1,
47
+ Data: 2,
48
+ Resync: 3,
49
+ ReplayEnd: 4,
50
+ InputAck: 5,
51
+ Status: 6,
52
+ Close: 7,
53
+ Input: 8,
54
+ Ack: 9,
55
+ Resize: 10,
56
+ Control: 11,
57
+ Signal: 12,
58
+ };
59
+ const frameTypeNames = [
60
+ "",
61
+ "WELCOME",
62
+ "DATA",
63
+ "RESYNC",
64
+ "REPLAY_END",
65
+ "INPUT_ACK",
66
+ "STATUS",
67
+ "CLOSE",
68
+ "INPUT",
69
+ "ACK",
70
+ "RESIZE",
71
+ "CONTROL",
72
+ "SIGNAL",
73
+ ];
74
+ /** Whether this version knows frame type `t`. A receiver ignores a frame it does not. */
75
+ export function isKnownFrameType(t) {
76
+ return Number.isInteger(t) && t > 0 && t < frameTypeNames.length;
77
+ }
78
+ /** The wire name of a frame type: `WELCOME`, `DATA`, …; `FrameType(42)` for one this version does not know. */
79
+ export function frameTypeName(t) {
80
+ return isKnownFrameType(t) ? frameTypeNames[t] : `FrameType(${t})`;
81
+ }
82
+ export class TerminalwireError extends Error {
83
+ kind;
84
+ constructor(kind, message) {
85
+ super(`terminalwire: ${message}`);
86
+ this.name = "TerminalwireError";
87
+ this.kind = kind;
88
+ }
89
+ }
90
+ const malformed = (why) => new TerminalwireError("malformed", why);
91
+ /**
92
+ * A view's part in its session (ADR-180 D7). A peek (ADR-190 D7) is output
93
+ * only and never counted as attached; a version that predates it reads it as
94
+ * a viewer. Members are appended, never reordered.
95
+ */
96
+ export const Role = { Controller: "controller", Viewer: "viewer", Peeker: "peeker" };
97
+ const roles = Object.values(Role);
98
+ /** Why a view ended, from a closed set (ADR-180 D4). Members are appended, never reordered. */
99
+ export const CloseReason = {
100
+ Detached: "detached",
101
+ Exited: "exited",
102
+ Handover: "handover",
103
+ Reauthenticate: "reauthenticate",
104
+ NotAttendee: "not-attendee",
105
+ Superseded: "superseded",
106
+ RunnerUnreachable: "runner-unreachable",
107
+ FlowControl: "flow-control",
108
+ Expired: "expired",
109
+ // A peek fell behind (ADR-190 D7 item 3): its client opens it again at once,
110
+ // with no resume offset, for a fresh replay. A version that predates it
111
+ // reads it as detached, and still reads `reconnect`.
112
+ Reopen: "reopen",
113
+ };
114
+ const closeReasons = Object.values(CloseReason);
115
+ /** What a CONTROL frame asks (ADR-180 D7). */
116
+ export const ControlAction = { Take: "take", Release: "release" };
117
+ const controlActions = Object.values(ControlAction);
118
+ // Each vocabulary's decoder collapses a value it does not know to the safe
119
+ // member (compat-policy §3.5): a role to viewer, so a client never believes it
120
+ // types into a session it does not control; a close reason to detached, so
121
+ // nothing about it is retried (`reconnect` is still read); an action to
122
+ // release, which never moves control to the sender.
123
+ const decodeRole = (s) => collapseRole(roles, s);
124
+ const decodeCloseReason = (s) => collapseCloseReason(closeReasons, s);
125
+ const decodeControlAction = (s) => s === ControlAction.Take ? ControlAction.Take : ControlAction.Release;
126
+ const MAX_U8 = 0xff;
127
+ const MAX_U16 = 0xffff;
128
+ const MAX_U32 = 0xffffffff;
129
+ // uint answers v when it is an integer the field's width holds, so an encoder
130
+ // never writes a body its own decoder refuses.
131
+ function uint(v, max, field) {
132
+ if (!Number.isInteger(v) || v < 0 || v > max)
133
+ throw new RangeError(`terminalwire: ${field} ${v} is out of range`);
134
+ return v;
135
+ }
136
+ function payloadBound(t) {
137
+ if (t === FrameType.Data || t === FrameType.Input)
138
+ return MAX_DATA_PAYLOAD;
139
+ return isKnownFrameType(t) ? MAX_CONTROL_PAYLOAD : MAX_PAYLOAD;
140
+ }
141
+ function asBytes(msg) {
142
+ return msg instanceof Uint8Array ? msg : new Uint8Array(msg);
143
+ }
144
+ /** Reads a header: the frame type and the payload length. */
145
+ export function parseHeader(msg) {
146
+ const b = asBytes(msg);
147
+ if (b.length < HEADER_SIZE)
148
+ throw new TerminalwireError("truncated", "a header is 4 bytes");
149
+ const type = b[0];
150
+ const length = b[1] | (b[2] << 8) | (b[3] << 16);
151
+ if (length > payloadBound(type)) {
152
+ throw new TerminalwireError("too_large", `a ${frameTypeName(type)} payload of ${length} bytes`);
153
+ }
154
+ return { type, length };
155
+ }
156
+ /** Reads a message holding exactly one frame. The payload aliases the message. */
157
+ export function decodeFrame(msg) {
158
+ const b = asBytes(msg);
159
+ const { type, length } = parseHeader(b);
160
+ if (b.length - HEADER_SIZE < length)
161
+ throw new TerminalwireError("truncated", "a frame shorter than its header");
162
+ if (b.length !== HEADER_SIZE + length)
163
+ throw malformed(`${b.length - HEADER_SIZE - length} bytes after the frame`);
164
+ return { type, payload: b.subarray(HEADER_SIZE) };
165
+ }
166
+ function withHeader(type, payload) {
167
+ if (payload.length > payloadBound(type)) {
168
+ throw new TerminalwireError("too_large", `a ${frameTypeName(type)} payload of ${payload.length} bytes`);
169
+ }
170
+ const out = new Uint8Array(HEADER_SIZE + payload.length);
171
+ out[0] = type;
172
+ out[1] = payload.length & 0xff;
173
+ out[2] = (payload.length >> 8) & 0xff;
174
+ out[3] = (payload.length >> 16) & 0xff;
175
+ out.set(payload, HEADER_SIZE);
176
+ return out;
177
+ }
178
+ function expectType(f, ...types) {
179
+ if (!types.includes(f.type))
180
+ throw new TerminalwireError("wrong_type", `a ${frameTypeName(f.type)} frame`);
181
+ }
182
+ /** A DATA or INPUT frame of `bytes`. */
183
+ export function encodeBytes(type, bytes) {
184
+ if (type !== FrameType.Data && type !== FrameType.Input)
185
+ throw new TerminalwireError("wrong_type", frameTypeName(type));
186
+ return withHeader(type, bytes);
187
+ }
188
+ export function encodeReplayEnd() {
189
+ return withHeader(FrameType.ReplayEnd, new Uint8Array(0));
190
+ }
191
+ /** An ACK or INPUT_ACK frame of `count` bytes. */
192
+ export function encodeCount(type, count) {
193
+ if (type !== FrameType.Ack && type !== FrameType.InputAck)
194
+ throw new TerminalwireError("wrong_type", frameTypeName(type));
195
+ if (!Number.isInteger(count) || count < 0 || count > MAX_U32)
196
+ throw new RangeError(`terminalwire: count ${count} is not a u32`);
197
+ const p = new Uint8Array(4);
198
+ new DataView(p.buffer).setUint32(0, count, true);
199
+ return withHeader(type, p);
200
+ }
201
+ /** An ACK or INPUT_ACK frame's count. Bytes past the first four are a later version's. */
202
+ export function decodeCount(f) {
203
+ expectType(f, FrameType.Ack, FrameType.InputAck);
204
+ if (f.payload.length < 4)
205
+ throw malformed("a count is 4 bytes");
206
+ return new DataView(f.payload.buffer, f.payload.byteOffset, 4).getUint32(0, true);
207
+ }
208
+ /**
209
+ * A SIGNAL frame of one keystroke's character, `char` the byte the key
210
+ * produces (0x03 for Ctrl-C). It rides outside the input window, so it is how
211
+ * an interrupt reaches a session whose window is full (PRD-031 SC6). The
212
+ * minder signals the foreground process group when the byte is the pty's
213
+ * interrupt, quit or suspend character and ISIG is set, and drops it
214
+ * otherwise; it never queues it and never grants it back. A client sends a
215
+ * keystroke as INPUT while its credit holds it, and a one-byte keystroke the
216
+ * credit cannot hold as one SIGNAL instead of holding it for credit, once per
217
+ * keystroke (sdk/terminalwire `TypeSignal` states the whole contract).
218
+ */
219
+ export function encodeSignal(char) {
220
+ if (!Number.isInteger(char) || char < 0 || char > MAX_U8)
221
+ throw new RangeError(`terminalwire: character ${char} is not a u8`);
222
+ return withHeader(FrameType.Signal, Uint8Array.of(char));
223
+ }
224
+ /** A SIGNAL frame's character. Bytes past the first are a later version's. */
225
+ export function decodeSignal(f) {
226
+ expectType(f, FrameType.Signal);
227
+ if (f.payload.length < 1)
228
+ throw malformed("a character is 1 byte");
229
+ return f.payload[0];
230
+ }
231
+ export function encodeResize(size) {
232
+ for (const v of [size.cols, size.rows]) {
233
+ if (!Number.isInteger(v) || v < 0 || v > MAX_U16)
234
+ throw new RangeError(`terminalwire: size ${v} is not a u16`);
235
+ }
236
+ const p = new Uint8Array(4);
237
+ const dv = new DataView(p.buffer);
238
+ dv.setUint16(0, size.cols, true);
239
+ dv.setUint16(2, size.rows, true);
240
+ return withHeader(FrameType.Resize, p);
241
+ }
242
+ /** A RESIZE frame's size. Bytes past the first four are a later version's. */
243
+ export function decodeResize(f) {
244
+ expectType(f, FrameType.Resize);
245
+ if (f.payload.length < 4)
246
+ throw malformed("a size is 4 bytes");
247
+ const dv = new DataView(f.payload.buffer, f.payload.byteOffset, 4);
248
+ return { cols: dv.getUint16(0, true), rows: dv.getUint16(2, true) };
249
+ }
250
+ // --- CBOR, the closed subset -------------------------------------------------
251
+ const MAJOR_UINT = 0;
252
+ const MAJOR_TEXT = 3;
253
+ const MAJOR_MAP = 5;
254
+ const CBOR_FALSE = 0xf4;
255
+ const CBOR_TRUE = 0xf5;
256
+ // An unknown field's value nests at most this deep.
257
+ const MAX_SKIP_DEPTH = 16;
258
+ const utf8Encoder = new TextEncoder();
259
+ // A lone surrogate has no UTF-8 form: TextEncoder would write U+FFFD for it.
260
+ const loneSurrogate = /\p{Surrogate}/u;
261
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
262
+ class Writer {
263
+ parts = [];
264
+ head(major, v) {
265
+ const p = this.parts;
266
+ if (v < 24)
267
+ p.push((major << 5) | v);
268
+ else if (v <= 0xff)
269
+ p.push((major << 5) | 24, v);
270
+ else if (v <= 0xffff)
271
+ p.push((major << 5) | 25, v >> 8, v & 0xff);
272
+ else if (v <= MAX_U32)
273
+ p.push((major << 5) | 26, (v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff);
274
+ else {
275
+ const big = BigInt(v);
276
+ p.push((major << 5) | 27);
277
+ for (let shift = 56n; shift >= 0n; shift -= 8n)
278
+ p.push(Number((big >> shift) & 0xffn));
279
+ }
280
+ }
281
+ text(s) {
282
+ if (loneSurrogate.test(s))
283
+ throw new RangeError("terminalwire: text that is not well-formed Unicode");
284
+ const b = utf8Encoder.encode(s);
285
+ this.head(MAJOR_TEXT, b.length);
286
+ for (const x of b)
287
+ this.parts.push(x);
288
+ }
289
+ value(v) {
290
+ if (typeof v === "boolean")
291
+ this.parts.push(v ? CBOR_TRUE : CBOR_FALSE);
292
+ else if (typeof v === "string")
293
+ this.text(v);
294
+ else
295
+ this.head(MAJOR_UINT, v);
296
+ }
297
+ bytes() {
298
+ return Uint8Array.from(this.parts);
299
+ }
300
+ }
301
+ // encodeBody writes the fields in order, leaving out each at its zero value,
302
+ // so absent and zero are one reading.
303
+ function encodeBody(type, fields) {
304
+ const present = fields.filter(([, v]) => v !== 0 && v !== "" && v !== false);
305
+ const w = new Writer();
306
+ w.head(MAJOR_MAP, present.length);
307
+ for (const [k, v] of present) {
308
+ w.text(k);
309
+ w.value(v);
310
+ }
311
+ return withHeader(type, w.bytes());
312
+ }
313
+ class Reader {
314
+ off = 0;
315
+ p;
316
+ dv;
317
+ constructor(p) {
318
+ this.p = p;
319
+ this.dv = new DataView(p.buffer, p.byteOffset, p.byteLength);
320
+ }
321
+ // head reads an initial byte and its argument. A 64-bit argument past what a
322
+ // number holds exactly reads as Infinity, which every bound refuses.
323
+ head() {
324
+ if (this.off >= this.p.length)
325
+ throw malformed("a body ends inside an item");
326
+ const ib = this.p[this.off++];
327
+ const major = ib >> 5;
328
+ const info = ib & 0x1f;
329
+ if (info < 24)
330
+ return [major, info];
331
+ const n = info === 24 ? 1 : info === 25 ? 2 : info === 26 ? 4 : info === 27 ? 8 : 0;
332
+ if (n === 0)
333
+ throw malformed(`additional information ${info}`);
334
+ if (this.p.length - this.off < n)
335
+ throw malformed("a body ends inside a head");
336
+ const at = this.off;
337
+ this.off += n;
338
+ switch (n) {
339
+ case 1:
340
+ return [major, this.p[at]];
341
+ case 2:
342
+ return [major, this.dv.getUint16(at)];
343
+ case 4:
344
+ return [major, this.dv.getUint32(at)];
345
+ default: {
346
+ const big = this.dv.getBigUint64(at);
347
+ return [major, big <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(big) : Infinity];
348
+ }
349
+ }
350
+ }
351
+ mapHeader() {
352
+ const [major, n] = this.head();
353
+ if (major !== MAJOR_MAP || n > this.p.length)
354
+ throw malformed("a body is one map");
355
+ return n;
356
+ }
357
+ text() {
358
+ const [major, n] = this.head();
359
+ if (major !== MAJOR_TEXT || n > this.p.length - this.off)
360
+ throw malformed("not a text string");
361
+ const s = this.p.subarray(this.off, this.off + n);
362
+ this.off += n;
363
+ try {
364
+ return utf8Decoder.decode(s);
365
+ }
366
+ catch {
367
+ throw malformed("text that is not UTF-8");
368
+ }
369
+ }
370
+ uint(max) {
371
+ const [major, v] = this.head();
372
+ if (major !== MAJOR_UINT || v > max)
373
+ throw malformed("not an unsigned integer in range");
374
+ return v;
375
+ }
376
+ bool() {
377
+ const b = this.p[this.off];
378
+ if (b !== CBOR_TRUE && b !== CBOR_FALSE)
379
+ throw malformed("not a boolean");
380
+ this.off++;
381
+ return b === CBOR_TRUE;
382
+ }
383
+ // skip passes over one well-formed definite-length item of any major type:
384
+ // the value of a field this version does not know.
385
+ skip(depth) {
386
+ if (depth > MAX_SKIP_DEPTH)
387
+ throw malformed("a value nested too deep");
388
+ const [major, v] = this.head();
389
+ switch (major) {
390
+ case 0:
391
+ case 1:
392
+ case 7:
393
+ return;
394
+ case 2:
395
+ case 3:
396
+ if (v > this.p.length - this.off)
397
+ throw malformed("a string past the body");
398
+ this.off += v;
399
+ return;
400
+ case 4:
401
+ case 5: {
402
+ if (major === 5 && v > this.p.length)
403
+ throw malformed("a map past the body");
404
+ const items = major === 5 ? v * 2 : v;
405
+ if (items > this.p.length - this.off)
406
+ throw malformed("an array past the body");
407
+ for (let i = 0; i < items; i++)
408
+ this.skip(depth + 1);
409
+ return;
410
+ }
411
+ default:
412
+ this.skip(depth + 1);
413
+ }
414
+ }
415
+ done() {
416
+ if (this.off !== this.p.length)
417
+ throw malformed("bytes after the body's map");
418
+ }
419
+ }
420
+ // decodeBody walks a body's map, handing each known key's value to its reader
421
+ // once and skipping every other.
422
+ function decodeBody(payload, fields) {
423
+ const r = new Reader(payload);
424
+ const pairs = r.mapHeader();
425
+ const seen = new Set();
426
+ for (let i = 0; i < pairs; i++) {
427
+ const k = r.text();
428
+ const read = Object.hasOwn(fields, k) ? fields[k] : undefined;
429
+ if (read === undefined) {
430
+ r.skip(0);
431
+ continue;
432
+ }
433
+ if (seen.has(k))
434
+ throw malformed(`field ${k} twice`);
435
+ seen.add(k);
436
+ read(r);
437
+ }
438
+ r.done();
439
+ }
440
+ function requireMember(v, members, what) {
441
+ if (!members.includes(v))
442
+ throw new TerminalwireError("not_in_vocabulary", `${what} ${JSON.stringify(v)}`);
443
+ }
444
+ export function encodeWelcome(w) {
445
+ requireMember(w.role, roles, "role");
446
+ return encodeBody(FrameType.Welcome, [
447
+ ["version", uint(w.version, MAX_U32, "version")],
448
+ ["view_id", w.viewId],
449
+ ["role", w.role],
450
+ ["sets_size", w.setsSize],
451
+ ["cols", uint(w.size.cols, MAX_U16, "cols")],
452
+ ["rows", uint(w.size.rows, MAX_U16, "rows")],
453
+ ["offset", uint(w.offset, MAX_OFFSET, "offset")],
454
+ ["replay", w.replay],
455
+ ["views", uint(w.views, MAX_U32, "views")],
456
+ ]);
457
+ }
458
+ export function decodeWelcome(f) {
459
+ expectType(f, FrameType.Welcome);
460
+ const w = { version: 0, viewId: "", role: Role.Viewer, setsSize: false, size: { cols: 0, rows: 0 }, offset: 0, replay: false, views: 0 };
461
+ decodeBody(f.payload, {
462
+ version: (r) => (w.version = r.uint(MAX_U32)),
463
+ view_id: (r) => (w.viewId = r.text()),
464
+ role: (r) => (w.role = decodeRole(r.text())),
465
+ sets_size: (r) => (w.setsSize = r.bool()),
466
+ cols: (r) => (w.size.cols = r.uint(MAX_U16)),
467
+ rows: (r) => (w.size.rows = r.uint(MAX_U16)),
468
+ offset: (r) => (w.offset = r.uint(MAX_OFFSET)),
469
+ replay: (r) => (w.replay = r.bool()),
470
+ views: (r) => (w.views = r.uint(MAX_U32)),
471
+ });
472
+ return w;
473
+ }
474
+ export function encodeResync(s) {
475
+ return encodeBody(FrameType.Resync, [
476
+ ["offset", uint(s.offset, MAX_OFFSET, "offset")],
477
+ ["replay", s.replay],
478
+ ]);
479
+ }
480
+ export function decodeResync(f) {
481
+ expectType(f, FrameType.Resync);
482
+ const s = { offset: 0, replay: false };
483
+ decodeBody(f.payload, {
484
+ offset: (r) => (s.offset = r.uint(MAX_OFFSET)),
485
+ replay: (r) => (s.replay = r.bool()),
486
+ });
487
+ return s;
488
+ }
489
+ export function encodeStatus(s) {
490
+ requireMember(s.role, roles, "role");
491
+ return encodeBody(FrameType.Status, [
492
+ ["role", s.role],
493
+ ["views", uint(s.views, MAX_U32, "views")],
494
+ ["cols", uint(s.size.cols, MAX_U16, "cols")],
495
+ ["rows", uint(s.size.rows, MAX_U16, "rows")],
496
+ ["controller", s.controller],
497
+ ["size_setter", s.sizeSetter],
498
+ ["handover_pending", s.handoverPending],
499
+ ["exited", s.exited],
500
+ ["exit_status", uint(s.exitStatus, MAX_U32, "exit_status")],
501
+ ["exit_signal", s.exitSignal],
502
+ ]);
503
+ }
504
+ export function decodeStatus(f) {
505
+ expectType(f, FrameType.Status);
506
+ const s = {
507
+ role: Role.Viewer,
508
+ views: 0,
509
+ size: { cols: 0, rows: 0 },
510
+ controller: "",
511
+ sizeSetter: "",
512
+ handoverPending: false,
513
+ exited: false,
514
+ exitStatus: 0,
515
+ exitSignal: "",
516
+ };
517
+ decodeBody(f.payload, {
518
+ role: (r) => (s.role = decodeRole(r.text())),
519
+ views: (r) => (s.views = r.uint(MAX_U32)),
520
+ cols: (r) => (s.size.cols = r.uint(MAX_U16)),
521
+ rows: (r) => (s.size.rows = r.uint(MAX_U16)),
522
+ controller: (r) => (s.controller = r.text()),
523
+ size_setter: (r) => (s.sizeSetter = r.text()),
524
+ handover_pending: (r) => (s.handoverPending = r.bool()),
525
+ exited: (r) => (s.exited = r.bool()),
526
+ exit_status: (r) => (s.exitStatus = r.uint(MAX_U32)),
527
+ exit_signal: (r) => (s.exitSignal = r.text()),
528
+ });
529
+ return s;
530
+ }
531
+ export function encodeClose(c) {
532
+ requireMember(c.reason, closeReasons, "close reason");
533
+ return encodeBody(FrameType.Close, [
534
+ ["reason", c.reason],
535
+ ["reconnect", c.reconnect],
536
+ ]);
537
+ }
538
+ export function decodeClose(f) {
539
+ expectType(f, FrameType.Close);
540
+ const c = { reason: CloseReason.Detached, reconnect: false };
541
+ decodeBody(f.payload, {
542
+ reason: (r) => (c.reason = decodeCloseReason(r.text())),
543
+ reconnect: (r) => (c.reconnect = r.bool()),
544
+ });
545
+ return c;
546
+ }
547
+ export function encodeControl(c) {
548
+ requireMember(c.action, controlActions, "control action");
549
+ return encodeBody(FrameType.Control, [["action", c.action]]);
550
+ }
551
+ export function decodeControl(f) {
552
+ expectType(f, FrameType.Control);
553
+ const c = { action: ControlAction.Release };
554
+ decodeBody(f.payload, { action: (r) => (c.action = decodeControlAction(r.text())) });
555
+ return c;
556
+ }
@@ -0,0 +1,4 @@
1
+ /** Reads a role against vocabulary, collapsing one outside it to viewer. */
2
+ export declare const collapseRole: (vocabulary: readonly string[], s: string) => string;
3
+ /** Reads a close reason against vocabulary, collapsing one outside it to detached. */
4
+ export declare const collapseCloseReason: (vocabulary: readonly string[], s: string) => string;
@@ -0,0 +1,7 @@
1
+ // The role and close-reason decoders' collapses, kept out of the public
2
+ // `terminalwire` namespace so a test can read a value with the vocabulary an
3
+ // earlier version knew.
4
+ /** Reads a role against vocabulary, collapsing one outside it to viewer. */
5
+ export const collapseRole = (vocabulary, s) => vocabulary.includes(s) ? s : "viewer";
6
+ /** Reads a close reason against vocabulary, collapsing one outside it to detached. */
7
+ export const collapseCloseReason = (vocabulary, s) => vocabulary.includes(s) ? s : "detached";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The plugin was not reached: what tera's UI proxy answers the page's script
3
+ * when the plugin behind it did not answer.
4
+ *
5
+ * A page load is answered a page saying why. Anything else — a `fetch`, an
6
+ * XHR upload, a feed stream, a write, a WebSocket upgrade — is answered in
7
+ * the kit's envelope, `{"error": {"code", "message", "retryable"}}`, with one
8
+ * of these codes, so a page tells a plugin that is down or starting from one
9
+ * of its own failures and shows the proxy's `message`, a sentence for the
10
+ * person naming the plugin and why. Every one is retryable, and the two 503s
11
+ * carry `Retry-After`:
12
+ *
13
+ * const res = await fetch("api/boards");
14
+ * if (!res.ok && (await res.clone().json()).error?.code === PLUGIN_NOT_ANSWERING) ...
15
+ */
16
+ import type { KitErrorCode } from "./generated/kit.ts";
17
+ /** The plugin is at its runner link's stream cap, so tera opened no new connection to it (503, `Retry-After`). */
18
+ export declare const PLUGIN_BUSY: KitErrorCode;
19
+ /** tera has not yet settled where the plugin runs, so it opened no connection to it (503, `Retry-After`). */
20
+ export declare const PLUGIN_STARTING: KitErrorCode;
21
+ /** The plugin is placed on a runner tera has no live link to (502). */
22
+ export declare const PLUGIN_RUNNER_UNREACHABLE: KitErrorCode;
23
+ /** The plugin is installed and its UI did not answer on its socket — it is down, as during an upgrade (502). */
24
+ export declare const PLUGIN_NOT_ANSWERING: KitErrorCode;
25
+ /** Every code the proxy answers a page's script with when it did not reach the plugin. */
26
+ export declare const PLUGIN_UNREACHED_CODES: readonly KitErrorCode[];
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The plugin was not reached: what tera's UI proxy answers the page's script
3
+ * when the plugin behind it did not answer.
4
+ *
5
+ * A page load is answered a page saying why. Anything else — a `fetch`, an
6
+ * XHR upload, a feed stream, a write, a WebSocket upgrade — is answered in
7
+ * the kit's envelope, `{"error": {"code", "message", "retryable"}}`, with one
8
+ * of these codes, so a page tells a plugin that is down or starting from one
9
+ * of its own failures and shows the proxy's `message`, a sentence for the
10
+ * person naming the plugin and why. Every one is retryable, and the two 503s
11
+ * carry `Retry-After`:
12
+ *
13
+ * const res = await fetch("api/boards");
14
+ * if (!res.ok && (await res.clone().json()).error?.code === PLUGIN_NOT_ANSWERING) ...
15
+ */
16
+ /** The plugin is at its runner link's stream cap, so tera opened no new connection to it (503, `Retry-After`). */
17
+ export const PLUGIN_BUSY = "plugin_busy";
18
+ /** tera has not yet settled where the plugin runs, so it opened no connection to it (503, `Retry-After`). */
19
+ export const PLUGIN_STARTING = "plugin_starting";
20
+ /** The plugin is placed on a runner tera has no live link to (502). */
21
+ export const PLUGIN_RUNNER_UNREACHABLE = "plugin_runner_unreachable";
22
+ /** The plugin is installed and its UI did not answer on its socket — it is down, as during an upgrade (502). */
23
+ export const PLUGIN_NOT_ANSWERING = "plugin_not_answering";
24
+ /** Every code the proxy answers a page's script with when it did not reach the plugin. */
25
+ export const PLUGIN_UNREACHED_CODES = [
26
+ PLUGIN_BUSY,
27
+ PLUGIN_STARTING,
28
+ PLUGIN_RUNNER_UNREACHABLE,
29
+ PLUGIN_NOT_ANSWERING,
30
+ ];
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@kottera/tera-web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-rc.10",
4
+ "description": "The browser half of a UI over tera: a framework-free client for the /tera/v1/ routes a plugin's webui kit serves (ADR-162 D4).",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kottera/sdk.git",
9
+ "directory": "web"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "NOTICE"
22
+ ],
23
+ "sideEffects": false,
24
+ "engines": {
25
+ "node": ">=22.18"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.build.json",
32
+ "test": "node --test --test-reporter=tap \"src/**/*.test.ts\""
33
+ }
34
+ }