@jacksontian/mwt 1.0.0 → 1.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 +24 -6
- package/lib/pty-manager.js +40 -0
- package/lib/ring-buffer.js +1 -1
- package/lib/server.js +145 -159
- package/lib/session-manager.js +66 -0
- package/package.json +8 -3
- package/public/css/style.css +378 -13
- package/public/index.html +77 -12
- package/public/js/app.js +203 -95
- package/public/js/drag-manager.js +42 -0
- package/public/js/font-manager.js +43 -0
- package/public/js/i18n.js +176 -0
- package/public/js/layout-manager.js +99 -15
- package/public/js/shortcut-manager.js +102 -0
- package/public/js/terminal-manager.js +149 -26
- package/public/js/theme-manager.js +6 -6
- package/public/js/ws-client.js +27 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# mwt - Multi-Window Terminal
|
|
2
2
|
|
|
3
|
-
A lightweight web-based multi-window terminal for **local development**. Run multiple shell sessions in the browser with
|
|
3
|
+
A lightweight web-based multi-window terminal for **local development**. Run multiple shell sessions in the browser with columns, rows, grid, and tab layouts.
|
|
4
4
|
|
|
5
5
|
> **Note:** mwt is designed for local use on your development machine. It does not provide authentication, encryption, or any access control — do not expose it to the network.
|
|
6
6
|
|
|
@@ -9,9 +9,13 @@ A lightweight web-based multi-window terminal for **local development**. Run mul
|
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
|
|
12
|
-
###
|
|
12
|
+
### Columns
|
|
13
13
|
|
|
14
|
-

|
|
15
|
+
|
|
16
|
+
### Rows
|
|
17
|
+
|
|
18
|
+

|
|
15
19
|
|
|
16
20
|
### Grid
|
|
17
21
|
|
|
@@ -42,15 +46,15 @@ mwt 想解决的问题很简单:**给本地开发提供一个轻量、直觉
|
|
|
42
46
|
|
|
43
47
|
**浏览器即界面** — 选择浏览器作为 UI 层,天然跨平台,不需要安装桌面应用。布局切换、主题跟随、快捷键操作都在浏览器中完成,所见即所得。
|
|
44
48
|
|
|
45
|
-
**会话不丢失** — 每个终端保留 100KB
|
|
49
|
+
**会话不丢失** — 每个终端保留 100KB 的输出缓冲区,刷新页面或网络断开后可以恢复现场;断线 30 分钟内服务端保留会话,自动重连后回放缓冲。**同一 `sessionId` 同时只允许一个标签页保持 WebSocket**,新开标签页会被拒绝,避免多页争抢同一组 PTY。详见下文 **Session & recovery**。
|
|
46
50
|
|
|
47
51
|
**够用就好** — 不做 SSH、不做认证、不做插件系统。mwt 只专注于一件事:在本地高效地管理多个终端窗口。功能边界清晰,代码量控制在 2000 行左右,任何人都可以快速理解和修改。
|
|
48
52
|
|
|
49
53
|
## Features
|
|
50
54
|
|
|
51
55
|
- **Multi-terminal** - Create and manage multiple terminal sessions simultaneously
|
|
52
|
-
- **
|
|
53
|
-
- **Session persistence** - Reconnect without losing terminal output (100KB buffer per terminal)
|
|
56
|
+
- **Four layouts** - Columns, rows, grid, and tabs, switch anytime
|
|
57
|
+
- **Session persistence** - Reconnect without losing recent terminal output (100KB buffer per terminal); **one browser tab** may be connected per session (see **Session & recovery**)
|
|
54
58
|
- **Auto-reconnect** - WebSocket disconnection recovery with exponential backoff
|
|
55
59
|
- **Dark / Light theme** - Manual toggle or follow system preference
|
|
56
60
|
- **Keyboard-driven** - Full keyboard shortcut support
|
|
@@ -87,6 +91,20 @@ Example:
|
|
|
87
91
|
mwt -p 8080
|
|
88
92
|
```
|
|
89
93
|
|
|
94
|
+
## Session & recovery
|
|
95
|
+
|
|
96
|
+
Understanding how the browser talks to the server avoids surprises (especially with multiple tabs).
|
|
97
|
+
|
|
98
|
+
| Topic | Behavior |
|
|
99
|
+
|--------|----------|
|
|
100
|
+
| **Session ID** | On first visit, the page stores a UUID in `localStorage` under `myterminal-session-id`. Every WebSocket connection sends this id so the server can attach to the same logical session (same PTY processes) after a refresh. |
|
|
101
|
+
| **Single active tab (互斥连接)** | Only **one** browser tab may keep the WebSocket open for that session. If you open mwt in a **second** tab while the first is still connected, the new tab’s connection is **rejected** and you see a full-page message: *mwt is already open in another tab.* This is intentional: two tabs would both try to drive the same shells. Close the other tab or use one tab only. |
|
|
102
|
+
| **Refresh & reconnect** | Reloading the **same** tab disconnects briefly, then reconnects with the same `sessionId`. Existing terminal ids are restored; the server replays recent output from its per-terminal ring buffer (see below). |
|
|
103
|
+
| **Output buffer** | While connected, the server keeps the **last ~100 KB** of output per terminal. After reconnect, that chunk is sent to the client so you don’t lose recent scrollback. It is not a full session log. |
|
|
104
|
+
| **Idle cleanup** | If **no** tab is connected for **30 minutes**, the server **kills** that session’s shells and clears its state. The next visit still uses the same `sessionId` in `localStorage`, but you’ll get an **empty** session (no old terminals)—create new ones as needed. |
|
|
105
|
+
|
|
106
|
+
**Summary:** one tab connected at a time; refresh is fine; a second simultaneous tab is blocked; long disconnects drop server-side sessions after 30 minutes.
|
|
107
|
+
|
|
90
108
|
## Keyboard Shortcuts
|
|
91
109
|
|
|
92
110
|
| Shortcut | Action |
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import pty from 'node-pty';
|
|
2
|
+
import { RingBuffer } from './ring-buffer.js';
|
|
3
|
+
|
|
4
|
+
const OUTPUT_BUFFER_SIZE = 100 * 1024; // 100KB per terminal
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Spawn a PTY process and attach it to a session terminal slot.
|
|
8
|
+
* Returns the terminal entry { pty, outputBuffer } on success,
|
|
9
|
+
* or null if spawn fails (the caller receives an 'exit' message).
|
|
10
|
+
*/
|
|
11
|
+
export function spawnTerminal({ id, cols, rows, cwd, onData, onExit }) {
|
|
12
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
13
|
+
let ptyProcess;
|
|
14
|
+
try {
|
|
15
|
+
ptyProcess = pty.spawn(shell, [], {
|
|
16
|
+
name: 'xterm-256color',
|
|
17
|
+
cols: cols || 80,
|
|
18
|
+
rows: rows || 24,
|
|
19
|
+
cwd,
|
|
20
|
+
env: process.env,
|
|
21
|
+
});
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error(`Failed to spawn shell "${shell}":`, err.message);
|
|
24
|
+
onExit(id, 1);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const outputBuffer = new RingBuffer(OUTPUT_BUFFER_SIZE);
|
|
29
|
+
|
|
30
|
+
ptyProcess.onData((data) => {
|
|
31
|
+
outputBuffer.write(data);
|
|
32
|
+
onData(id, data);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
36
|
+
onExit(id, exitCode);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return { pty: ptyProcess, outputBuffer };
|
|
40
|
+
}
|
package/lib/ring-buffer.js
CHANGED
package/lib/server.js
CHANGED
|
@@ -2,10 +2,10 @@ import { createServer } from 'node:http';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join, extname } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import {
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
7
|
+
import { SessionManager } from './session-manager.js';
|
|
8
|
+
import { spawnTerminal } from './pty-manager.js';
|
|
9
9
|
|
|
10
10
|
const __dirname = fileURLToPath(new URL('..', import.meta.url));
|
|
11
11
|
|
|
@@ -21,192 +21,178 @@ const MIME_TYPES = {
|
|
|
21
21
|
'.ico': 'image/x-icon',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
25
|
-
const CLEANUP_INTERVAL_MS = 60 * 1000; // check every minute
|
|
26
|
-
const OUTPUT_BUFFER_SIZE = 100 * 1024; // 100KB per terminal
|
|
27
|
-
|
|
28
24
|
function openBrowser(url) {
|
|
29
25
|
const cmd = process.platform === 'darwin' ? 'open'
|
|
30
|
-
: process.platform === 'win32' ? '
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
: process.platform === 'win32' ? 'cmd'
|
|
27
|
+
: 'xdg-open';
|
|
28
|
+
const args = process.platform === 'win32' ? ['/c', 'start', url] : [url];
|
|
29
|
+
execFile(cmd, args);
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
export function start(port = 1987, host = '127.0.0.1', options = {}) {
|
|
33
|
+
const startCwd = process.cwd();
|
|
34
|
+
const sessions = new SessionManager();
|
|
35
|
+
|
|
36
|
+
const publicDir = join(__dirname, 'public');
|
|
37
|
+
const SECURITY_HEADERS = {
|
|
38
|
+
'X-Content-Type-Options': 'nosniff',
|
|
39
|
+
'X-Frame-Options': 'DENY',
|
|
40
|
+
'Cache-Control': 'no-store',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// HTTP server for static files
|
|
44
|
+
const server = createServer(async (req, res) => {
|
|
45
|
+
let filePath;
|
|
46
|
+
const url = req.url.split('?')[0];
|
|
47
|
+
|
|
48
|
+
if (url === '/') {
|
|
49
|
+
filePath = join(publicDir, 'index.html');
|
|
50
|
+
} else {
|
|
51
|
+
filePath = join(publicDir, url);
|
|
52
|
+
}
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const url = req.url.split('?')[0];
|
|
44
|
-
|
|
45
|
-
if (url === '/') {
|
|
46
|
-
filePath = join(__dirname, 'public', 'index.html');
|
|
47
|
-
} else {
|
|
48
|
-
filePath = join(__dirname, 'public', url);
|
|
49
|
-
}
|
|
54
|
+
// Prevent path traversal: resolved path must stay within publicDir
|
|
55
|
+
if (!filePath.startsWith(publicDir + '/') && filePath !== join(publicDir, 'index.html')) {
|
|
56
|
+
res.writeHead(403, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS });
|
|
57
|
+
res.end('Forbidden');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
50
60
|
|
|
51
|
-
|
|
52
|
-
const data = await readFile(filePath);
|
|
61
|
+
// Only serve known file types
|
|
53
62
|
const ext = extname(filePath);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
res.end('Not Found');
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// WebSocket server
|
|
63
|
-
const wss = new WebSocketServer({ server });
|
|
64
|
-
|
|
65
|
-
wss.on('connection', (ws, req) => {
|
|
66
|
-
const url = new URL(req.url, 'http://localhost');
|
|
67
|
-
const sessionId = url.searchParams.get('sessionId');
|
|
68
|
-
if (!sessionId) {
|
|
69
|
-
ws.close(4001, 'Missing sessionId');
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
let session = sessions.get(sessionId);
|
|
74
|
-
if (!session) {
|
|
75
|
-
session = { terminals: new Map(), ws: null, disconnectedAt: null };
|
|
76
|
-
sessions.set(sessionId, session);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Detach old WS if still open
|
|
80
|
-
if (session.ws && session.ws !== ws && session.ws.readyState === 1) {
|
|
81
|
-
session.ws.close();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
session.ws = ws;
|
|
85
|
-
session.disconnectedAt = null;
|
|
86
|
-
|
|
87
|
-
// Send session-restore with existing terminal IDs
|
|
88
|
-
const terminalIds = [...session.terminals.keys()];
|
|
89
|
-
ws.send(JSON.stringify({ type: 'session-restore', terminals: terminalIds }));
|
|
90
|
-
|
|
91
|
-
// Send buffered output for each existing terminal
|
|
92
|
-
for (const [id, entry] of session.terminals) {
|
|
93
|
-
const buffered = entry.outputBuffer.read();
|
|
94
|
-
if (buffered) {
|
|
95
|
-
ws.send(JSON.stringify({ type: 'buffer', id, data: buffered }));
|
|
63
|
+
if (!MIME_TYPES[ext]) {
|
|
64
|
+
res.writeHead(403, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS });
|
|
65
|
+
res.end('Forbidden');
|
|
66
|
+
return;
|
|
96
67
|
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Signal that all buffer messages have been sent
|
|
100
|
-
if (terminalIds.length > 0) {
|
|
101
|
-
ws.send(JSON.stringify({ type: 'restore-complete' }));
|
|
102
|
-
}
|
|
103
68
|
|
|
104
|
-
ws.on('message', async (raw) => {
|
|
105
|
-
let msg;
|
|
106
69
|
try {
|
|
107
|
-
|
|
70
|
+
const data = await readFile(filePath);
|
|
71
|
+
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext], ...SECURITY_HEADERS });
|
|
72
|
+
res.end(data);
|
|
108
73
|
} catch {
|
|
74
|
+
res.writeHead(404, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS });
|
|
75
|
+
res.end('Not Found');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// WebSocket server
|
|
80
|
+
const wss = new WebSocketServer({ server });
|
|
81
|
+
|
|
82
|
+
wss.on('connection', (ws, req) => {
|
|
83
|
+
const url = new URL(req.url, 'http://localhost');
|
|
84
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
85
|
+
if (!sessionId) {
|
|
86
|
+
ws.close(4001, 'Missing sessionId');
|
|
109
87
|
return;
|
|
110
88
|
}
|
|
111
89
|
|
|
112
|
-
|
|
113
|
-
case 'create': {
|
|
114
|
-
const shell = process.env.SHELL || '/bin/bash';
|
|
115
|
-
let ptyProcess;
|
|
116
|
-
try {
|
|
117
|
-
ptyProcess = pty.spawn(shell, [], {
|
|
118
|
-
name: 'xterm-256color',
|
|
119
|
-
cols: msg.cols || 80,
|
|
120
|
-
rows: msg.rows || 24,
|
|
121
|
-
cwd: startCwd,
|
|
122
|
-
env: process.env,
|
|
123
|
-
});
|
|
124
|
-
} catch (err) {
|
|
125
|
-
console.error(`Failed to spawn shell "${shell}":`, err.message);
|
|
126
|
-
if (ws.readyState === 1) {
|
|
127
|
-
ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode: 1 }));
|
|
128
|
-
}
|
|
129
|
-
break;
|
|
130
|
-
}
|
|
90
|
+
const session = sessions.getOrCreate(sessionId);
|
|
131
91
|
|
|
132
|
-
|
|
133
|
-
|
|
92
|
+
// Reject new connection if session already has an active WebSocket
|
|
93
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
94
|
+
ws.close(4409, 'already connected');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
134
97
|
|
|
135
|
-
|
|
136
|
-
outputBuffer.write(data);
|
|
137
|
-
if (session.ws && session.ws.readyState === 1) {
|
|
138
|
-
session.ws.send(JSON.stringify({ type: 'data', id: msg.id, data }));
|
|
139
|
-
}
|
|
140
|
-
});
|
|
98
|
+
sessions.connect(session, ws);
|
|
141
99
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
100
|
+
// Send session-restore with existing terminal IDs
|
|
101
|
+
const terminalIds = [...session.terminals.keys()];
|
|
102
|
+
ws.send(JSON.stringify({ type: 'session-restore', terminals: terminalIds }));
|
|
103
|
+
|
|
104
|
+
// Send buffered output for each existing terminal
|
|
105
|
+
for (const [id, entry] of session.terminals) {
|
|
106
|
+
const buffered = entry.outputBuffer.read();
|
|
107
|
+
if (buffered) {
|
|
108
|
+
ws.send(JSON.stringify({ type: 'buffer', id, data: buffered }));
|
|
149
109
|
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Signal that all buffer messages have been sent
|
|
113
|
+
if (terminalIds.length > 0) {
|
|
114
|
+
ws.send(JSON.stringify({ type: 'restore-complete' }));
|
|
115
|
+
}
|
|
150
116
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
117
|
+
ws.on('message', async (raw) => {
|
|
118
|
+
let msg;
|
|
119
|
+
try {
|
|
120
|
+
msg = JSON.parse(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
return;
|
|
155
123
|
}
|
|
156
124
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
125
|
+
switch (msg.type) {
|
|
126
|
+
case 'create': {
|
|
127
|
+
const entry = spawnTerminal({
|
|
128
|
+
id: msg.id,
|
|
129
|
+
cols: msg.cols,
|
|
130
|
+
rows: msg.rows,
|
|
131
|
+
cwd: startCwd,
|
|
132
|
+
onData: (id, data) => {
|
|
133
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
134
|
+
session.ws.send(JSON.stringify({ type: 'data', id, data }));
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
onExit: (id, exitCode) => {
|
|
138
|
+
sessions.deleteTerminal(session, id);
|
|
139
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
140
|
+
session.ws.send(JSON.stringify({ type: 'exit', id, exitCode }));
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
if (entry) {
|
|
145
|
+
session.terminals.set(msg.id, entry);
|
|
164
146
|
}
|
|
147
|
+
break;
|
|
165
148
|
}
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
149
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
150
|
+
case 'data': {
|
|
151
|
+
const entry = session.terminals.get(msg.id);
|
|
152
|
+
if (entry) { entry.pty.write(msg.data); }
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'resize': {
|
|
157
|
+
const entry = session.terminals.get(msg.id);
|
|
158
|
+
if (entry) {
|
|
159
|
+
try {
|
|
160
|
+
entry.pty.resize(msg.cols, msg.rows);
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore invalid resize
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'close': {
|
|
169
|
+
const entry = session.terminals.get(msg.id);
|
|
170
|
+
if (entry) {
|
|
171
|
+
entry.pty.kill();
|
|
172
|
+
sessions.deleteTerminal(session, msg.id);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
174
175
|
}
|
|
175
|
-
break;
|
|
176
176
|
}
|
|
177
|
+
});
|
|
177
178
|
|
|
178
|
-
|
|
179
|
+
ws.on('close', () => {
|
|
180
|
+
sessions.disconnect(session, ws);
|
|
181
|
+
});
|
|
179
182
|
});
|
|
180
183
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
server.listen(port, host, () => {
|
|
185
|
+
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
|
|
186
|
+
console.log(`mwt running at ${url}`);
|
|
187
|
+
if (options.open !== false) {
|
|
188
|
+
openBrowser(url);
|
|
185
189
|
}
|
|
186
190
|
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
server.listen(port, host, () => {
|
|
190
|
-
const url = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
|
|
191
|
-
console.log(`mwt running at ${url}`);
|
|
192
|
-
if (options.open !== false) {
|
|
193
|
-
openBrowser(url);
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
// Clean up sessions that have been disconnected too long
|
|
198
|
-
const cleanupTimer = setInterval(() => {
|
|
199
|
-
const now = Date.now();
|
|
200
|
-
for (const [sessionId, session] of sessions) {
|
|
201
|
-
if (session.disconnectedAt && (now - session.disconnectedAt) > SESSION_TIMEOUT_MS) {
|
|
202
|
-
for (const [, entry] of session.terminals) {
|
|
203
|
-
entry.pty.kill();
|
|
204
|
-
}
|
|
205
|
-
session.terminals.clear();
|
|
206
|
-
sessions.delete(sessionId);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}, CLEANUP_INTERVAL_MS);
|
|
210
|
-
cleanupTimer.unref();
|
|
211
191
|
|
|
212
|
-
|
|
192
|
+
// Kill all PTY processes on exit to avoid orphan processes
|
|
193
|
+
function killAll() { sessions.killAll(); }
|
|
194
|
+
|
|
195
|
+
process.once('exit', killAll);
|
|
196
|
+
process.once('SIGINT', () => { killAll(); process.exit(130); });
|
|
197
|
+
process.once('SIGTERM', () => { killAll(); process.exit(143); });
|
|
198
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
2
|
+
const CLEANUP_INTERVAL_MS = 60 * 1000; // check every minute
|
|
3
|
+
|
|
4
|
+
export class SessionManager {
|
|
5
|
+
constructor() {
|
|
6
|
+
this._sessions = new Map(); // sessionId -> { terminals, ws, disconnectedAt }
|
|
7
|
+
|
|
8
|
+
const timer = setInterval(() => this._cleanup(), CLEANUP_INTERVAL_MS);
|
|
9
|
+
timer.unref();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Return existing session or create a new one. */
|
|
13
|
+
getOrCreate(sessionId) {
|
|
14
|
+
let session = this._sessions.get(sessionId);
|
|
15
|
+
if (!session) {
|
|
16
|
+
session = { terminals: new Map(), ws: null, disconnectedAt: null };
|
|
17
|
+
this._sessions.set(sessionId, session);
|
|
18
|
+
}
|
|
19
|
+
return session;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get(sessionId) {
|
|
23
|
+
return this._sessions.get(sessionId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Mark a session as connected to the given WebSocket. */
|
|
27
|
+
connect(session, ws) {
|
|
28
|
+
session.ws = ws;
|
|
29
|
+
session.disconnectedAt = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Mark a session as disconnected. */
|
|
33
|
+
disconnect(session, ws) {
|
|
34
|
+
if (session.ws === ws) {
|
|
35
|
+
session.ws = null;
|
|
36
|
+
session.disconnectedAt = Date.now();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Remove a terminal entry from a session. */
|
|
41
|
+
deleteTerminal(session, id) {
|
|
42
|
+
session.terminals.delete(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Kill all PTY processes across all sessions. */
|
|
46
|
+
killAll() {
|
|
47
|
+
for (const session of this._sessions.values()) {
|
|
48
|
+
for (const entry of session.terminals.values()) {
|
|
49
|
+
try { entry.pty.kill(); } catch {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_cleanup() {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
for (const [sessionId, session] of this._sessions) {
|
|
57
|
+
if (session.disconnectedAt && (now - session.disconnectedAt) > SESSION_TIMEOUT_MS) {
|
|
58
|
+
for (const [, entry] of session.terminals) {
|
|
59
|
+
entry.pty.kill();
|
|
60
|
+
}
|
|
61
|
+
session.terminals.clear();
|
|
62
|
+
this._sessions.delete(sessionId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacksontian/mwt",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Multi-window terminal with
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Multi-window terminal with columns, grid, and tab layouts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mwt": "bin/mwt.js"
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "mocha",
|
|
23
23
|
"cov": "c8 mocha",
|
|
24
|
-
"cov:report": "c8 report --reporter=text --reporter=html"
|
|
24
|
+
"cov:report": "c8 report --reporter=text --reporter=html",
|
|
25
|
+
"lint": "eslint .",
|
|
26
|
+
"lint:fix": "eslint . --fix"
|
|
25
27
|
},
|
|
26
28
|
"engines": {
|
|
27
29
|
"node": ">=18"
|
|
@@ -31,7 +33,10 @@
|
|
|
31
33
|
"ws": "^8.18.0"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
36
|
+
"@eslint/js": "^10.0.1",
|
|
34
37
|
"c8": "^11.0.0",
|
|
38
|
+
"eslint": "^10.1.0",
|
|
39
|
+
"globals": "^17.4.0",
|
|
35
40
|
"mocha": "^11.7.5"
|
|
36
41
|
},
|
|
37
42
|
"publishConfig": {
|