diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index c78f29c..bced236 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -53,4 +53,21 @@ describe("resolveConfig", () => { expect(config.deviceSecret).toBe("secret123"); expect(config.port).toBe(4010); }); + + it("reads previewPath from legacy spawndock.config.json", () => { + const dir = mkdtempSync(join(tmpdir(), "spawndock-config-")); + writeFileSync( + join(dir, "spawndock.config.json"), + JSON.stringify({ + controlPlaneUrl: "http://localhost:8787", + projectSlug: "my-app", + deviceSecret: "secret123", + localPort: 4010, + previewPath: "/preview/my-app", + }), + ); + + const config = resolveConfig([], dir); + expect(config.previewPath).toBe("/preview/my-app"); + }); }); diff --git a/src/__tests__/tunnel.test.ts b/src/__tests__/tunnel.test.ts new file mode 100644 index 0000000..da7a56f --- /dev/null +++ b/src/__tests__/tunnel.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { buildWsUrl } from "../tunnel.js"; + +describe("buildWsUrl", () => { + it("includes protocol version and token query params", () => { + const url = buildWsUrl({ + controlPlane: "https://api.example.com", + projectSlug: "demo", + deviceSecret: "secret123", + port: 3000, + }); + + expect(url).toBe( + "wss://api.example.com/tunnel/connect?protocolVersion=1&token=secret123", + ); + }); +}); diff --git a/src/config.ts b/src/config.ts index 8faaf36..239c571 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ export interface TunnelConfig { projectSlug: string; deviceSecret: string; port: number; + previewPath?: string; } const PRIMARY_CONFIG_FILE = "spawndock.dev-tunnel.json"; @@ -46,8 +47,10 @@ function normalizeConfig(data: unknown): Partial { : typeof record.localPort === "number" ? record.localPort : undefined; + const previewPath = + typeof record.previewPath === "string" ? record.previewPath : undefined; - return { controlPlane, projectSlug, deviceSecret, port }; + return { controlPlane, projectSlug, deviceSecret, port, previewPath }; } function readConfigFile(dir: string): Partial { @@ -134,10 +137,11 @@ export function resolveConfig( const projectSlug = args.projectSlug ?? env.projectSlug ?? file.projectSlug; const deviceSecret = args.deviceSecret ?? env.deviceSecret ?? file.deviceSecret; const port = args.port ?? env.port ?? file.port ?? 3000; + const previewPath = file.previewPath; if (!controlPlane) throw new Error("Missing --control-plane or SPAWNDOCK_CONTROL_PLANE"); if (!projectSlug) throw new Error("Missing --project-slug or SPAWNDOCK_PROJECT_SLUG"); if (!deviceSecret) throw new Error("Missing --device-secret or SPAWNDOCK_DEVICE_SECRET"); - return { controlPlane, projectSlug, deviceSecret, port }; + return { controlPlane, projectSlug, deviceSecret, port, previewPath }; } diff --git a/src/tunnel.ts b/src/tunnel.ts index b832243..6be73b2 100644 --- a/src/tunnel.ts +++ b/src/tunnel.ts @@ -10,6 +10,22 @@ export function createTunnel(config: TunnelConfig): void { connect(wsUrl, config, localOrigin); } +function resolveForwardedPath(previewPath: string | undefined, path: string): string { + if (!previewPath || previewPath.length === 0) { + return path; + } + + const normalizedPreviewPath = previewPath.replace(/\/$/, ""); + const [pathname, query = ""] = path.split("?"); + + const forwardedPath = + pathname === "/" + ? `${normalizedPreviewPath}/` + : `${normalizedPreviewPath}${pathname}`; + + return query.length > 0 ? `${forwardedPath}?${query}` : forwardedPath; +} + export function buildWsUrl(config: TunnelConfig): string { if (!URL.canParse(config.controlPlane)) { throw new Error(`Invalid control plane URL: ${config.controlPlane}`); @@ -64,7 +80,13 @@ function connect(wsUrl: string, config: TunnelConfig, localOrigin: string): void if (msg.type === "http-request") { try { - const response = await proxyRequest(msg.request, localOrigin); + const response = await proxyRequest( + { + ...msg.request, + path: resolveForwardedPath(config.previewPath, msg.request.path), + }, + localOrigin, + ); ws.send(serialize({ type: "http-response", response })); } catch (err: any) { ws.send(serialize({