Skip to content

Commit 7273355

Browse files
hugocasaclaude
andcommitted
feat: redirect /<prefix> across symmetric webmux instances
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8173814 commit 7273355

15 files changed

Lines changed: 655 additions & 9 deletions

File tree

backend/src/__tests__/domain-policies.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, it } from "bun:test";
2-
import { allocateServicePorts } from "../domain/policies";
2+
import {
3+
allocateServicePorts,
4+
deriveInstancePrefix,
5+
isValidInstancePrefix,
6+
sanitizeInstancePrefix,
7+
} from "../domain/policies";
38

49
describe("allocateServicePorts", () => {
510
it("allocates the first free slot across existing worktree metadata", () => {
@@ -40,3 +45,53 @@ describe("allocateServicePorts", () => {
4045
});
4146
});
4247
});
48+
49+
describe("sanitizeInstancePrefix", () => {
50+
it("lowercases and replaces non-alphanumerics with hyphens", () => {
51+
expect(sanitizeInstancePrefix("My Project")).toBe("my-project");
52+
expect(sanitizeInstancePrefix("Some_Repo.v2")).toBe("some-repo-v2");
53+
});
54+
55+
it("collapses runs of hyphens and trims edges", () => {
56+
expect(sanitizeInstancePrefix("--__foo bar__--")).toBe("foo-bar");
57+
});
58+
59+
it("returns an empty string when nothing usable remains", () => {
60+
expect(sanitizeInstancePrefix("***")).toBe("");
61+
});
62+
});
63+
64+
describe("isValidInstancePrefix", () => {
65+
it("accepts lowercase alphanumeric and hyphens", () => {
66+
expect(isValidInstancePrefix("webmux")).toBe(true);
67+
expect(isValidInstancePrefix("webmux-2")).toBe(true);
68+
expect(isValidInstancePrefix("ab12-cd")).toBe(true);
69+
});
70+
71+
it("rejects uppercase, leading hyphen, or invalid chars", () => {
72+
expect(isValidInstancePrefix("Webmux")).toBe(false);
73+
expect(isValidInstancePrefix("-bad")).toBe(false);
74+
expect(isValidInstancePrefix("has space")).toBe(false);
75+
expect(isValidInstancePrefix("")).toBe(false);
76+
});
77+
});
78+
79+
describe("deriveInstancePrefix", () => {
80+
it("returns the basename when no collision", () => {
81+
expect(deriveInstancePrefix("/home/me/projects/webmux", [])).toBe("webmux");
82+
expect(deriveInstancePrefix("/srv/widgets/", [])).toBe("widgets");
83+
});
84+
85+
it("falls back to a default when the basename has no alphanumerics", () => {
86+
expect(deriveInstancePrefix("/repo/...", [])).toBe("webmux");
87+
});
88+
89+
it("appends -2, -3, ... to avoid collisions", () => {
90+
expect(deriveInstancePrefix("/a/webmux", ["webmux"])).toBe("webmux-2");
91+
expect(deriveInstancePrefix("/a/webmux", ["webmux", "webmux-2"])).toBe("webmux-3");
92+
});
93+
94+
it("sanitizes weird basenames", () => {
95+
expect(deriveInstancePrefix("/projects/My Cool App!", [])).toBe("my-cool-app");
96+
});
97+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { writeFileSync } from "node:fs";
6+
import { createInstanceRegistry, type InstanceEntry } from "../adapters/instance-registry";
7+
8+
describe("instance-registry", () => {
9+
const tempDirs: string[] = [];
10+
11+
afterEach(async () => {
12+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
13+
});
14+
15+
async function freshRegistry(): Promise<{ dir: string; registry: ReturnType<typeof createInstanceRegistry> }> {
16+
const dir = await mkdtemp(join(tmpdir(), "webmux-instance-registry-"));
17+
tempDirs.push(dir);
18+
return { dir, registry: createInstanceRegistry(dir) };
19+
}
20+
21+
function makeEntry(overrides: Partial<InstanceEntry> = {}): InstanceEntry {
22+
return {
23+
prefix: "demo",
24+
port: 5111,
25+
projectDir: "/repo/demo",
26+
pid: process.pid,
27+
startedAt: Date.now(),
28+
...overrides,
29+
};
30+
}
31+
32+
it("registers, lists, and deregisters an entry", async () => {
33+
const { registry } = await freshRegistry();
34+
const entry = makeEntry();
35+
36+
registry.register(entry);
37+
expect(registry.listLive()).toEqual([entry]);
38+
39+
registry.deregister(entry.port);
40+
expect(registry.listLive()).toEqual([]);
41+
});
42+
43+
it("returns an empty list when the registry directory does not exist", async () => {
44+
const dir = await mkdtemp(join(tmpdir(), "webmux-instance-registry-"));
45+
await rm(dir, { recursive: true, force: true });
46+
const registry = createInstanceRegistry(dir);
47+
expect(registry.listLive()).toEqual([]);
48+
});
49+
50+
it("evicts entries whose PID is no longer alive", async () => {
51+
const { dir, registry } = await freshRegistry();
52+
const live = makeEntry({ port: 5111 });
53+
const dead = makeEntry({ port: 5112, pid: 1 << 30 });
54+
55+
registry.register(live);
56+
registry.register(dead);
57+
58+
const result = registry.listLive();
59+
expect(result.map((e) => e.port)).toEqual([live.port]);
60+
61+
// The dead entry's file should be cleaned up on read.
62+
const remaining = createInstanceRegistry(dir).listLive();
63+
expect(remaining).toEqual([live]);
64+
});
65+
66+
it("ignores malformed json files", async () => {
67+
const { dir, registry } = await freshRegistry();
68+
registry.register(makeEntry({ port: 5111 }));
69+
writeFileSync(join(dir, "5112.json"), "not json");
70+
writeFileSync(join(dir, "5113.json"), JSON.stringify({ prefix: 1 }));
71+
72+
expect(registry.listLive().map((e) => e.port)).toEqual([5111]);
73+
});
74+
75+
it("deregister is a no-op when the file is missing", async () => {
76+
const { registry } = await freshRegistry();
77+
expect(() => registry.deregister(9999)).not.toThrow();
78+
});
79+
80+
it("overwrites an existing entry when registering the same port twice", async () => {
81+
const { registry } = await freshRegistry();
82+
registry.register(makeEntry({ port: 5111, prefix: "alpha" }));
83+
registry.register(makeEntry({ port: 5111, prefix: "beta" }));
84+
85+
const entries = registry.listLive();
86+
expect(entries).toHaveLength(1);
87+
expect(entries[0]?.prefix).toBe("beta");
88+
});
89+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { decidePeerRouting } from "../domain/peer-routing";
3+
import type { InstanceEntry } from "../adapters/instance-registry";
4+
5+
function peer(overrides: Partial<InstanceEntry> = {}): InstanceEntry {
6+
return {
7+
prefix: "demo",
8+
port: 5111,
9+
projectDir: "/repo/demo",
10+
pid: 123,
11+
startedAt: 1,
12+
...overrides,
13+
};
14+
}
15+
16+
describe("decidePeerRouting", () => {
17+
it("passes through when there is no recognizable prefix segment", () => {
18+
expect(decidePeerRouting("/", [peer()], 9999).kind).toBe("passthrough");
19+
expect(decidePeerRouting("", [peer()], 9999).kind).toBe("passthrough");
20+
expect(decidePeerRouting("/Some-Caps", [peer()], 9999).kind).toBe("passthrough");
21+
});
22+
23+
it("passes through reserved segments", () => {
24+
expect(decidePeerRouting("/api/config", [peer({ prefix: "api" })], 9999).kind).toBe("passthrough");
25+
expect(decidePeerRouting("/ws/anything", [peer({ prefix: "ws" })], 9999).kind).toBe("passthrough");
26+
expect(decidePeerRouting("/assets/foo.js", [peer({ prefix: "assets" })], 9999).kind).toBe("passthrough");
27+
});
28+
29+
it("passes through when the prefix is not in the registry", () => {
30+
const peers = [peer({ prefix: "alpha", port: 5111 })];
31+
expect(decidePeerRouting("/beta", peers, 5111).kind).toBe("passthrough");
32+
expect(decidePeerRouting("/beta/whatever", peers, 5111).kind).toBe("passthrough");
33+
});
34+
35+
it("redirects to the peer's port when the prefix matches a different instance", () => {
36+
const peers = [peer({ prefix: "windmill", port: 5112 })];
37+
expect(decidePeerRouting("/windmill", peers, 5111)).toEqual({
38+
kind: "redirect",
39+
port: 5112,
40+
path: "/",
41+
});
42+
expect(decidePeerRouting("/windmill/foo/bar", peers, 5111)).toEqual({
43+
kind: "redirect",
44+
port: 5112,
45+
path: "/foo/bar",
46+
});
47+
});
48+
49+
it("rewrites in place when the matching peer is this instance", () => {
50+
const peers = [peer({ prefix: "webmux", port: 5111 })];
51+
expect(decidePeerRouting("/webmux", peers, 5111)).toEqual({
52+
kind: "rewrite",
53+
path: "/",
54+
});
55+
expect(decidePeerRouting("/webmux/deep/link", peers, 5111)).toEqual({
56+
kind: "rewrite",
57+
path: "/deep/link",
58+
});
59+
});
60+
61+
it("preserves trailing path segments and ignores query (caller appends it)", () => {
62+
const peers = [peer({ prefix: "wm", port: 5113 })];
63+
expect(decidePeerRouting("/wm/x/y/z", peers, 5111)).toEqual({
64+
kind: "redirect",
65+
port: 5113,
66+
path: "/x/y/z",
67+
});
68+
});
69+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { log } from "../lib/log";
4+
5+
export interface InstanceEntry {
6+
prefix: string;
7+
port: number;
8+
projectDir: string;
9+
pid: number;
10+
startedAt: number;
11+
}
12+
13+
export interface InstanceRegistry {
14+
register(entry: InstanceEntry): void;
15+
deregister(port: number): void;
16+
listLive(): InstanceEntry[];
17+
}
18+
19+
function defaultRegistryDir(): string {
20+
const home = Bun.env.HOME ?? "/root";
21+
return join(home, ".webmux", "instances");
22+
}
23+
24+
function isAlive(pid: number): boolean {
25+
try {
26+
process.kill(pid, 0);
27+
return true;
28+
} catch {
29+
return false;
30+
}
31+
}
32+
33+
function isInstanceEntry(value: unknown): value is InstanceEntry {
34+
if (typeof value !== "object" || value === null) return false;
35+
const v = value as Record<string, unknown>;
36+
return typeof v.prefix === "string"
37+
&& typeof v.port === "number"
38+
&& typeof v.projectDir === "string"
39+
&& typeof v.pid === "number"
40+
&& typeof v.startedAt === "number";
41+
}
42+
43+
export function createInstanceRegistry(dir: string = defaultRegistryDir()): InstanceRegistry {
44+
function ensureDir(): void {
45+
mkdirSync(dir, { recursive: true });
46+
}
47+
48+
function entryPath(port: number): string {
49+
return join(dir, `${port}.json`);
50+
}
51+
52+
function readEntry(filename: string): InstanceEntry | null {
53+
try {
54+
const raw = readFileSync(join(dir, filename), "utf8");
55+
const parsed: unknown = JSON.parse(raw);
56+
return isInstanceEntry(parsed) ? parsed : null;
57+
} catch {
58+
return null;
59+
}
60+
}
61+
62+
return {
63+
register(entry: InstanceEntry): void {
64+
ensureDir();
65+
const finalPath = entryPath(entry.port);
66+
const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.tmp`;
67+
const text = `${JSON.stringify(entry, null, 2)}\n`;
68+
writeFileSync(tmpPath, text);
69+
renameSync(tmpPath, finalPath);
70+
},
71+
72+
deregister(port: number): void {
73+
try {
74+
unlinkSync(entryPath(port));
75+
} catch (err: unknown) {
76+
const code = (err as { code?: string } | null)?.code;
77+
if (code !== "ENOENT") {
78+
log.debug(`[instance-registry] deregister(${port}) failed: ${String(err)}`);
79+
}
80+
}
81+
},
82+
83+
listLive(): InstanceEntry[] {
84+
let filenames: string[];
85+
try {
86+
filenames = readdirSync(dir).filter((name) => name.endsWith(".json"));
87+
} catch {
88+
return [];
89+
}
90+
91+
const live: InstanceEntry[] = [];
92+
for (const filename of filenames) {
93+
const entry = readEntry(filename);
94+
if (!entry) continue;
95+
if (!isAlive(entry.pid)) {
96+
try {
97+
unlinkSync(join(dir, filename));
98+
} catch {
99+
// best effort — another process may have cleaned it already
100+
}
101+
continue;
102+
}
103+
live.push(entry);
104+
}
105+
return live;
106+
},
107+
};
108+
}

backend/src/domain/peer-routing.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { InstanceEntry } from "../adapters/instance-registry";
2+
import { isValidInstancePrefix } from "./policies";
3+
4+
export type PeerRouting =
5+
| { kind: "passthrough" }
6+
| { kind: "rewrite"; path: string }
7+
| { kind: "redirect"; port: number; path: string };
8+
9+
/** Pure decision: given a request pathname and the live peer list, decide
10+
* whether to redirect to a peer, rewrite the URL in place (when the prefix
11+
* matches this instance), or let the request pass through to the SPA handler. */
12+
export function decidePeerRouting(
13+
pathname: string,
14+
peers: InstanceEntry[],
15+
selfPort: number,
16+
): PeerRouting {
17+
const firstSegment = pathname.split("/")[1];
18+
if (!firstSegment || !isValidInstancePrefix(firstSegment)) {
19+
return { kind: "passthrough" };
20+
}
21+
// Defense-in-depth: paths handled by the route map above never reach this code,
22+
// but if a peer ever picked a colliding prefix we'd refuse to shadow them.
23+
if (firstSegment === "api" || firstSegment === "ws" || firstSegment === "assets") {
24+
return { kind: "passthrough" };
25+
}
26+
27+
const peer = peers.find((entry) => entry.prefix === firstSegment);
28+
if (!peer) return { kind: "passthrough" };
29+
30+
const remaining = pathname.slice(firstSegment.length + 1) || "/";
31+
if (peer.port === selfPort) {
32+
return { kind: "rewrite", path: remaining };
33+
}
34+
return { kind: "redirect", port: peer.port, path: remaining };
35+
}

0 commit comments

Comments
 (0)