Skip to content

Commit a38a87a

Browse files
committed
Add htty-server-host for testing.
1 parent b66607b commit a38a87a

2 files changed

Lines changed: 319 additions & 0 deletions

File tree

bin/htty-server-host

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
#!/usr/bin/env node
2+
3+
import pty from "node-pty";
4+
import {SESSION_STATUS} from "@socketry/htty";
5+
import {Session} from "@socketry/htty/Session";
6+
7+
const DEFAULT_TIMEOUT_MS = 10000;
8+
const DEFAULT_PATH = "/";
9+
const MAX_OUTPUT_CAPTURE = 8192;
10+
11+
function usage() {
12+
console.error("Usage: htty-server-host [--request-path <path>] [--header <name:value>] [--timeout <ms>] [--print-headers] -- <command> [args...]");
13+
console.error("");
14+
console.error("Examples:");
15+
console.error(" htty-server-host -- ./bin/htty-media-player sample.mp4");
16+
console.error(" htty-server-host --request-path / --timeout 15000 -- ruby my_server.rb");
17+
console.error(" htty-server-host --request-path /media --header range:bytes=0-0 -- ./bin/htty-media-player sample.mp3");
18+
console.error(" htty-server-host --print-headers -- ./bin/htty-media-player sample.mp3");
19+
}
20+
21+
function parseArgs(argv) {
22+
let path = DEFAULT_PATH;
23+
let timeoutMs = DEFAULT_TIMEOUT_MS;
24+
let printHeaders = false;
25+
const requestHeaders = {};
26+
const commandParts = [];
27+
28+
for (let index = 0; index < argv.length; index += 1) {
29+
const value = argv[index];
30+
31+
if (value === "--") {
32+
commandParts.push(...argv.slice(index + 1));
33+
break;
34+
}
35+
36+
if (value === "--path" || value === "--request-path") {
37+
path = argv[index + 1] || DEFAULT_PATH;
38+
index += 1;
39+
continue;
40+
}
41+
42+
if (value === "--header") {
43+
const header = argv[index + 1] || "";
44+
const separator = header.indexOf(":");
45+
if (separator <= 0) {
46+
throw new Error(`Invalid header format: ${header}. Expected name:value`);
47+
}
48+
49+
const name = header.slice(0, separator).trim().toLowerCase();
50+
const headerValue = header.slice(separator + 1).trim();
51+
if (!name) {
52+
throw new Error(`Invalid header name in: ${header}`);
53+
}
54+
55+
requestHeaders[name] = headerValue;
56+
index += 1;
57+
continue;
58+
}
59+
60+
if (value === "--timeout") {
61+
timeoutMs = Number(argv[index + 1]);
62+
index += 1;
63+
continue;
64+
}
65+
66+
if (value === "--print-headers") {
67+
printHeaders = true;
68+
continue;
69+
}
70+
71+
if (value === "-h" || value === "--help") {
72+
return {help: true};
73+
}
74+
75+
commandParts.push(...argv.slice(index));
76+
break;
77+
}
78+
79+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
80+
throw new Error(`Invalid timeout: ${timeoutMs}`);
81+
}
82+
83+
if (commandParts.length === 0) {
84+
throw new Error("No command provided.");
85+
}
86+
87+
const [command, ...args] = commandParts;
88+
return {command, args, path, timeoutMs, printHeaders, requestHeaders, help: false};
89+
}
90+
91+
function withTimeout(promise, timeoutMs, label) {
92+
let timer = null;
93+
const timeout = new Promise((_, reject) => {
94+
timer = setTimeout(() => {
95+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
96+
}, timeoutMs);
97+
});
98+
99+
return Promise.race([promise, timeout]).finally(() => {
100+
if (timer) {
101+
clearTimeout(timer);
102+
}
103+
});
104+
}
105+
106+
function waitForEvent(emitter, eventName, timeoutMs, filter = null) {
107+
return withTimeout(new Promise((resolve) => {
108+
const handler = (...args) => {
109+
if (filter && !filter(...args)) {
110+
return;
111+
}
112+
emitter.off(eventName, handler);
113+
resolve(args);
114+
};
115+
emitter.on(eventName, handler);
116+
}), timeoutMs, `Waiting for ${eventName}`);
117+
}
118+
119+
function waitForStreamCompletion(stream, timeoutMs) {
120+
return withTimeout(new Promise((resolve, reject) => {
121+
let settled = false;
122+
123+
const finish = (error = null) => {
124+
if (settled) {
125+
return;
126+
}
127+
128+
settled = true;
129+
stream.off?.("data", onData);
130+
stream.off?.("end", onEnd);
131+
stream.off?.("close", onClose);
132+
stream.off?.("error", onError);
133+
134+
if (error) {
135+
reject(error);
136+
} else {
137+
resolve();
138+
}
139+
};
140+
141+
const onData = () => {
142+
// Drain and discard; protocol validation only needs successful framing.
143+
};
144+
145+
const onEnd = () => finish();
146+
const onClose = () => finish();
147+
const onError = (error) => finish(error);
148+
149+
stream.on?.("data", onData);
150+
stream.once?.("end", onEnd);
151+
stream.once?.("close", onClose);
152+
stream.once?.("error", onError);
153+
stream.resume?.();
154+
}), timeoutMs, "Response stream completion");
155+
}
156+
157+
function trimCapturedOutput(text) {
158+
if (!text) {
159+
return "";
160+
}
161+
162+
if (text.length <= MAX_OUTPUT_CAPTURE) {
163+
return text;
164+
}
165+
166+
return text.slice(text.length - MAX_OUTPUT_CAPTURE);
167+
}
168+
169+
async function main() {
170+
const options = parseArgs(process.argv.slice(2));
171+
if (options.help) {
172+
usage();
173+
return;
174+
}
175+
176+
const child = pty.spawn(options.command, options.args, {
177+
name: "xterm-256color",
178+
cols: 120,
179+
rows: 40,
180+
cwd: process.cwd(),
181+
encoding: null,
182+
env: {
183+
...process.env,
184+
HTTY: "1",
185+
},
186+
});
187+
188+
let capturedOutput = "";
189+
const processHandle = {
190+
onData(callback) {
191+
return child.onData(callback);
192+
},
193+
onExit(callback) {
194+
return child.onExit(callback);
195+
},
196+
write(data) {
197+
try {
198+
child.write(data);
199+
return true;
200+
} catch (error) {
201+
if (error?.code === "EIO") {
202+
return false;
203+
}
204+
205+
throw error;
206+
}
207+
},
208+
resize(cols, rows) {
209+
child.resize(cols, rows);
210+
},
211+
kill() {
212+
child.kill();
213+
},
214+
};
215+
216+
const session = new Session(processHandle, {
217+
id: "htty-server-host",
218+
command: options.command,
219+
args: options.args,
220+
cwd: process.cwd(),
221+
title: "htty-server-host",
222+
defaultTitle: "htty-server-host",
223+
usePty: true,
224+
showTerminalTab: false,
225+
});
226+
227+
session.on("terminal-data", (chunk) => {
228+
capturedOutput += chunk;
229+
capturedOutput = trimCapturedOutput(capturedOutput);
230+
});
231+
232+
session.on("state", (state) => {
233+
if (state.status === SESSION_STATUS.ERROR) {
234+
console.error("[htty-server-host] session error", state);
235+
}
236+
});
237+
238+
let attached = false;
239+
let status = null;
240+
241+
try {
242+
await waitForEvent(
243+
session,
244+
"state",
245+
options.timeoutMs,
246+
(state) => state.status === SESSION_STATUS.ATTACHED && state.phase === "ready",
247+
);
248+
attached = true;
249+
250+
const client = session.client;
251+
if (!client) {
252+
throw new Error("HTTY session attached but client is unavailable.");
253+
}
254+
255+
const response = await withTimeout(
256+
client.request({path: options.path, method: "GET", headers: options.requestHeaders}),
257+
options.timeoutMs,
258+
"HTTY request",
259+
);
260+
status = response.status;
261+
262+
if (status < 200 || status >= 400) {
263+
throw new Error(`HTTY request failed with status ${status}`);
264+
}
265+
266+
if (options.printHeaders) {
267+
console.log("[htty-server-host] response headers:");
268+
for (const [name, value] of Object.entries(response.headers || {})) {
269+
console.log(` ${name}: ${value}`);
270+
}
271+
}
272+
273+
await waitForStreamCompletion(response.stream, options.timeoutMs);
274+
275+
console.log(`[htty-server-host] HTTY validated: status=${status} path=${options.path}`);
276+
277+
if (session.isHttyActive()) {
278+
session.sendInterrupt();
279+
280+
try {
281+
await withTimeout(Promise.race([
282+
waitForEvent(session, "reset", options.timeoutMs),
283+
waitForEvent(session, "exit", options.timeoutMs),
284+
]), options.timeoutMs, "HTTY teardown");
285+
} catch (error) {
286+
console.error(`[htty-server-host] teardown warning: ${error.message}`);
287+
}
288+
}
289+
290+
process.exitCode = 0;
291+
} catch (error) {
292+
const snippet = trimCapturedOutput(capturedOutput);
293+
if (snippet) {
294+
console.error("[htty-server-host] terminal output (tail):");
295+
console.error(snippet);
296+
}
297+
298+
throw error;
299+
} finally {
300+
session.close();
301+
}
302+
303+
if (!attached) {
304+
throw new Error("HTTY did not attach.");
305+
}
306+
307+
if (status == null) {
308+
throw new Error("HTTY request did not complete.");
309+
}
310+
}
311+
312+
main().catch((error) => {
313+
console.error(`[htty-server-host] FAIL: ${error.message}`);
314+
if (error.stack) {
315+
console.error(error.stack);
316+
}
317+
process.exit(1);
318+
});

releases.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Fix size of update window.
66
- Fix handling of HTTY commmands during shutdown.
7+
- Add internal `htty-server-host` command for testing.
78

89
## v0.4.2
910

0 commit comments

Comments
 (0)