Skip to content

Commit 8ff8009

Browse files
committed
fix: proxy Laravel Vite assets with CORS
1 parent e3f4561 commit 8ff8009

3 files changed

Lines changed: 180 additions & 20 deletions

File tree

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ 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.
30+
- Detects Laravel + Vite dev output, exposes both servers, rewrites Laravel's `public/hot` file to the Tailscale Vite URL, and proxies Vite assets with CORS headers so module scripts can load cross-origin.
31+
- Avoids overwriting an existing default Tailscale Serve mapping: if `https://<host>` is already serving another project, `lizardtail` chooses the first free `8443+` HTTPS port and prints that URL.
3132

3233
## Requirements
3334

@@ -136,13 +137,13 @@ lizardtail --port 3000 npm run dev
136137

137138
### MagicDNS URL with an explicit port
138139

139-
By default, Tailscale Serve uses HTTPS port 443, so the URL has no port:
140+
By default, Tailscale Serve uses HTTPS port 443, so the URL has no port when 443 is free or already points at the same local target:
140141

141142
```text
142143
https://my-host.tailabc.ts.net
143144
```
144145

145-
If you want a URL with a port, choose the Tailscale HTTPS port separately:
146+
If port 443 is already serving another project, `lizardtail` automatically chooses the first free `8443+` port so multiple projects can be served at the same time. You can also choose the Tailscale HTTPS port explicitly:
146147

147148
```bash
148149
lizardtail --tailscale-port 8450 pnpm dev
@@ -159,8 +160,9 @@ https://my-host.tailabc.ts.net:8450
159160
Laravel development commands often start both the PHP app server and the Vite asset server. When `lizardtail` sees both, it:
160161

161162
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.
163+
2. starts a small local proxy in front of Vite that adds CORS headers;
164+
3. exposes that Vite proxy on a separate Tailscale HTTPS port;
165+
4. writes `public/hot` to the Tailscale Vite URL so Laravel renders assets from the reachable Vite server.
164166

165167
```bash
166168
lizardtail composer run dev
@@ -172,7 +174,7 @@ You can choose the Vite Tailscale port explicitly:
172174
lizardtail --vite-tailscale-port 8453 composer run dev
173175
```
174176

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`.
177+
`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.
176178

177179
If your app server lands on a known port and you only want to expose that server, you can force it:
178180

src/index.ts

Lines changed: 125 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
44
import { once } from "node:events";
55
import { existsSync, realpathSync } from "node:fs";
66
import { writeFile } from "node:fs/promises";
7+
import { createServer, request as httpRequest } from "node:http";
78
import net from "node:net";
89
import path from "node:path";
910
import process from "node:process";
@@ -373,24 +374,60 @@ async function getTailscaleDnsName(): Promise<string | undefined> {
373374
return status.Self?.DNSName?.replace(/\.$/, "");
374375
}
375376

377+
interface TailscaleServeStatus {
378+
Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }>;
379+
}
380+
381+
async function getTailscaleServeStatus(): Promise<TailscaleServeStatus | undefined> {
382+
try {
383+
const { stdout } = await exec("tailscale", ["serve", "status", "--json"]);
384+
return JSON.parse(stdout) as TailscaleServeStatus;
385+
} catch {
386+
return undefined;
387+
}
388+
}
389+
390+
async function getTailscaleServeProxy(port: number): Promise<string | undefined> {
391+
const status = await getTailscaleServeStatus();
392+
if (!status?.Web) return undefined;
393+
394+
const suffix = `:${port}`;
395+
const webKey = Object.keys(status.Web).find((key) => key.endsWith(suffix));
396+
if (!webKey) return undefined;
397+
398+
return status.Web[webKey]?.Handlers?.["/"]?.Proxy;
399+
}
400+
401+
function normalizeProxyTarget(target: string): string {
402+
return target.replace("http://localhost:", "http://127.0.0.1:").replace(/\/$/, "");
403+
}
404+
405+
async function resolveTailscaleHttpsPort(target: string, requestedPort?: number): Promise<number | undefined> {
406+
if (requestedPort !== undefined) return requestedPort;
407+
408+
const defaultProxy = await getTailscaleServeProxy(443);
409+
if (!defaultProxy || normalizeProxyTarget(defaultProxy) === normalizeProxyTarget(target)) return undefined;
410+
411+
return chooseTailscaleHttpsPort();
412+
}
413+
376414
async function chooseTailscaleHttpsPort(excludedPort?: number): Promise<number> {
377415
const usedPorts = new Set<number>();
416+
const status = await getTailscaleServeStatus();
378417

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]);
418+
if (status?.Web) {
419+
for (const key of Object.keys(status.Web)) {
420+
const match = key.match(/:(\d{2,5})$/);
421+
const port = match?.[1] ? validDetectedPort(match[1]) : undefined;
383422
if (port) usedPorts.add(port);
384423
}
385-
} catch {
386-
// `tailscale serve status` is advisory. If it fails, still choose a reasonable default.
387424
}
388425

389426
for (let port = 8443; port <= 8999; port += 1) {
390427
if (port !== excludedPort && !usedPorts.has(port)) return port;
391428
}
392429

393-
throw new Error("could not find an available Tailscale HTTPS port for the Vite server");
430+
throw new Error("could not find an available Tailscale HTTPS port");
394431
}
395432

396433
async function writeLaravelHotFile(viteUrl: string): Promise<string | undefined> {
@@ -402,24 +439,96 @@ async function writeLaravelHotFile(viteUrl: string): Promise<string | undefined>
402439
return hotPath;
403440
}
404441

442+
async function startCorsProxy(targetHost: string, targetPort: number): Promise<{ host: string; port: number }> {
443+
const server = createServer((incoming, response) => {
444+
if (incoming.method === "OPTIONS") {
445+
response.writeHead(204, corsHeaders());
446+
response.end();
447+
return;
448+
}
449+
450+
const upstream = httpRequest(
451+
{
452+
host: targetHost,
453+
port: targetPort,
454+
method: incoming.method,
455+
path: incoming.url,
456+
headers: { ...incoming.headers, host: `${targetHost}:${targetPort}` },
457+
},
458+
(upstreamResponse) => {
459+
response.writeHead(upstreamResponse.statusCode ?? 502, {
460+
...upstreamResponse.headers,
461+
...corsHeaders(),
462+
});
463+
upstreamResponse.pipe(response);
464+
},
465+
);
466+
467+
upstream.on("error", (error) => {
468+
response.writeHead(502, corsHeaders());
469+
response.end(`lizardtail Vite proxy error: ${error.message}`);
470+
});
471+
472+
incoming.pipe(upstream);
473+
});
474+
475+
server.on("upgrade", (request, socket, head) => {
476+
const upstream = net.connect(targetPort, targetHost, () => {
477+
upstream.write(`${request.method ?? "GET"} ${request.url ?? "/"} HTTP/${request.httpVersion}\r\n`);
478+
for (const [name, value] of Object.entries(request.headers)) {
479+
if (value === undefined) continue;
480+
upstream.write(`${name}: ${Array.isArray(value) ? value.join(",") : value}\r\n`);
481+
}
482+
upstream.write(`\r\n`);
483+
if (head.length > 0) upstream.write(head);
484+
socket.pipe(upstream).pipe(socket);
485+
});
486+
487+
upstream.on("error", () => socket.destroy());
488+
});
489+
490+
await new Promise<void>((resolve, reject) => {
491+
server.once("error", reject);
492+
server.listen(0, "127.0.0.1", () => {
493+
server.off("error", reject);
494+
resolve();
495+
});
496+
});
497+
498+
const address = server.address();
499+
if (!address || typeof address === "string") throw new Error("failed to start Vite CORS proxy");
500+
501+
return { host: "127.0.0.1", port: address.port };
502+
}
503+
504+
function corsHeaders(): Record<string, string> {
505+
return {
506+
"Access-Control-Allow-Origin": "*",
507+
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
508+
"Access-Control-Allow-Headers": "*",
509+
};
510+
}
511+
405512
export async function exposeWithTailscale(host: string, port: number, tailscalePort?: number): Promise<string> {
406513
await exec("tailscale", ["status"]);
407514

408515
const target = `http://${host}:${port}`;
516+
const resolvedTailscalePort = await resolveTailscaleHttpsPort(target, tailscalePort);
517+
409518
try {
410-
await exec("tailscale", tailscaleServeCommand(target, tailscalePort));
519+
await exec("tailscale", tailscaleServeCommand(target, resolvedTailscalePort));
411520
} catch (firstError) {
412521
if (isTailscaleServePermissionError(firstError)) {
413-
throw new Error(tailscaleServePermissionHelp(target, tailscalePort));
522+
throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort));
414523
}
415524

416525
if (host !== "127.0.0.1" && host !== "localhost") throw firstError;
417526

418527
try {
419-
await exec("tailscale", tailscaleServeCommand(String(port), tailscalePort));
528+
await exec("tailscale", tailscaleServeCommand(String(port), resolvedTailscalePort));
420529
} catch (fallbackError) {
421530
if (isTailscaleServePermissionError(fallbackError)) {
422-
throw new Error(tailscaleServePermissionHelp(target, tailscalePort));
531+
throw new Error(tailscaleServePermissionHelp(target, resolvedTailscalePort));
423532
}
424533
throw fallbackError;
425534
}
@@ -429,10 +538,10 @@ export async function exposeWithTailscale(host: string, port: number, tailscaleP
429538
const status = JSON.parse(stdout) as { Self?: { DNSName?: string; TailscaleIPs?: string[] } };
430539
const dnsName = status.Self?.DNSName?.replace(/\.$/, "");
431540

432-
if (dnsName) return tailscaleUrl(dnsName, tailscalePort);
541+
if (dnsName) return tailscaleUrl(dnsName, resolvedTailscalePort);
433542

434543
const ip = status.Self?.TailscaleIPs?.find((value) => /^\d+\.\d+\.\d+\.\d+$/.test(value));
435-
if (ip) return tailscaleUrl(ip, tailscalePort);
544+
if (ip) return tailscaleUrl(ip, resolvedTailscalePort);
436545

437546
throw new Error("could not determine this device's Tailscale DNS name or IP");
438547
}
@@ -494,12 +603,14 @@ export async function main(): Promise<void> {
494603
}
495604

496605
const appUrl = await exposeWithTailscale(options.host, appPort, options.tailscalePort);
606+
const viteProxy = await startCorsProxy(viteHost, vitePort);
497607
const viteTailscalePort = options.viteTailscalePort ?? (await chooseTailscaleHttpsPort(options.tailscalePort));
498-
const viteUrl = await exposeWithTailscale(viteHost, vitePort, viteTailscalePort);
608+
const viteUrl = await exposeWithTailscale(viteProxy.host, viteProxy.port, viteTailscalePort);
499609
const hotPath = await writeLaravelHotFile(viteUrl);
500610

501611
console.error(`lizardtail: serving Laravel via Tailscale: ${appUrl}`);
502612
console.error(`lizardtail: serving Vite assets via Tailscale: ${viteUrl}`);
613+
console.error(`lizardtail: proxying Vite through local CORS proxy: http://${viteProxy.host}:${viteProxy.port} -> ${viteLocalUrl}`);
503614
if (hotPath) console.error(`lizardtail: wrote Laravel Vite hot file: ${hotPath}`);
504615
console.error("");
505616
})().catch((error: unknown) => {

tests/index.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,53 @@ exit 1
136136
}
137137
});
138138

139+
test("exposeWithTailscale auto-selects a port when default HTTPS already serves another target", async () => {
140+
const tempDir = await mkdtemp(path.join(tmpdir(), "lizardtail-test-"));
141+
const tailscalePath = path.join(tempDir, "tailscale");
142+
const tailscaleLog = path.join(tempDir, "tailscale.log");
143+
const originalPath = process.env.PATH;
144+
145+
await writeFile(
146+
tailscalePath,
147+
`#!/usr/bin/env bash
148+
printf '%s\n' "$*" >> "$TAILSCALE_LOG"
149+
if [ "$1" = "status" ] && [ "$2" = "--json" ]; then
150+
echo '{"Self":{"DNSName":"test-host.tailnet.ts.net."}}'
151+
exit 0
152+
fi
153+
if [ "$1" = "status" ]; then
154+
echo 'ok'
155+
exit 0
156+
fi
157+
if [ "$1" = "serve" ] && [ "$2" = "status" ] && [ "$3" = "--json" ]; then
158+
echo '{"Web":{"test-host.tailnet.ts.net:443":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:3001"}}},"test-host.tailnet.ts.net:8443":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:5173"}}}}}'
159+
exit 0
160+
fi
161+
if [ "$1" = "serve" ]; then
162+
echo 'serve ok'
163+
exit 0
164+
fi
165+
exit 1
166+
`,
167+
{ mode: 0o755 },
168+
);
169+
170+
try {
171+
process.env.PATH = `${tempDir}${path.delimiter}${originalPath ?? ""}`;
172+
process.env.TAILSCALE_LOG = tailscaleLog;
173+
174+
const url = await exposeWithTailscale("127.0.0.1", 8001);
175+
176+
assert.equal(url, "https://test-host.tailnet.ts.net:8444");
177+
const calls = await readFile(tailscaleLog, "utf8");
178+
assert.match(calls, /serve --bg --https 8444 http:\/\/127\.0\.0\.1:8001/);
179+
} finally {
180+
process.env.PATH = originalPath;
181+
delete process.env.TAILSCALE_LOG;
182+
await rm(tempDir, { recursive: true, force: true });
183+
}
184+
});
185+
139186
test("exposeWithTailscale explains how to fix Tailscale Serve permission errors", async () => {
140187
const tempDir = await mkdtemp(path.join(tmpdir(), "lizardtail-test-"));
141188
const tailscalePath = path.join(tempDir, "tailscale");

0 commit comments

Comments
 (0)