Skip to content

Commit e3f4561

Browse files
committed
feat: expose Laravel Vite asset server
1 parent afe8430 commit e3f4561

3 files changed

Lines changed: 169 additions & 13 deletions

File tree

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Use it when your dev server is running on a remote machine and you want to open
2727
- Prints the HTTPS MagicDNS URL for the current Tailscale device.
2828
- Supports an explicit `--port` when automatic detection is not possible.
2929
- Supports `--tailscale-port` when you want the MagicDNS URL to include a specific HTTPS port.
30+
- Detects Laravel + Vite dev output, exposes both servers, and rewrites Laravel's `public/hot` file to the Tailscale Vite URL.
3031

3132
## Requirements
3233

@@ -102,7 +103,8 @@ lizardtail -- npm run dev -- --host 0.0.0.0
102103
| `--port <port>` | auto-detect | Expose this port instead of reading one from command output. |
103104
| `--host <host>` | `127.0.0.1` | Local host to pass to Tailscale Serve. |
104105
| `--timeout <ms>` | `30000` | How long to wait for a port to appear in command output. |
105-
| `--tailscale-port <port>` | `443` | Expose on this Tailscale HTTPS port and print it in the MagicDNS URL. Alias: `--https-port`. |
106+
| `--tailscale-port <port>` | `443` | Expose the main app on this Tailscale HTTPS port and print it in the MagicDNS URL. Alias: `--https-port`. |
107+
| `--vite-tailscale-port <port>` | first free `8443+` | Expose a detected Laravel Vite asset server on this Tailscale HTTPS port. Alias: `--vite-https-port`. |
106108
| `--no-open-check` | enabled | Skip waiting for the local port to accept connections before calling Tailscale. |
107109
| `-h`, `--help` | | Show help. |
108110

@@ -154,19 +156,29 @@ https://my-host.tailabc.ts.net:8450
154156

155157
### Laravel / `composer run dev`
156158

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:
159+
Laravel development commands often start both the PHP app server and the Vite asset server. When `lizardtail` sees both, it:
160+
161+
1. exposes the Laravel app server;
162+
2. exposes the Vite asset server on a separate Tailscale HTTPS port;
163+
3. writes `public/hot` to the Tailscale Vite URL so Laravel renders assets from the reachable Vite server.
158164

159165
```bash
160166
lizardtail composer run dev
161167
```
162168

163-
If your app server lands on a known port, you can force it:
169+
You can choose the Vite Tailscale port explicitly:
164170

165171
```bash
166-
lizardtail --port 8001 composer run dev
172+
lizardtail --vite-tailscale-port 8453 composer run dev
167173
```
168174

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.
175+
`lizardtail` also sets `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` for the child command when it can read your Tailscale MagicDNS name. If browser assets still fail, your Vite config may also need CORS enabled, for example by running Vite with `--cors`.
176+
177+
If your app server lands on a known port and you only want to expose that server, you can force it:
178+
179+
```bash
180+
lizardtail --port 8001 composer run dev
181+
```
170182

171183
### Longer startup timeout
172184

src/index.ts

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import { spawn } from "node:child_process";
44
import { once } from "node:events";
5-
import { realpathSync } from "node:fs";
5+
import { existsSync, realpathSync } from "node:fs";
6+
import { writeFile } from "node:fs/promises";
67
import net from "node:net";
8+
import path from "node:path";
79
import process from "node:process";
810
import { fileURLToPath } from "node:url";
911

@@ -12,6 +14,7 @@ export interface Options {
1214
host: string;
1315
port?: number;
1416
tailscalePort?: number;
17+
viteTailscalePort?: number;
1518
timeoutMs: number;
1619
openCheck: boolean;
1720
}
@@ -28,7 +31,9 @@ Options:
2831
--host <host> Local host to expose. Default: 127.0.0.1
2932
--timeout <ms> Port-detection timeout. Default: ${DEFAULT_TIMEOUT_MS}
3033
--tailscale-port <port>
31-
Expose on this Tailscale HTTPS port instead of 443.
34+
Expose the main app on this Tailscale HTTPS port instead of 443.
35+
--vite-tailscale-port <port>
36+
Expose a detected Laravel Vite server on this Tailscale HTTPS port.
3237
--no-open-check Skip waiting for the local port to accept connections.
3338
-h, --help Show this help.
3439
@@ -111,6 +116,23 @@ export function parseArgs(argv: string[]): Options {
111116
continue;
112117
}
113118

119+
if (arg === "--vite-tailscale-port" || arg === "--vite-https-port") {
120+
const value = argv[++i];
121+
if (!value) usage();
122+
options.viteTailscalePort = parsePort(value);
123+
continue;
124+
}
125+
126+
if (arg.startsWith("--vite-tailscale-port=")) {
127+
options.viteTailscalePort = parsePort(arg.slice("--vite-tailscale-port=".length));
128+
continue;
129+
}
130+
131+
if (arg.startsWith("--vite-https-port=")) {
132+
options.viteTailscalePort = parsePort(arg.slice("--vite-https-port=".length));
133+
continue;
134+
}
135+
114136
if (arg === "--timeout") {
115137
const value = argv[++i];
116138
if (!value) usage();
@@ -222,6 +244,34 @@ function validDetectedPort(value: string): number | undefined {
222244
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : undefined;
223245
}
224246

247+
interface LaravelViteDetection {
248+
appPort?: number;
249+
vitePort?: number;
250+
viteHost?: string;
251+
}
252+
253+
export function detectLaravelViteServers(text: string): LaravelViteDetection {
254+
const clean = stripAnsi(text);
255+
const detection: LaravelViteDetection = {};
256+
257+
const appMatch = clean.match(/\[server\][^\n\r]*Server running on \[http:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\]|::1):(\d{1,5})\]/i);
258+
const appPort = appMatch?.[1] ? validDetectedPort(appMatch[1]) : undefined;
259+
if (appPort) detection.appPort = appPort;
260+
261+
const viteMatch = [...clean.matchAll(/\[vite\][^\n\r]*(?:Local:)\s*http:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1):(\d{1,5})\/?/gi)].at(-1);
262+
const vitePort = viteMatch?.[2] ? validDetectedPort(viteMatch[2]) : undefined;
263+
if (vitePort) {
264+
detection.vitePort = vitePort;
265+
detection.viteHost = normalizeLocalHost(viteMatch?.[1] ?? "localhost");
266+
}
267+
268+
return detection;
269+
}
270+
271+
function normalizeLocalHost(host: string): string {
272+
return host === "[::1]" || host === "::1" ? "localhost" : host;
273+
}
274+
225275
function errorMessage(error: unknown): string {
226276
return error instanceof Error ? error.message : String(error);
227277
}
@@ -256,10 +306,12 @@ Or expose this server manually with:
256306
sudo tailscale ${tailscaleServeCommand(target, tailscalePort).join(" ")}`;
257307
}
258308

259-
async function exec(command: string, args: string[], opts: { input?: string } = {}): Promise<{ stdout: string; stderr: string }> {
309+
async function exec(command: string, args: string[], opts: { input?: string; env?: NodeJS.ProcessEnv } = {}): Promise<{ stdout: string; stderr: string }> {
310+
const childEnv = opts.env ?? process.env;
311+
260312
const child = spawn(command, args, {
261313
stdio: [opts.input ? "pipe" : "ignore", "pipe", "pipe"],
262-
env: process.env,
314+
env: childEnv,
263315
});
264316

265317
let stdout = "";
@@ -315,6 +367,41 @@ export async function waitForOpenPort(host: string, port: number, timeoutMs: num
315367
throw new Error(`timed out waiting for ${host}:${port} to accept connections`);
316368
}
317369

370+
async function getTailscaleDnsName(): Promise<string | undefined> {
371+
const { stdout } = await exec("tailscale", ["status", "--json"]);
372+
const status = JSON.parse(stdout) as { Self?: { DNSName?: string } };
373+
return status.Self?.DNSName?.replace(/\.$/, "");
374+
}
375+
376+
async function chooseTailscaleHttpsPort(excludedPort?: number): Promise<number> {
377+
const usedPorts = new Set<number>();
378+
379+
try {
380+
const { stdout } = await exec("tailscale", ["serve", "status"]);
381+
for (const match of stdout.matchAll(/https:\/\/[^\s:]+:(\d{2,5})/g)) {
382+
const port = validDetectedPort(match[1]);
383+
if (port) usedPorts.add(port);
384+
}
385+
} catch {
386+
// `tailscale serve status` is advisory. If it fails, still choose a reasonable default.
387+
}
388+
389+
for (let port = 8443; port <= 8999; port += 1) {
390+
if (port !== excludedPort && !usedPorts.has(port)) return port;
391+
}
392+
393+
throw new Error("could not find an available Tailscale HTTPS port for the Vite server");
394+
}
395+
396+
async function writeLaravelHotFile(viteUrl: string): Promise<string | undefined> {
397+
const publicDir = path.join(process.cwd(), "public");
398+
if (!existsSync(publicDir)) return undefined;
399+
400+
const hotPath = path.join(publicDir, "hot");
401+
await writeFile(hotPath, viteUrl);
402+
return hotPath;
403+
}
404+
318405
export async function exposeWithTailscale(host: string, port: number, tailscalePort?: number): Promise<string> {
319406
await exec("tailscale", ["status"]);
320407

@@ -353,10 +440,18 @@ export async function exposeWithTailscale(host: string, port: number, tailscaleP
353440
export async function main(): Promise<void> {
354441
const options = parseArgs(process.argv.slice(2));
355442
const [command, ...args] = options.command;
443+
const tailscaleDnsName = await getTailscaleDnsName().catch(() => undefined);
444+
const childEnv: NodeJS.ProcessEnv = { ...process.env, FORCE_COLOR: process.env.FORCE_COLOR ?? "1" };
445+
446+
if (tailscaleDnsName) {
447+
childEnv.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS = childEnv.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS
448+
? `${childEnv.__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS},${tailscaleDnsName}`
449+
: tailscaleDnsName;
450+
}
356451

357452
const child = spawn(command, args, {
358453
stdio: ["inherit", "pipe", "pipe"],
359-
env: { ...process.env, FORCE_COLOR: process.env.FORCE_COLOR ?? "1" },
454+
env: childEnv,
360455
});
361456

362457
let exposed = false;
@@ -384,6 +479,36 @@ export async function main(): Promise<void> {
384479
});
385480
};
386481

482+
const exposeLaravelVite = (appPort: number, vitePort: number, viteHost: string) => {
483+
if (exposed || exposing) return;
484+
exposed = true;
485+
exposing = (async () => {
486+
const appLocalUrl = `http://${options.host}:${appPort}`;
487+
const viteLocalUrl = `http://${viteHost}:${vitePort}`;
488+
console.error(`\nlizardtail: detected Laravel app server on ${appLocalUrl}`);
489+
console.error(`lizardtail: detected Vite asset server on ${viteLocalUrl}`);
490+
491+
if (options.openCheck) {
492+
await waitForOpenPort(options.host, appPort, 10_000);
493+
await waitForOpenPort(viteHost, vitePort, 10_000);
494+
}
495+
496+
const appUrl = await exposeWithTailscale(options.host, appPort, options.tailscalePort);
497+
const viteTailscalePort = options.viteTailscalePort ?? (await chooseTailscaleHttpsPort(options.tailscalePort));
498+
const viteUrl = await exposeWithTailscale(viteHost, vitePort, viteTailscalePort);
499+
const hotPath = await writeLaravelHotFile(viteUrl);
500+
501+
console.error(`lizardtail: serving Laravel via Tailscale: ${appUrl}`);
502+
console.error(`lizardtail: serving Vite assets via Tailscale: ${viteUrl}`);
503+
if (hotPath) console.error(`lizardtail: wrote Laravel Vite hot file: ${hotPath}`);
504+
console.error("");
505+
})().catch((error: unknown) => {
506+
console.error(`lizardtail: failed to expose Laravel/Vite servers: ${errorMessage(error)}`);
507+
stopChild();
508+
process.exitCode = 1;
509+
});
510+
};
511+
387512
child.stdout.setEncoding("utf8");
388513
child.stderr.setEncoding("utf8");
389514

@@ -395,6 +520,12 @@ export async function main(): Promise<void> {
395520
if (detectionTimer) clearTimeout(detectionTimer);
396521
detectionTimer = setTimeout(() => {
397522
detectionTimer = undefined;
523+
const laravelVite = detectLaravelViteServers(recentOutput);
524+
if (laravelVite.appPort && laravelVite.vitePort) {
525+
exposeLaravelVite(laravelVite.appPort, laravelVite.vitePort, laravelVite.viteHost ?? "localhost");
526+
return;
527+
}
528+
398529
const settledPort = detectPortFromText(recentOutput);
399530
if (settledPort) expose(settledPort);
400531
}, DETECTION_SETTLE_MS);

tests/index.test.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import path from "node:path";
66
import { fileURLToPath } from "node:url";
77
import { test } from "node:test";
88

9-
import { DEFAULT_TIMEOUT_MS, detectPortFromText, exposeWithTailscale, parseArgs, stripAnsi } from "../src/index.ts";
9+
import { DEFAULT_TIMEOUT_MS, detectLaravelViteServers, detectPortFromText, exposeWithTailscale, parseArgs, stripAnsi } from "../src/index.ts";
1010

1111
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
1212
const cliPath = path.join(repoRoot, "dist", "index.js");
@@ -42,6 +42,18 @@ test("detectPortFromText prefers Laravel app server output over Vite output", ()
4242
assert.equal(detectPortFromText(output), 8001);
4343
});
4444

45+
test("detectLaravelViteServers finds both Laravel and Vite ports", () => {
46+
const output = `[vite] VITE v8.0.13 ready in 299 ms
47+
[vite] ➜ Local: http://localhost:5174/
48+
[server] INFO Server running on [http://127.0.0.1:8001].`;
49+
50+
assert.deepEqual(detectLaravelViteServers(output), {
51+
appPort: 8001,
52+
vitePort: 5174,
53+
viteHost: "localhost",
54+
});
55+
});
56+
4557
test("parseArgs parses options before the command", () => {
4658
assert.deepEqual(parseArgs(["--host", "localhost", "--port", "3000", "--timeout=5000", "--no-open-check", "pnpm", "dev"]), {
4759
command: ["pnpm", "dev"],
@@ -70,11 +82,12 @@ test("parseArgs uses documented defaults", () => {
7082
});
7183
});
7284

73-
test("parseArgs supports an explicit Tailscale HTTPS port", () => {
74-
assert.deepEqual(parseArgs(["--tailscale-port", "8450", "pnpm", "dev"]), {
85+
test("parseArgs supports explicit Tailscale HTTPS ports", () => {
86+
assert.deepEqual(parseArgs(["--tailscale-port", "8450", "--vite-tailscale-port", "8453", "pnpm", "dev"]), {
7587
command: ["pnpm", "dev"],
7688
host: "127.0.0.1",
7789
tailscalePort: 8450,
90+
viteTailscalePort: 8453,
7891
timeoutMs: DEFAULT_TIMEOUT_MS,
7992
openCheck: true,
8093
});

0 commit comments

Comments
 (0)