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.
Files changed (50) hide show
  1. package/.hop-tmux.conf +18 -0
  2. package/INTEGRATION.md +92 -0
  3. package/README.md +260 -0
  4. package/hay/CHANGELOG.md +46 -0
  5. package/hay/DEV_NOTES.md +39 -0
  6. package/hay/README.md +290 -0
  7. package/hay/apps/cli/package.json +25 -0
  8. package/hay/apps/cli/src/index.ts +1250 -0
  9. package/hay/apps/cli/tsconfig.json +8 -0
  10. package/hay/apps/server/package.json +37 -0
  11. package/hay/apps/server/src/index.ts +103 -0
  12. package/hay/apps/server/src/lib.ts +161 -0
  13. package/hay/apps/server/src/pty.ts +68 -0
  14. package/hay/apps/server/src/rooms.ts +421 -0
  15. package/hay/apps/server/src/utils.ts +20 -0
  16. package/hay/apps/server/test/rooms.test.ts +146 -0
  17. package/hay/apps/server/tsconfig.json +9 -0
  18. package/hay/apps/web/index.html +12 -0
  19. package/hay/apps/web/package.json +32 -0
  20. package/hay/apps/web/scripts/run-e2e.mjs +130 -0
  21. package/hay/apps/web/src/App.tsx +1302 -0
  22. package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
  23. package/hay/apps/web/src/main.tsx +13 -0
  24. package/hay/apps/web/src/setupTests.ts +1 -0
  25. package/hay/apps/web/src/styles.css +1198 -0
  26. package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
  27. package/hay/apps/web/src/utils/presence.ts +24 -0
  28. package/hay/apps/web/src/vite-env.d.ts +10 -0
  29. package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
  30. package/hay/apps/web/test/presence.test.ts +18 -0
  31. package/hay/apps/web/tsconfig.json +8 -0
  32. package/hay/apps/web/vite.config.ts +13 -0
  33. package/hay/apps/web/vitest.config.ts +12 -0
  34. package/hay/package-lock.json +5988 -0
  35. package/hay/package.json +18 -0
  36. package/hay/packages/shared/package.json +27 -0
  37. package/hay/packages/shared/src/colors.ts +16 -0
  38. package/hay/packages/shared/src/index.ts +2 -0
  39. package/hay/packages/shared/src/protocol.ts +90 -0
  40. package/hay/packages/shared/test/protocol.test.ts +33 -0
  41. package/hay/packages/shared/tsconfig.json +7 -0
  42. package/hay/tsconfig.base.json +15 -0
  43. package/hay-web/assets/index-CTTzApRp.css +32 -0
  44. package/hay-web/assets/index-FDfdolCs.js +60 -0
  45. package/hay-web/assets/index-FDfdolCs.js.map +1 -0
  46. package/hay-web/index.html +13 -0
  47. package/hop +3717 -0
  48. package/package.json +52 -0
  49. package/sessions.html +243 -0
  50. package/tunnel-shell.js +254 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -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
+ };