hop2 0.8.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/.hop-tmux.conf +18 -0
- package/INTEGRATION.md +92 -0
- package/README.md +260 -0
- package/hay/CHANGELOG.md +46 -0
- package/hay/DEV_NOTES.md +39 -0
- package/hay/README.md +290 -0
- package/hay/apps/cli/package.json +25 -0
- package/hay/apps/cli/src/index.ts +1250 -0
- package/hay/apps/cli/tsconfig.json +8 -0
- package/hay/apps/server/package.json +37 -0
- package/hay/apps/server/src/index.ts +103 -0
- package/hay/apps/server/src/lib.ts +161 -0
- package/hay/apps/server/src/pty.ts +68 -0
- package/hay/apps/server/src/rooms.ts +421 -0
- package/hay/apps/server/src/utils.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +146 -0
- package/hay/apps/server/tsconfig.json +9 -0
- package/hay/apps/web/index.html +12 -0
- package/hay/apps/web/package.json +32 -0
- package/hay/apps/web/scripts/run-e2e.mjs +130 -0
- package/hay/apps/web/src/App.tsx +1302 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
- package/hay/apps/web/src/main.tsx +13 -0
- package/hay/apps/web/src/setupTests.ts +1 -0
- package/hay/apps/web/src/styles.css +1198 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
- package/hay/apps/web/src/utils/presence.ts +24 -0
- package/hay/apps/web/src/vite-env.d.ts +10 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
- package/hay/apps/web/test/presence.test.ts +18 -0
- package/hay/apps/web/tsconfig.json +8 -0
- package/hay/apps/web/vite.config.ts +13 -0
- package/hay/apps/web/vitest.config.ts +12 -0
- package/hay/package-lock.json +5988 -0
- package/hay/package.json +18 -0
- package/hay/packages/shared/package.json +27 -0
- package/hay/packages/shared/src/colors.ts +16 -0
- package/hay/packages/shared/src/index.ts +2 -0
- package/hay/packages/shared/src/protocol.ts +90 -0
- package/hay/packages/shared/test/protocol.test.ts +33 -0
- package/hay/packages/shared/tsconfig.json +7 -0
- package/hay/tsconfig.base.json +15 -0
- package/hay-web/assets/index-CTTzApRp.css +32 -0
- package/hay-web/assets/index-FDfdolCs.js +60 -0
- package/hay-web/assets/index-FDfdolCs.js.map +1 -0
- package/hay-web/index.html +13 -0
- package/hop +3717 -0
- package/package.json +52 -0
- package/sessions.html +243 -0
- package/tunnel-shell.js +254 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hay-server",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": true,
|
|
6
|
+
"main": "./dist/lib.js",
|
|
7
|
+
"types": "./dist/lib.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/lib.js",
|
|
11
|
+
"types": "./dist/lib.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./standalone": {
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "tsx watch src/index.ts",
|
|
19
|
+
"build": "tsup src/lib.ts src/index.ts --format esm --dts",
|
|
20
|
+
"test": "vitest run"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"hay-shared": "*",
|
|
24
|
+
"express": "^4.21.2",
|
|
25
|
+
"node-pty-prebuilt-multiarch": "^0.10.1-pre.5",
|
|
26
|
+
"ws": "^8.18.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/express": "^4.17.21",
|
|
30
|
+
"@types/node": "^22.10.5",
|
|
31
|
+
"@types/ws": "^8.5.12",
|
|
32
|
+
"tsup": "^8.3.5",
|
|
33
|
+
"tsx": "^4.19.2",
|
|
34
|
+
"typescript": "^5.7.3",
|
|
35
|
+
"vitest": "^2.1.8"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import express from "express";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import type { WebSocket } from "ws";
|
|
8
|
+
import { createPty } from "./pty";
|
|
9
|
+
import { RoomManager } from "./rooms";
|
|
10
|
+
import { sanitizeName, sanitizeRoom } from "./utils";
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
const PORT = Number(process.env.PORT ?? 4001);
|
|
15
|
+
const SERVE_WEB = process.env.SERVE_WEB === "true";
|
|
16
|
+
const WEB_DIST_PATH = process.env.WEB_DIST_PATH;
|
|
17
|
+
const CWD = process.env.CWD; // Working directory for PTY sessions
|
|
18
|
+
|
|
19
|
+
const app = express();
|
|
20
|
+
app.get("/health", (_req, res) => {
|
|
21
|
+
res.json({ ok: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (SERVE_WEB && WEB_DIST_PATH) {
|
|
25
|
+
const distPath = path.isAbsolute(WEB_DIST_PATH)
|
|
26
|
+
? WEB_DIST_PATH
|
|
27
|
+
: path.resolve(__dirname, WEB_DIST_PATH);
|
|
28
|
+
|
|
29
|
+
app.use(express.static(distPath));
|
|
30
|
+
app.get("*", (_req, res) => {
|
|
31
|
+
res.sendFile(path.join(distPath, "index.html"));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const server = http.createServer(app);
|
|
36
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
37
|
+
|
|
38
|
+
const rooms = new RoomManager(createPty, CWD);
|
|
39
|
+
|
|
40
|
+
const toSocketAdapter = (ws: WebSocket) => {
|
|
41
|
+
return {
|
|
42
|
+
send: (data: string) => {
|
|
43
|
+
if (ws.readyState === ws.OPEN) {
|
|
44
|
+
ws.send(data);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
onMessage: (handler: (data: string) => void) => {
|
|
48
|
+
ws.on("message", (data) => handler(data.toString()));
|
|
49
|
+
},
|
|
50
|
+
onClose: (handler: () => void) => {
|
|
51
|
+
ws.on("close", handler);
|
|
52
|
+
},
|
|
53
|
+
onError: (handler: (err: Error) => void) => {
|
|
54
|
+
ws.on("error", handler);
|
|
55
|
+
},
|
|
56
|
+
close: () => {
|
|
57
|
+
ws.close();
|
|
58
|
+
},
|
|
59
|
+
isOpen: () => ws.readyState === ws.OPEN
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
server.on("upgrade", (req, socket, head) => {
|
|
64
|
+
const url = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`);
|
|
65
|
+
if (url.pathname !== "/ws") {
|
|
66
|
+
socket.destroy();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
70
|
+
wss.emit("connection", ws, req);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
wss.on("connection", (ws, req) => {
|
|
75
|
+
const url = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`);
|
|
76
|
+
const roomId = sanitizeRoom(url.searchParams.get("room"));
|
|
77
|
+
const name = sanitizeName(url.searchParams.get("name"));
|
|
78
|
+
const cols = Number(url.searchParams.get("cols") ?? 80);
|
|
79
|
+
const rows = Number(url.searchParams.get("rows") ?? 24);
|
|
80
|
+
|
|
81
|
+
const clientId = randomUUID();
|
|
82
|
+
const colorIndex = Math.floor(Math.random() * 1000);
|
|
83
|
+
|
|
84
|
+
const room = rooms.getRoom(roomId, {
|
|
85
|
+
cols: Number.isFinite(cols) ? cols : 80,
|
|
86
|
+
rows: Number.isFinite(rows) ? rows : 24
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
room.attachClient(
|
|
90
|
+
{
|
|
91
|
+
id: clientId,
|
|
92
|
+
name,
|
|
93
|
+
colorIndex,
|
|
94
|
+
cols,
|
|
95
|
+
rows
|
|
96
|
+
},
|
|
97
|
+
toSocketAdapter(ws)
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
server.listen(PORT, () => {
|
|
102
|
+
console.log(`Termshare server running on http://localhost:${PORT}`);
|
|
103
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Termshare Server Library
|
|
2
|
+
// Export everything needed for embedding into other apps
|
|
3
|
+
|
|
4
|
+
export { Room, RoomManager, type SocketAdapter, type ClientInfo } from "./rooms";
|
|
5
|
+
export { createPty, type PtyFactory } from "./pty";
|
|
6
|
+
export { sanitizeName, sanitizeRoom } from "./utils";
|
|
7
|
+
|
|
8
|
+
// Re-export shared types
|
|
9
|
+
export {
|
|
10
|
+
type ClientMessage,
|
|
11
|
+
type ServerMessage,
|
|
12
|
+
type PresenceClient,
|
|
13
|
+
safeParseClientMessage,
|
|
14
|
+
safeParseServerMessage,
|
|
15
|
+
pickPresenceColor
|
|
16
|
+
} from "hay-shared";
|
|
17
|
+
|
|
18
|
+
import http from "node:http";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { WebSocketServer } from "ws";
|
|
21
|
+
import type { WebSocket } from "ws";
|
|
22
|
+
import { RoomManager, type SocketAdapter } from "./rooms";
|
|
23
|
+
import { createPty, type PtyFactory } from "./pty";
|
|
24
|
+
import { sanitizeName, sanitizeRoom } from "./utils";
|
|
25
|
+
|
|
26
|
+
export const generateClientId = () => randomUUID();
|
|
27
|
+
|
|
28
|
+
export const createSocketAdapter = (ws: WebSocket): SocketAdapter => ({
|
|
29
|
+
send: (data: string) => {
|
|
30
|
+
if (ws.readyState === ws.OPEN) {
|
|
31
|
+
ws.send(data);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
onMessage: (handler: (data: string) => void) => {
|
|
35
|
+
ws.on("message", (data) => handler(data.toString()));
|
|
36
|
+
},
|
|
37
|
+
onClose: (handler: () => void) => {
|
|
38
|
+
ws.on("close", handler);
|
|
39
|
+
},
|
|
40
|
+
onError: (handler: (err: Error) => void) => {
|
|
41
|
+
ws.on("error", handler);
|
|
42
|
+
},
|
|
43
|
+
close: () => {
|
|
44
|
+
ws.close();
|
|
45
|
+
},
|
|
46
|
+
isOpen: () => ws.readyState === ws.OPEN
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export type TermshareServerOptions = {
|
|
50
|
+
/** HTTP server to attach WebSocket to */
|
|
51
|
+
server: http.Server;
|
|
52
|
+
/** WebSocket path (default: "/ws") */
|
|
53
|
+
path?: string;
|
|
54
|
+
/** Custom PTY factory (default: native pty) */
|
|
55
|
+
ptyFactory?: PtyFactory;
|
|
56
|
+
/** Working directory for new PTY sessions (default: process.cwd()) */
|
|
57
|
+
cwd?: string;
|
|
58
|
+
/** Called when a client connects */
|
|
59
|
+
onConnect?: (clientId: string, roomId: string, name: string) => void;
|
|
60
|
+
/** Called when a client disconnects */
|
|
61
|
+
onDisconnect?: (clientId: string, roomId: string) => void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Attach termshare to an existing HTTP server.
|
|
66
|
+
* This is the main entry point for embedding termshare into other apps.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* import http from "http";
|
|
71
|
+
* import { attachTermshare } from "@termshare/server";
|
|
72
|
+
*
|
|
73
|
+
* const server = http.createServer();
|
|
74
|
+
* const termshare = attachTermshare({ server });
|
|
75
|
+
*
|
|
76
|
+
* server.listen(4001, () => {
|
|
77
|
+
* console.log("Server running on http://localhost:4001");
|
|
78
|
+
* });
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function attachTermshare(options: TermshareServerOptions) {
|
|
82
|
+
const { server, path = "/ws", ptyFactory = createPty, cwd, onConnect, onDisconnect } = options;
|
|
83
|
+
|
|
84
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
85
|
+
const rooms = new RoomManager(ptyFactory, cwd);
|
|
86
|
+
|
|
87
|
+
const toSocketAdapter = (ws: WebSocket): SocketAdapter => ({
|
|
88
|
+
send: (data: string) => {
|
|
89
|
+
if (ws.readyState === ws.OPEN) {
|
|
90
|
+
ws.send(data);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
onMessage: (handler: (data: string) => void) => {
|
|
94
|
+
ws.on("message", (data) => handler(data.toString()));
|
|
95
|
+
},
|
|
96
|
+
onClose: (handler: () => void) => {
|
|
97
|
+
ws.on("close", handler);
|
|
98
|
+
},
|
|
99
|
+
onError: (handler: (err: Error) => void) => {
|
|
100
|
+
ws.on("error", handler);
|
|
101
|
+
},
|
|
102
|
+
close: () => {
|
|
103
|
+
ws.close();
|
|
104
|
+
},
|
|
105
|
+
isOpen: () => ws.readyState === ws.OPEN
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
server.on("upgrade", (req, socket, head) => {
|
|
109
|
+
const url = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`);
|
|
110
|
+
if (url.pathname !== path) {
|
|
111
|
+
return; // Let other handlers deal with it
|
|
112
|
+
}
|
|
113
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
114
|
+
wss.emit("connection", ws, req);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
wss.on("connection", (ws, req) => {
|
|
119
|
+
const url = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`);
|
|
120
|
+
const roomId = sanitizeRoom(url.searchParams.get("room"));
|
|
121
|
+
const name = sanitizeName(url.searchParams.get("name"));
|
|
122
|
+
const cols = Number(url.searchParams.get("cols") ?? 80);
|
|
123
|
+
const rows = Number(url.searchParams.get("rows") ?? 24);
|
|
124
|
+
|
|
125
|
+
const clientId = randomUUID();
|
|
126
|
+
const colorIndex = Math.floor(Math.random() * 1000);
|
|
127
|
+
|
|
128
|
+
const room = rooms.getRoom(roomId, {
|
|
129
|
+
cols: Number.isFinite(cols) ? cols : 80,
|
|
130
|
+
rows: Number.isFinite(rows) ? rows : 24
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
room.attachClient(
|
|
134
|
+
{
|
|
135
|
+
id: clientId,
|
|
136
|
+
name,
|
|
137
|
+
colorIndex,
|
|
138
|
+
cols,
|
|
139
|
+
rows
|
|
140
|
+
},
|
|
141
|
+
toSocketAdapter(ws)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
onConnect?.(clientId, roomId, name);
|
|
145
|
+
|
|
146
|
+
ws.on("close", () => {
|
|
147
|
+
onDisconnect?.(clientId, roomId);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
/** The WebSocket server instance */
|
|
153
|
+
wss,
|
|
154
|
+
/** The room manager instance */
|
|
155
|
+
rooms,
|
|
156
|
+
/** Close all connections and clean up */
|
|
157
|
+
close: () => {
|
|
158
|
+
wss.close();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import pty, { IPty } from "node-pty-prebuilt-multiarch";
|
|
4
|
+
|
|
5
|
+
export type PtyFactory = (options: {
|
|
6
|
+
cols: number;
|
|
7
|
+
rows: number;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
}) => IPty;
|
|
10
|
+
|
|
11
|
+
const resolveShell = () => {
|
|
12
|
+
if (process.env.SHELL) {
|
|
13
|
+
return process.env.SHELL;
|
|
14
|
+
}
|
|
15
|
+
if (os.platform() === "win32") {
|
|
16
|
+
return process.env.COMSPEC ?? "cmd.exe";
|
|
17
|
+
}
|
|
18
|
+
return "/bin/bash";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const createPty: PtyFactory = ({ cols, rows, cwd }) => {
|
|
22
|
+
const mode = process.env.PTY_MODE ?? "native";
|
|
23
|
+
if (mode === "mock") {
|
|
24
|
+
return createMockPty();
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const env: Record<string, string> = {};
|
|
28
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
env[key] = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
env.TERM = "xterm-256color";
|
|
34
|
+
if (!env.COLORTERM) {
|
|
35
|
+
env.COLORTERM = "truecolor";
|
|
36
|
+
}
|
|
37
|
+
return pty.spawn(resolveShell(), [], {
|
|
38
|
+
cols,
|
|
39
|
+
rows,
|
|
40
|
+
cwd: cwd ?? process.cwd(),
|
|
41
|
+
env
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (mode === "auto") {
|
|
45
|
+
return createMockPty();
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const createMockPty = (): IPty => {
|
|
52
|
+
const emitter = new EventEmitter();
|
|
53
|
+
return {
|
|
54
|
+
onData: (handler: (data: string) => void) => {
|
|
55
|
+
emitter.on("data", handler);
|
|
56
|
+
return {
|
|
57
|
+
dispose: () => emitter.off("data", handler)
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
write: (data: string) => {
|
|
61
|
+
const echo = data.replace(/\r/g, "\r\n");
|
|
62
|
+
setTimeout(() => emitter.emit("data", echo), 10);
|
|
63
|
+
},
|
|
64
|
+
resize: () => {},
|
|
65
|
+
kill: () => {},
|
|
66
|
+
onExit: () => ({ dispose: () => {} })
|
|
67
|
+
} as unknown as IPty;
|
|
68
|
+
};
|