hop2 0.9.0 → 0.10.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 (46) hide show
  1. package/AGENTS.md +33 -0
  2. package/INTEGRATION.md +77 -85
  3. package/MULTI-AGENT-CHECKLIST.md +37 -0
  4. package/README-MCP.md +232 -0
  5. package/README.md +209 -148
  6. package/demo/30s-product-demo.md +98 -0
  7. package/demo/README.md +106 -0
  8. package/demo/director.mjs +236 -0
  9. package/demo/drive-hop-terminal.mjs +70 -0
  10. package/demo/hop-demo-lib.mjs +525 -0
  11. package/demo/record-hop-view.mjs +26 -0
  12. package/demo/static-preview-server.mjs +45 -0
  13. package/demo/stitch-demo-video.mjs +128 -0
  14. package/hay/CHANGELOG.md +6 -0
  15. package/hay/DEV_NOTES.md +25 -0
  16. package/hay/README.md +136 -198
  17. package/hay/apps/cli/src/index.ts +321 -67
  18. package/hay/apps/server/src/index.ts +4 -3
  19. package/hay/apps/server/src/lib.ts +5 -4
  20. package/hay/apps/server/src/pty.ts +20 -7
  21. package/hay/apps/server/src/rooms.ts +226 -12
  22. package/hay/apps/server/test/rooms.test.ts +82 -3
  23. package/hay/apps/web/src/App.tsx +487 -112
  24. package/hay/apps/web/src/components/MobileKeyboard.tsx +43 -2
  25. package/hay/apps/web/src/styles.css +259 -29
  26. package/hay/package-lock.json +4 -4
  27. package/hay/packages/shared/src/protocol.ts +8 -0
  28. package/hay-web/assets/index-BmWjq_Nh.css +32 -0
  29. package/hay-web/assets/index-D9aOC5Zt.js +63 -0
  30. package/hay-web/assets/{index-FDfdolCs.js.map → index-D9aOC5Zt.js.map} +1 -1
  31. package/hay-web/index.html +2 -2
  32. package/hop +6801 -1714
  33. package/lib/naming-themes.js +194 -0
  34. package/lib/terminal-access.js +17 -0
  35. package/mcp/hop-mcp.js +4730 -0
  36. package/package.json +6 -3
  37. package/scripts/hay-host.js +189 -0
  38. package/sessions.html +378 -26
  39. package/setup-mcp.js +443 -0
  40. package/tests/hop-mcp.integration.test.cjs +3014 -0
  41. package/tests/terminal-access.unit.test.cjs +21 -0
  42. package/tests/terminal-api.integration.test.cjs +298 -0
  43. package/.hop-tmux.conf +0 -18
  44. package/hay-web/assets/index-CTTzApRp.css +0 -32
  45. package/hay-web/assets/index-FDfdolCs.js +0 -60
  46. package/tunnel-shell.js +0 -254
@@ -0,0 +1,525 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import net from "node:net";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { chromium, devices } from "../hay/node_modules/playwright/index.mjs";
8
+
9
+ const DEFAULT_HOP_HOME = process.env.HOP_HOME || path.join(process.env.HOME, ".hop2");
10
+ const DEFAULT_CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
11
+ const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
12
+
13
+ export async function sleep(ms) {
14
+ await delay(ms);
15
+ }
16
+
17
+ export function parseArgs(argv) {
18
+ const args = {};
19
+ for (let i = 0; i < argv.length; i += 1) {
20
+ const token = argv[i];
21
+ if (!token.startsWith("--")) continue;
22
+ const key = token.slice(2);
23
+ const next = argv[i + 1];
24
+ if (!next || next.startsWith("--")) {
25
+ args[key] = true;
26
+ continue;
27
+ }
28
+ args[key] = next;
29
+ i += 1;
30
+ }
31
+ return args;
32
+ }
33
+
34
+ export function getHopState(hopHome = DEFAULT_HOP_HOME) {
35
+ const statePath = path.join(hopHome, ".tunnel-state");
36
+ const secretPath = path.join(hopHome, ".session_secret");
37
+ if (!fs.existsSync(statePath)) {
38
+ throw new Error(`Hop state file not found: ${statePath}`);
39
+ }
40
+ if (!fs.existsSync(secretPath)) {
41
+ throw new Error(`Hop session secret file not found: ${secretPath}`);
42
+ }
43
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
44
+ const sessionSecret = fs.readFileSync(secretPath, "utf8").trim();
45
+ if (!state.port) {
46
+ throw new Error(`Invalid Hop state file: missing port in ${statePath}`);
47
+ }
48
+ return {
49
+ hopHome,
50
+ port: Number(state.port),
51
+ publicUrl: state.url || null,
52
+ localUrl: `http://127.0.0.1:${state.port}`,
53
+ sessionSecret
54
+ };
55
+ }
56
+
57
+ export function ensureDir(dirPath) {
58
+ fs.mkdirSync(dirPath, { recursive: true });
59
+ return dirPath;
60
+ }
61
+
62
+ export function resetDir(dirPath) {
63
+ fs.rmSync(dirPath, { recursive: true, force: true });
64
+ fs.mkdirSync(dirPath, { recursive: true });
65
+ return dirPath;
66
+ }
67
+
68
+ export async function waitForFile(filePath, timeoutMs = 10000) {
69
+ const startedAt = Date.now();
70
+ while (!fs.existsSync(filePath)) {
71
+ if (Date.now() - startedAt > timeoutMs) {
72
+ throw new Error(`Timed out waiting for file: ${filePath}`);
73
+ }
74
+ await sleep(200);
75
+ }
76
+ }
77
+
78
+ export async function waitForHopReady(hopHome, timeoutMs = 10000) {
79
+ const statePath = path.join(hopHome, ".tunnel-state");
80
+ await waitForFile(statePath, timeoutMs);
81
+ const startedAt = Date.now();
82
+ while (true) {
83
+ const state = getHopState(hopHome);
84
+ try {
85
+ const response = await fetch(`${state.localUrl}/api/sessions`, {
86
+ headers: {
87
+ Authorization: `Bearer ${state.sessionSecret}`
88
+ }
89
+ });
90
+ if (response.ok) {
91
+ return state;
92
+ }
93
+ } catch {
94
+ // keep polling
95
+ }
96
+ if (Date.now() - startedAt > timeoutMs) {
97
+ throw new Error(`Timed out waiting for Hop API in ${hopHome}`);
98
+ }
99
+ await sleep(250);
100
+ }
101
+ }
102
+
103
+ function withNodePath(env = {}) {
104
+ const currentPath = process.env.PATH || "";
105
+ const nextPath = currentPath.includes("/opt/homebrew/bin")
106
+ ? currentPath
107
+ : `/opt/homebrew/bin:${currentPath}`;
108
+ return {
109
+ ...process.env,
110
+ ...env,
111
+ PATH: nextPath
112
+ };
113
+ }
114
+
115
+ export function runHopCli(args, options = {}) {
116
+ const result = spawnSync("/opt/homebrew/bin/node", [path.join(REPO_ROOT, "hop"), ...args], {
117
+ cwd: REPO_ROOT,
118
+ env: withNodePath(options.env || {}),
119
+ encoding: "utf8",
120
+ stdio: options.stdio || "pipe"
121
+ });
122
+ if (result.status !== 0) {
123
+ const stderr = result.stderr || "";
124
+ const stdout = result.stdout || "";
125
+ throw new Error(`hop ${args.join(" ")} failed: ${stderr || stdout || result.status}`);
126
+ }
127
+ return result;
128
+ }
129
+
130
+ export async function startHopDaemon(hopHome) {
131
+ ensureDir(hopHome);
132
+ runHopCli(["start"], {
133
+ env: { HOP_HOME: hopHome },
134
+ stdio: "pipe"
135
+ });
136
+ return waitForHopReady(hopHome, 15000);
137
+ }
138
+
139
+ export function stopHopDaemon(hopHome) {
140
+ try {
141
+ runHopCli(["stop"], {
142
+ env: { HOP_HOME: hopHome },
143
+ stdio: "pipe"
144
+ });
145
+ } catch {
146
+ // Best effort shutdown for demo homes.
147
+ }
148
+ }
149
+
150
+ export function prepareSanitizedWorkspace(workspaceDir, options = {}) {
151
+ const reset = options.reset !== false;
152
+ if (reset) {
153
+ resetDir(workspaceDir);
154
+ } else {
155
+ ensureDir(workspaceDir);
156
+ }
157
+ const readmePath = path.join(workspaceDir, "README.md");
158
+ const appPath = path.join(workspaceDir, "index.html");
159
+ if (!fs.existsSync(readmePath)) {
160
+ fs.writeFileSync(readmePath, `# Demo Project
161
+
162
+ This is a sanitized workspace for Hop product demos.
163
+
164
+ - No personal paths
165
+ - No personal usernames
166
+ - Safe to record on screen
167
+ `);
168
+ }
169
+ if (!fs.existsSync(appPath)) {
170
+ fs.writeFileSync(appPath, `<!doctype html>
171
+ <html lang="en">
172
+ <head>
173
+ <meta charset="utf-8">
174
+ <meta name="viewport" content="width=device-width, initial-scale=1">
175
+ <title>Hop Demo Workspace</title>
176
+ <style>
177
+ body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 48px; background: linear-gradient(135deg, #f9f5ef, #efe6d6); color: #1f2937; }
178
+ .card { max-width: 720px; background: rgba(255,255,255,0.85); padding: 32px; border-radius: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.08); }
179
+ h1 { margin-top: 0; font-size: 44px; }
180
+ p { font-size: 18px; line-height: 1.6; }
181
+ </style>
182
+ </head>
183
+ <body>
184
+ <div class="card">
185
+ <h1>Hop Demo Workspace</h1>
186
+ <p>Visible, steerable agent terminals for real work.</p>
187
+ <p>This file exists to give the demo agent something clean to read and change.</p>
188
+ </div>
189
+ </body>
190
+ </html>
191
+ `);
192
+ }
193
+ return workspaceDir;
194
+ }
195
+
196
+ export function buildSanitizedLaunchCommand(command, options = {}) {
197
+ const prompt = options.prompt || "demo@hop ~/workspace % ";
198
+ const homeDir = options.homeDir || path.join(os.tmpdir(), "hop-demo-home");
199
+ const cwdLabel = options.cwdLabel || "workspace";
200
+ ensureDir(homeDir);
201
+ return [
202
+ `export HOME=${JSON.stringify(homeDir)}`,
203
+ "export USER=demo",
204
+ "export LOGNAME=demo",
205
+ "export HOSTNAME=hop-demo",
206
+ "export TERM_PROGRAM=HopDemo",
207
+ "unset VIRTUAL_ENV CONDA_DEFAULT_ENV CONDA_PROMPT_MODIFIER",
208
+ `export PS1=${JSON.stringify(prompt)}`,
209
+ `PROMPT=${JSON.stringify(prompt)}`,
210
+ `RPROMPT=''`,
211
+ `cd ${JSON.stringify(options.cwd || ".")}`,
212
+ `printf '\\033]0;%s\\007' ${JSON.stringify(cwdLabel)}`,
213
+ "clear",
214
+ command
215
+ ].join("; ");
216
+ }
217
+
218
+ export async function hopApi(state, pathname, options = {}) {
219
+ const method = options.method || "GET";
220
+ const headers = {
221
+ Authorization: `Bearer ${state.sessionSecret}`,
222
+ ...options.headers
223
+ };
224
+ let body = options.body;
225
+ if (body && typeof body === "object" && !(body instanceof Buffer)) {
226
+ headers["Content-Type"] = "application/json";
227
+ body = JSON.stringify(body);
228
+ }
229
+ const response = await fetch(`${state.localUrl}${pathname}`, {
230
+ method,
231
+ headers,
232
+ body
233
+ });
234
+ const text = await response.text();
235
+ let payload = null;
236
+ if (text) {
237
+ try {
238
+ payload = JSON.parse(text);
239
+ } catch {
240
+ payload = { raw: text };
241
+ }
242
+ }
243
+ if (!response.ok) {
244
+ const detail = payload && payload.error ? payload.error : text || `${response.status}`;
245
+ throw new Error(`${method} ${pathname} failed: ${detail}`);
246
+ }
247
+ return payload;
248
+ }
249
+
250
+ export async function createSession(state, options) {
251
+ return hopApi(state, "/api/sessions", {
252
+ method: "POST",
253
+ body: options
254
+ });
255
+ }
256
+
257
+ export async function deleteSession(state, internalName) {
258
+ return hopApi(state, "/api/sessions/delete", {
259
+ method: "POST",
260
+ body: { internalName }
261
+ });
262
+ }
263
+
264
+ export async function createTerminal(state, options) {
265
+ return hopApi(state, "/api/terminals", {
266
+ method: "POST",
267
+ body: options
268
+ });
269
+ }
270
+
271
+ export async function attachTerminal(state, options) {
272
+ return hopApi(state, "/api/terminals/attach", {
273
+ method: "POST",
274
+ body: options
275
+ });
276
+ }
277
+
278
+ export async function ensureTerminal(state, options) {
279
+ try {
280
+ return await createTerminal(state, options);
281
+ } catch (error) {
282
+ if (!String(error.message || error).includes("Session name already in use")) {
283
+ throw error;
284
+ }
285
+ return attachTerminal(state, { name: options.name, cols: options.cols, rows: options.rows });
286
+ }
287
+ }
288
+
289
+ export async function execTerminal(state, terminalId, command) {
290
+ return hopApi(state, `/api/terminals/${encodeURIComponent(terminalId)}/exec`, {
291
+ method: "POST",
292
+ body: { command }
293
+ });
294
+ }
295
+
296
+ export async function writeTerminal(state, terminalId, data) {
297
+ return hopApi(state, `/api/terminals/${encodeURIComponent(terminalId)}/write`, {
298
+ method: "POST",
299
+ body: { data }
300
+ });
301
+ }
302
+
303
+ export async function resizeTerminal(state, terminalId, cols, rows) {
304
+ return hopApi(state, `/api/terminals/${encodeURIComponent(terminalId)}/resize`, {
305
+ method: "POST",
306
+ body: { cols, rows }
307
+ });
308
+ }
309
+
310
+ export async function closeTerminal(state, terminalId, killSession = false) {
311
+ return hopApi(state, `/api/terminals/${encodeURIComponent(terminalId)}?killSession=${killSession ? "true" : "false"}`, {
312
+ method: "DELETE"
313
+ });
314
+ }
315
+
316
+ export async function waitForTerminalOutput(state, terminalId, options = {}) {
317
+ const timeoutMs = Number(options.timeoutMs || 15000);
318
+ const pattern = options.pattern ? String(options.pattern) : null;
319
+ const controller = new AbortController();
320
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
321
+ const response = await fetch(`${state.localUrl}/api/terminals/${encodeURIComponent(terminalId)}/stream`, {
322
+ headers: {
323
+ Authorization: `Bearer ${state.sessionSecret}`
324
+ },
325
+ signal: controller.signal
326
+ });
327
+ if (!response.ok) {
328
+ clearTimeout(timer);
329
+ throw new Error(`Terminal stream failed: ${response.status}`);
330
+ }
331
+ const decoder = new TextDecoder();
332
+ let pending = "";
333
+ let text = "";
334
+ try {
335
+ for await (const chunk of response.body) {
336
+ pending += decoder.decode(chunk, { stream: true });
337
+ let boundary = pending.indexOf("\n\n");
338
+ while (boundary !== -1) {
339
+ const rawEvent = pending.slice(0, boundary);
340
+ pending = pending.slice(boundary + 2);
341
+ boundary = pending.indexOf("\n\n");
342
+ const dataLine = rawEvent
343
+ .split("\n")
344
+ .find((line) => line.startsWith("data: "));
345
+ if (!dataLine) continue;
346
+ const payload = JSON.parse(dataLine.slice(6));
347
+ if (payload.type !== "output" && payload.type !== "snapshot") continue;
348
+ const chunkText = typeof payload.data === "string" ? payload.data : "";
349
+ text += chunkText;
350
+ if (!pattern || text.includes(pattern)) {
351
+ clearTimeout(timer);
352
+ controller.abort();
353
+ return { payload, text };
354
+ }
355
+ }
356
+ }
357
+ } catch (error) {
358
+ if (error.name === "AbortError") {
359
+ clearTimeout(timer);
360
+ return { payload: null, text };
361
+ }
362
+ clearTimeout(timer);
363
+ throw error;
364
+ }
365
+ clearTimeout(timer);
366
+ return { payload: null, text };
367
+ }
368
+
369
+ function buildCookie(state) {
370
+ return {
371
+ name: "tunnel_session",
372
+ value: state.sessionSecret,
373
+ domain: "127.0.0.1",
374
+ path: "/",
375
+ httpOnly: true,
376
+ secure: false,
377
+ sameSite: "Lax"
378
+ };
379
+ }
380
+
381
+ export async function openRecordedContext(state, options = {}) {
382
+ const outDir = options.outDir || path.join(process.cwd(), "demo-output");
383
+ fs.mkdirSync(outDir, { recursive: true });
384
+ const browser = await chromium.launch({
385
+ headless: options.headless !== false,
386
+ executablePath: options.chromePath || DEFAULT_CHROME_PATH
387
+ });
388
+ const useMobile = Boolean(options.mobile);
389
+ const contextOptions = useMobile
390
+ ? {
391
+ ...devices["iPhone 14"],
392
+ recordVideo: {
393
+ dir: outDir,
394
+ size: { width: 390, height: 844 }
395
+ }
396
+ }
397
+ : {
398
+ viewport: {
399
+ width: Number(options.width || 1920),
400
+ height: Number(options.height || 1080)
401
+ },
402
+ recordVideo: {
403
+ dir: outDir,
404
+ size: {
405
+ width: Number(options.width || 1920),
406
+ height: Number(options.height || 1080)
407
+ }
408
+ }
409
+ };
410
+ const context = await browser.newContext(contextOptions);
411
+ await context.addCookies([buildCookie(state)]);
412
+ return { browser, context, outDir };
413
+ }
414
+
415
+ export async function recordHopView(state, options = {}) {
416
+ const { browser, context, outDir } = await openRecordedContext(state, options);
417
+ const page = await context.newPage();
418
+ const targetPath = options.session
419
+ ? `/s/${encodeURIComponent(options.session)}`
420
+ : "/sessions";
421
+ await page.goto(`${state.localUrl}${targetPath}`, {
422
+ waitUntil: "networkidle",
423
+ timeout: Number(options.timeoutMs || 30000)
424
+ });
425
+ if (options.sessionPrefix || options.allowedSessions) {
426
+ const allowedSessions = Array.isArray(options.allowedSessions)
427
+ ? options.allowedSessions
428
+ : null;
429
+ const sessionPrefix = options.sessionPrefix || null;
430
+ await page.evaluate(({ prefix, allowed }) => {
431
+ const allowName = (name) => {
432
+ if (!name) return false;
433
+ if (Array.isArray(allowed) && allowed.length > 0) {
434
+ return allowed.includes(name);
435
+ }
436
+ if (prefix) {
437
+ return name.startsWith(prefix);
438
+ }
439
+ return true;
440
+ };
441
+
442
+ document.querySelectorAll(".session-item-wrapper, .session-list-item").forEach((node) => {
443
+ const label = node.querySelector(".session-title, .session-name, .session-list-name");
444
+ const text = (label?.textContent || node.textContent || "").trim();
445
+ if (!allowName(text)) {
446
+ node.remove();
447
+ }
448
+ });
449
+ }, {
450
+ prefix: sessionPrefix,
451
+ allowed: allowedSessions
452
+ });
453
+ }
454
+ if (options.pageMode === "session-picker") {
455
+ await page.addStyleTag({
456
+ content: `
457
+ body { background: linear-gradient(180deg, #f6f1e8 0%, #f9f7f2 100%) !important; }
458
+ .container {
459
+ max-width: 1080px !important;
460
+ margin: 32px auto 0 !important;
461
+ transform: scale(1.08);
462
+ transform-origin: top center;
463
+ }
464
+ `
465
+ });
466
+ }
467
+ const settleMs = Number(options.settleMs || 1000);
468
+ const durationMs = Number(options.durationMs || 5000);
469
+ if (settleMs > 0) {
470
+ await sleep(settleMs);
471
+ }
472
+ const screenshotPath = path.join(outDir, options.screenshot || "capture.png");
473
+ await page.screenshot({ path: screenshotPath, fullPage: true });
474
+ if (durationMs > 0) {
475
+ await sleep(durationMs);
476
+ }
477
+ await context.close();
478
+ await browser.close();
479
+ return { outDir, screenshotPath };
480
+ }
481
+
482
+ export async function findFreePort() {
483
+ return new Promise((resolve, reject) => {
484
+ const server = net.createServer();
485
+ server.listen(0, "127.0.0.1", () => {
486
+ const address = server.address();
487
+ server.close(() => {
488
+ if (!address || typeof address !== "object") {
489
+ reject(new Error("Failed to allocate a local port"));
490
+ return;
491
+ }
492
+ resolve(address.port);
493
+ });
494
+ });
495
+ server.on("error", reject);
496
+ });
497
+ }
498
+
499
+ export async function waitForHttp(url, timeoutMs = 10000) {
500
+ const startedAt = Date.now();
501
+ while (true) {
502
+ try {
503
+ const response = await fetch(url);
504
+ if (response.ok) {
505
+ return;
506
+ }
507
+ } catch {
508
+ // keep polling
509
+ }
510
+ if (Date.now() - startedAt > timeoutMs) {
511
+ throw new Error(`Timed out waiting for ${url}`);
512
+ }
513
+ await sleep(250);
514
+ }
515
+ }
516
+
517
+ export async function startStaticPreviewServer(rootDir, port) {
518
+ const child = spawn("/opt/homebrew/bin/node", [path.join(REPO_ROOT, "demo", "static-preview-server.mjs"), "--root", rootDir, "--port", String(port)], {
519
+ cwd: REPO_ROOT,
520
+ env: withNodePath(),
521
+ stdio: "pipe"
522
+ });
523
+ await waitForHttp(`http://127.0.0.1:${port}`, 10000);
524
+ return child;
525
+ }
@@ -0,0 +1,26 @@
1
+ import { getHopState, parseArgs, recordHopView } from "./hop-demo-lib.mjs";
2
+
3
+ const args = parseArgs(process.argv.slice(2));
4
+ const state = getHopState(args["hop-home"]);
5
+
6
+ const result = await recordHopView(state, {
7
+ session: args.session,
8
+ outDir: args.out || args["out-dir"],
9
+ mobile: args.mobile === true || args.mobile === "true",
10
+ width: args.width,
11
+ height: args.height,
12
+ headless: args.headless === "false" ? false : true,
13
+ timeoutMs: args.timeout,
14
+ settleMs: args.settle,
15
+ durationMs: args.duration,
16
+ screenshot: args.screenshot
17
+ });
18
+
19
+ console.log(JSON.stringify({
20
+ ok: true,
21
+ localUrl: state.localUrl,
22
+ publicUrl: state.publicUrl,
23
+ session: args.session || null,
24
+ outDir: result.outDir,
25
+ screenshot: result.screenshotPath
26
+ }, null, 2));
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import http from "node:http";
4
+ import { parseArgs } from "./hop-demo-lib.mjs";
5
+
6
+ const args = parseArgs(process.argv.slice(2));
7
+ const rootDir = path.resolve(args.root || process.cwd());
8
+ const port = Number(args.port || 43110);
9
+
10
+ const mimeTypes = {
11
+ ".html": "text/html; charset=utf-8",
12
+ ".css": "text/css; charset=utf-8",
13
+ ".js": "application/javascript; charset=utf-8",
14
+ ".json": "application/json; charset=utf-8",
15
+ ".svg": "image/svg+xml",
16
+ ".png": "image/png",
17
+ ".jpg": "image/jpeg",
18
+ ".jpeg": "image/jpeg",
19
+ ".webp": "image/webp"
20
+ };
21
+
22
+ const server = http.createServer((req, res) => {
23
+ const requestPath = req.url === "/" ? "/index.html" : String(req.url || "/index.html").split("?")[0];
24
+ const fullPath = path.resolve(path.join(rootDir, `.${requestPath}`));
25
+ if (!fullPath.startsWith(rootDir)) {
26
+ res.writeHead(403);
27
+ res.end("Forbidden");
28
+ return;
29
+ }
30
+ if (!fs.existsSync(fullPath) || fs.statSync(fullPath).isDirectory()) {
31
+ res.writeHead(404);
32
+ res.end("Not found");
33
+ return;
34
+ }
35
+ const ext = path.extname(fullPath).toLowerCase();
36
+ res.writeHead(200, {
37
+ "Content-Type": mimeTypes[ext] || "application/octet-stream",
38
+ "Cache-Control": "no-store"
39
+ });
40
+ fs.createReadStream(fullPath).pipe(res);
41
+ });
42
+
43
+ server.listen(port, "127.0.0.1", () => {
44
+ process.stdout.write(`listening ${port}\n`);
45
+ });