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
151 changes: 116 additions & 35 deletions tools/cli/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ export async function readServerInfo(): Promise<ServerInfo | null> {
// Legacy 3-line format: pid\nmode\nfilePath
const lines = content.split('\n');
if (lines.length >= 2 && !isNaN(Number(lines[0]))) {
return { version: 1, pid: Number(lines[0]), mode: lines[1] as any, port: PORT };
return {
version: 1,
pid: Number(lines[0]),
mode: lines[1] as any,
port: PORT,
};
}
// Corrupt — clean up
await fs.unlink(PID_FILE).catch(() => {});
Expand Down Expand Up @@ -64,35 +69,72 @@ async function validateAndResolve(filePath: string): Promise<string> {

// --- 2-state server detection ---

async function isServerRunning(): Promise<{ running: boolean; info: ServerInfo | null }> {
const info = await readServerInfo();
if (!info) return { running: false, info: null };

// Check if process is alive
async function probePort(): Promise<{
running: boolean;
info: ServerInfo | null;
}> {
try {
process.kill(info.pid, 0);
const res = await fetch(`http://localhost:${PORT}/api/health`, {
signal: AbortSignal.timeout(1000),
});
if (!res.ok) return { running: false, info: null };
const body = await res.json();
if (body.version !== 2) return { running: false, info: null };

// Recover PID file from health response
const recoveredInfo: ServerInfo = {
version: 2,
pid: body.pid,
mode: 'daemon',
port: PORT,
};
await writeServerInfo(recoveredInfo);
console.log(
`[vync] Discovered existing server (PID ${body.pid}), recovered PID file.`
);
return { running: true, info: recoveredInfo };
} catch {
await fs.unlink(PID_FILE).catch(() => {});
return { running: false, info: null };
}
}

// Health check
try {
const res = await fetch(`http://localhost:${info.port}/api/health`, {
signal: AbortSignal.timeout(1000),
});
if (res.ok) {
const body = await res.json();
if (body.version === 2) return { running: true, info };
// Old server -> stop it
await vyncStop();
return { running: false, info: null };
async function isServerRunning(): Promise<{
running: boolean;
info: ServerInfo | null;
}> {
const info = await readServerInfo();

if (info) {
// Check if process is alive
try {
process.kill(info.pid, 0);
} catch {
await fs.unlink(PID_FILE).catch(() => {});
// Fall through to port probe
return probePort();
}
} catch {}

// PID alive but HTTP dead -> stale
await fs.unlink(PID_FILE).catch(() => {});
return { running: false, info: null };
// Health check
try {
const res = await fetch(`http://localhost:${info.port}/api/health`, {
signal: AbortSignal.timeout(1000),
});
if (res.ok) {
const body = await res.json();
if (body.version === 2) return { running: true, info };
// Old server -> stop it
await vyncStop();
return { running: false, info: null };
}
} catch {}

// PID alive but HTTP dead -> stale
await fs.unlink(PID_FILE).catch(() => {});
return { running: false, info: null };
}

// No PID file — probe port as fallback
return probePort();
}

// --- Helpers ---
Expand All @@ -105,13 +147,20 @@ async function registerFile(port: number, filePath: string): Promise<void> {
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`[vync] Registration failed: ${(body as any).error || res.statusText}`);
throw new Error(
`[vync] Registration failed: ${(body as any).error || res.statusText}`
);
}
}

async function openBrowserWithFile(port: number, filePath: string): Promise<void> {
async function openBrowserWithFile(
port: number,
filePath: string
): Promise<void> {
const openModule = await import('open');
await openModule.default(`http://localhost:${port}/?file=${encodeURIComponent(filePath)}`);
await openModule.default(
`http://localhost:${port}/?file=${encodeURIComponent(filePath)}`
);
}

// --- Startup helpers ---
Expand Down Expand Up @@ -144,7 +193,10 @@ async function runForeground(resolved: string): Promise<void> {
});

const { startServer } = await import('../server/server.js');
const { shutdown } = await startServer({ initialFile: resolved, openBrowser: true });
const { shutdown } = await startServer({
initialFile: resolved,
openBrowser: true,
});

const cleanup = async () => {
await shutdown();
Expand Down Expand Up @@ -182,7 +234,12 @@ async function runElectron(resolved: string): Promise<void> {
process.exit(1);
}

await writeServerInfo({ version: 2, pid: childPid, mode: 'electron', port: PORT });
await writeServerInfo({
version: 2,
pid: childPid,
mode: 'electron',
port: PORT,
});
child.unref();
fsSync.closeSync(logFd);

Expand All @@ -203,7 +260,24 @@ async function runElectron(resolved: string): Promise<void> {
try {
const res = await fetch(`${url}/api/health`);
if (res.ok) {
console.log(`[vync] Vync app running (PID ${childPid})`);
const body = await res.json();
// Verify this is our server, not a ghost
if (body.pid !== childPid) {
// Ghost server detected — reuse it instead
console.log(
`[vync] Existing server found (PID ${body.pid}), reusing.`
);
await writeServerInfo({
version: 2,
pid: body.pid,
mode: 'daemon',
port: PORT,
});
} else {
console.log(`[vync] Vync app running (PID ${childPid})`);
}
// Register file (same as runDaemon)
await registerFile(PORT, resolved);
console.log(`[vync] Log: ${LOG_FILE}`);
return;
}
Expand Down Expand Up @@ -256,7 +330,12 @@ async function runDaemon(resolved: string): Promise<void> {
}

// Save child PID (not this process's PID)
await writeServerInfo({ version: 2, pid: childPid, mode: 'daemon', port: PORT });
await writeServerInfo({
version: 2,
pid: childPid,
mode: 'daemon',
port: PORT,
});
child.unref();
fsSync.closeSync(logFd);

Expand Down Expand Up @@ -349,7 +428,9 @@ export async function vyncClose(
const resolved = resolveVyncPath(filePath);
try {
const res = await fetch(
`http://localhost:${info.port}/api/files?file=${encodeURIComponent(resolved)}`,
`http://localhost:${info.port}/api/files?file=${encodeURIComponent(
resolved
)}`,
{ method: 'DELETE' }
);
if (res.ok) console.log(`[vync] Closed: ${resolved}`);
Expand All @@ -359,7 +440,9 @@ export async function vyncClose(
}
} else {
try {
await fetch(`http://localhost:${info.port}/api/files?all=true`, { method: 'DELETE' });
await fetch(`http://localhost:${info.port}/api/files?all=true`, {
method: 'DELETE',
});
console.log('[vync] All files closed.');
} catch {
console.error('[vync] Server not reachable.');
Expand Down Expand Up @@ -449,9 +532,7 @@ export async function vyncStop(): Promise<void> {
}

if (!portFree) {
console.error(
`[vync] Warning: port ${port} still in use after stop.`
);
console.error(`[vync] Warning: port ${port} still in use after stop.`);
}
console.log(`[vync] Server stopped (PID ${info.pid})`);
}
51 changes: 48 additions & 3 deletions tools/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,54 @@ async function openFile(filePath: string): Promise<void> {
staticDir,
});
} catch (err: any) {
dialog.showErrorBox('Vync Error', err.message);
app.quit();
return;
// EADDRINUSE: try connecting to existing server
if (err.message.includes('already in use')) {
const existingUrl = 'http://localhost:3100';
try {
const res = await fetch(`${existingUrl}/api/health`, {
signal: AbortSignal.timeout(2000),
});
if (res.ok) {
const body = await res.json();
if (body.version === 2) {
// Reuse existing server (no shutdown responsibility)
serverHandle = { shutdown: async () => {}, url: existingUrl };
console.log(`[vync] Reusing existing server (PID ${body.pid})`);
// Register the file with existing server
await fetch(`${existingUrl}/api/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filePath: resolved }),
});
} else {
dialog.showErrorBox(
'Vync Error',
'Incompatible server on port 3100'
);
app.quit();
return;
}
} else {
dialog.showErrorBox(
'Vync Error',
'Port 3100 in use by non-Vync process'
);
app.quit();
return;
}
} catch {
dialog.showErrorBox(
'Vync Error',
'Port 3100 in use but server not responding'
);
app.quit();
return;
}
} else {
dialog.showErrorBox('Vync Error', err.message);
app.quit();
return;
}
}
}

Expand Down
41 changes: 33 additions & 8 deletions tools/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import path from 'node:path';
import express from 'express';
import { createWsServer } from './ws-handler.js';
import { FileRegistry } from './file-registry.js';
import { addAllowedDir, createHostGuard, validateFilePath } from './security.js';
import {
addAllowedDir,
createHostGuard,
validateFilePath,
} from './security.js';
import type { VyncFile } from '@vync/shared';

const DEFAULT_PORT = 3100;
Expand Down Expand Up @@ -55,7 +59,12 @@ export async function startServer(

// --- Health endpoint ---
app.get('/api/health', (_req, res) => {
res.json({ version: 2, mode: 'hub', fileCount: registry.listFiles().length });
res.json({
version: 2,
mode: 'hub',
pid: process.pid,
fileCount: registry.listFiles().length,
});
});

// --- File registration API ---
Expand All @@ -79,7 +88,10 @@ export async function startServer(
status: alreadyRegistered ? 'already_registered' : 'registered',
});
} catch (err: any) {
if (err.message.includes('outside allowed') || err.message.includes('Only .vync')) {
if (
err.message.includes('outside allowed') ||
err.message.includes('Only .vync')
) {
res.status(403).json({ error: err.message });
} else if (err.message.includes('Maximum')) {
res.status(429).json({ error: err.message });
Expand Down Expand Up @@ -116,7 +128,9 @@ export async function startServer(
app.get('/api/sync', async (req, res) => {
const filePath = req.query.file as string;
if (!filePath) {
res.status(400).json({ error: 'file_required', files: registry.listFiles() });
res
.status(400)
.json({ error: 'file_required', files: registry.listFiles() });
return;
}
const sync = registry.getSync(filePath);
Expand Down Expand Up @@ -194,7 +208,10 @@ export async function startServer(
if (vite) await vite.close();
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 3000);
server.close(() => { clearTimeout(timer); resolve(); });
server.close(() => {
clearTimeout(timer);
resolve();
});
});
};

Expand All @@ -221,7 +238,9 @@ export async function startServer(

if (options.openBrowser && options.initialFile) {
const openModule = await import('open');
await openModule.default(`${url}/?file=${encodeURIComponent(options.initialFile)}`);
await openModule.default(
`${url}/?file=${encodeURIComponent(options.initialFile)}`
);
}

return { shutdown, server, url, registry };
Expand All @@ -239,8 +258,14 @@ if (isDirectRun) {

startServer({ initialFile: resolvedFile })
.then(({ shutdown }) => {
process.on('SIGINT', async () => { await shutdown(); process.exit(0); });
process.on('SIGTERM', async () => { await shutdown(); process.exit(0); });
process.on('SIGINT', async () => {
await shutdown();
process.exit(0);
});
process.on('SIGTERM', async () => {
await shutdown();
process.exit(0);
});
})
.catch((err) => {
console.error('[vync] Fatal error:', err.message);
Expand Down
Loading