herdr-remote-relay 0.3.12 → 0.3.13

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 (64) hide show
  1. package/bin/herdr-remote-relay.js +20 -20
  2. package/dist/auth-store.d.ts +114 -0
  3. package/dist/auth-store.js +276 -0
  4. package/dist/metrics.d.ts +76 -0
  5. package/dist/metrics.js +249 -0
  6. package/dist/protocol/frames.d.ts +27 -0
  7. package/dist/protocol/frames.js +110 -0
  8. package/dist/protocol/http.d.ts +161 -0
  9. package/dist/protocol/http.js +3 -0
  10. package/dist/protocol/index.d.ts +5 -0
  11. package/dist/protocol/index.js +7 -0
  12. package/dist/protocol/messages.d.ts +321 -0
  13. package/dist/protocol/messages.js +18 -0
  14. package/dist/protocol/paste.d.ts +14 -0
  15. package/dist/protocol/paste.js +50 -0
  16. package/dist/protocol/terminal.d.ts +79 -0
  17. package/dist/protocol/terminal.js +150 -0
  18. package/dist/relay-config.d.ts +74 -0
  19. package/dist/relay-config.js +316 -0
  20. package/dist/relay-server.d.ts +64 -0
  21. package/dist/relay-server.js +194 -0
  22. package/dist/scroll-input.d.ts +17 -0
  23. package/dist/scroll-input.js +124 -0
  24. package/dist/server/client-channel.d.ts +3 -0
  25. package/dist/server/client-channel.js +365 -0
  26. package/dist/server/font-proxy.d.ts +19 -0
  27. package/dist/server/font-proxy.js +155 -0
  28. package/dist/server/host-broadcasts.d.ts +25 -0
  29. package/dist/server/host-broadcasts.js +79 -0
  30. package/dist/server/host-channel.d.ts +3 -0
  31. package/dist/server/host-channel.js +266 -0
  32. package/dist/server/http-api.d.ts +3 -0
  33. package/dist/server/http-api.js +214 -0
  34. package/dist/server/maintenance.d.ts +5 -0
  35. package/dist/server/maintenance.js +64 -0
  36. package/dist/server/requests.d.ts +34 -0
  37. package/dist/server/requests.js +142 -0
  38. package/dist/server/sessions.d.ts +36 -0
  39. package/dist/server/sessions.js +225 -0
  40. package/dist/server/sockets.d.ts +12 -0
  41. package/dist/server/sockets.js +64 -0
  42. package/dist/server/status.d.ts +11 -0
  43. package/dist/server/status.js +95 -0
  44. package/dist/server/transport.d.ts +16 -0
  45. package/dist/server/transport.js +110 -0
  46. package/dist/server/types.d.ts +123 -0
  47. package/dist/server/types.js +3 -0
  48. package/dist/server/version.d.ts +2 -0
  49. package/dist/server/version.js +5 -0
  50. package/dist/state.d.ts +6 -0
  51. package/dist/state.js +47 -0
  52. package/package.json +32 -8
  53. package/web/dist/assets/index-B-5zRmL_.js +61 -0
  54. package/web/dist/assets/{index-CPVUfBt4.css → index-DKt8KLog.css} +1 -1
  55. package/web/dist/assets/{vendor-xterm-Bbu8R_5E.js → vendor-xterm-BhCxil_4.js} +2 -2
  56. package/web/dist/index.html +3 -3
  57. package/src/auth-store.js +0 -256
  58. package/src/metrics.js +0 -241
  59. package/src/relay-config.js +0 -312
  60. package/src/relay-server.js +0 -1799
  61. package/src/scroll-input.js +0 -139
  62. package/src/state.js +0 -47
  63. package/src/stream-frame.js +0 -238
  64. package/web/dist/assets/index-6WucmdNP.js +0 -61
@@ -0,0 +1,321 @@
1
+ import type { HostTerminalFont, HostTerminalPalette } from './terminal.js';
2
+ export declare const WS_HOST_PATH = "/ws/host";
3
+ export declare const WS_CLIENT_PATH = "/ws/client";
4
+ /** What a peer may announce in its hello's `capabilities`. */
5
+ export declare const CAPABILITY: {
6
+ /** Keep browser sessions alive across a host reconnect. */
7
+ readonly hostHandoff: "host_handoff";
8
+ /** The host sends heartbeats only while a browser is watching. */
9
+ readonly idleHeartbeat: "idle_heartbeat";
10
+ /** The host routes output by 16-bit stream index (v2 frames). */
11
+ readonly binaryFrameV2: "binary_frame_v2";
12
+ };
13
+ export type ClientRole = 'controller' | 'viewer';
14
+ export interface ClientHelloMessage {
15
+ type: 'hello';
16
+ protocol: 1;
17
+ token?: string;
18
+ pairCode?: string;
19
+ clientId: string;
20
+ cols: number;
21
+ rows: number;
22
+ capabilities?: string[];
23
+ }
24
+ export interface ClientClaimControlMessage {
25
+ type: 'claim_control';
26
+ force?: boolean;
27
+ }
28
+ export interface ClientReleaseControlMessage {
29
+ type: 'release_control';
30
+ }
31
+ export interface ClientResizeMessage {
32
+ type: 'resize';
33
+ cols: number;
34
+ rows: number;
35
+ }
36
+ export interface ClientPingMessage {
37
+ type: 'ping';
38
+ }
39
+ export interface ClientPasteFileMessage {
40
+ type: 'paste_file';
41
+ mime: string;
42
+ dataBase64: string;
43
+ }
44
+ /**
45
+ * Start Herdr on the workstation this window is paired to. Carries nothing:
46
+ * the relay routes it to the window's own host, and the host decides where and
47
+ * as whom Herdr runs.
48
+ */
49
+ export interface ClientHerdrStartMessage {
50
+ type: 'herdr_start';
51
+ }
52
+ /** One slice of an announced font file, by the file's hash. */
53
+ export interface ClientHostFontChunkRequestMessage {
54
+ type: 'host_font_chunk_request';
55
+ sha256: string;
56
+ index: number;
57
+ }
58
+ /** Cut `text`'s characters out of an announced large font. */
59
+ export interface ClientHostFontSubsetRequestMessage {
60
+ type: 'host_font_subset_request';
61
+ sha256: string;
62
+ text: string;
63
+ requestId: string;
64
+ }
65
+ /** Ask the workstation to read its terminal's font settings again. */
66
+ export interface ClientHostFontRefreshMessage {
67
+ type: 'host_font_refresh';
68
+ }
69
+ export type ClientJsonMessage = ClientHelloMessage | ClientHostFontChunkRequestMessage | ClientHostFontSubsetRequestMessage | ClientHostFontRefreshMessage | ClientClaimControlMessage | ClientReleaseControlMessage | ClientResizeMessage | ClientPingMessage | ClientPasteFileMessage | ClientHerdrStartMessage;
70
+ export interface ServerReadyMessage {
71
+ type: 'ready';
72
+ role: ClientRole;
73
+ controllerId?: string | null;
74
+ hostId?: string;
75
+ hostname?: string;
76
+ clientId?: string;
77
+ terminalPalette?: HostTerminalPalette | null;
78
+ terminalFont?: HostTerminalFont | null;
79
+ /** How many windows share this terminal, this one included. */
80
+ clientCount?: number;
81
+ }
82
+ export interface ServerPairedMessage {
83
+ type: 'paired';
84
+ token: string;
85
+ deviceId?: string;
86
+ hostId?: string;
87
+ expiresAt?: number | string;
88
+ }
89
+ export interface ServerControlStateMessage {
90
+ type: 'control_state';
91
+ role: ClientRole;
92
+ controllerId?: string | null;
93
+ /** How many windows share this terminal, this one included. */
94
+ clientCount?: number;
95
+ }
96
+ export interface ServerControlGrantedMessage {
97
+ type: 'control_granted';
98
+ }
99
+ export interface ServerHostReconnectingMessage {
100
+ type: 'host_reconnecting';
101
+ code?: string;
102
+ }
103
+ export interface ServerSessionReadyMessage {
104
+ type: 'session_ready';
105
+ clientId?: string;
106
+ }
107
+ export interface ServerSessionRestartedMessage {
108
+ type: 'session_restarted';
109
+ streamId?: string;
110
+ cols?: number;
111
+ rows?: number;
112
+ hostname?: string;
113
+ terminalPalette?: HostTerminalPalette | null;
114
+ terminalFont?: HostTerminalFont | null;
115
+ }
116
+ export interface ServerExitMessage {
117
+ type: 'exit';
118
+ code?: number | null;
119
+ reason?: string;
120
+ }
121
+ export interface ServerErrorMessage {
122
+ type: 'error';
123
+ code: string | number;
124
+ message: string;
125
+ }
126
+ export interface ServerPongMessage {
127
+ type: 'pong';
128
+ }
129
+ export interface ServerPasteFileReadyMessage {
130
+ type: 'paste_file_ready';
131
+ path: string;
132
+ }
133
+ /** Herdr's own vocabulary for what a pane's agent is doing. */
134
+ export type AgentStatus = 'blocked' | 'done' | 'working' | 'idle' | 'unknown';
135
+ export interface AgentStatusEntry {
136
+ paneId: string | null;
137
+ workspaceId: string | null;
138
+ agent: string | null;
139
+ title: string | null;
140
+ status: string | null;
141
+ focused: boolean;
142
+ }
143
+ /**
144
+ * What the workstation's agents are doing.
145
+ *
146
+ * Broadcast to every window watching this workstation rather than scoped to a
147
+ * stream: it is a fact about the machine, not about one terminal. The host
148
+ * reads it from Herdr's socket API.
149
+ */
150
+ export interface ServerAgentStatusMessage {
151
+ type: 'agent_status';
152
+ focusedPaneId?: string | null;
153
+ focusedAgent?: string | null;
154
+ counts: Partial<Record<AgentStatus, number>>;
155
+ total: number;
156
+ agents: AgentStatusEntry[];
157
+ }
158
+ /**
159
+ * Whether the workstation's herdr-remote is behind the newest release. Checked
160
+ * by the host when a window opens and kept by the relay for later windows.
161
+ */
162
+ export interface ServerUpdateStatusMessage {
163
+ type: 'update_status';
164
+ /** The version the workstation is running. */
165
+ current: string;
166
+ /** The version on disk; ahead of `current` when an update awaits a restart. */
167
+ installed: string;
168
+ latest: string;
169
+ updateAvailable: boolean;
170
+ restartPending: boolean;
171
+ }
172
+ /** The workstation re-read its terminal font; sent to every window. */
173
+ export interface ServerTerminalFontMessage {
174
+ type: 'terminal_font';
175
+ terminalFont: HostTerminalFont | null;
176
+ }
177
+ export interface ServerHostFontChunkMessage {
178
+ type: 'host_font_chunk';
179
+ sha256: string;
180
+ index: number;
181
+ total: number;
182
+ dataBase64: string;
183
+ }
184
+ /**
185
+ * A cut is ready. Small ones carry their bytes; a large one is pulled like a
186
+ * font file, by `subsetSha`, a slice at a time.
187
+ */
188
+ export interface ServerHostFontSubsetMessage {
189
+ type: 'host_font_subset_ready';
190
+ requestId: string;
191
+ sha256: string;
192
+ subsetSha: string;
193
+ bytes: number;
194
+ dataBase64?: string;
195
+ }
196
+ export type ServerJsonMessage = ServerTerminalFontMessage | ServerHostFontSubsetMessage | ServerHostFontChunkMessage | ServerReadyMessage | ServerPairedMessage | ServerControlStateMessage | ServerControlGrantedMessage | ServerHostReconnectingMessage | ServerSessionRestartedMessage | ServerSessionReadyMessage | ServerExitMessage | ServerErrorMessage | ServerPongMessage | ServerPasteFileReadyMessage | ServerAgentStatusMessage | ServerUpdateStatusMessage;
197
+ /**
198
+ * Every host message about one browser's session names it by stream. The
199
+ * relay sends both fields with the same value; a host reads `clientId` first.
200
+ */
201
+ interface StreamAddressed {
202
+ clientId?: string;
203
+ streamId?: string;
204
+ }
205
+ export interface HostHelloMessage {
206
+ type: 'host_hello';
207
+ protocol: 1;
208
+ hostId: string;
209
+ token: string;
210
+ password?: string | null;
211
+ hostname?: string;
212
+ platform?: string;
213
+ arch?: string;
214
+ terminalPalette?: HostTerminalPalette | null;
215
+ terminalFont?: HostTerminalFont | null;
216
+ capabilities?: string[];
217
+ }
218
+ /** One PTY the host is running, as the relay's status page lists it. */
219
+ export interface HostPtySummary {
220
+ id: string;
221
+ pid: number | null;
222
+ command: string;
223
+ cols: number;
224
+ rows: number;
225
+ cwd?: string;
226
+ createdAt?: string;
227
+ [key: string]: unknown;
228
+ }
229
+ export interface HostHeartbeatMessage {
230
+ type: 'heartbeat';
231
+ load: {
232
+ load1m?: number;
233
+ load5m?: number;
234
+ load15m?: number;
235
+ rssBytes?: number;
236
+ heapUsedBytes?: number;
237
+ };
238
+ ptys: HostPtySummary[];
239
+ }
240
+ export interface HostShutdownMessage {
241
+ type: 'host_shutdown';
242
+ }
243
+ export interface HostSessionReadyMessage extends StreamAddressed {
244
+ type: 'session_ready';
245
+ }
246
+ export interface HostSessionExitMessage extends StreamAddressed {
247
+ type: 'session_exit';
248
+ code: number | null;
249
+ }
250
+ export interface HostPasteFileReadyMessage extends StreamAddressed {
251
+ type: 'paste_file_ready';
252
+ path: string;
253
+ }
254
+ export interface HostErrorMessage extends StreamAddressed {
255
+ type: 'error';
256
+ code: string;
257
+ message: string;
258
+ }
259
+ export type HostAgentStatusMessage = ServerAgentStatusMessage;
260
+ export type HostUpdateStatusMessage = ServerUpdateStatusMessage;
261
+ export type HostTerminalFontMessage = ServerTerminalFontMessage;
262
+ export type HostFontChunkMessage = ServerHostFontChunkMessage & StreamAddressed;
263
+ export type HostFontSubsetMessage = ServerHostFontSubsetMessage & StreamAddressed;
264
+ export type HostMessage = HostHelloMessage | HostHeartbeatMessage | HostShutdownMessage | HostAgentStatusMessage | HostUpdateStatusMessage | HostTerminalFontMessage | HostSessionReadyMessage | HostSessionExitMessage | HostFontChunkMessage | HostFontSubsetMessage | HostPasteFileReadyMessage | HostErrorMessage;
265
+ export interface RelayHostReadyMessage {
266
+ type: 'host_ready';
267
+ protocol: 1;
268
+ hostId: string;
269
+ clientCount: number;
270
+ }
271
+ /** How many browsers watch; the host stops its telemetry at zero. */
272
+ export interface RelayClientCountMessage {
273
+ type: 'client_count';
274
+ clientCount: number;
275
+ }
276
+ export interface RelaySessionStartMessage extends StreamAddressed {
277
+ type: 'session_start';
278
+ cols: number;
279
+ rows: number;
280
+ role: ClientRole;
281
+ /** Present when the host negotiated v2 frames and an index was free. */
282
+ streamIndex?: number;
283
+ }
284
+ export interface RelaySessionStopMessage extends StreamAddressed {
285
+ type: 'session_stop';
286
+ }
287
+ export interface RelayResizeMessage extends StreamAddressed {
288
+ type: 'resize';
289
+ cols: number;
290
+ rows: number;
291
+ }
292
+ export interface RelayHerdrStartMessage extends StreamAddressed {
293
+ type: 'herdr_start';
294
+ }
295
+ export interface RelayHostFontRefreshMessage extends StreamAddressed {
296
+ type: 'host_font_refresh';
297
+ }
298
+ export interface RelayHostFontChunkRequestMessage extends StreamAddressed {
299
+ type: 'host_font_chunk_request';
300
+ sha256: string;
301
+ index: number;
302
+ }
303
+ export interface RelayHostFontSubsetRequestMessage extends StreamAddressed {
304
+ type: 'host_font_subset_request';
305
+ requestId: string;
306
+ sha256: string;
307
+ text: string;
308
+ }
309
+ export interface RelayPasteFileMessage extends StreamAddressed {
310
+ type: 'paste_file';
311
+ mime: string;
312
+ dataBase64: string;
313
+ }
314
+ /** Sent instead of `host_ready` when the handshake is refused. */
315
+ export interface RelayErrorMessage {
316
+ type: 'error';
317
+ code: string;
318
+ message: string;
319
+ }
320
+ export type RelayToHostMessage = RelayHostReadyMessage | RelayClientCountMessage | RelaySessionStartMessage | RelaySessionStopMessage | RelayResizeMessage | RelayHerdrStartMessage | RelayHostFontRefreshMessage | RelayHostFontChunkRequestMessage | RelayHostFontSubsetRequestMessage | RelayPasteFileMessage | RelayErrorMessage;
321
+ export {};
@@ -0,0 +1,18 @@
1
+ // Every JSON message on the relay's two WebSocket channels, by direction:
2
+ //
3
+ // host connector ──HostMessage──▶ relay ──ServerJsonMessage──▶ browser
4
+ // host connector ◀─RelayToHostMessage── relay ◀─ClientJsonMessage── browser
5
+ //
6
+ // Browser-safe: types and constants only. Terminal bytes travel separately as
7
+ // binary frames (see ./frames).
8
+ export const WS_HOST_PATH = '/ws/host';
9
+ export const WS_CLIENT_PATH = '/ws/client';
10
+ /** What a peer may announce in its hello's `capabilities`. */
11
+ export const CAPABILITY = {
12
+ /** Keep browser sessions alive across a host reconnect. */
13
+ hostHandoff: 'host_handoff',
14
+ /** The host sends heartbeats only while a browser is watching. */
15
+ idleHeartbeat: 'idle_heartbeat',
16
+ /** The host routes output by 16-bit stream index (v2 frames). */
17
+ binaryFrameV2: 'binary_frame_v2',
18
+ };
@@ -0,0 +1,14 @@
1
+ /** Largest decoded image a browser may paste. */
2
+ export declare const PASTE_MAX_BYTES: number;
3
+ /** Image types a paste may carry, with the extension the host saves them under. */
4
+ export declare const PASTE_IMAGE_EXTENSIONS: {
5
+ readonly 'image/png': ".png";
6
+ readonly 'image/jpeg': ".jpg";
7
+ readonly 'image/webp': ".webp";
8
+ readonly 'image/gif': ".gif";
9
+ };
10
+ export type PasteImageMimeType = keyof typeof PASTE_IMAGE_EXTENSIONS;
11
+ export declare const PASTE_IMAGE_MIME_TYPES: PasteImageMimeType[];
12
+ export declare function isPasteImageMime(value: unknown): value is PasteImageMimeType;
13
+ /** Whether `bytes` begin with the file signature `mime` promises. */
14
+ export declare function hasImageSignature(mime: string, bytes: Uint8Array): boolean;
@@ -0,0 +1,50 @@
1
+ // Rules for an image pasted in a browser: the browser compresses to fit them,
2
+ // the relay refuses what breaks them, and the host checks again before it
3
+ // writes a file. Browser-safe: no Node APIs.
4
+ /** Largest decoded image a browser may paste. */
5
+ export const PASTE_MAX_BYTES = 3 * 1024 * 1024;
6
+ /** Image types a paste may carry, with the extension the host saves them under. */
7
+ export const PASTE_IMAGE_EXTENSIONS = {
8
+ 'image/png': '.png',
9
+ 'image/jpeg': '.jpg',
10
+ 'image/webp': '.webp',
11
+ 'image/gif': '.gif',
12
+ };
13
+ export const PASTE_IMAGE_MIME_TYPES = Object.keys(PASTE_IMAGE_EXTENSIONS);
14
+ export function isPasteImageMime(value) {
15
+ return typeof value === 'string' && Object.hasOwn(PASTE_IMAGE_EXTENSIONS, value);
16
+ }
17
+ /** Whether `bytes` begin with the file signature `mime` promises. */
18
+ export function hasImageSignature(mime, bytes) {
19
+ switch (mime) {
20
+ case 'image/png':
21
+ return (bytes.length >= 8 &&
22
+ bytes[0] === 0x89 &&
23
+ bytes[1] === 0x50 &&
24
+ bytes[2] === 0x4e &&
25
+ bytes[3] === 0x47);
26
+ case 'image/jpeg':
27
+ return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
28
+ case 'image/webp':
29
+ return (bytes.length >= 12 &&
30
+ // 'RIFF'
31
+ bytes[0] === 0x52 &&
32
+ bytes[1] === 0x49 &&
33
+ bytes[2] === 0x46 &&
34
+ bytes[3] === 0x46 &&
35
+ // 'WEBP'
36
+ bytes[8] === 0x57 &&
37
+ bytes[9] === 0x45 &&
38
+ bytes[10] === 0x42 &&
39
+ bytes[11] === 0x50);
40
+ case 'image/gif':
41
+ // 'GIF8'
42
+ return (bytes.length >= 6 &&
43
+ bytes[0] === 0x47 &&
44
+ bytes[1] === 0x49 &&
45
+ bytes[2] === 0x46 &&
46
+ bytes[3] === 0x38);
47
+ default:
48
+ return false;
49
+ }
50
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The sixteen ANSI slots a host may report, in index order. A palette is
3
+ * all-or-nothing: half the host's colors mixed with half the browser's would
4
+ * look worse than either set on its own.
5
+ */
6
+ export declare const ANSI_PALETTE_KEYS: readonly ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
7
+ export type AnsiPaletteKey = (typeof ANSI_PALETTE_KEYS)[number];
8
+ /** The sixteen ANSI slots, exactly as the host's terminal reported them. */
9
+ export type HostAnsiPalette = Record<AnsiPaletteKey, string>;
10
+ /**
11
+ * The workstation's terminal colors, answered by the host's own emulator to
12
+ * the OSC 10/11/12 and OSC 4 queries. The browser renders with these instead
13
+ * of inventing a palette, which is what makes the web view look like the
14
+ * session does on the workstation.
15
+ */
16
+ export interface HostTerminalPalette {
17
+ background?: string;
18
+ foreground?: string;
19
+ cursor?: string;
20
+ ansi?: HostAnsiPalette;
21
+ }
22
+ /** The faces a host may offer, in the order a browser registers them. */
23
+ export declare const TERMINAL_FONT_STYLES: readonly ["regular", "bold", "italic", "boldItalic"];
24
+ export type HostFontStyle = (typeof TERMINAL_FONT_STYLES)[number];
25
+ export declare const TERMINAL_FONT_FORMATS: readonly ["truetype", "opentype"];
26
+ export type HostFontFormat = (typeof TERMINAL_FONT_FORMATS)[number];
27
+ /** `cjk`: the face Hanzi fall back to; `all`: the family itself, too big to send whole. */
28
+ export declare const TERMINAL_FONT_SUBSET_SCOPES: readonly ["cjk", "all"];
29
+ export type HostFontSubsetScope = (typeof TERMINAL_FONT_SUBSET_SCOPES)[number];
30
+ /** One font file; a CJK face is larger than this and is left to the browser. */
31
+ export declare const MAX_TERMINAL_FONT_BYTES: number;
32
+ /** Raw bytes per `host_font_chunk`; base64 keeps it far below any payload cap. */
33
+ export declare const TERMINAL_FONT_CHUNK_BYTES: number;
34
+ /** One file behind the workstation's terminal font, known here only by hash. */
35
+ export interface HostFontFace {
36
+ style: HostFontStyle;
37
+ format: HostFontFormat;
38
+ bytes: number;
39
+ sha256: string;
40
+ }
41
+ /** A font too large to send whole, cut to the characters a window draws. */
42
+ export interface HostFontSubsetSource {
43
+ family: string;
44
+ style: 'regular';
45
+ scope: HostFontSubsetScope;
46
+ /** Identifies the source font; subsets are cached under it. */
47
+ sha256: string;
48
+ }
49
+ /**
50
+ * The font the workstation's terminal draws with, read by the host from that
51
+ * terminal's own settings. `faces` lists the files a browser without the font
52
+ * may fetch, a slice at a time.
53
+ */
54
+ export interface HostTerminalFont {
55
+ family: string;
56
+ /** In CSS pixels, converted from the terminal's points. */
57
+ sizePx?: number;
58
+ /** Which terminal it came from: `gnome-terminal`, `kitty`, … */
59
+ source?: string;
60
+ faces: HostFontFace[];
61
+ subsets?: HostFontSubsetSource[];
62
+ }
63
+ /**
64
+ * Validates a terminal palette crossing the wire.
65
+ *
66
+ * The host reports what its own terminal answered to the OSC color queries,
67
+ * and the browser paints with it. Anything that is not a plain `#rrggbb`
68
+ * string is dropped here, so a compromised or buggy host cannot push arbitrary
69
+ * data into a browser's renderer options.
70
+ */
71
+ export declare function sanitizeTerminalPalette(value: unknown): HostTerminalPalette | null;
72
+ /**
73
+ * Validates the terminal font a host reports.
74
+ *
75
+ * The browser draws the session in the family the workstation's terminal
76
+ * uses, and may fetch the font files by their hash. Every field is optional
77
+ * except the family; anything malformed is dropped rather than repaired.
78
+ */
79
+ export declare function sanitizeTerminalFont(value: unknown): HostTerminalFont | null;
@@ -0,0 +1,150 @@
1
+ // The workstation's terminal palette and font, as a host reports them and a
2
+ // browser receives them. Browser-safe: no Node APIs, so the web app imports it.
3
+ /**
4
+ * The sixteen ANSI slots a host may report, in index order. A palette is
5
+ * all-or-nothing: half the host's colors mixed with half the browser's would
6
+ * look worse than either set on its own.
7
+ */
8
+ export const ANSI_PALETTE_KEYS = [
9
+ 'black',
10
+ 'red',
11
+ 'green',
12
+ 'yellow',
13
+ 'blue',
14
+ 'magenta',
15
+ 'cyan',
16
+ 'white',
17
+ 'brightBlack',
18
+ 'brightRed',
19
+ 'brightGreen',
20
+ 'brightYellow',
21
+ 'brightBlue',
22
+ 'brightMagenta',
23
+ 'brightCyan',
24
+ 'brightWhite',
25
+ ];
26
+ /** The faces a host may offer, in the order a browser registers them. */
27
+ export const TERMINAL_FONT_STYLES = ['regular', 'bold', 'italic', 'boldItalic'];
28
+ export const TERMINAL_FONT_FORMATS = ['truetype', 'opentype'];
29
+ /** `cjk`: the face Hanzi fall back to; `all`: the family itself, too big to send whole. */
30
+ export const TERMINAL_FONT_SUBSET_SCOPES = ['cjk', 'all'];
31
+ /** One font file; a CJK face is larger than this and is left to the browser. */
32
+ export const MAX_TERMINAL_FONT_BYTES = 16 * 1024 * 1024;
33
+ /** Raw bytes per `host_font_chunk`; base64 keeps it far below any payload cap. */
34
+ export const TERMINAL_FONT_CHUNK_BYTES = 256 * 1024;
35
+ const HEX_COLOR = /^#[0-9a-f]{6}$/i;
36
+ /**
37
+ * A family name ends up inside a CSS `font-family` list in the browser. Quotes,
38
+ * separators and escapes are what would let a name break out of its slot, and
39
+ * no real family needs them.
40
+ */
41
+ const FONT_FAMILY_FORBIDDEN = /["'\\;,{}<>@\u0000-\u001f\u007f]/;
42
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
43
+ function isRecord(value) {
44
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
45
+ }
46
+ function isOneOf(list, value) {
47
+ return list.includes(value);
48
+ }
49
+ function hexColor(value) {
50
+ return typeof value === 'string' && HEX_COLOR.test(value) ? value.toLowerCase() : undefined;
51
+ }
52
+ /**
53
+ * Validates a terminal palette crossing the wire.
54
+ *
55
+ * The host reports what its own terminal answered to the OSC color queries,
56
+ * and the browser paints with it. Anything that is not a plain `#rrggbb`
57
+ * string is dropped here, so a compromised or buggy host cannot push arbitrary
58
+ * data into a browser's renderer options.
59
+ */
60
+ export function sanitizeTerminalPalette(value) {
61
+ if (!isRecord(value))
62
+ return null;
63
+ const palette = {};
64
+ for (const key of ['background', 'foreground', 'cursor']) {
65
+ const color = hexColor(value[key]);
66
+ if (color)
67
+ palette[key] = color;
68
+ }
69
+ const ansi = value.ansi;
70
+ if (isRecord(ansi)) {
71
+ const collected = {};
72
+ for (const key of ANSI_PALETTE_KEYS) {
73
+ const color = hexColor(ansi[key]);
74
+ if (color)
75
+ collected[key] = color;
76
+ }
77
+ if (Object.keys(collected).length === ANSI_PALETTE_KEYS.length) {
78
+ palette.ansi = collected;
79
+ }
80
+ }
81
+ return Object.keys(palette).length > 0 ? palette : null;
82
+ }
83
+ function familyName(value) {
84
+ const family = typeof value === 'string' ? value.trim() : '';
85
+ if (!family || family.length > 128 || FONT_FAMILY_FORBIDDEN.test(family))
86
+ return null;
87
+ return family;
88
+ }
89
+ /**
90
+ * Validates the terminal font a host reports.
91
+ *
92
+ * The browser draws the session in the family the workstation's terminal
93
+ * uses, and may fetch the font files by their hash. Every field is optional
94
+ * except the family; anything malformed is dropped rather than repaired.
95
+ */
96
+ export function sanitizeTerminalFont(value) {
97
+ if (!isRecord(value))
98
+ return null;
99
+ const family = familyName(value.family);
100
+ if (!family)
101
+ return null;
102
+ const size = Number(value.sizePx);
103
+ const sizePx = Number.isFinite(size) && size >= 4 && size <= 96 ? Math.round(size * 10) / 10 : null;
104
+ const source = typeof value.source === 'string' && /^[a-z0-9-]{1,32}$/.test(value.source)
105
+ ? value.source
106
+ : null;
107
+ const faces = [];
108
+ const seen = new Set();
109
+ for (const face of Array.isArray(value.faces) ? value.faces.slice(0, 16) : []) {
110
+ if (!isRecord(face))
111
+ continue;
112
+ const { style, format, bytes, sha256 } = face;
113
+ if (!isOneOf(TERMINAL_FONT_STYLES, style) || seen.has(style))
114
+ continue;
115
+ if (!isOneOf(TERMINAL_FONT_FORMATS, format))
116
+ continue;
117
+ if (typeof bytes !== 'number' || !Number.isInteger(bytes))
118
+ continue;
119
+ if (bytes <= 0 || bytes > MAX_TERMINAL_FONT_BYTES)
120
+ continue;
121
+ if (typeof sha256 !== 'string' || !SHA256_HEX.test(sha256))
122
+ continue;
123
+ seen.add(style);
124
+ faces.push({ style, format, bytes, sha256 });
125
+ }
126
+ // Large fonts (CJK above all) are offered a few characters at a time.
127
+ const subsets = [];
128
+ for (const entry of Array.isArray(value.subsets) ? value.subsets.slice(0, 16) : []) {
129
+ if (!isRecord(entry))
130
+ continue;
131
+ const name = familyName(entry.family);
132
+ const { scope, sha256 } = entry;
133
+ if (!name || entry.style !== 'regular')
134
+ continue;
135
+ if (!isOneOf(TERMINAL_FONT_SUBSET_SCOPES, scope))
136
+ continue;
137
+ if (typeof sha256 !== 'string' || !SHA256_HEX.test(sha256))
138
+ continue;
139
+ if (subsets.some((known) => known.scope === scope))
140
+ continue;
141
+ subsets.push({ family: name, style: 'regular', scope, sha256 });
142
+ }
143
+ return {
144
+ family,
145
+ ...(sizePx !== null && { sizePx }),
146
+ ...(source !== null && { source }),
147
+ faces,
148
+ subsets,
149
+ };
150
+ }