Skip to content

Commit afe8430

Browse files
committed
fix: avoid detecting build durations as ports
1 parent 454e181 commit afe8430

3 files changed

Lines changed: 93 additions & 10 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Use it when your dev server is running on a remote machine and you want to open
2020
- Runs any command you pass it, such as `pnpm dev`, `npm run dev`, `bun run dev`, or `python -m http.server`.
2121
- Streams the child command's stdout/stderr normally.
2222
- Detects common dev-server output formats, including `http://localhost:5173`, `http://127.0.0.1:3000`, `started server on 0.0.0.0:8080`, and `PORT=4321`.
23+
- Ignores timing output like `ready in 500 ms` so it does not accidentally expose port `500`.
24+
- Waits briefly after the first candidate port so multi-process commands, such as Laravel plus Vite, can print the better app-server URL.
2325
- Waits for the detected port to accept local connections before exposing it.
2426
- Runs `tailscale serve --bg http://127.0.0.1:<port>`.
2527
- Prints the HTTPS MagicDNS URL for the current Tailscale device.
@@ -150,6 +152,22 @@ That prints a URL like:
150152
https://my-host.tailabc.ts.net:8450
151153
```
152154

155+
### Laravel / `composer run dev`
156+
157+
Laravel development commands often start both the PHP app server and the Vite asset server. `lizardtail` prefers output from the app server when it can see both ports:
158+
159+
```bash
160+
lizardtail composer run dev
161+
```
162+
163+
If your app server lands on a known port, you can force it:
164+
165+
```bash
166+
lizardtail --port 8001 composer run dev
167+
```
168+
169+
If browser assets fail to load, the Vite server may also need to be exposed or your Laravel/Vite config may need to allow the Tailscale hostname.
170+
153171
### Longer startup timeout
154172

155173
```bash

src/index.ts

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface Options {
1717
}
1818

1919
export const DEFAULT_TIMEOUT_MS = 30_000;
20+
const DETECTION_SETTLE_MS = 1_500;
2021

2122
export function printUsage(): void {
2223
console.error(`Usage: lizardtail [options] -- <command> [args...]
@@ -158,19 +159,62 @@ export function stripAnsi(input: string): string {
158159
return input.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
159160
}
160161

162+
interface PortCandidate {
163+
port: number;
164+
score: number;
165+
}
166+
161167
export function detectPortFromText(text: string): number | undefined {
168+
const candidates = detectPortCandidates(text);
169+
candidates.sort((a, b) => b.score - a.score);
170+
return candidates[0]?.port;
171+
}
172+
173+
function detectPortCandidates(text: string): PortCandidate[] {
162174
const clean = stripAnsi(text);
175+
const candidates: PortCandidate[] = [];
176+
177+
for (const line of clean.split(/\r?\n/)) {
178+
candidates.push(...detectPortCandidatesFromLine(line));
179+
}
163180

164-
const localUrl = clean.match(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1)(?::(\d{1,5}))?/i);
165-
if (localUrl?.[1]) return validDetectedPort(localUrl[1]);
181+
return candidates;
182+
}
183+
184+
function detectPortCandidatesFromLine(line: string): PortCandidate[] {
185+
const candidates: PortCandidate[] = [];
186+
const lowerLine = line.toLowerCase();
187+
const lineLooksLikeDuration = /\b\d{1,5}\s*ms\b/i.test(line);
188+
const lineLooksLikeServer = /\b(server|listening|started|running)\b/i.test(line) || /\[server\]/i.test(line);
189+
const lineLooksLikeVite = /\[vite\]|\bvite\b/i.test(line);
190+
191+
const localUrlPattern = /https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{1,5})/gi;
192+
for (const match of line.matchAll(localUrlPattern)) {
193+
const port = validDetectedPort(match[2]);
194+
if (!port) continue;
195+
196+
let score = 70;
197+
if (lineLooksLikeServer) score += 30;
198+
if (lineLooksLikeVite) score -= 15;
199+
if (match[1] === "0.0.0.0") score -= 10;
200+
candidates.push({ port, score });
201+
}
166202

167-
const anyLocalUrl = clean.match(/(?:Local|localhost|loopback|listening|ready|server|started|running)[^\n\r]*?(?:on|at|:)?\s*(?:https?:\/\/)?(?:[^\s:]+:)?(\d{2,5})/i);
168-
if (anyLocalUrl?.[1]) return validDetectedPort(anyLocalUrl[1]);
203+
if (lineLooksLikeDuration) return candidates;
169204

170-
const portPhrase = clean.match(/\b(?:port|PORT)\s*(?:=|:|is|on)?\s*(\d{2,5})\b/);
171-
if (portPhrase?.[1]) return validDetectedPort(portPhrase[1]);
205+
const portPhrase = line.match(/\b(?:port|PORT)\s*(?:=|:|is|on)?\s*(\d{2,5})\b/);
206+
if (portPhrase?.[1]) {
207+
const port = validDetectedPort(portPhrase[1]);
208+
if (port) candidates.push({ port, score: lowerLine.includes("in use") ? 20 : 45 });
209+
}
210+
211+
const serverPort = line.match(/\b(?:listening|started|running|server)\b[^\n\r]*:(\d{2,5})\b/i);
212+
if (serverPort?.[1]) {
213+
const port = validDetectedPort(serverPort[1]);
214+
if (port) candidates.push({ port, score: lineLooksLikeServer ? 60 : 40 });
215+
}
172216

173-
return undefined;
217+
return candidates;
174218
}
175219

176220
function validDetectedPort(value: string): number | undefined {
@@ -318,6 +362,7 @@ export async function main(): Promise<void> {
318362
let exposed = false;
319363
let exposing: Promise<void> | undefined;
320364
let recentOutput = "";
365+
let detectionTimer: NodeJS.Timeout | undefined;
321366

322367
const stopChild = () => {
323368
if (!child.killed) child.kill("SIGTERM");
@@ -345,7 +390,14 @@ export async function main(): Promise<void> {
345390
const inspectChunk = (chunk: string) => {
346391
recentOutput = (recentOutput + chunk).slice(-8_000);
347392
const detectedPort = detectPortFromText(recentOutput);
348-
if (detectedPort) expose(detectedPort);
393+
if (!detectedPort || exposed || exposing) return;
394+
395+
if (detectionTimer) clearTimeout(detectionTimer);
396+
detectionTimer = setTimeout(() => {
397+
detectionTimer = undefined;
398+
const settledPort = detectPortFromText(recentOutput);
399+
if (settledPort) expose(settledPort);
400+
}, DETECTION_SETTLE_MS);
349401
};
350402

351403
child.stdout.on("data", (chunk: string) => {
@@ -383,6 +435,7 @@ export async function main(): Promise<void> {
383435

384436
const [code, signal] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null];
385437
if (timeout) clearTimeout(timeout);
438+
if (detectionTimer) clearTimeout(detectionTimer);
386439
if (exposing) await exposing;
387440

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

tests/index.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,22 @@ test("detectPortFromText strips ANSI escape sequences", () => {
2424
assert.equal(detectPortFromText(output), 24678);
2525
});
2626

27-
test("detectPortFromText ignores invalid and missing ports", () => {
27+
test("detectPortFromText ignores invalid ports, missing ports, and build durations", () => {
2828
assert.equal(detectPortFromText("Server ready"), undefined);
2929
assert.equal(detectPortFromText("Local: http://localhost"), undefined);
3030
assert.equal(detectPortFromText("port 70000"), undefined);
31+
assert.equal(detectPortFromText("VITE v8.0.13 ready in 500 ms"), undefined);
32+
});
33+
34+
// Laravel's `composer run dev` commonly runs Vite and `php artisan serve` together.
35+
// Prefer the app server URL over Vite's asset server when both appear in the recent output.
36+
// Also ensure Vite's "ready in 500 ms" timing line is not mistaken for port 500.
37+
test("detectPortFromText prefers Laravel app server output over Vite output", () => {
38+
const output = `[vite] VITE v8.0.13 ready in 500 ms
39+
[vite] ➜ Local: http://localhost:5174/
40+
[server] INFO Server running on [http://127.0.0.1:8001].`;
41+
42+
assert.equal(detectPortFromText(output), 8001);
3143
});
3244

3345
test("parseArgs parses options before the command", () => {
@@ -196,7 +208,7 @@ exit 1
196208
const server = http.createServer((req, res) => res.end("ok"));
197209
server.listen(0, "127.0.0.1", () => {
198210
console.log("Local: http://localhost:" + server.address().port);
199-
setTimeout(() => server.close(), 1200);
211+
setTimeout(() => server.close(), 2500);
200212
});`,
201213
],
202214
{

0 commit comments

Comments
 (0)