@jhebe/web-terminal 0.2.0

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/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # web-terminal
2
+
3
+ `web-terminal` is a framework-agnostic terminal-session library. A hosting
4
+ backend instantiates a private in-memory session manager; browser applications
5
+ interact through the limited verbs `open`, `close`, `attach`, `detach`, and
6
+ `display`.
7
+
8
+ ## Backend
9
+
10
+ ```ts
11
+ import { createServer } from 'node:http';
12
+ import {
13
+ attachTerminalServer,
14
+ createTerminalSessionManager,
15
+ } from '@jhebe/web-terminal/server';
16
+
17
+ const server = createServer(app);
18
+ const manager = createTerminalSessionManager({
19
+ cwd: '/workspace',
20
+ idleTimeoutMs: 10 * 60 * 1000,
21
+ maxSessions: 100,
22
+ maxObserversPerSession: 32,
23
+ maxInputBytes: 64 * 1024,
24
+ maxWriteBytes: 64 * 1024,
25
+ });
26
+
27
+ manager.observe(sessionId, 'host-monitor', {
28
+ output: (data) => process.stdout.write(data),
29
+ exit: (code) => console.log('Exited:', code),
30
+ });
31
+
32
+ manager.write(sessionId, 'echo controlled by host\r');
33
+ manager.unobserve(sessionId, 'host-monitor');
34
+
35
+ attachTerminalServer({
36
+ server,
37
+ manager,
38
+ maxPayloadBytes: 64 * 1024,
39
+ authorize: (request, action, sessionId) => {
40
+ return authorizeUser(request, action, sessionId);
41
+ },
42
+ });
43
+ ```
44
+
45
+ The host owns authentication, permissions, names, business context, and page
46
+ layout. The manager owns PTY lifetime, session identity, attachments, bounded
47
+ history, and idle cleanup.
48
+
49
+ ## Browser
50
+
51
+ ```ts
52
+ import { createTerminalClient } from '@jhebe/web-terminal';
53
+ import '@jhebe/web-terminal/style.css';
54
+
55
+ const client = createTerminalClient({
56
+ socketUrl: 'wss://example.com/terminal',
57
+ });
58
+
59
+ const session = await client.open({ name: '小鍵' });
60
+ const view = await client.display(session.id, element);
61
+
62
+ await client.write(session.id, 'echo inserted by the host UI\r');
63
+ await client.detach(); // Session and process continue.
64
+ await client.display(session.id, anotherElement);
65
+ await client.close(session.id); // Session and process end permanently.
66
+ ```
67
+
68
+ This version is intentionally in-memory and single-process. Sessions do not
69
+ survive backend restart and are not coordinated across multiple backend
70
+ instances.
71
+
72
+ ## CLI
73
+
74
+ The package installs a `web-terminal` command that uses the same authenticated
75
+ WebSocket endpoint:
76
+
77
+ ```bash
78
+ web-terminal --url ws://127.0.0.1:5173/terminal sessions
79
+ web-terminal --url ws://127.0.0.1:5173/terminal open "小鍵"
80
+ web-terminal --url ws://127.0.0.1:5173/terminal send SESSION_ID "echo hello"
81
+ web-terminal --url ws://127.0.0.1:5173/terminal follow SESSION_ID
82
+ web-terminal --url ws://127.0.0.1:5173/terminal close SESSION_ID
83
+ ```
84
+
85
+ Use `--token` or `WEB_TERMINAL_TOKEN` when the host authorization callback
86
+ expects a bearer token.
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { randomUUID } from 'node:crypto';
4
+ import process from 'node:process';
5
+ import { WebSocket } from 'ws';
6
+
7
+ async function main() {
8
+ const { command, arguments: commandArguments, url, token } = parseArguments(
9
+ process.argv.slice(2),
10
+ );
11
+
12
+ if (!command || command === 'help' || command === '--help') {
13
+ printHelp();
14
+ process.exit(0);
15
+ }
16
+
17
+ const client = new SessionWireClient(url, token);
18
+
19
+ try {
20
+ await client.connect();
21
+
22
+ switch (command) {
23
+ case 'sessions': {
24
+ const sessions = await client.waitForSessions();
25
+ if (sessions.length === 0) {
26
+ console.log('No terminal sessions.');
27
+ break;
28
+ }
29
+ for (const session of sessions) {
30
+ console.log(
31
+ [
32
+ session.id,
33
+ session.state,
34
+ session.attached ? 'displayed' : 'detached',
35
+ `${session.observers} observer(s)`,
36
+ session.name,
37
+ ].join('\t'),
38
+ );
39
+ }
40
+ break;
41
+ }
42
+ case 'open': {
43
+ const response = await client.request({
44
+ type: 'open',
45
+ requestId: randomUUID(),
46
+ ...(commandArguments.length === 0
47
+ ? {}
48
+ : { name: commandArguments.join(' ') }),
49
+ });
50
+ requireResponse(response, 'opened');
51
+ console.log(response.session.id);
52
+ break;
53
+ }
54
+ case 'send':
55
+ case 'write': {
56
+ const [sessionId, ...inputParts] = commandArguments;
57
+ if (!sessionId || inputParts.length === 0) {
58
+ throw new Error(`${command} requires a session ID and input.`);
59
+ }
60
+ const data =
61
+ command === 'send'
62
+ ? `${inputParts.join(' ')}\r`
63
+ : inputParts.join(' ');
64
+ const response = await client.request({
65
+ type: 'write',
66
+ requestId: randomUUID(),
67
+ sessionId,
68
+ data,
69
+ });
70
+ requireResponse(response, 'written');
71
+ break;
72
+ }
73
+ case 'follow': {
74
+ const [sessionId] = commandArguments;
75
+ if (!sessionId) {
76
+ throw new Error('follow requires a session ID.');
77
+ }
78
+ const response = await client.request({
79
+ type: 'observe',
80
+ requestId: randomUUID(),
81
+ sessionId,
82
+ });
83
+ requireResponse(response, 'observing');
84
+ process.stdout.write(response.history);
85
+
86
+ if (response.session.state === 'exited') {
87
+ console.error(
88
+ `\nSession has already exited with code ${
89
+ response.session.exitCode ?? 'unknown'
90
+ }.`,
91
+ );
92
+ } else {
93
+ await new Promise((resolve) => {
94
+ const finish = () => {
95
+ unsubscribe();
96
+ resolve();
97
+ };
98
+ const unsubscribe = client.onMessage((message) => {
99
+ if (message.type === 'output' && message.sessionId === sessionId) {
100
+ process.stdout.write(message.data);
101
+ } else if (
102
+ message.type === 'exit' &&
103
+ message.sessionId === sessionId
104
+ ) {
105
+ console.error(`\nSession exited with code ${message.exitCode}.`);
106
+ finish();
107
+ } else if (
108
+ message.type === 'sessions' &&
109
+ !message.sessions.some((session) => session.id === sessionId)
110
+ ) {
111
+ console.error('\nSession was closed.');
112
+ finish();
113
+ }
114
+ });
115
+ process.once('SIGINT', finish);
116
+ client.onceClosed(finish);
117
+ });
118
+ }
119
+ if (client.connected) {
120
+ await client.request({
121
+ type: 'unobserve',
122
+ requestId: randomUUID(),
123
+ sessionId,
124
+ });
125
+ }
126
+ break;
127
+ }
128
+ case 'close': {
129
+ const [sessionId] = commandArguments;
130
+ if (!sessionId) {
131
+ throw new Error('close requires a session ID.');
132
+ }
133
+ const response = await client.request({
134
+ type: 'close',
135
+ requestId: randomUUID(),
136
+ sessionId,
137
+ });
138
+ requireResponse(response, 'closed');
139
+ break;
140
+ }
141
+ default:
142
+ throw new Error(`Unknown command: ${command}`);
143
+ }
144
+ } catch (error) {
145
+ console.error(error instanceof Error ? error.message : String(error));
146
+ process.exitCode = 1;
147
+ } finally {
148
+ client.close();
149
+ }
150
+ }
151
+
152
+ class SessionWireClient {
153
+ #socket;
154
+ #pending = new Map();
155
+ #listeners = new Set();
156
+ #closeListeners = new Set();
157
+ #sessions;
158
+ #sessionWaiters = [];
159
+
160
+ constructor(url, token) {
161
+ this.url = url;
162
+ this.token = token;
163
+ }
164
+
165
+ get connected() {
166
+ return this.#socket?.readyState === WebSocket.OPEN;
167
+ }
168
+
169
+ connect() {
170
+ return new Promise((resolve, reject) => {
171
+ const headers = this.token
172
+ ? { Authorization: `Bearer ${this.token}` }
173
+ : undefined;
174
+ const socket = new WebSocket(this.url, { headers });
175
+ this.#socket = socket;
176
+ socket.once('open', resolve);
177
+ socket.once('error', reject);
178
+ socket.on('message', (raw) => this.#handle(JSON.parse(raw.toString())));
179
+ socket.on('close', () => {
180
+ for (const pending of this.#pending.values()) {
181
+ pending.reject(new Error('Terminal connection closed.'));
182
+ }
183
+ this.#pending.clear();
184
+ for (const listener of this.#closeListeners) {
185
+ listener();
186
+ }
187
+ });
188
+ });
189
+ }
190
+
191
+ request(message) {
192
+ if (!this.connected) {
193
+ return Promise.reject(new Error('Terminal CLI is not connected.'));
194
+ }
195
+ return new Promise((resolve, reject) => {
196
+ this.#pending.set(message.requestId, { resolve, reject });
197
+ this.#socket.send(JSON.stringify(message));
198
+ });
199
+ }
200
+
201
+ waitForSessions() {
202
+ if (this.#sessions) {
203
+ return Promise.resolve(this.#sessions);
204
+ }
205
+ return new Promise((resolve) => this.#sessionWaiters.push(resolve));
206
+ }
207
+
208
+ onMessage(listener) {
209
+ this.#listeners.add(listener);
210
+ return () => this.#listeners.delete(listener);
211
+ }
212
+
213
+ onceClosed(listener) {
214
+ this.#closeListeners.add(listener);
215
+ }
216
+
217
+ close() {
218
+ this.#socket?.close(1000, 'CLI completed');
219
+ }
220
+
221
+ #handle(message) {
222
+ if (message.type === 'sessions') {
223
+ this.#sessions = message.sessions;
224
+ for (const resolve of this.#sessionWaiters.splice(0)) {
225
+ resolve(message.sessions);
226
+ }
227
+ }
228
+
229
+ if (message.type === 'error') {
230
+ if (message.requestId) {
231
+ const pending = this.#pending.get(message.requestId);
232
+ this.#pending.delete(message.requestId);
233
+ pending?.reject(new Error(message.message));
234
+ } else {
235
+ console.error(message.message);
236
+ }
237
+ return;
238
+ }
239
+
240
+ if (message.requestId) {
241
+ const pending = this.#pending.get(message.requestId);
242
+ this.#pending.delete(message.requestId);
243
+ pending?.resolve(message);
244
+ }
245
+
246
+ for (const listener of this.#listeners) {
247
+ listener(message);
248
+ }
249
+ }
250
+ }
251
+
252
+ function parseArguments(args) {
253
+ const remaining = [];
254
+ let url =
255
+ process.env.WEB_TERMINAL_URL ?? 'ws://127.0.0.1:5173/terminal';
256
+ let token = process.env.WEB_TERMINAL_TOKEN;
257
+
258
+ for (let index = 0; index < args.length; index += 1) {
259
+ const argument = args[index];
260
+ if (argument === '--url') {
261
+ url = requireOptionValue(args, ++index, '--url');
262
+ } else if (argument === '--token') {
263
+ token = requireOptionValue(args, ++index, '--token');
264
+ } else {
265
+ remaining.push(argument);
266
+ }
267
+ }
268
+
269
+ return {
270
+ command: remaining[0],
271
+ arguments: remaining.slice(1),
272
+ url,
273
+ token,
274
+ };
275
+ }
276
+
277
+ function requireOptionValue(args, index, option) {
278
+ const value = args[index];
279
+ if (!value) {
280
+ throw new Error(`${option} requires a value.`);
281
+ }
282
+ return value;
283
+ }
284
+
285
+ function requireResponse(response, expectedType) {
286
+ if (response.type !== expectedType) {
287
+ throw new Error(
288
+ `Expected ${expectedType}, received ${response.type ?? 'unknown'}.`,
289
+ );
290
+ }
291
+ }
292
+
293
+ function printHelp() {
294
+ console.log(`Usage: web-terminal [--url URL] [--token TOKEN] <command>
295
+
296
+ Commands:
297
+ sessions List visible sessions
298
+ open [name] Open a named session and print its ID
299
+ send <session-id> <command> Write a command followed by Enter
300
+ write <session-id> <data> Write exact input without Enter
301
+ follow <session-id> Print history and follow live output
302
+ close <session-id> Permanently close a session
303
+
304
+ Environment:
305
+ WEB_TERMINAL_URL
306
+ WEB_TERMINAL_TOKEN`);
307
+ }
308
+
309
+ await main();
@@ -0,0 +1,3 @@
1
+ export { createTerminalClient, TerminalClient, type DisplayOptions, type OpenOptions, type TerminalClientEvent, type TerminalClientOptions, } from './terminal-client.js';
2
+ export { TerminalView } from './terminal-view.js';
3
+ export type { ClientMessage, ServerMessage, SessionSummary, } from './protocol.js';
@@ -0,0 +1,98 @@
1
+ export interface SessionSummary {
2
+ readonly id: string;
3
+ readonly name: string;
4
+ readonly state: 'running' | 'exited';
5
+ readonly attached: boolean;
6
+ readonly observers: number;
7
+ readonly createdAt: string;
8
+ readonly lastActiveAt: string;
9
+ readonly exitCode?: number;
10
+ }
11
+ export type ClientMessage = {
12
+ type: 'open';
13
+ requestId: string;
14
+ name?: string;
15
+ } | {
16
+ type: 'close';
17
+ requestId: string;
18
+ sessionId: string;
19
+ } | {
20
+ type: 'attach';
21
+ requestId: string;
22
+ sessionId: string;
23
+ } | {
24
+ type: 'detach';
25
+ requestId: string;
26
+ sessionId: string;
27
+ } | {
28
+ type: 'write';
29
+ requestId: string;
30
+ sessionId: string;
31
+ data: string;
32
+ } | {
33
+ type: 'observe';
34
+ requestId: string;
35
+ sessionId: string;
36
+ } | {
37
+ type: 'unobserve';
38
+ requestId: string;
39
+ sessionId: string;
40
+ } | {
41
+ type: 'input';
42
+ sessionId: string;
43
+ data: string;
44
+ } | {
45
+ type: 'resize';
46
+ sessionId: string;
47
+ cols: number;
48
+ rows: number;
49
+ };
50
+ export type ServerMessage = {
51
+ type: 'sessions';
52
+ sessions: SessionSummary[];
53
+ } | {
54
+ type: 'opened';
55
+ requestId: string;
56
+ session: SessionSummary;
57
+ } | {
58
+ type: 'closed';
59
+ requestId: string;
60
+ sessionId: string;
61
+ } | {
62
+ type: 'attached';
63
+ requestId: string;
64
+ session: SessionSummary;
65
+ history: string;
66
+ } | {
67
+ type: 'detached';
68
+ requestId: string;
69
+ sessionId: string;
70
+ } | {
71
+ type: 'written';
72
+ requestId: string;
73
+ sessionId: string;
74
+ } | {
75
+ type: 'observing';
76
+ requestId: string;
77
+ session: SessionSummary;
78
+ history: string;
79
+ } | {
80
+ type: 'unobserved';
81
+ requestId: string;
82
+ sessionId: string;
83
+ } | {
84
+ type: 'output';
85
+ sessionId: string;
86
+ data: string;
87
+ } | {
88
+ type: 'exit';
89
+ sessionId: string;
90
+ exitCode: number;
91
+ signal?: number;
92
+ } | {
93
+ type: 'error';
94
+ message: string;
95
+ requestId?: string;
96
+ };
97
+ export declare function parseClientMessage(value: unknown): ClientMessage | undefined;
98
+ export declare function parseServerMessage(value: unknown): ServerMessage | undefined;
@@ -0,0 +1,62 @@
1
+ import type { ITerminalOptions } from '@xterm/xterm';
2
+ import { type SessionSummary } from './protocol.js';
3
+ import { TerminalView } from './terminal-view.js';
4
+ export interface TerminalClientOptions {
5
+ socketUrl: string | (() => string);
6
+ autoConnect?: boolean;
7
+ onError?: (error: Error) => void;
8
+ }
9
+ export interface OpenOptions {
10
+ name?: string;
11
+ }
12
+ export interface DisplayOptions {
13
+ terminalOptions?: ITerminalOptions;
14
+ }
15
+ export interface TerminalClientEvent {
16
+ readonly type: 'connected' | 'disconnected' | 'opened' | 'attached' | 'detached' | 'closed' | 'exit';
17
+ readonly sessionId?: string;
18
+ readonly exitCode?: number;
19
+ }
20
+ export declare class TerminalClient {
21
+ private readonly options;
22
+ private readonly sessionListeners;
23
+ private readonly eventListeners;
24
+ private readonly pending;
25
+ private socket;
26
+ private connectPromise;
27
+ private sessionSnapshot;
28
+ private attachedSessionId;
29
+ private view;
30
+ private viewSessionId;
31
+ private bufferedOutput;
32
+ constructor(options: TerminalClientOptions);
33
+ get sessions(): readonly SessionSummary[];
34
+ get connected(): boolean;
35
+ get attachedSession(): string | undefined;
36
+ connect(): Promise<void>;
37
+ disconnect(): void;
38
+ open(options?: OpenOptions): Promise<SessionSummary>;
39
+ close(sessionId: string): Promise<void>;
40
+ write(sessionId: string, data: string): Promise<void>;
41
+ attach(sessionId: string): Promise<{
42
+ session: SessionSummary;
43
+ history: string;
44
+ }>;
45
+ detach(): Promise<void>;
46
+ private detachSession;
47
+ display(sessionId: string, target: HTMLElement, options?: DisplayOptions): Promise<TerminalView>;
48
+ subscribeSessions(listener: (sessions: readonly SessionSummary[]) => void): () => void;
49
+ subscribeEvents(listener: (event: TerminalClientEvent) => void): () => void;
50
+ dispose(): void;
51
+ private input;
52
+ private resize;
53
+ private releaseView;
54
+ private disposeView;
55
+ private request;
56
+ private send;
57
+ private handleMessage;
58
+ private rejectPending;
59
+ private emitEvent;
60
+ private reportError;
61
+ }
62
+ export declare function createTerminalClient(options: TerminalClientOptions): TerminalClient;
@@ -0,0 +1,28 @@
1
+ import { Terminal, type ITerminalOptions } from '@xterm/xterm';
2
+ import '@xterm/xterm/css/xterm.css';
3
+ import './web-terminal.css';
4
+ export interface TerminalViewController {
5
+ input(data: string): void;
6
+ resize(cols: number, rows: number): void;
7
+ release(view: TerminalView): void;
8
+ }
9
+ export interface TerminalViewOptions {
10
+ target: HTMLElement;
11
+ controller: TerminalViewController;
12
+ terminalOptions?: ITerminalOptions;
13
+ }
14
+ export declare class TerminalView {
15
+ readonly terminal: Terminal;
16
+ private readonly fitAddon;
17
+ private readonly target;
18
+ private readonly controller;
19
+ private readonly resizeObserver;
20
+ private disposed;
21
+ constructor(options: TerminalViewOptions);
22
+ write(data: string): void;
23
+ focus(): void;
24
+ fit(): void;
25
+ dispose(): void;
26
+ disposeVisuals(): void;
27
+ private assertActive;
28
+ }
@@ -0,0 +1,2 @@
1
+ .xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre;font-family:monospace}.xterm .xterm-accessibility-tree>div{transform-origin:0;width:fit-content}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;z-index:11;background:0 0;transition:opacity .1s linear}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{display:none;position:absolute}.xterm .xterm-scrollable-element>.shadow.top{width:100%;height:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;display:block;top:0;left:3px}.xterm .xterm-scrollable-element>.shadow.left{width:3px;height:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;display:block;top:3px;left:0}.xterm .xterm-scrollable-element>.shadow.top-left-corner{width:3px;height:3px;display:block;top:0;left:0}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset}.web-terminal{box-sizing:border-box;background:#111318;min-width:0;min-height:0;overflow:hidden}.web-terminal .xterm{height:100%;padding:12px}.web-terminal .xterm-viewport{scrollbar-color:#434957 transparent;scrollbar-width:thin}
2
+ /*$vite$:1*/