Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/cli/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe('startServer', () => {
const mkdirSpy = spyOn(fs, 'mkdirSync').mockImplementation(() => undefined);
const openSpy = spyOn(fs, 'openSync').mockReturnValue(99);
const closeSpy = spyOn(fs, 'closeSync').mockImplementation(() => {});
const fakeChild = { unref: mock(() => {}) };
const fakeChild = { unref: mock(() => {}), on: mock(() => {}) };
const spawnMock = mock(() => fakeChild);

globalThis.fetch = mock(
Expand Down Expand Up @@ -152,7 +152,7 @@ describe('startServer', () => {

test('spawns server and resolves when it becomes reachable', async () => {
const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
const fakeChild = { unref: mock(() => {}) };
const fakeChild = { unref: mock(() => {}), on: mock(() => {}) };
const spawnMock = mock(() => fakeChild);

let calls = 0;
Expand All @@ -162,25 +162,36 @@ describe('startServer', () => {
return new Response('[]', { status: 200 });
}) as unknown as typeof fetch;

const configModule = await import('../config');
const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({
...configModule.DEFAULT_CONFIG,
});

const { startServer } = await import('./http');
await startServer(10000, spawnMock as never);

expect(calls).toBeGreaterThanOrEqual(3);
expect(fakeChild.unref).toHaveBeenCalled();

existsSpy.mockRestore();
configSpy.mockRestore();
});

test('exits with error when server does not start within timeout', async () => {
const existsSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
const spawnMock = mock(() => ({ unref: () => {} }));
const spawnMock = mock(() => ({ unref: () => {}, on: () => {} }));
const exitSpy = spyOn(process, 'exit').mockImplementation((() => {}) as () => never);
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});

globalThis.fetch = mock(async () => {
throw new Error('not yet');
}) as unknown as typeof fetch;

const configModule = await import('../config');
const configSpy = spyOn(configModule, 'loadConfig').mockReturnValue({
...configModule.DEFAULT_CONFIG,
});

const { startServer } = await import('./http');
await startServer(100, spawnMock as never);

Expand All @@ -190,6 +201,7 @@ describe('startServer', () => {
existsSpy.mockRestore();
exitSpy.mockRestore();
errorSpy.mockRestore();
configSpy.mockRestore();
});
});

Expand Down
25 changes: 22 additions & 3 deletions src/cli/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,23 @@ export async function isServerRunning(): Promise<boolean> {
*/
export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn): Promise<void> {
const isBun = typeof (globalThis as Record<string, unknown>).Bun !== 'undefined';
const isTs = isBun && __filename.endsWith('.ts');

// On Windows, Bun's net.Socket doesn't support the fd-based named-pipe
// wrapping that node-pty's ConPTY backend requires, so node-pty fails under
// Bun on Windows. bunx works because its #!/usr/bin/env node shim runs the
// server with Node.js instead. Mirror that here explicitly.
const useNode = process.platform === 'win32' && isBun;
const serverExec = useNode ? 'node' : process.execPath;

// When the executor is Node.js it cannot run .ts files directly, so always
// resolve to the compiled .js entry regardless of whether the CLI itself is
// running from source.
const isTs = isBun && !useNode && __filename.endsWith('.ts');
const serverEntry = path.resolve(__dirname, isTs ? '../server/index.ts' : '../server/index.js');
if (!fs.existsSync(serverEntry)) {
console.error(`webtty: server entry not found at ${serverEntry}`);
process.exit(1);
(process.exit as (code?: number) => void)(1);
return;
}

const config = loadConfig();
Expand All @@ -56,11 +68,18 @@ export async function startServer(timeoutMs = 10000, _spawn = childProcess.spawn
stdio = ['ignore', logFd, logFd];
}

const child = _spawn(process.execPath, [serverEntry], {
const child = _spawn(serverExec, [serverEntry], {
detached: true,
stdio,
env: { ...process.env, PORT: String(PORT) },
});
child.on('error', (err) => {
const hint = useNode
? ' (Node.js must be on PATH when running webtty under Bun on Windows)'
: '';
console.error(`webtty: failed to start server: ${err.message}${hint}`);
process.exit(1);
});
child.unref();
if (logFd !== undefined) fs.closeSync(logFd);

Expand Down
4 changes: 2 additions & 2 deletions src/server/websocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,9 @@ describe('websocket', () => {
}>;
const session = sessions.find((s) => s.id === 'ws-test-pid-route');
expect(session).toBeDefined();
expect(typeof session!.pid).toBe('number');
expect(typeof session?.pid).toBe('number');

const res = await fetch(`${baseUrl}/p/${session!.pid}`, { redirect: 'manual' });
const res = await fetch(`${baseUrl}/p/${session?.pid}`, { redirect: 'manual' });
expect(res.status).toBe(302);
expect(res.headers.get('location')).toBe('/s/ws-test-pid-route');
});
Expand Down
Loading