Skip to content

Commit b30379c

Browse files
committed
fix: detect companion services with explicit ports
1 parent 47c9982 commit b30379c

5 files changed

Lines changed: 411 additions & 30 deletions

File tree

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,14 @@ lizardtail --vite-tailscale-port 8453 composer run dev
207207

208208
`lizardtail` also sets `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` for the child command when it can read your Tailscale MagicDNS name. The local proxy handles CORS for module scripts loaded from the Vite Tailscale URL.
209209

210-
If your app server lands on a known port and you only want to expose that server, you can force it:
210+
If your app server lands on a known port, pass it with `--port`. Lizard Tail treats that as the main app port, but still watches briefly for companion services such as Vite and exposes them when detected:
211211

212212
```bash
213213
lizardtail --port 8001 composer run dev
214214
```
215215

216+
If no companion service appears during the short settle window, it falls back to exposing only the explicit port.
217+
216218
### Public internet sharing
217219

218220
By default, URLs are only reachable from devices in your tailnet. To intentionally publish through Tailscale Funnel:
@@ -287,10 +289,11 @@ lizardtail --timeout 60000 pnpm dev
287289

288290
1. `lizardtail` starts the command you provide.
289291
2. It streams the command output to your terminal.
290-
3. It scans recent output for a local port.
291-
4. Once it finds a port, it waits for `127.0.0.1:<port>` or the configured `--host` to accept connections.
292-
5. It chooses the first free Tailscale HTTPS port from `8443` upward, unless `--tailscale-port` was provided. Ports in the configured blocked-port list are refused or skipped.
293-
6. It runs Tailscale Serve for private tailnet-only access:
292+
3. It scans recent output for local ports and known multi-service setups.
293+
4. With `--port`, it treats that port as the known main app port but still waits briefly for companion-service output before falling back to a single-port exposure.
294+
5. Once it chooses what to expose, it waits for `127.0.0.1:<port>` or the configured `--host` to accept connections.
295+
6. It chooses the first free Tailscale HTTPS port from `8443` upward, unless `--tailscale-port` was provided. Ports in the configured blocked-port list are refused or skipped.
296+
7. It runs Tailscale Serve for private tailnet-only access:
294297

295298
```bash
296299
tailscale serve --bg --https <tailscale-port> http://<host>:<port>
@@ -304,7 +307,7 @@ lizardtail --timeout 60000 pnpm dev
304307

305308
On older Tailscale versions, if that form fails for `127.0.0.1`/`localhost`, it falls back to the same command with just `<port>` as the target.
306309

307-
7. It reads `tailscale status --json`, extracts the current device's MagicDNS name, and prints:
310+
8. It reads `tailscale status --json`, extracts the current device's MagicDNS name, and prints:
308311

309312
```text
310313
https://<device-name>.<tailnet>.ts.net:<tailscale-port>
@@ -320,7 +323,9 @@ tailscale serve --https=<port> off
320323
tailscale funnel --https=<port> off
321324
```
322325

323-
It only tracks ports created by the current `lizardtail` process.
326+
For Laravel + Vite, it also closes the local Vite proxy and restores `public/hot` to the exact content seen before Lizard Tail rewrote it. If there was no previous `public/hot`, it removes the generated file. If another process changes the file after Lizard Tail writes it, Lizard Tail leaves that newer content in place and warns instead of clobbering it.
327+
328+
It only tracks ports and files created or modified by the current `lizardtail` process.
324329

325330
## Troubleshooting
326331

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "lizardtail",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "A Tailscale Serve wrapper around other commands that detects dev-server ports and prints usable URLs.",
55
"type": "module",
66
"bin": {

src/index.ts

Lines changed: 163 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import { spawn } from "node:child_process";
44
import { once } from "node:events";
55
import { existsSync, realpathSync } from "node:fs";
6-
import { readFile, writeFile } from "node:fs/promises";
6+
import { readFile, rm, writeFile } from "node:fs/promises";
77
import { createServer, request as httpRequest } from "node:http";
8+
import type { Server } from "node:http";
89
import net from "node:net";
910
import path from "node:path";
1011
import process from "node:process";
@@ -611,16 +612,78 @@ async function chooseTailscaleHttpsPort(excludedPort?: number, config = DEFAULT_
611612
throw new Error("could not find an available Tailscale HTTPS port");
612613
}
613614

614-
async function writeLaravelHotFile(viteUrl: string): Promise<string | undefined> {
615+
interface LaravelHotFileSnapshot {
616+
path: string;
617+
existed: boolean;
618+
previousContent?: string;
619+
}
620+
621+
interface LaravelHotFileState extends LaravelHotFileSnapshot {
622+
generatedContent: string;
623+
}
624+
625+
function looksLikeLaravelProject(cwd = process.cwd()): boolean {
626+
return existsSync(path.join(cwd, "artisan")) && existsSync(path.join(cwd, "public"));
627+
}
628+
629+
function resolveLaravelViteSetup(text: string, explicitAppPort?: number, cwd = process.cwd()): Required<LaravelViteDetection> | undefined {
630+
const detection = detectLaravelViteServers(text);
631+
const appPort = detection.appPort ?? (explicitAppPort && detection.vitePort && looksLikeLaravelProject(cwd) ? explicitAppPort : undefined);
632+
if (!appPort || !detection.vitePort) return undefined;
633+
634+
return {
635+
appPort,
636+
vitePort: detection.vitePort,
637+
viteHost: detection.viteHost ?? "localhost",
638+
};
639+
}
640+
641+
async function snapshotLaravelHotFile(): Promise<LaravelHotFileSnapshot | undefined> {
615642
const publicDir = path.join(process.cwd(), "public");
616643
if (!existsSync(publicDir)) return undefined;
617644

618645
const hotPath = path.join(publicDir, "hot");
619-
await writeFile(hotPath, viteUrl);
620-
return hotPath;
646+
try {
647+
return { path: hotPath, existed: true, previousContent: await readFile(hotPath, "utf8") };
648+
} catch (error) {
649+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
650+
return { path: hotPath, existed: false };
651+
}
652+
}
653+
654+
async function writeLaravelHotFile(viteUrl: string, snapshot?: LaravelHotFileSnapshot): Promise<LaravelHotFileState | undefined> {
655+
const state = snapshot ?? (await snapshotLaravelHotFile());
656+
if (!state) return undefined;
657+
658+
await writeFile(state.path, viteUrl);
659+
return { ...state, generatedContent: viteUrl };
660+
}
661+
662+
async function restoreLaravelHotFile(state: LaravelHotFileState): Promise<void> {
663+
let currentContent: string | undefined;
664+
try {
665+
currentContent = await readFile(state.path, "utf8");
666+
} catch (error) {
667+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
668+
throw error;
669+
}
670+
671+
if (currentContent !== state.generatedContent) {
672+
console.error(`lizardtail: leaving Laravel Vite hot file unchanged because it was modified after lizardtail wrote it: ${state.path}`);
673+
return;
674+
}
675+
676+
if (state.existed) await writeFile(state.path, state.previousContent ?? "");
677+
else await rm(state.path, { force: true });
678+
}
679+
680+
interface CorsProxy {
681+
host: string;
682+
port: number;
683+
server: Server;
621684
}
622685

623-
async function startCorsProxy(targetHost: string, targetPort: number): Promise<{ host: string; port: number }> {
686+
async function startCorsProxy(targetHost: string, targetPort: number): Promise<CorsProxy> {
624687
const server = createServer((incoming, response) => {
625688
if (incoming.method === "OPTIONS") {
626689
response.writeHead(204, corsHeaders());
@@ -656,7 +719,7 @@ async function startCorsProxy(targetHost: string, targetPort: number): Promise<{
656719
server.on("upgrade", (request, socket, head) => {
657720
const upstream = net.connect(targetPort, targetHost, () => {
658721
upstream.write(`${request.method ?? "GET"} ${request.url ?? "/"} HTTP/${request.httpVersion}\r\n`);
659-
for (const [name, value] of Object.entries(request.headers)) {
722+
for (const [name, value] of Object.entries({ ...request.headers, host: `${targetHost}:${targetPort}` })) {
660723
if (value === undefined) continue;
661724
upstream.write(`${name}: ${Array.isArray(value) ? value.join(",") : value}\r\n`);
662725
}
@@ -679,14 +742,15 @@ async function startCorsProxy(targetHost: string, targetPort: number): Promise<{
679742
const address = server.address();
680743
if (!address || typeof address === "string") throw new Error("failed to start Vite CORS proxy");
681744

682-
return { host: "127.0.0.1", port: address.port };
745+
return { host: "127.0.0.1", port: address.port, server };
683746
}
684747

685748
function corsHeaders(): Record<string, string> {
686749
return {
687750
"Access-Control-Allow-Origin": "*",
688751
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
689752
"Access-Control-Allow-Headers": "*",
753+
"Access-Control-Allow-Private-Network": "true",
690754
};
691755
}
692756

@@ -761,16 +825,22 @@ export async function main(): Promise<void> {
761825
: tailscaleDnsName;
762826
}
763827

828+
const initialLaravelHotFileSnapshot = await snapshotLaravelHotFile();
829+
764830
const child = spawn(command, args, {
765831
stdio: ["inherit", "pipe", "pipe"],
766832
env: childEnv,
833+
detached: process.platform !== "win32",
767834
});
768835

769836
let exposed = false;
770837
let exposing: Promise<void> | undefined;
771838
let recentOutput = "";
772839
let detectionTimer: NodeJS.Timeout | undefined;
840+
let explicitPortFallbackTimer: NodeJS.Timeout | undefined;
773841
const createdTailscalePorts = new Map<number, ExposureMode>();
842+
const localProxies: Server[] = [];
843+
let laravelHotFileState: LaravelHotFileState | undefined;
774844

775845
const cleanupTailscaleServe = async () => {
776846
for (const [port, mode] of createdTailscalePorts) {
@@ -784,12 +854,60 @@ export async function main(): Promise<void> {
784854
createdTailscalePorts.clear();
785855
};
786856

857+
const cleanupLaravelHotFile = async () => {
858+
if (!laravelHotFileState) return;
859+
860+
try {
861+
await restoreLaravelHotFile(laravelHotFileState);
862+
console.error(`lizardtail: restored Laravel Vite hot file: ${laravelHotFileState.path}`);
863+
} catch (error) {
864+
console.error(`lizardtail: failed to restore Laravel Vite hot file ${laravelHotFileState.path}: ${errorMessage(error)}`);
865+
} finally {
866+
laravelHotFileState = undefined;
867+
}
868+
};
869+
870+
const cleanupLocalProxies = async () => {
871+
await Promise.all(
872+
localProxies.splice(0).map(
873+
(server) =>
874+
new Promise<void>((resolve) => {
875+
server.close(() => resolve());
876+
}),
877+
),
878+
);
879+
};
880+
881+
const signalChild = (signal: NodeJS.Signals | "SIGKILL") => {
882+
if (child.exitCode !== null || child.signalCode !== null) return;
883+
884+
if (process.platform !== "win32" && child.pid) {
885+
try {
886+
process.kill(-child.pid, signal);
887+
return;
888+
} catch (error) {
889+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
890+
return;
891+
}
892+
}
893+
894+
if (!child.killed) child.kill(signal);
895+
};
896+
787897
const stopChild = () => {
788-
if (!child.killed) child.kill("SIGTERM");
898+
signalChild("SIGTERM");
899+
};
900+
901+
const clearDetectionTimers = () => {
902+
if (detectionTimer) clearTimeout(detectionTimer);
903+
if (explicitPortFallbackTimer) clearTimeout(explicitPortFallbackTimer);
904+
detectionTimer = undefined;
905+
explicitPortFallbackTimer = undefined;
789906
};
790907

791908
const expose = (port: number) => {
792909
if (exposed || exposing) return;
910+
clearDetectionTimers();
793911
exposed = true;
794912
exposing = (async () => {
795913
const localUrl = `http://${options.host}:${port}`;
@@ -810,6 +928,7 @@ export async function main(): Promise<void> {
810928

811929
const exposeLaravelVite = (appPort: number, vitePort: number, viteHost: string) => {
812930
if (exposed || exposing) return;
931+
clearDetectionTimers();
813932
exposed = true;
814933
exposing = (async () => {
815934
const appLocalUrl = `http://${options.host}:${appPort}`;
@@ -827,15 +946,16 @@ export async function main(): Promise<void> {
827946
const appExposure = await exposeWithTailscaleDetailed(options.host, appPort, options.tailscalePort, options.public, config);
828947
createdTailscalePorts.set(appExposure.httpsPort, appExposure.mode);
829948
const viteProxy = await startCorsProxy(viteHost, vitePort);
949+
localProxies.push(viteProxy.server);
830950
const viteTailscalePort = options.viteTailscalePort ?? (await chooseTailscaleHttpsPort(appExposure.httpsPort, config));
831951
const viteExposure = await exposeWithTailscaleDetailed(viteProxy.host, viteProxy.port, viteTailscalePort, options.public, config);
832952
createdTailscalePorts.set(viteExposure.httpsPort, viteExposure.mode);
833-
const hotPath = await writeLaravelHotFile(viteExposure.url);
953+
laravelHotFileState = await writeLaravelHotFile(viteExposure.url, initialLaravelHotFileSnapshot);
834954

835955
console.error(`lizardtail: serving Laravel via Tailscale: ${appExposure.url}`);
836956
console.error(`lizardtail: serving Vite assets via Tailscale: ${viteExposure.url}`);
837957
console.error(`lizardtail: proxying Vite through local CORS proxy: http://${viteProxy.host}:${viteProxy.port} -> ${viteLocalUrl}`);
838-
if (hotPath) console.error(`lizardtail: wrote Laravel Vite hot file: ${hotPath}`);
958+
if (laravelHotFileState) console.error(`lizardtail: wrote Laravel Vite hot file: ${laravelHotFileState.path}`);
839959
console.error(`lizardtail: cleanup command: tailscale ${appExposure.mode} --https=${appExposure.httpsPort} off`);
840960
console.error(`lizardtail: cleanup command: tailscale ${viteExposure.mode} --https=${viteExposure.httpsPort} off`);
841961
console.error("");
@@ -851,15 +971,25 @@ export async function main(): Promise<void> {
851971

852972
const inspectChunk = (chunk: string) => {
853973
recentOutput = (recentOutput + chunk).slice(-8_000);
974+
if (exposed || exposing) return;
975+
976+
const laravelVite = resolveLaravelViteSetup(recentOutput, options.port);
977+
if (laravelVite) {
978+
exposeLaravelVite(laravelVite.appPort, laravelVite.vitePort, laravelVite.viteHost);
979+
return;
980+
}
981+
982+
if (options.port) return;
983+
854984
const detectedPort = detectPortFromText(recentOutput);
855-
if (!detectedPort || exposed || exposing) return;
985+
if (!detectedPort) return;
856986

857987
if (detectionTimer) clearTimeout(detectionTimer);
858988
detectionTimer = setTimeout(() => {
859989
detectionTimer = undefined;
860-
const laravelVite = detectLaravelViteServers(recentOutput);
861-
if (laravelVite.appPort && laravelVite.vitePort) {
862-
exposeLaravelVite(laravelVite.appPort, laravelVite.vitePort, laravelVite.viteHost ?? "localhost");
990+
const settledLaravelVite = resolveLaravelViteSetup(recentOutput);
991+
if (settledLaravelVite) {
992+
exposeLaravelVite(settledLaravelVite.appPort, settledLaravelVite.vitePort, settledLaravelVite.viteHost);
863993
return;
864994
}
865995

@@ -870,12 +1000,12 @@ export async function main(): Promise<void> {
8701000

8711001
child.stdout.on("data", (chunk: string) => {
8721002
process.stdout.write(chunk);
873-
if (!options.port) inspectChunk(chunk);
1003+
inspectChunk(chunk);
8741004
});
8751005

8761006
child.stderr.on("data", (chunk: string) => {
8771007
process.stderr.write(chunk);
878-
if (!options.port) inspectChunk(chunk);
1008+
inspectChunk(chunk);
8791009
});
8801010

8811011
child.on("error", (error) => {
@@ -887,7 +1017,14 @@ export async function main(): Promise<void> {
8871017
process.exit(1);
8881018
});
8891019

890-
if (options.port) expose(options.port);
1020+
if (options.port) {
1021+
explicitPortFallbackTimer = setTimeout(() => {
1022+
explicitPortFallbackTimer = undefined;
1023+
const laravelVite = resolveLaravelViteSetup(recentOutput, options.port);
1024+
if (laravelVite) exposeLaravelVite(laravelVite.appPort, laravelVite.vitePort, laravelVite.viteHost);
1025+
else expose(options.port!);
1026+
}, Math.min(DETECTION_SETTLE_MS, options.timeoutMs));
1027+
}
8911028

8921029
const timeout = options.port
8931030
? undefined
@@ -899,16 +1036,23 @@ export async function main(): Promise<void> {
8991036
}
9001037
}, options.timeoutMs);
9011038

1039+
let forceKillTimer: NodeJS.Timeout | undefined;
9021040
for (const signal of ["SIGINT", "SIGTERM"] as const) {
9031041
process.once(signal, () => {
904-
child.kill(signal);
1042+
signalChild(signal);
1043+
forceKillTimer = setTimeout(() => {
1044+
signalChild("SIGKILL");
1045+
}, 5_000);
9051046
});
9061047
}
9071048

9081049
const [code, signal] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null];
1050+
if (forceKillTimer) clearTimeout(forceKillTimer);
9091051
if (timeout) clearTimeout(timeout);
910-
if (detectionTimer) clearTimeout(detectionTimer);
1052+
clearDetectionTimers();
9111053
if (exposing) await exposing;
1054+
await cleanupLaravelHotFile();
1055+
await cleanupLocalProxies();
9121056
await cleanupTailscaleServe();
9131057

9141058
if (signal) process.kill(process.pid, signal);

0 commit comments

Comments
 (0)