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,421 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { IPty } from "node-pty-prebuilt-multiarch";
3
+ import { pickPresenceColor, safeParseClientMessage } from "hay-shared";
4
+ import type { ClientMessage, PresenceClient, ServerMessage } from "hay-shared";
5
+ import type { PtyFactory } from "./pty";
6
+
7
+ export type SocketAdapter = {
8
+ send: (data: string) => void;
9
+ onMessage: (handler: (data: string) => void) => void;
10
+ onClose: (handler: () => void) => void;
11
+ onError: (handler: (err: Error) => void) => void;
12
+ close: () => void;
13
+ isOpen: () => boolean;
14
+ };
15
+
16
+ export type ClientInfo = {
17
+ id: string;
18
+ name: string;
19
+ colorIndex: number;
20
+ cols: number;
21
+ rows: number;
22
+ };
23
+
24
+ const MAX_BUFFER_SIZE = 200_000;
25
+ // Keep rooms alive indefinitely; only explicit kill/remove should end a session.
26
+ const CLEANUP_DELAY_MS = 0;
27
+ const CONTROL_SEQUENCE_TAIL = 32;
28
+ const DEBUG_STATE = process.env.HAY_DEBUG === "1";
29
+
30
+ const now = () => Date.now();
31
+
32
+ const clampBuffer = (value: string) => {
33
+ if (value.length <= MAX_BUFFER_SIZE) {
34
+ return value;
35
+ }
36
+ return value.slice(value.length - MAX_BUFFER_SIZE);
37
+ };
38
+
39
+ const toPresence = (client: ClientState): PresenceClient => ({
40
+ id: client.id,
41
+ name: client.name,
42
+ color: client.color,
43
+ typing: client.typing,
44
+ lastActive: client.lastActive
45
+ });
46
+
47
+ class ClientState {
48
+ id: string;
49
+ name: string;
50
+ color: string;
51
+ typing = false;
52
+ lastActive = now();
53
+ socket: SocketAdapter;
54
+ cols: number;
55
+ rows: number;
56
+
57
+ constructor(info: ClientInfo, socket: SocketAdapter) {
58
+ this.id = info.id;
59
+ this.name = info.name;
60
+ this.color = pickPresenceColor(info.colorIndex);
61
+ this.socket = socket;
62
+ this.cols = info.cols;
63
+ this.rows = info.rows;
64
+ }
65
+ }
66
+
67
+ export class Room extends EventEmitter {
68
+ id: string;
69
+ private pty: IPty;
70
+ private clients = new Map<string, ClientState>();
71
+ private collabMode = true;
72
+ private controllerId: string | null = null;
73
+ private activeClientId: string | null = null;
74
+ private activeCols: number;
75
+ private activeRows: number;
76
+ private outputBuffer = "";
77
+ private alternateScreen = false;
78
+ private cursorHidden = false;
79
+ private controlSequenceTail = "";
80
+ private cleanupTimer: NodeJS.Timeout | null = null;
81
+ private ended = false;
82
+
83
+ constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, cwd?: string) {
84
+ super();
85
+ this.id = id;
86
+ this.pty = ptyFactory({ cols: initialSize.cols, rows: initialSize.rows, cwd });
87
+ this.activeCols = initialSize.cols;
88
+ this.activeRows = initialSize.rows;
89
+
90
+ this.pty.onData((data: string) => {
91
+ this.outputBuffer = clampBuffer(this.outputBuffer + data);
92
+ this.updateTerminalState(data);
93
+ this.broadcast({ type: "output", data });
94
+ });
95
+
96
+ const handleExit = (exitCode?: number | null, signal?: number | string) => {
97
+ const normalizedExit = Number.isFinite(exitCode) ? Number(exitCode) : null;
98
+ const normalizedSignal = signal !== undefined && signal !== null ? String(signal) : null;
99
+ console.log(
100
+ `[hay] PTY exit room=${this.id} code=${normalizedExit ?? "null"} signal=${normalizedSignal ?? "null"}`
101
+ );
102
+ this.endSession({
103
+ exitCode: normalizedExit,
104
+ signal: normalizedSignal,
105
+ message: "Session ended"
106
+ });
107
+ };
108
+
109
+ if (typeof this.pty.onExit === "function") {
110
+ this.pty.onExit((event) => {
111
+ handleExit(event.exitCode, event.signal);
112
+ });
113
+ }
114
+
115
+ const legacyOn = (this.pty as unknown as { on?: (event: string, handler: (...args: any[]) => void) => void }).on;
116
+ if (typeof legacyOn === "function") {
117
+ legacyOn.call(this.pty, "exit", (exitCode: number, signal?: number) => {
118
+ handleExit(exitCode, signal);
119
+ });
120
+ }
121
+ }
122
+
123
+ attachClient(info: ClientInfo, socket: SocketAdapter) {
124
+ if (this.cleanupTimer) {
125
+ clearTimeout(this.cleanupTimer);
126
+ this.cleanupTimer = null;
127
+ }
128
+
129
+ const client = new ClientState(info, socket);
130
+ this.clients.set(client.id, client);
131
+
132
+ socket.send(
133
+ JSON.stringify({
134
+ type: "hello",
135
+ clientId: client.id,
136
+ roomId: this.id,
137
+ color: client.color,
138
+ collabMode: this.collabMode,
139
+ controllerId: this.controllerId
140
+ } satisfies ServerMessage)
141
+ );
142
+
143
+ socket.send(
144
+ JSON.stringify({
145
+ type: "active_size",
146
+ clientId: this.activeClientId ?? client.id,
147
+ cols: this.activeCols,
148
+ rows: this.activeRows
149
+ } satisfies ServerMessage)
150
+ );
151
+
152
+ if (this.outputBuffer) {
153
+ socket.send(JSON.stringify({
154
+ type: "snapshot",
155
+ data: this.outputBuffer,
156
+ alternateScreen: this.alternateScreen,
157
+ cursorHidden: this.cursorHidden
158
+ } satisfies ServerMessage));
159
+ }
160
+
161
+ this.broadcastPresence();
162
+
163
+ socket.onMessage((payload) => {
164
+ const message = safeParseClientMessage(payload);
165
+ if (!message) {
166
+ socket.send(JSON.stringify({ type: "error", message: "Invalid message" } satisfies ServerMessage));
167
+ return;
168
+ }
169
+ this.handleMessage(client.id, message);
170
+ });
171
+
172
+ socket.onClose(() => {
173
+ this.removeClient(client.id);
174
+ });
175
+
176
+ socket.onError(() => {
177
+ this.removeClient(client.id);
178
+ });
179
+ }
180
+
181
+ private handleMessage(clientId: string, message: ClientMessage) {
182
+ const client = this.clients.get(clientId);
183
+ if (!client) {
184
+ return;
185
+ }
186
+
187
+ switch (message.type) {
188
+ case "input":
189
+ this.handleInput(client, message.data);
190
+ break;
191
+ case "resize":
192
+ this.handleResize(client, message.cols, message.rows);
193
+ break;
194
+ case "typing":
195
+ client.typing = message.active;
196
+ client.lastActive = now();
197
+ this.broadcastPresence();
198
+ break;
199
+ case "toggle_collab":
200
+ this.collabMode = message.enabled;
201
+ this.controllerId = message.enabled ? null : clientId;
202
+ this.broadcastCollab();
203
+ break;
204
+ case "take_control":
205
+ this.collabMode = false;
206
+ this.controllerId = clientId;
207
+ this.broadcastCollab();
208
+ break;
209
+ case "release_control":
210
+ if (this.controllerId === clientId) {
211
+ this.collabMode = true;
212
+ this.controllerId = null;
213
+ this.broadcastCollab();
214
+ }
215
+ break;
216
+ case "ping":
217
+ client.socket.send(JSON.stringify({ type: "pong", t: message.t } satisfies ServerMessage));
218
+ break;
219
+ case "kill_session":
220
+ this.kill();
221
+ break;
222
+ default:
223
+ break;
224
+ }
225
+ }
226
+
227
+ private updateTerminalState(data: string) {
228
+ const combined = this.controlSequenceTail + data;
229
+ const regex = /\x1b\[\?([0-9;]*)([hl])/g;
230
+ let match: RegExpExecArray | null;
231
+ let nextAlternate = this.alternateScreen;
232
+ let nextCursorHidden = this.cursorHidden;
233
+
234
+ while ((match = regex.exec(combined)) !== null) {
235
+ const params = match[1].split(";").filter(Boolean);
236
+ const mode = match[2];
237
+ for (const param of params) {
238
+ if (param === "25") {
239
+ nextCursorHidden = mode === "l";
240
+ } else if (param === "47" || param === "1047" || param === "1049") {
241
+ nextAlternate = mode === "h";
242
+ }
243
+ }
244
+ }
245
+
246
+ if (nextAlternate !== this.alternateScreen) {
247
+ if (DEBUG_STATE) {
248
+ console.log(`[hay] room=${this.id} alternateScreen=${nextAlternate}`);
249
+ }
250
+ this.alternateScreen = nextAlternate;
251
+ }
252
+ if (nextCursorHidden !== this.cursorHidden) {
253
+ if (DEBUG_STATE) {
254
+ console.log(`[hay] room=${this.id} cursorHidden=${nextCursorHidden}`);
255
+ }
256
+ this.cursorHidden = nextCursorHidden;
257
+ }
258
+
259
+ this.controlSequenceTail = combined.slice(-CONTROL_SEQUENCE_TAIL);
260
+ }
261
+
262
+ kill() {
263
+ if (this.cleanupTimer) {
264
+ clearTimeout(this.cleanupTimer);
265
+ this.cleanupTimer = null;
266
+ }
267
+ this.pty.kill();
268
+ this.endSession({ exitCode: null, signal: null, message: "Session terminated" });
269
+ }
270
+
271
+ private handleInput(client: ClientState, data: string) {
272
+ if (!this.collabMode && this.controllerId !== client.id) {
273
+ client.socket.send(
274
+ JSON.stringify({ type: "input_rejected", reason: "Control is locked" } satisfies ServerMessage)
275
+ );
276
+ return;
277
+ }
278
+ client.lastActive = now();
279
+ this.pty.write(data);
280
+ }
281
+
282
+ private handleResize(client: ClientState, cols: number, rows: number) {
283
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
284
+ if (cols < 1 || cols > 500 || rows < 1 || rows > 200) return;
285
+
286
+ client.cols = cols;
287
+ client.rows = rows;
288
+ // Treat resize as activity so the resizer becomes the active size source.
289
+ client.lastActive = now();
290
+ // Only resize PTY if this client is the most recently active (was typing)
291
+ // This prevents disrupting the active typer when others resize their windows
292
+ const isActive = [...this.clients.values()].every(c => c.lastActive <= client.lastActive);
293
+ if (isActive) {
294
+ this.pty.resize(cols, rows);
295
+ this.activeCols = cols;
296
+ this.activeRows = rows;
297
+ this.activeClientId = client.id;
298
+ // Broadcast the new active size to other clients so they can adjust
299
+ this.broadcastActiveSize(client);
300
+ }
301
+ }
302
+
303
+ private broadcastActiveSize(client: ClientState) {
304
+ // Broadcast to OTHER clients (not the one typing)
305
+ const payload = JSON.stringify({
306
+ type: "active_size",
307
+ clientId: client.id,
308
+ cols: client.cols,
309
+ rows: client.rows
310
+ } satisfies ServerMessage);
311
+ for (const c of this.clients.values()) {
312
+ if (c.id !== client.id && c.socket.isOpen()) {
313
+ c.socket.send(payload);
314
+ }
315
+ }
316
+ }
317
+
318
+ private broadcast(message: ServerMessage) {
319
+ const payload = JSON.stringify(message);
320
+ for (const client of this.clients.values()) {
321
+ if (client.socket.isOpen()) {
322
+ client.socket.send(payload);
323
+ }
324
+ }
325
+ }
326
+
327
+ private broadcastPresence() {
328
+ const clients = [...this.clients.values()].map(toPresence);
329
+ this.broadcast({ type: "presence", clients });
330
+ }
331
+
332
+ private broadcastCollab() {
333
+ this.broadcast({ type: "collab", enabled: this.collabMode, controllerId: this.controllerId });
334
+ }
335
+
336
+ private removeClient(clientId: string) {
337
+ const wasController = this.controllerId === clientId;
338
+ this.clients.delete(clientId);
339
+ if (wasController && !this.collabMode) {
340
+ this.collabMode = true;
341
+ this.controllerId = null;
342
+ this.broadcastCollab();
343
+ }
344
+ this.broadcastPresence();
345
+ this.scheduleCleanup();
346
+ }
347
+
348
+ private scheduleCleanup() {
349
+ if (CLEANUP_DELAY_MS <= 0) {
350
+ return;
351
+ }
352
+ if (this.clients.size > 0 || this.cleanupTimer) {
353
+ return;
354
+ }
355
+ this.cleanupTimer = setTimeout(() => {
356
+ if (this.clients.size === 0) {
357
+ this.pty.kill();
358
+ this.emit("empty");
359
+ }
360
+ }, CLEANUP_DELAY_MS);
361
+ }
362
+
363
+ private endSession(payload: { exitCode: number | null; signal: string | null; message: string }) {
364
+ if (this.ended) return;
365
+ this.ended = true;
366
+ if (this.cleanupTimer) {
367
+ clearTimeout(this.cleanupTimer);
368
+ this.cleanupTimer = null;
369
+ }
370
+ const message = JSON.stringify({ type: "session_ended", ...payload } satisfies ServerMessage);
371
+ for (const client of this.clients.values()) {
372
+ if (client.socket.isOpen()) {
373
+ client.socket.send(message);
374
+ }
375
+ client.socket.close();
376
+ }
377
+ this.clients.clear();
378
+ this.emit("empty");
379
+ }
380
+ }
381
+
382
+ export class RoomManager {
383
+ private rooms = new Map<string, Room>();
384
+ private ptyFactory: PtyFactory;
385
+ private cwd?: string;
386
+
387
+ constructor(ptyFactory: PtyFactory, cwd?: string) {
388
+ this.ptyFactory = ptyFactory;
389
+ this.cwd = cwd;
390
+ }
391
+
392
+ getRoom(roomId: string, initialSize: { cols: number; rows: number }, cwd?: string) {
393
+ const existing = this.rooms.get(roomId);
394
+ if (existing) {
395
+ return existing;
396
+ }
397
+ const room = new Room(roomId, this.ptyFactory, initialSize, cwd ?? this.cwd);
398
+ room.on("empty", () => {
399
+ this.rooms.delete(roomId);
400
+ });
401
+ this.rooms.set(roomId, room);
402
+ return room;
403
+ }
404
+
405
+ hasRoom(roomId: string) {
406
+ return this.rooms.has(roomId);
407
+ }
408
+
409
+ closeRoom(roomId: string) {
410
+ const room = this.rooms.get(roomId);
411
+ if (!room) return;
412
+ room.kill();
413
+ this.rooms.delete(roomId);
414
+ }
415
+
416
+ closeAll() {
417
+ for (const roomId of this.rooms.keys()) {
418
+ this.closeRoom(roomId);
419
+ }
420
+ }
421
+ }
@@ -0,0 +1,20 @@
1
+ const MAX_NAME_LENGTH = 24;
2
+ const MAX_ROOM_LENGTH = 32;
3
+
4
+ const normalize = (value: string) => value.replace(/[^a-zA-Z0-9 _.-]/g, "").trim();
5
+
6
+ export const sanitizeName = (value: string | null | undefined) => {
7
+ const cleaned = normalize(value ?? "");
8
+ if (!cleaned) {
9
+ return "Guest";
10
+ }
11
+ return cleaned.slice(0, MAX_NAME_LENGTH);
12
+ };
13
+
14
+ export const sanitizeRoom = (value: string | null | undefined) => {
15
+ const cleaned = normalize(value ?? "");
16
+ if (!cleaned) {
17
+ return "main";
18
+ }
19
+ return cleaned.slice(0, MAX_ROOM_LENGTH);
20
+ };
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RoomManager } from "../src/rooms";
3
+ import type { PtyFactory } from "../src/pty";
4
+
5
+ type Message = { type: string; [key: string]: unknown };
6
+
7
+ class FakePty {
8
+ writes: string[] = [];
9
+ resizes: Array<{ cols: number; rows: number }> = [];
10
+ killed = false;
11
+ private handlers: Array<(data: string) => void> = [];
12
+
13
+ onData(handler: (data: string) => void) {
14
+ this.handlers.push(handler);
15
+ }
16
+
17
+ emit(data: string) {
18
+ for (const handler of this.handlers) {
19
+ handler(data);
20
+ }
21
+ }
22
+
23
+ write(data: string) {
24
+ this.writes.push(data);
25
+ }
26
+
27
+ resize(cols: number, rows: number) {
28
+ this.resizes.push({ cols, rows });
29
+ }
30
+
31
+ kill() {
32
+ this.killed = true;
33
+ }
34
+ }
35
+
36
+ class FakeSocket {
37
+ messages: string[] = [];
38
+ private messageHandler: ((data: string) => void) | null = null;
39
+ private closeHandler: (() => void) | null = null;
40
+ private errorHandler: ((err: Error) => void) | null = null;
41
+
42
+ send(data: string) {
43
+ this.messages.push(data);
44
+ }
45
+
46
+ onMessage(handler: (data: string) => void) {
47
+ this.messageHandler = handler;
48
+ }
49
+
50
+ onClose(handler: () => void) {
51
+ this.closeHandler = handler;
52
+ }
53
+
54
+ onError(handler: (err: Error) => void) {
55
+ this.errorHandler = handler;
56
+ }
57
+
58
+ isOpen() {
59
+ return true;
60
+ }
61
+
62
+ emitMessage(payload: Message) {
63
+ this.messageHandler?.(JSON.stringify(payload));
64
+ }
65
+
66
+ close() {
67
+ this.closeHandler?.();
68
+ }
69
+
70
+ error(err: Error) {
71
+ this.errorHandler?.(err);
72
+ }
73
+ }
74
+
75
+ const readMessages = (socket: FakeSocket) => {
76
+ return socket.messages.map((message) => JSON.parse(message) as Message);
77
+ };
78
+
79
+ const findMessages = (socket: FakeSocket, type: string) => {
80
+ return readMessages(socket).filter((message) => message.type === type);
81
+ };
82
+
83
+ describe("Room", () => {
84
+ it("broadcasts pty output to all clients", () => {
85
+ let ptyInstance: FakePty | null = null;
86
+ const factory: PtyFactory = () => {
87
+ ptyInstance = new FakePty() as unknown as FakePty;
88
+ return ptyInstance as any;
89
+ };
90
+
91
+ const manager = new RoomManager(factory);
92
+ const room = manager.getRoom("alpha", { cols: 80, rows: 24 });
93
+ const socketA = new FakeSocket();
94
+ const socketB = new FakeSocket();
95
+
96
+ room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, socketA);
97
+ room.attachClient({ id: "b", name: "Blake", colorIndex: 1, cols: 80, rows: 24 }, socketB);
98
+
99
+ ptyInstance?.emit("hello");
100
+
101
+ const outputsA = findMessages(socketA, "output");
102
+ const outputsB = findMessages(socketB, "output");
103
+
104
+ expect(outputsA.at(-1)?.data).toBe("hello");
105
+ expect(outputsB.at(-1)?.data).toBe("hello");
106
+ });
107
+
108
+ it("rejects input when control is locked", () => {
109
+ let ptyInstance: FakePty | null = null;
110
+ const factory: PtyFactory = () => {
111
+ ptyInstance = new FakePty() as unknown as FakePty;
112
+ return ptyInstance as any;
113
+ };
114
+
115
+ const manager = new RoomManager(factory);
116
+ const room = manager.getRoom("bravo", { cols: 80, rows: 24 });
117
+ const socketA = new FakeSocket();
118
+ const socketB = new FakeSocket();
119
+
120
+ room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, socketA);
121
+ room.attachClient({ id: "b", name: "Blake", colorIndex: 1, cols: 80, rows: 24 }, socketB);
122
+
123
+ socketA.emitMessage({ type: "take_control" });
124
+ socketB.emitMessage({ type: "input", data: "ls" });
125
+
126
+ expect(ptyInstance?.writes).toEqual([]);
127
+ const rejected = findMessages(socketB, "input_rejected");
128
+ expect(rejected.length).toBeGreaterThan(0);
129
+ });
130
+
131
+ it("updates presence on disconnect", () => {
132
+ const manager = new RoomManager(() => new FakePty() as any);
133
+ const room = manager.getRoom("charlie", { cols: 80, rows: 24 });
134
+ const socketA = new FakeSocket();
135
+ const socketB = new FakeSocket();
136
+
137
+ room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, socketA);
138
+ room.attachClient({ id: "b", name: "Blake", colorIndex: 1, cols: 80, rows: 24 }, socketB);
139
+
140
+ socketB.close();
141
+
142
+ const presenceMessages = findMessages(socketA, "presence");
143
+ const latest = presenceMessages.at(-1) as any;
144
+ expect(latest?.clients).toHaveLength(1);
145
+ });
146
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "types": ["node"],
6
+ "lib": ["ES2020"]
7
+ },
8
+ "include": ["src", "test"]
9
+ }
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <title>Hay</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "hay-web",
3
+ "version": "0.8.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview --port 5173",
10
+ "test": "vitest run",
11
+ "test:e2e": "node scripts/run-e2e.mjs"
12
+ },
13
+ "dependencies": {
14
+ "hay-shared": "*",
15
+ "react": "^18.3.1",
16
+ "react-dom": "^18.3.1",
17
+ "xterm": "^5.3.0",
18
+ "@xterm/addon-fit": "^0.11.0"
19
+ },
20
+ "devDependencies": {
21
+ "@testing-library/jest-dom": "^6.6.3",
22
+ "@testing-library/react": "^16.1.0",
23
+ "@types/react": "^18.3.12",
24
+ "@types/react-dom": "^18.3.1",
25
+ "@vitejs/plugin-react": "^4.3.4",
26
+ "jsdom": "^25.0.1",
27
+ "playwright": "^1.49.1",
28
+ "typescript": "^5.7.3",
29
+ "vite": "^5.4.11",
30
+ "vitest": "^2.1.8"
31
+ }
32
+ }