feat(server,app,website): prefix service URLs with project slug

Service hostnames now follow DNS convention with a project label so
independently opened projects no longer collide on routes like
`web.localhost`. Format becomes `{script}.{project}.localhost` for
default branches and `{script}.{branch}.{project}.localhost` for other
branches. The project slug comes from the GitHub repository name when a
remote exists, otherwise from the workspace directory basename.

Server changes:
- `buildScriptHostname` takes `{ projectSlug, branchName, scriptName }`
  and slugifies all labels at the boundary; `untitled` fallback only
  applies at hostname-label sites, never to shared `slugify`.
- `deriveProjectSlug(cwd)` and `parseGitHubRepoNameFromRemote` derive
  the slug; GitHub URL parsing now requires `host === "github.com"` to
  avoid embedded-path false matches.
- `ScriptRouteEntry` carries `projectSlug`; branch-rename handler reuses
  it instead of re-reading git in hot paths.
- Status projection derives slug once per call and uses route hostname
  when available; only falls back to building when no route exists.
- Spawn cleanup removes routes by `{ workspaceId, scriptName }` so
  cleanup after a branch rename doesn't leave a stale entry behind.

App and shared message tests updated to the new fixture shape; service
URLs continue to be opaque server-provided strings.

Landing-page service URL examples updated; allowlist docs unchanged.
This commit is contained in:
Mohamed Boudra
2026-04-18 19:31:23 +07:00
parent 23b20169ef
commit 7819019cea
23 changed files with 680 additions and 224 deletions
@@ -2,28 +2,70 @@ import { describe, expect, it } from "vitest";
import { buildScriptHostname } from "./script-hostname.js";
describe("buildScriptHostname", () => {
it("slugifies script names with spaces on default branches", () => {
expect(buildScriptHostname(null, "npm run dev")).toBe("npm-run-dev.localhost");
it("builds default branch hostnames with script and project labels", () => {
expect(
buildScriptHostname({
projectSlug: "paseo",
branchName: null,
scriptName: "web",
}),
).toBe("web.paseo.localhost");
});
it("slugifies script names with special characters", () => {
expect(buildScriptHostname(null, "Web/API @ Dev")).toBe("web-api-dev.localhost");
it("omits the branch label for main and master", () => {
expect(
buildScriptHostname({
projectSlug: "paseo",
branchName: "main",
scriptName: "web",
}),
).toBe("web.paseo.localhost");
expect(
buildScriptHostname({
projectSlug: "paseo",
branchName: "master",
scriptName: "web",
}),
).toBe("web.paseo.localhost");
});
it("omits the branch prefix for main and master", () => {
expect(buildScriptHostname("main", "npm run dev")).toBe("npm-run-dev.localhost");
expect(buildScriptHostname("master", "npm run dev")).toBe("npm-run-dev.localhost");
it("builds non-default branch hostnames with script, branch, and project labels", () => {
expect(
buildScriptHostname({
projectSlug: "paseo",
branchName: "feature-auth",
scriptName: "web",
}),
).toBe("web.feature-auth.paseo.localhost");
});
it("adds a slugified branch prefix for non-default branches", () => {
expect(buildScriptHostname("feature/cool stuff", "api")).toBe(
"feature-cool-stuff.api.localhost",
);
it("slugifies script, default branch project, and non-default branch labels", () => {
expect(
buildScriptHostname({
projectSlug: "Paseo App",
branchName: "Feature/Auth Flow",
scriptName: "Web/API @ Dev",
}),
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
});
it("slugifies both the branch name and script name together", () => {
expect(buildScriptHostname("feat/add auth", "npm run dev")).toBe(
"feat-add-auth.npm-run-dev.localhost",
);
it("accepts already slugified labels because slugify is idempotent", () => {
expect(
buildScriptHostname({
projectSlug: "paseo-app",
branchName: "feature-auth-flow",
scriptName: "web-api-dev",
}),
).toBe("web-api-dev.feature-auth-flow.paseo-app.localhost");
});
it("uses untitled as the hostname-label fallback when labels collapse to empty", () => {
expect(
buildScriptHostname({
projectSlug: "日本語",
branchName: "***",
scriptName: "---",
}),
).toBe("untitled.untitled.untitled.localhost");
});
});
+19 -4
View File
@@ -1,12 +1,27 @@
import { slugify } from "./worktree.js";
export function buildScriptHostname(branchName: string | null, scriptName: string): string {
const serviceHostnameLabel = slugify(scriptName);
type BuildScriptHostnameOptions = {
projectSlug: string;
branchName: string | null;
scriptName: string;
};
function toHostnameLabel(value: string): string {
return slugify(value) || "untitled";
}
export function buildScriptHostname({
projectSlug,
branchName,
scriptName,
}: BuildScriptHostnameOptions): string {
const serviceHostnameLabel = toHostnameLabel(scriptName);
const projectHostnameLabel = toHostnameLabel(projectSlug);
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
if (isDefaultBranch) {
return `${serviceHostnameLabel}.localhost`;
return `${serviceHostnameLabel}.${projectHostnameLabel}.localhost`;
}
return `${slugify(branchName)}.${serviceHostnameLabel}.localhost`;
return `${serviceHostnameLabel}.${toHostnameLabel(branchName)}.${projectHostnameLabel}.localhost`;
}
@@ -1034,9 +1034,41 @@ describe("paseo worktree manager", () => {
});
describe("slugify", () => {
function expectValidHostnameLabel(label: string): void {
expect(label.length).toBeGreaterThan(0);
expect(label.length).toBeLessThanOrEqual(63);
expect(label).toMatch(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
}
it("converts to lowercase kebab-case", () => {
expect(slugify("Hello World")).toBe("hello-world");
expect(slugify("FOO_BAR")).toBe("foo-bar");
expect(slugify("My GREAT App")).toBe("my-great-app");
});
it("replaces dots with hyphens", () => {
expect(slugify("my.app")).toBe("my-app");
expect(slugify("v1.2.3")).toBe("v1-2-3");
});
it("collapses multiple consecutive spaces to one hyphen", () => {
expect(slugify("feature cool stuff")).toBe("feature-cool-stuff");
});
it("replaces slashes with hyphens", () => {
expect(slugify("feature/cool stuff")).toBe("feature-cool-stuff");
expect(slugify("owner/repo")).toBe("owner-repo");
});
it("strips unsupported unicode characters", () => {
expect(slugify("café")).toBe("caf");
expect(slugify("日本語")).toBe("");
});
it("removes leading and trailing punctuation", () => {
expect(slugify("-foo-")).toBe("foo");
expect(slugify("__bar__")).toBe("bar");
expect(slugify(".baz.")).toBe("baz");
});
it("truncates long strings at word boundary", () => {
@@ -1044,6 +1076,7 @@ describe("slugify", () => {
"https-stackoverflow-com-questions-68349031-only-run-actions-on-non-draft-pull-request";
const result = slugify(longInput);
expect(result.length).toBeLessThanOrEqual(50);
expectValidHostnameLabel(result);
expect(result).toBe("https-stackoverflow-com-questions-68349031-only");
});
@@ -1052,5 +1085,35 @@ describe("slugify", () => {
const result = slugify(longInput);
expect(result.length).toBe(50);
expect(result.endsWith("-")).toBe(false);
expectValidHostnameLabel(result);
});
it("keeps very long names within the hostname label length limit", () => {
const result = slugify("Release Candidate ".repeat(12));
expect(result.length).toBeLessThanOrEqual(63);
expectValidHostnameLabel(result);
});
it("returns empty when names collapse to empty", () => {
expect(slugify("---")).toBe("");
expect(slugify("***")).toBe("");
expect(slugify("日本語")).toBe("");
});
it("is idempotent for representative inputs", () => {
const inputs = [
"my.app",
"feature/cool stuff",
" Café Launch ",
"__bar__",
"Release Candidate ".repeat(12),
"release***candidate",
];
for (const input of inputs) {
const slug = slugify(input);
expect(slugify(slug)).toBe(slug);
}
});
});