@slicervm/sdk 0.1.5 → 0.1.6

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.
package/dist/shell.cjs ADDED
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ // src/shell.ts
4
+ var FRAME_TYPE_DATA = 1;
5
+ var FRAME_TYPE_WINDOW_SIZE = 2;
6
+ var FRAME_TYPE_SHUTDOWN = 3;
7
+ var FRAME_TYPE_HEARTBEAT = 4;
8
+ var FRAME_TYPE_SESSION_CLOSE = 5;
9
+ var HEADER_SIZE = 5;
10
+ function encodeFrame(frameType, payload) {
11
+ const payloadLen = payload ? payload.byteLength : 0;
12
+ const buf = new Uint8Array(HEADER_SIZE + payloadLen);
13
+ const view = new DataView(buf.buffer);
14
+ view.setUint8(0, frameType);
15
+ view.setUint32(1, payloadLen, false);
16
+ if (payload) buf.set(payload, HEADER_SIZE);
17
+ return buf;
18
+ }
19
+ function parseFrame(data) {
20
+ if (data.byteLength < HEADER_SIZE) return null;
21
+ const view = new DataView(data);
22
+ const frameType = view.getUint8(0);
23
+ const payloadLen = view.getUint32(1, false);
24
+ if (data.byteLength < HEADER_SIZE + payloadLen) return null;
25
+ const payload = new Uint8Array(data, HEADER_SIZE, payloadLen);
26
+ return { frameType, payload };
27
+ }
28
+ var encoder = new TextEncoder();
29
+ var decoder = new TextDecoder();
30
+ var SlicerShellSession = class {
31
+ constructor(terminal, options) {
32
+ this.terminal = terminal;
33
+ this.options = options;
34
+ }
35
+ terminal;
36
+ options;
37
+ ws = null;
38
+ heartbeatTimer = null;
39
+ dataDisposable = null;
40
+ /** True when the WebSocket is open and relaying. */
41
+ get connected() {
42
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
43
+ }
44
+ /** Open the WebSocket and begin relaying. */
45
+ connect() {
46
+ if (this.ws) return;
47
+ this.options.onStateChange?.("connecting");
48
+ const ws = new WebSocket(this.options.url);
49
+ ws.binaryType = "arraybuffer";
50
+ this.ws = ws;
51
+ ws.onopen = () => {
52
+ this.options.onStateChange?.("connected");
53
+ this.terminal.reset();
54
+ this.sendResize(this.terminal.cols, this.terminal.rows);
55
+ this.startHeartbeat();
56
+ };
57
+ ws.onmessage = (ev) => {
58
+ if (!(ev.data instanceof ArrayBuffer)) return;
59
+ const frame = parseFrame(ev.data);
60
+ if (!frame) return;
61
+ switch (frame.frameType) {
62
+ case FRAME_TYPE_DATA:
63
+ this.terminal.write(decoder.decode(frame.payload));
64
+ break;
65
+ case FRAME_TYPE_SHUTDOWN:
66
+ case FRAME_TYPE_SESSION_CLOSE:
67
+ this.teardown();
68
+ break;
69
+ }
70
+ };
71
+ ws.onclose = () => {
72
+ this.teardown();
73
+ };
74
+ ws.onerror = () => {
75
+ this.options.onError?.("WebSocket error");
76
+ this.teardown();
77
+ };
78
+ this.dataDisposable = this.terminal.onData((data) => {
79
+ if (!this.connected) return;
80
+ const payload = encoder.encode(data);
81
+ this.ws.send(encodeFrame(FRAME_TYPE_DATA, payload));
82
+ });
83
+ }
84
+ /** Send a graceful shutdown frame and close. */
85
+ disconnect() {
86
+ if (this.ws) {
87
+ try {
88
+ this.ws.send(encodeFrame(FRAME_TYPE_SHUTDOWN));
89
+ } catch {
90
+ }
91
+ }
92
+ this.teardown();
93
+ }
94
+ /** Send a window resize. Call this from FitAddon's onResize or a ResizeObserver. */
95
+ resize(cols, rows) {
96
+ if (!this.connected) return;
97
+ this.sendResize(cols, rows);
98
+ }
99
+ // --- internals -------------------------------------------------------------
100
+ sendResize(cols, rows) {
101
+ const payload = new Uint8Array(8);
102
+ const view = new DataView(payload.buffer);
103
+ view.setUint32(0, cols, false);
104
+ view.setUint32(4, rows, false);
105
+ this.ws.send(encodeFrame(FRAME_TYPE_WINDOW_SIZE, payload));
106
+ }
107
+ startHeartbeat() {
108
+ this.stopHeartbeat();
109
+ const intervalMs = this.options.heartbeatIntervalMs ?? 3e4;
110
+ this.heartbeatTimer = setInterval(() => {
111
+ if (!this.connected) return;
112
+ this.ws.send(encodeFrame(FRAME_TYPE_HEARTBEAT));
113
+ }, intervalMs);
114
+ }
115
+ stopHeartbeat() {
116
+ if (this.heartbeatTimer !== null) {
117
+ clearInterval(this.heartbeatTimer);
118
+ this.heartbeatTimer = null;
119
+ }
120
+ }
121
+ teardown() {
122
+ this.stopHeartbeat();
123
+ if (this.dataDisposable) {
124
+ this.dataDisposable.dispose();
125
+ this.dataDisposable = null;
126
+ }
127
+ if (this.ws) {
128
+ const ws = this.ws;
129
+ this.ws = null;
130
+ ws.onopen = null;
131
+ ws.onmessage = null;
132
+ ws.onclose = null;
133
+ ws.onerror = null;
134
+ try {
135
+ ws.close();
136
+ } catch {
137
+ }
138
+ }
139
+ this.options.onStateChange?.("disconnected");
140
+ }
141
+ };
142
+
143
+ exports.FRAME_TYPE_DATA = FRAME_TYPE_DATA;
144
+ exports.FRAME_TYPE_HEARTBEAT = FRAME_TYPE_HEARTBEAT;
145
+ exports.FRAME_TYPE_SESSION_CLOSE = FRAME_TYPE_SESSION_CLOSE;
146
+ exports.FRAME_TYPE_SHUTDOWN = FRAME_TYPE_SHUTDOWN;
147
+ exports.FRAME_TYPE_WINDOW_SIZE = FRAME_TYPE_WINDOW_SIZE;
148
+ exports.SlicerShellSession = SlicerShellSession;
149
+ exports.encodeFrame = encodeFrame;
150
+ exports.parseFrame = parseFrame;
151
+ //# sourceMappingURL=shell.cjs.map
152
+ //# sourceMappingURL=shell.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/shell.ts"],"names":[],"mappings":";;;AAaO,IAAM,eAAA,GAAkB;AACxB,IAAM,sBAAA,GAAyB;AAC/B,IAAM,mBAAA,GAAsB;AAC5B,IAAM,oBAAA,GAAuB;AAC7B,IAAM,wBAAA,GAA2B;AAIxC,IAAM,WAAA,GAAc,CAAA;AAGb,SAAS,WAAA,CAAY,WAAmB,OAAA,EAAkC;AAC/E,EAAA,MAAM,UAAA,GAAa,OAAA,GAAU,OAAA,CAAQ,UAAA,GAAa,CAAA;AAClD,EAAA,MAAM,GAAA,GAAM,IAAI,UAAA,CAAW,WAAA,GAAc,UAAU,CAAA;AACnD,EAAA,MAAM,IAAA,GAAO,IAAI,QAAA,CAAS,GAAA,CAAI,MAAM,CAAA;AACpC,EAAA,IAAA,CAAK,QAAA,CAAS,GAAG,SAAS,CAAA;AAC1B,EAAA,IAAA,CAAK,SAAA,CAAU,CAAA,EAAG,UAAA,EAAY,KAAK,CAAA;AACnC,EAAA,IAAI,OAAA,EAAS,GAAA,CAAI,GAAA,CAAI,OAAA,EAAS,WAAW,CAAA;AACzC,EAAA,OAAO,GAAA;AACT;AAGO,SAAS,WAAW,IAAA,EAAsE;AAC/F,EAAA,IAAI,IAAA,CAAK,UAAA,GAAa,WAAA,EAAa,OAAO,IAAA;AAC1C,EAAA,MAAM,IAAA,GAAO,IAAI,QAAA,CAAS,IAAI,CAAA;AAC9B,EAAA,MAAM,SAAA,GAAY,IAAA,CAAK,QAAA,CAAS,CAAC,CAAA;AACjC,EAAA,MAAM,UAAA,GAAa,IAAA,CAAK,SAAA,CAAU,CAAA,EAAG,KAAK,CAAA;AAC1C,EAAA,IAAI,IAAA,CAAK,UAAA,GAAa,WAAA,GAAc,UAAA,EAAY,OAAO,IAAA;AACvD,EAAA,MAAM,OAAA,GAAU,IAAI,UAAA,CAAW,IAAA,EAAM,aAAa,UAAU,CAAA;AAC5D,EAAA,OAAO,EAAE,WAAW,OAAA,EAAQ;AAC9B;AAoDA,IAAM,OAAA,GAAU,IAAI,WAAA,EAAY;AAChC,IAAM,OAAA,GAAU,IAAI,WAAA,EAAY;AAEzB,IAAM,qBAAN,MAAyB;AAAA,EAK9B,WAAA,CACmB,UACA,OAAA,EACjB;AAFiB,IAAA,IAAA,CAAA,QAAA,GAAA,QAAA;AACA,IAAA,IAAA,CAAA,OAAA,GAAA,OAAA;AAAA,EAChB;AAAA,EAFgB,QAAA;AAAA,EACA,OAAA;AAAA,EANX,EAAA,GAA8B,IAAA;AAAA,EAC9B,cAAA,GAAwD,IAAA;AAAA,EACxD,cAAA,GAAiD,IAAA;AAAA;AAAA,EAQzD,IAAI,SAAA,GAAqB;AACvB,IAAA,OAAO,KAAK,EAAA,KAAO,IAAA,IAAQ,IAAA,CAAK,EAAA,CAAG,eAAe,SAAA,CAAU,IAAA;AAAA,EAC9D;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,IAAI,KAAK,EAAA,EAAI;AACb,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,YAAY,CAAA;AAEzC,IAAA,MAAM,EAAA,GAAK,IAAI,SAAA,CAAU,IAAA,CAAK,QAAQ,GAAG,CAAA;AACzC,IAAA,EAAA,CAAG,UAAA,GAAa,aAAA;AAChB,IAAA,IAAA,CAAK,EAAA,GAAK,EAAA;AAEV,IAAA,EAAA,CAAG,SAAS,MAAM;AAChB,MAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,WAAW,CAAA;AACxC,MAAA,IAAA,CAAK,SAAS,KAAA,EAAM;AACpB,MAAA,IAAA,CAAK,WAAW,IAAA,CAAK,QAAA,CAAS,IAAA,EAAM,IAAA,CAAK,SAAS,IAAI,CAAA;AACtD,MAAA,IAAA,CAAK,cAAA,EAAe;AAAA,IACtB,CAAA;AAEA,IAAA,EAAA,CAAG,SAAA,GAAY,CAAC,EAAA,KAA0B;AACxC,MAAA,IAAI,EAAE,EAAA,CAAG,IAAA,YAAgB,WAAA,CAAA,EAAc;AACvC,MAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,EAAA,CAAG,IAAI,CAAA;AAChC,MAAA,IAAI,CAAC,KAAA,EAAO;AAEZ,MAAA,QAAQ,MAAM,SAAA;AAAW,QACvB,KAAK,eAAA;AACH,UAAA,IAAA,CAAK,SAAS,KAAA,CAAM,OAAA,CAAQ,MAAA,CAAO,KAAA,CAAM,OAAO,CAAC,CAAA;AACjD,UAAA;AAAA,QACF,KAAK,mBAAA;AAAA,QACL,KAAK,wBAAA;AACH,UAAA,IAAA,CAAK,QAAA,EAAS;AACd,UAAA;AAAA;AACJ,IACF,CAAA;AAEA,IAAA,EAAA,CAAG,UAAU,MAAM;AACjB,MAAA,IAAA,CAAK,QAAA,EAAS;AAAA,IAChB,CAAA;AAEA,IAAA,EAAA,CAAG,UAAU,MAAM;AACjB,MAAA,IAAA,CAAK,OAAA,CAAQ,UAAU,iBAAiB,CAAA;AACxC,MAAA,IAAA,CAAK,QAAA,EAAS;AAAA,IAChB,CAAA;AAEA,IAAA,IAAA,CAAK,cAAA,GAAiB,IAAA,CAAK,QAAA,CAAS,MAAA,CAAO,CAAC,IAAA,KAAiB;AAC3D,MAAA,IAAI,CAAC,KAAK,SAAA,EAAW;AACrB,MAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,MAAA,CAAO,IAAI,CAAA;AACnC,MAAA,IAAA,CAAK,EAAA,CAAI,IAAA,CAAK,WAAA,CAAY,eAAA,EAAiB,OAAO,CAAC,CAAA;AAAA,IACrD,CAAC,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,IAAI,KAAK,EAAA,EAAI;AACX,MAAA,IAAI;AACF,QAAA,IAAA,CAAK,EAAA,CAAG,IAAA,CAAK,WAAA,CAAY,mBAAmB,CAAC,CAAA;AAAA,MAC/C,CAAA,CAAA,MAAQ;AAAA,MAER;AAAA,IACF;AACA,IAAA,IAAA,CAAK,QAAA,EAAS;AAAA,EAChB;AAAA;AAAA,EAGA,MAAA,CAAO,MAAc,IAAA,EAAoB;AACvC,IAAA,IAAI,CAAC,KAAK,SAAA,EAAW;AACrB,IAAA,IAAA,CAAK,UAAA,CAAW,MAAM,IAAI,CAAA;AAAA,EAC5B;AAAA;AAAA,EAIQ,UAAA,CAAW,MAAc,IAAA,EAAoB;AACnD,IAAA,MAAM,OAAA,GAAU,IAAI,UAAA,CAAW,CAAC,CAAA;AAChC,IAAA,MAAM,IAAA,GAAO,IAAI,QAAA,CAAS,OAAA,CAAQ,MAAM,CAAA;AACxC,IAAA,IAAA,CAAK,SAAA,CAAU,CAAA,EAAG,IAAA,EAAM,KAAK,CAAA;AAC7B,IAAA,IAAA,CAAK,SAAA,CAAU,CAAA,EAAG,IAAA,EAAM,KAAK,CAAA;AAC7B,IAAA,IAAA,CAAK,EAAA,CAAI,IAAA,CAAK,WAAA,CAAY,sBAAA,EAAwB,OAAO,CAAC,CAAA;AAAA,EAC5D;AAAA,EAEQ,cAAA,GAAuB;AAC7B,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,MAAM,UAAA,GAAa,IAAA,CAAK,OAAA,CAAQ,mBAAA,IAAuB,GAAA;AACvD,IAAA,IAAA,CAAK,cAAA,GAAiB,YAAY,MAAM;AACtC,MAAA,IAAI,CAAC,KAAK,SAAA,EAAW;AACrB,MAAA,IAAA,CAAK,EAAA,CAAI,IAAA,CAAK,WAAA,CAAY,oBAAoB,CAAC,CAAA;AAAA,IACjD,GAAG,UAAU,CAAA;AAAA,EACf;AAAA,EAEQ,aAAA,GAAsB;AAC5B,IAAA,IAAI,IAAA,CAAK,mBAAmB,IAAA,EAAM;AAChC,MAAA,aAAA,CAAc,KAAK,cAAc,CAAA;AACjC,MAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AAAA,IACxB;AAAA,EACF;AAAA,EAEQ,QAAA,GAAiB;AACvB,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,IAAI,KAAK,cAAA,EAAgB;AACvB,MAAA,IAAA,CAAK,eAAe,OAAA,EAAQ;AAC5B,MAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AAAA,IACxB;AACA,IAAA,IAAI,KAAK,EAAA,EAAI;AACX,MAAA,MAAM,KAAK,IAAA,CAAK,EAAA;AAChB,MAAA,IAAA,CAAK,EAAA,GAAK,IAAA;AACV,MAAA,EAAA,CAAG,MAAA,GAAS,IAAA;AACZ,MAAA,EAAA,CAAG,SAAA,GAAY,IAAA;AACf,MAAA,EAAA,CAAG,OAAA,GAAU,IAAA;AACb,MAAA,EAAA,CAAG,OAAA,GAAU,IAAA;AACb,MAAA,IAAI;AACF,QAAA,EAAA,CAAG,KAAA,EAAM;AAAA,MACX,CAAA,CAAA,MAAQ;AAAA,MAER;AAAA,IACF;AACA,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,cAAc,CAAA;AAAA,EAC7C;AACF","file":"shell.cjs","sourcesContent":["/**\n * Browser-compatible shell adapter for Slicer VMs.\n *\n * Provides frame encode/decode helpers and a `SlicerShellSession` class that\n * wires a standard browser WebSocket to an xterm.js Terminal instance using\n * the Slicer binary shell protocol.\n *\n * This module intentionally avoids Node-only imports so it can be used in\n * browser bundles.\n */\n\n// --- frame type constants ----------------------------------------------------\n\nexport const FRAME_TYPE_DATA = 0x01;\nexport const FRAME_TYPE_WINDOW_SIZE = 0x02;\nexport const FRAME_TYPE_SHUTDOWN = 0x03;\nexport const FRAME_TYPE_HEARTBEAT = 0x04;\nexport const FRAME_TYPE_SESSION_CLOSE = 0x05;\n\n// --- frame helpers -----------------------------------------------------------\n\nconst HEADER_SIZE = 5;\n\n/** Encode a frame into the 5-byte-header binary protocol. */\nexport function encodeFrame(frameType: number, payload?: Uint8Array): Uint8Array {\n const payloadLen = payload ? payload.byteLength : 0;\n const buf = new Uint8Array(HEADER_SIZE + payloadLen);\n const view = new DataView(buf.buffer);\n view.setUint8(0, frameType);\n view.setUint32(1, payloadLen, false); // big-endian\n if (payload) buf.set(payload, HEADER_SIZE);\n return buf;\n}\n\n/** Parse a binary frame. Returns null if the data is malformed. */\nexport function parseFrame(data: ArrayBuffer): { frameType: number; payload: Uint8Array } | null {\n if (data.byteLength < HEADER_SIZE) return null;\n const view = new DataView(data);\n const frameType = view.getUint8(0);\n const payloadLen = view.getUint32(1, false); // big-endian\n if (data.byteLength < HEADER_SIZE + payloadLen) return null;\n const payload = new Uint8Array(data, HEADER_SIZE, payloadLen);\n return { frameType, payload };\n}\n\n// --- types -------------------------------------------------------------------\n\nexport interface ShellSessionOptions {\n /** WebSocket URL for the shell endpoint (ws:// or wss://). */\n url: string;\n /** Heartbeat interval in ms. Default 30000. */\n heartbeatIntervalMs?: number;\n /** Called when connection state changes. */\n onStateChange?: (state: 'connecting' | 'connected' | 'disconnected') => void;\n /** Called on error. */\n onError?: (error: string) => void;\n}\n\n/**\n * Minimal xterm.js Terminal interface — only the methods SlicerShellSession\n * actually calls. Avoids requiring @xterm/xterm as a dependency.\n */\nexport interface XTermLike {\n onData: (cb: (data: string) => void) => { dispose: () => void };\n write: (data: string) => void;\n reset: () => void;\n cols: number;\n rows: number;\n}\n\n// --- minimal browser type stubs ----------------------------------------------\n// The SDK targets Node (lib: ES2022, no DOM). Declare the subset of browser\n// APIs the class actually uses so this file compiles without adding \"DOM\" to\n// the project-wide tsconfig.\n\ninterface BrowserWebSocket {\n binaryType: string;\n readyState: number;\n onopen: ((ev: unknown) => void) | null;\n onmessage: ((ev: { data: unknown }) => void) | null;\n onclose: ((ev: unknown) => void) | null;\n onerror: ((ev: unknown) => void) | null;\n send(data: ArrayBufferLike | Uint8Array | string): void;\n close(code?: number, reason?: string): void;\n}\n\ninterface BrowserWebSocketConstructor {\n new (url: string): BrowserWebSocket;\n readonly OPEN: number;\n}\n\ndeclare const WebSocket: BrowserWebSocketConstructor;\n\n// --- class -------------------------------------------------------------------\n\nconst encoder = new TextEncoder();\nconst decoder = new TextDecoder();\n\nexport class SlicerShellSession {\n private ws: BrowserWebSocket | null = null;\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null;\n private dataDisposable: { dispose: () => void } | null = null;\n\n constructor(\n private readonly terminal: XTermLike,\n private readonly options: ShellSessionOptions,\n ) {}\n\n /** True when the WebSocket is open and relaying. */\n get connected(): boolean {\n return this.ws !== null && this.ws.readyState === WebSocket.OPEN;\n }\n\n /** Open the WebSocket and begin relaying. */\n connect(): void {\n if (this.ws) return;\n this.options.onStateChange?.('connecting');\n\n const ws = new WebSocket(this.options.url);\n ws.binaryType = 'arraybuffer';\n this.ws = ws;\n\n ws.onopen = () => {\n this.options.onStateChange?.('connected');\n this.terminal.reset();\n this.sendResize(this.terminal.cols, this.terminal.rows);\n this.startHeartbeat();\n };\n\n ws.onmessage = (ev: { data: unknown }) => {\n if (!(ev.data instanceof ArrayBuffer)) return;\n const frame = parseFrame(ev.data);\n if (!frame) return;\n\n switch (frame.frameType) {\n case FRAME_TYPE_DATA:\n this.terminal.write(decoder.decode(frame.payload));\n break;\n case FRAME_TYPE_SHUTDOWN:\n case FRAME_TYPE_SESSION_CLOSE:\n this.teardown();\n break;\n }\n };\n\n ws.onclose = () => {\n this.teardown();\n };\n\n ws.onerror = () => {\n this.options.onError?.('WebSocket error');\n this.teardown();\n };\n\n this.dataDisposable = this.terminal.onData((data: string) => {\n if (!this.connected) return;\n const payload = encoder.encode(data);\n this.ws!.send(encodeFrame(FRAME_TYPE_DATA, payload));\n });\n }\n\n /** Send a graceful shutdown frame and close. */\n disconnect(): void {\n if (this.ws) {\n try {\n this.ws.send(encodeFrame(FRAME_TYPE_SHUTDOWN));\n } catch {\n /* ws may already be closed */\n }\n }\n this.teardown();\n }\n\n /** Send a window resize. Call this from FitAddon's onResize or a ResizeObserver. */\n resize(cols: number, rows: number): void {\n if (!this.connected) return;\n this.sendResize(cols, rows);\n }\n\n // --- internals -------------------------------------------------------------\n\n private sendResize(cols: number, rows: number): void {\n const payload = new Uint8Array(8);\n const view = new DataView(payload.buffer);\n view.setUint32(0, cols, false); // big-endian\n view.setUint32(4, rows, false);\n this.ws!.send(encodeFrame(FRAME_TYPE_WINDOW_SIZE, payload));\n }\n\n private startHeartbeat(): void {\n this.stopHeartbeat();\n const intervalMs = this.options.heartbeatIntervalMs ?? 30_000;\n this.heartbeatTimer = setInterval(() => {\n if (!this.connected) return;\n this.ws!.send(encodeFrame(FRAME_TYPE_HEARTBEAT));\n }, intervalMs);\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatTimer !== null) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n }\n\n private teardown(): void {\n this.stopHeartbeat();\n if (this.dataDisposable) {\n this.dataDisposable.dispose();\n this.dataDisposable = null;\n }\n if (this.ws) {\n const ws = this.ws;\n this.ws = null;\n ws.onopen = null;\n ws.onmessage = null;\n ws.onclose = null;\n ws.onerror = null;\n try {\n ws.close();\n } catch {\n /* ignore */\n }\n }\n this.options.onStateChange?.('disconnected');\n }\n}\n"]}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Browser-compatible shell adapter for Slicer VMs.
3
+ *
4
+ * Provides frame encode/decode helpers and a `SlicerShellSession` class that
5
+ * wires a standard browser WebSocket to an xterm.js Terminal instance using
6
+ * the Slicer binary shell protocol.
7
+ *
8
+ * This module intentionally avoids Node-only imports so it can be used in
9
+ * browser bundles.
10
+ */
11
+ declare const FRAME_TYPE_DATA = 1;
12
+ declare const FRAME_TYPE_WINDOW_SIZE = 2;
13
+ declare const FRAME_TYPE_SHUTDOWN = 3;
14
+ declare const FRAME_TYPE_HEARTBEAT = 4;
15
+ declare const FRAME_TYPE_SESSION_CLOSE = 5;
16
+ /** Encode a frame into the 5-byte-header binary protocol. */
17
+ declare function encodeFrame(frameType: number, payload?: Uint8Array): Uint8Array;
18
+ /** Parse a binary frame. Returns null if the data is malformed. */
19
+ declare function parseFrame(data: ArrayBuffer): {
20
+ frameType: number;
21
+ payload: Uint8Array;
22
+ } | null;
23
+ interface ShellSessionOptions {
24
+ /** WebSocket URL for the shell endpoint (ws:// or wss://). */
25
+ url: string;
26
+ /** Heartbeat interval in ms. Default 30000. */
27
+ heartbeatIntervalMs?: number;
28
+ /** Called when connection state changes. */
29
+ onStateChange?: (state: 'connecting' | 'connected' | 'disconnected') => void;
30
+ /** Called on error. */
31
+ onError?: (error: string) => void;
32
+ }
33
+ /**
34
+ * Minimal xterm.js Terminal interface — only the methods SlicerShellSession
35
+ * actually calls. Avoids requiring @xterm/xterm as a dependency.
36
+ */
37
+ interface XTermLike {
38
+ onData: (cb: (data: string) => void) => {
39
+ dispose: () => void;
40
+ };
41
+ write: (data: string) => void;
42
+ reset: () => void;
43
+ cols: number;
44
+ rows: number;
45
+ }
46
+ declare class SlicerShellSession {
47
+ private readonly terminal;
48
+ private readonly options;
49
+ private ws;
50
+ private heartbeatTimer;
51
+ private dataDisposable;
52
+ constructor(terminal: XTermLike, options: ShellSessionOptions);
53
+ /** True when the WebSocket is open and relaying. */
54
+ get connected(): boolean;
55
+ /** Open the WebSocket and begin relaying. */
56
+ connect(): void;
57
+ /** Send a graceful shutdown frame and close. */
58
+ disconnect(): void;
59
+ /** Send a window resize. Call this from FitAddon's onResize or a ResizeObserver. */
60
+ resize(cols: number, rows: number): void;
61
+ private sendResize;
62
+ private startHeartbeat;
63
+ private stopHeartbeat;
64
+ private teardown;
65
+ }
66
+
67
+ export { FRAME_TYPE_DATA, FRAME_TYPE_HEARTBEAT, FRAME_TYPE_SESSION_CLOSE, FRAME_TYPE_SHUTDOWN, FRAME_TYPE_WINDOW_SIZE, type ShellSessionOptions, SlicerShellSession, type XTermLike, encodeFrame, parseFrame };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Browser-compatible shell adapter for Slicer VMs.
3
+ *
4
+ * Provides frame encode/decode helpers and a `SlicerShellSession` class that
5
+ * wires a standard browser WebSocket to an xterm.js Terminal instance using
6
+ * the Slicer binary shell protocol.
7
+ *
8
+ * This module intentionally avoids Node-only imports so it can be used in
9
+ * browser bundles.
10
+ */
11
+ declare const FRAME_TYPE_DATA = 1;
12
+ declare const FRAME_TYPE_WINDOW_SIZE = 2;
13
+ declare const FRAME_TYPE_SHUTDOWN = 3;
14
+ declare const FRAME_TYPE_HEARTBEAT = 4;
15
+ declare const FRAME_TYPE_SESSION_CLOSE = 5;
16
+ /** Encode a frame into the 5-byte-header binary protocol. */
17
+ declare function encodeFrame(frameType: number, payload?: Uint8Array): Uint8Array;
18
+ /** Parse a binary frame. Returns null if the data is malformed. */
19
+ declare function parseFrame(data: ArrayBuffer): {
20
+ frameType: number;
21
+ payload: Uint8Array;
22
+ } | null;
23
+ interface ShellSessionOptions {
24
+ /** WebSocket URL for the shell endpoint (ws:// or wss://). */
25
+ url: string;
26
+ /** Heartbeat interval in ms. Default 30000. */
27
+ heartbeatIntervalMs?: number;
28
+ /** Called when connection state changes. */
29
+ onStateChange?: (state: 'connecting' | 'connected' | 'disconnected') => void;
30
+ /** Called on error. */
31
+ onError?: (error: string) => void;
32
+ }
33
+ /**
34
+ * Minimal xterm.js Terminal interface — only the methods SlicerShellSession
35
+ * actually calls. Avoids requiring @xterm/xterm as a dependency.
36
+ */
37
+ interface XTermLike {
38
+ onData: (cb: (data: string) => void) => {
39
+ dispose: () => void;
40
+ };
41
+ write: (data: string) => void;
42
+ reset: () => void;
43
+ cols: number;
44
+ rows: number;
45
+ }
46
+ declare class SlicerShellSession {
47
+ private readonly terminal;
48
+ private readonly options;
49
+ private ws;
50
+ private heartbeatTimer;
51
+ private dataDisposable;
52
+ constructor(terminal: XTermLike, options: ShellSessionOptions);
53
+ /** True when the WebSocket is open and relaying. */
54
+ get connected(): boolean;
55
+ /** Open the WebSocket and begin relaying. */
56
+ connect(): void;
57
+ /** Send a graceful shutdown frame and close. */
58
+ disconnect(): void;
59
+ /** Send a window resize. Call this from FitAddon's onResize or a ResizeObserver. */
60
+ resize(cols: number, rows: number): void;
61
+ private sendResize;
62
+ private startHeartbeat;
63
+ private stopHeartbeat;
64
+ private teardown;
65
+ }
66
+
67
+ export { FRAME_TYPE_DATA, FRAME_TYPE_HEARTBEAT, FRAME_TYPE_SESSION_CLOSE, FRAME_TYPE_SHUTDOWN, FRAME_TYPE_WINDOW_SIZE, type ShellSessionOptions, SlicerShellSession, type XTermLike, encodeFrame, parseFrame };
package/dist/shell.js ADDED
@@ -0,0 +1,143 @@
1
+ // src/shell.ts
2
+ var FRAME_TYPE_DATA = 1;
3
+ var FRAME_TYPE_WINDOW_SIZE = 2;
4
+ var FRAME_TYPE_SHUTDOWN = 3;
5
+ var FRAME_TYPE_HEARTBEAT = 4;
6
+ var FRAME_TYPE_SESSION_CLOSE = 5;
7
+ var HEADER_SIZE = 5;
8
+ function encodeFrame(frameType, payload) {
9
+ const payloadLen = payload ? payload.byteLength : 0;
10
+ const buf = new Uint8Array(HEADER_SIZE + payloadLen);
11
+ const view = new DataView(buf.buffer);
12
+ view.setUint8(0, frameType);
13
+ view.setUint32(1, payloadLen, false);
14
+ if (payload) buf.set(payload, HEADER_SIZE);
15
+ return buf;
16
+ }
17
+ function parseFrame(data) {
18
+ if (data.byteLength < HEADER_SIZE) return null;
19
+ const view = new DataView(data);
20
+ const frameType = view.getUint8(0);
21
+ const payloadLen = view.getUint32(1, false);
22
+ if (data.byteLength < HEADER_SIZE + payloadLen) return null;
23
+ const payload = new Uint8Array(data, HEADER_SIZE, payloadLen);
24
+ return { frameType, payload };
25
+ }
26
+ var encoder = new TextEncoder();
27
+ var decoder = new TextDecoder();
28
+ var SlicerShellSession = class {
29
+ constructor(terminal, options) {
30
+ this.terminal = terminal;
31
+ this.options = options;
32
+ }
33
+ terminal;
34
+ options;
35
+ ws = null;
36
+ heartbeatTimer = null;
37
+ dataDisposable = null;
38
+ /** True when the WebSocket is open and relaying. */
39
+ get connected() {
40
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
41
+ }
42
+ /** Open the WebSocket and begin relaying. */
43
+ connect() {
44
+ if (this.ws) return;
45
+ this.options.onStateChange?.("connecting");
46
+ const ws = new WebSocket(this.options.url);
47
+ ws.binaryType = "arraybuffer";
48
+ this.ws = ws;
49
+ ws.onopen = () => {
50
+ this.options.onStateChange?.("connected");
51
+ this.terminal.reset();
52
+ this.sendResize(this.terminal.cols, this.terminal.rows);
53
+ this.startHeartbeat();
54
+ };
55
+ ws.onmessage = (ev) => {
56
+ if (!(ev.data instanceof ArrayBuffer)) return;
57
+ const frame = parseFrame(ev.data);
58
+ if (!frame) return;
59
+ switch (frame.frameType) {
60
+ case FRAME_TYPE_DATA:
61
+ this.terminal.write(decoder.decode(frame.payload));
62
+ break;
63
+ case FRAME_TYPE_SHUTDOWN:
64
+ case FRAME_TYPE_SESSION_CLOSE:
65
+ this.teardown();
66
+ break;
67
+ }
68
+ };
69
+ ws.onclose = () => {
70
+ this.teardown();
71
+ };
72
+ ws.onerror = () => {
73
+ this.options.onError?.("WebSocket error");
74
+ this.teardown();
75
+ };
76
+ this.dataDisposable = this.terminal.onData((data) => {
77
+ if (!this.connected) return;
78
+ const payload = encoder.encode(data);
79
+ this.ws.send(encodeFrame(FRAME_TYPE_DATA, payload));
80
+ });
81
+ }
82
+ /** Send a graceful shutdown frame and close. */
83
+ disconnect() {
84
+ if (this.ws) {
85
+ try {
86
+ this.ws.send(encodeFrame(FRAME_TYPE_SHUTDOWN));
87
+ } catch {
88
+ }
89
+ }
90
+ this.teardown();
91
+ }
92
+ /** Send a window resize. Call this from FitAddon's onResize or a ResizeObserver. */
93
+ resize(cols, rows) {
94
+ if (!this.connected) return;
95
+ this.sendResize(cols, rows);
96
+ }
97
+ // --- internals -------------------------------------------------------------
98
+ sendResize(cols, rows) {
99
+ const payload = new Uint8Array(8);
100
+ const view = new DataView(payload.buffer);
101
+ view.setUint32(0, cols, false);
102
+ view.setUint32(4, rows, false);
103
+ this.ws.send(encodeFrame(FRAME_TYPE_WINDOW_SIZE, payload));
104
+ }
105
+ startHeartbeat() {
106
+ this.stopHeartbeat();
107
+ const intervalMs = this.options.heartbeatIntervalMs ?? 3e4;
108
+ this.heartbeatTimer = setInterval(() => {
109
+ if (!this.connected) return;
110
+ this.ws.send(encodeFrame(FRAME_TYPE_HEARTBEAT));
111
+ }, intervalMs);
112
+ }
113
+ stopHeartbeat() {
114
+ if (this.heartbeatTimer !== null) {
115
+ clearInterval(this.heartbeatTimer);
116
+ this.heartbeatTimer = null;
117
+ }
118
+ }
119
+ teardown() {
120
+ this.stopHeartbeat();
121
+ if (this.dataDisposable) {
122
+ this.dataDisposable.dispose();
123
+ this.dataDisposable = null;
124
+ }
125
+ if (this.ws) {
126
+ const ws = this.ws;
127
+ this.ws = null;
128
+ ws.onopen = null;
129
+ ws.onmessage = null;
130
+ ws.onclose = null;
131
+ ws.onerror = null;
132
+ try {
133
+ ws.close();
134
+ } catch {
135
+ }
136
+ }
137
+ this.options.onStateChange?.("disconnected");
138
+ }
139
+ };
140
+
141
+ export { FRAME_TYPE_DATA, FRAME_TYPE_HEARTBEAT, FRAME_TYPE_SESSION_CLOSE, FRAME_TYPE_SHUTDOWN, FRAME_TYPE_WINDOW_SIZE, SlicerShellSession, encodeFrame, parseFrame };
142
+ //# sourceMappingURL=shell.js.map
143
+ //# sourceMappingURL=shell.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/shell.ts"],"names":[],"mappings":";AAaO,IAAM,eAAA,GAAkB;AACxB,IAAM,sBAAA,GAAyB;AAC/B,IAAM,mBAAA,GAAsB;AAC5B,IAAM,oBAAA,GAAuB;AAC7B,IAAM,wBAAA,GAA2B;AAIxC,IAAM,WAAA,GAAc,CAAA;AAGb,SAAS,WAAA,CAAY,WAAmB,OAAA,EAAkC;AAC/E,EAAA,MAAM,UAAA,GAAa,OAAA,GAAU,OAAA,CAAQ,UAAA,GAAa,CAAA;AAClD,EAAA,MAAM,GAAA,GAAM,IAAI,UAAA,CAAW,WAAA,GAAc,UAAU,CAAA;AACnD,EAAA,MAAM,IAAA,GAAO,IAAI,QAAA,CAAS,GAAA,CAAI,MAAM,CAAA;AACpC,EAAA,IAAA,CAAK,QAAA,CAAS,GAAG,SAAS,CAAA;AAC1B,EAAA,IAAA,CAAK,SAAA,CAAU,CAAA,EAAG,UAAA,EAAY,KAAK,CAAA;AACnC,EAAA,IAAI,OAAA,EAAS,GAAA,CAAI,GAAA,CAAI,OAAA,EAAS,WAAW,CAAA;AACzC,EAAA,OAAO,GAAA;AACT;AAGO,SAAS,WAAW,IAAA,EAAsE;AAC/F,EAAA,IAAI,IAAA,CAAK,UAAA,GAAa,WAAA,EAAa,OAAO,IAAA;AAC1C,EAAA,MAAM,IAAA,GAAO,IAAI,QAAA,CAAS,IAAI,CAAA;AAC9B,EAAA,MAAM,SAAA,GAAY,IAAA,CAAK,QAAA,CAAS,CAAC,CAAA;AACjC,EAAA,MAAM,UAAA,GAAa,IAAA,CAAK,SAAA,CAAU,CAAA,EAAG,KAAK,CAAA;AAC1C,EAAA,IAAI,IAAA,CAAK,UAAA,GAAa,WAAA,GAAc,UAAA,EAAY,OAAO,IAAA;AACvD,EAAA,MAAM,OAAA,GAAU,IAAI,UAAA,CAAW,IAAA,EAAM,aAAa,UAAU,CAAA;AAC5D,EAAA,OAAO,EAAE,WAAW,OAAA,EAAQ;AAC9B;AAoDA,IAAM,OAAA,GAAU,IAAI,WAAA,EAAY;AAChC,IAAM,OAAA,GAAU,IAAI,WAAA,EAAY;AAEzB,IAAM,qBAAN,MAAyB;AAAA,EAK9B,WAAA,CACmB,UACA,OAAA,EACjB;AAFiB,IAAA,IAAA,CAAA,QAAA,GAAA,QAAA;AACA,IAAA,IAAA,CAAA,OAAA,GAAA,OAAA;AAAA,EAChB;AAAA,EAFgB,QAAA;AAAA,EACA,OAAA;AAAA,EANX,EAAA,GAA8B,IAAA;AAAA,EAC9B,cAAA,GAAwD,IAAA;AAAA,EACxD,cAAA,GAAiD,IAAA;AAAA;AAAA,EAQzD,IAAI,SAAA,GAAqB;AACvB,IAAA,OAAO,KAAK,EAAA,KAAO,IAAA,IAAQ,IAAA,CAAK,EAAA,CAAG,eAAe,SAAA,CAAU,IAAA;AAAA,EAC9D;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,IAAI,KAAK,EAAA,EAAI;AACb,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,YAAY,CAAA;AAEzC,IAAA,MAAM,EAAA,GAAK,IAAI,SAAA,CAAU,IAAA,CAAK,QAAQ,GAAG,CAAA;AACzC,IAAA,EAAA,CAAG,UAAA,GAAa,aAAA;AAChB,IAAA,IAAA,CAAK,EAAA,GAAK,EAAA;AAEV,IAAA,EAAA,CAAG,SAAS,MAAM;AAChB,MAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,WAAW,CAAA;AACxC,MAAA,IAAA,CAAK,SAAS,KAAA,EAAM;AACpB,MAAA,IAAA,CAAK,WAAW,IAAA,CAAK,QAAA,CAAS,IAAA,EAAM,IAAA,CAAK,SAAS,IAAI,CAAA;AACtD,MAAA,IAAA,CAAK,cAAA,EAAe;AAAA,IACtB,CAAA;AAEA,IAAA,EAAA,CAAG,SAAA,GAAY,CAAC,EAAA,KAA0B;AACxC,MAAA,IAAI,EAAE,EAAA,CAAG,IAAA,YAAgB,WAAA,CAAA,EAAc;AACvC,MAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,EAAA,CAAG,IAAI,CAAA;AAChC,MAAA,IAAI,CAAC,KAAA,EAAO;AAEZ,MAAA,QAAQ,MAAM,SAAA;AAAW,QACvB,KAAK,eAAA;AACH,UAAA,IAAA,CAAK,SAAS,KAAA,CAAM,OAAA,CAAQ,MAAA,CAAO,KAAA,CAAM,OAAO,CAAC,CAAA;AACjD,UAAA;AAAA,QACF,KAAK,mBAAA;AAAA,QACL,KAAK,wBAAA;AACH,UAAA,IAAA,CAAK,QAAA,EAAS;AACd,UAAA;AAAA;AACJ,IACF,CAAA;AAEA,IAAA,EAAA,CAAG,UAAU,MAAM;AACjB,MAAA,IAAA,CAAK,QAAA,EAAS;AAAA,IAChB,CAAA;AAEA,IAAA,EAAA,CAAG,UAAU,MAAM;AACjB,MAAA,IAAA,CAAK,OAAA,CAAQ,UAAU,iBAAiB,CAAA;AACxC,MAAA,IAAA,CAAK,QAAA,EAAS;AAAA,IAChB,CAAA;AAEA,IAAA,IAAA,CAAK,cAAA,GAAiB,IAAA,CAAK,QAAA,CAAS,MAAA,CAAO,CAAC,IAAA,KAAiB;AAC3D,MAAA,IAAI,CAAC,KAAK,SAAA,EAAW;AACrB,MAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,MAAA,CAAO,IAAI,CAAA;AACnC,MAAA,IAAA,CAAK,EAAA,CAAI,IAAA,CAAK,WAAA,CAAY,eAAA,EAAiB,OAAO,CAAC,CAAA;AAAA,IACrD,CAAC,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,IAAI,KAAK,EAAA,EAAI;AACX,MAAA,IAAI;AACF,QAAA,IAAA,CAAK,EAAA,CAAG,IAAA,CAAK,WAAA,CAAY,mBAAmB,CAAC,CAAA;AAAA,MAC/C,CAAA,CAAA,MAAQ;AAAA,MAER;AAAA,IACF;AACA,IAAA,IAAA,CAAK,QAAA,EAAS;AAAA,EAChB;AAAA;AAAA,EAGA,MAAA,CAAO,MAAc,IAAA,EAAoB;AACvC,IAAA,IAAI,CAAC,KAAK,SAAA,EAAW;AACrB,IAAA,IAAA,CAAK,UAAA,CAAW,MAAM,IAAI,CAAA;AAAA,EAC5B;AAAA;AAAA,EAIQ,UAAA,CAAW,MAAc,IAAA,EAAoB;AACnD,IAAA,MAAM,OAAA,GAAU,IAAI,UAAA,CAAW,CAAC,CAAA;AAChC,IAAA,MAAM,IAAA,GAAO,IAAI,QAAA,CAAS,OAAA,CAAQ,MAAM,CAAA;AACxC,IAAA,IAAA,CAAK,SAAA,CAAU,CAAA,EAAG,IAAA,EAAM,KAAK,CAAA;AAC7B,IAAA,IAAA,CAAK,SAAA,CAAU,CAAA,EAAG,IAAA,EAAM,KAAK,CAAA;AAC7B,IAAA,IAAA,CAAK,EAAA,CAAI,IAAA,CAAK,WAAA,CAAY,sBAAA,EAAwB,OAAO,CAAC,CAAA;AAAA,EAC5D;AAAA,EAEQ,cAAA,GAAuB;AAC7B,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,MAAM,UAAA,GAAa,IAAA,CAAK,OAAA,CAAQ,mBAAA,IAAuB,GAAA;AACvD,IAAA,IAAA,CAAK,cAAA,GAAiB,YAAY,MAAM;AACtC,MAAA,IAAI,CAAC,KAAK,SAAA,EAAW;AACrB,MAAA,IAAA,CAAK,EAAA,CAAI,IAAA,CAAK,WAAA,CAAY,oBAAoB,CAAC,CAAA;AAAA,IACjD,GAAG,UAAU,CAAA;AAAA,EACf;AAAA,EAEQ,aAAA,GAAsB;AAC5B,IAAA,IAAI,IAAA,CAAK,mBAAmB,IAAA,EAAM;AAChC,MAAA,aAAA,CAAc,KAAK,cAAc,CAAA;AACjC,MAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AAAA,IACxB;AAAA,EACF;AAAA,EAEQ,QAAA,GAAiB;AACvB,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,IAAI,KAAK,cAAA,EAAgB;AACvB,MAAA,IAAA,CAAK,eAAe,OAAA,EAAQ;AAC5B,MAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AAAA,IACxB;AACA,IAAA,IAAI,KAAK,EAAA,EAAI;AACX,MAAA,MAAM,KAAK,IAAA,CAAK,EAAA;AAChB,MAAA,IAAA,CAAK,EAAA,GAAK,IAAA;AACV,MAAA,EAAA,CAAG,MAAA,GAAS,IAAA;AACZ,MAAA,EAAA,CAAG,SAAA,GAAY,IAAA;AACf,MAAA,EAAA,CAAG,OAAA,GAAU,IAAA;AACb,MAAA,EAAA,CAAG,OAAA,GAAU,IAAA;AACb,MAAA,IAAI;AACF,QAAA,EAAA,CAAG,KAAA,EAAM;AAAA,MACX,CAAA,CAAA,MAAQ;AAAA,MAER;AAAA,IACF;AACA,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,cAAc,CAAA;AAAA,EAC7C;AACF","file":"shell.js","sourcesContent":["/**\n * Browser-compatible shell adapter for Slicer VMs.\n *\n * Provides frame encode/decode helpers and a `SlicerShellSession` class that\n * wires a standard browser WebSocket to an xterm.js Terminal instance using\n * the Slicer binary shell protocol.\n *\n * This module intentionally avoids Node-only imports so it can be used in\n * browser bundles.\n */\n\n// --- frame type constants ----------------------------------------------------\n\nexport const FRAME_TYPE_DATA = 0x01;\nexport const FRAME_TYPE_WINDOW_SIZE = 0x02;\nexport const FRAME_TYPE_SHUTDOWN = 0x03;\nexport const FRAME_TYPE_HEARTBEAT = 0x04;\nexport const FRAME_TYPE_SESSION_CLOSE = 0x05;\n\n// --- frame helpers -----------------------------------------------------------\n\nconst HEADER_SIZE = 5;\n\n/** Encode a frame into the 5-byte-header binary protocol. */\nexport function encodeFrame(frameType: number, payload?: Uint8Array): Uint8Array {\n const payloadLen = payload ? payload.byteLength : 0;\n const buf = new Uint8Array(HEADER_SIZE + payloadLen);\n const view = new DataView(buf.buffer);\n view.setUint8(0, frameType);\n view.setUint32(1, payloadLen, false); // big-endian\n if (payload) buf.set(payload, HEADER_SIZE);\n return buf;\n}\n\n/** Parse a binary frame. Returns null if the data is malformed. */\nexport function parseFrame(data: ArrayBuffer): { frameType: number; payload: Uint8Array } | null {\n if (data.byteLength < HEADER_SIZE) return null;\n const view = new DataView(data);\n const frameType = view.getUint8(0);\n const payloadLen = view.getUint32(1, false); // big-endian\n if (data.byteLength < HEADER_SIZE + payloadLen) return null;\n const payload = new Uint8Array(data, HEADER_SIZE, payloadLen);\n return { frameType, payload };\n}\n\n// --- types -------------------------------------------------------------------\n\nexport interface ShellSessionOptions {\n /** WebSocket URL for the shell endpoint (ws:// or wss://). */\n url: string;\n /** Heartbeat interval in ms. Default 30000. */\n heartbeatIntervalMs?: number;\n /** Called when connection state changes. */\n onStateChange?: (state: 'connecting' | 'connected' | 'disconnected') => void;\n /** Called on error. */\n onError?: (error: string) => void;\n}\n\n/**\n * Minimal xterm.js Terminal interface — only the methods SlicerShellSession\n * actually calls. Avoids requiring @xterm/xterm as a dependency.\n */\nexport interface XTermLike {\n onData: (cb: (data: string) => void) => { dispose: () => void };\n write: (data: string) => void;\n reset: () => void;\n cols: number;\n rows: number;\n}\n\n// --- minimal browser type stubs ----------------------------------------------\n// The SDK targets Node (lib: ES2022, no DOM). Declare the subset of browser\n// APIs the class actually uses so this file compiles without adding \"DOM\" to\n// the project-wide tsconfig.\n\ninterface BrowserWebSocket {\n binaryType: string;\n readyState: number;\n onopen: ((ev: unknown) => void) | null;\n onmessage: ((ev: { data: unknown }) => void) | null;\n onclose: ((ev: unknown) => void) | null;\n onerror: ((ev: unknown) => void) | null;\n send(data: ArrayBufferLike | Uint8Array | string): void;\n close(code?: number, reason?: string): void;\n}\n\ninterface BrowserWebSocketConstructor {\n new (url: string): BrowserWebSocket;\n readonly OPEN: number;\n}\n\ndeclare const WebSocket: BrowserWebSocketConstructor;\n\n// --- class -------------------------------------------------------------------\n\nconst encoder = new TextEncoder();\nconst decoder = new TextDecoder();\n\nexport class SlicerShellSession {\n private ws: BrowserWebSocket | null = null;\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null;\n private dataDisposable: { dispose: () => void } | null = null;\n\n constructor(\n private readonly terminal: XTermLike,\n private readonly options: ShellSessionOptions,\n ) {}\n\n /** True when the WebSocket is open and relaying. */\n get connected(): boolean {\n return this.ws !== null && this.ws.readyState === WebSocket.OPEN;\n }\n\n /** Open the WebSocket and begin relaying. */\n connect(): void {\n if (this.ws) return;\n this.options.onStateChange?.('connecting');\n\n const ws = new WebSocket(this.options.url);\n ws.binaryType = 'arraybuffer';\n this.ws = ws;\n\n ws.onopen = () => {\n this.options.onStateChange?.('connected');\n this.terminal.reset();\n this.sendResize(this.terminal.cols, this.terminal.rows);\n this.startHeartbeat();\n };\n\n ws.onmessage = (ev: { data: unknown }) => {\n if (!(ev.data instanceof ArrayBuffer)) return;\n const frame = parseFrame(ev.data);\n if (!frame) return;\n\n switch (frame.frameType) {\n case FRAME_TYPE_DATA:\n this.terminal.write(decoder.decode(frame.payload));\n break;\n case FRAME_TYPE_SHUTDOWN:\n case FRAME_TYPE_SESSION_CLOSE:\n this.teardown();\n break;\n }\n };\n\n ws.onclose = () => {\n this.teardown();\n };\n\n ws.onerror = () => {\n this.options.onError?.('WebSocket error');\n this.teardown();\n };\n\n this.dataDisposable = this.terminal.onData((data: string) => {\n if (!this.connected) return;\n const payload = encoder.encode(data);\n this.ws!.send(encodeFrame(FRAME_TYPE_DATA, payload));\n });\n }\n\n /** Send a graceful shutdown frame and close. */\n disconnect(): void {\n if (this.ws) {\n try {\n this.ws.send(encodeFrame(FRAME_TYPE_SHUTDOWN));\n } catch {\n /* ws may already be closed */\n }\n }\n this.teardown();\n }\n\n /** Send a window resize. Call this from FitAddon's onResize or a ResizeObserver. */\n resize(cols: number, rows: number): void {\n if (!this.connected) return;\n this.sendResize(cols, rows);\n }\n\n // --- internals -------------------------------------------------------------\n\n private sendResize(cols: number, rows: number): void {\n const payload = new Uint8Array(8);\n const view = new DataView(payload.buffer);\n view.setUint32(0, cols, false); // big-endian\n view.setUint32(4, rows, false);\n this.ws!.send(encodeFrame(FRAME_TYPE_WINDOW_SIZE, payload));\n }\n\n private startHeartbeat(): void {\n this.stopHeartbeat();\n const intervalMs = this.options.heartbeatIntervalMs ?? 30_000;\n this.heartbeatTimer = setInterval(() => {\n if (!this.connected) return;\n this.ws!.send(encodeFrame(FRAME_TYPE_HEARTBEAT));\n }, intervalMs);\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatTimer !== null) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n }\n\n private teardown(): void {\n this.stopHeartbeat();\n if (this.dataDisposable) {\n this.dataDisposable.dispose();\n this.dataDisposable = null;\n }\n if (this.ws) {\n const ws = this.ws;\n this.ws = null;\n ws.onopen = null;\n ws.onmessage = null;\n ws.onclose = null;\n ws.onerror = null;\n try {\n ws.close();\n } catch {\n /* ignore */\n }\n }\n this.options.onStateChange?.('disconnected');\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slicervm/sdk",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "TypeScript SDK for the Slicer VM control-plane API.",
5
5
  "license": "MIT",
6
6
  "author": "Alex Ellis <alex@openfaas.com>",
@@ -38,6 +38,16 @@
38
38
  "types": "./dist/index.d.cts",
39
39
  "default": "./dist/index.cjs"
40
40
  }
41
+ },
42
+ "./shell": {
43
+ "import": {
44
+ "types": "./dist/shell.d.ts",
45
+ "default": "./dist/shell.js"
46
+ },
47
+ "require": {
48
+ "types": "./dist/shell.d.cts",
49
+ "default": "./dist/shell.cjs"
50
+ }
41
51
  }
42
52
  },
43
53
  "files": [