@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 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 side-by-side, grid, and tab layouts.
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
  [![Node.js](https://img.shields.io/node/v/@jacksontian/mwt)](https://nodejs.org)
10
10
  [![License](https://img.shields.io/npm/l/@jacksontian/mwt)](LICENSE)
11
11
 
12
- ### Side by Side
12
+ ### Columns
13
13
 
14
- ![Side by Side](./figures/sidebyside.png)
14
+ ![Columns](./figures/columns.png)
15
+
16
+ ### Rows
17
+
18
+ ![Rows](./figures/rows.png)
15
19
 
16
20
  ### Grid
17
21
 
@@ -42,15 +46,15 @@ mwt 想解决的问题很简单:**给本地开发提供一个轻量、直觉
42
46
 
43
47
  **浏览器即界面** — 选择浏览器作为 UI 层,天然跨平台,不需要安装桌面应用。布局切换、主题跟随、快捷键操作都在浏览器中完成,所见即所得。
44
48
 
45
- **会话不丢失** — 每个终端保留 100KB 的输出缓冲区,刷新页面或网络断开后可以恢复现场。断线 30 分钟内自动重连,开发流程不被打断。
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
- - **Three layouts** - Side-by-side, grid, and tabs, switch anytime
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
+ }
@@ -32,7 +32,7 @@ export class RingBuffer {
32
32
  }
33
33
 
34
34
  read() {
35
- if (this.length === 0) return '';
35
+ if (this.length === 0) {return '';}
36
36
 
37
37
  if (this.length < this.capacity) {
38
38
  // Haven't wrapped yet — data starts at 0
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 { exec } from 'node:child_process';
5
+ import { execFile } from 'node:child_process';
6
6
  import { WebSocketServer } from 'ws';
7
- import pty from 'node-pty';
8
- import { RingBuffer } from './ring-buffer.js';
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' ? 'start'
31
- : 'xdg-open';
32
- exec(`${cmd} ${url}`);
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
- const startCwd = process.cwd();
38
- const sessions = new Map(); // sessionId -> { terminals, ws, disconnectedAt }
39
-
40
- // HTTP server for static files
41
- const server = createServer(async (req, res) => {
42
- let filePath;
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
- try {
52
- const data = await readFile(filePath);
61
+ // Only serve known file types
53
62
  const ext = extname(filePath);
54
- res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
55
- res.end(data);
56
- } catch {
57
- res.writeHead(404, { 'Content-Type': 'text/plain' });
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
- msg = JSON.parse(raw);
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
- switch (msg.type) {
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
- const outputBuffer = new RingBuffer(OUTPUT_BUFFER_SIZE);
133
- session.terminals.set(msg.id, { pty: ptyProcess, outputBuffer });
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
- ptyProcess.onData((data) => {
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
- ptyProcess.onExit(({ exitCode }) => {
143
- session.terminals.delete(msg.id);
144
- if (session.ws && session.ws.readyState === 1) {
145
- session.ws.send(JSON.stringify({ type: 'exit', id: msg.id, exitCode }));
146
- }
147
- });
148
- break;
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
- case 'data': {
152
- const entry = session.terminals.get(msg.id);
153
- if (entry) entry.pty.write(msg.data);
154
- break;
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
- case 'resize': {
158
- const entry = session.terminals.get(msg.id);
159
- if (entry) {
160
- try {
161
- entry.pty.resize(msg.cols, msg.rows);
162
- } catch {
163
- // ignore invalid resize
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
- case 'close': {
170
- const entry = session.terminals.get(msg.id);
171
- if (entry) {
172
- entry.pty.kill();
173
- session.terminals.delete(msg.id);
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
- ws.on('close', () => {
182
- if (session.ws === ws) {
183
- session.ws = null;
184
- session.disconnectedAt = Date.now();
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
- } // end start
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.0.0",
4
- "description": "Multi-window terminal with side-by-side, grid, and tab layouts",
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": {