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
@@ -6,9 +6,9 @@ import { resolveWorkspaceScriptLink } from "./workspace-script-links";
const runningService: WorkspaceScriptPayload = {
scriptName: "web",
type: "service",
hostname: "feature.web.localhost",
hostname: "web.feature.paseo.localhost",
port: 3000,
proxyUrl: "http://feature.web.localhost:6767",
proxyUrl: "http://web.feature.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
@@ -26,8 +26,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect(
resolveLink({ type: "directTcp", endpoint: "localhost:6767", display: "localhost:6767" }),
).toEqual({
openUrl: "http://feature.web.localhost:6767",
labelUrl: "http://feature.web.localhost:6767",
openUrl: "http://web.feature.paseo.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767",
});
});
@@ -35,8 +35,8 @@ describe("resolveWorkspaceScriptLink", () => {
expect(
resolveLink({ type: "directSocket", endpoint: "/tmp/paseo.sock", display: "socket" }),
).toEqual({
openUrl: "http://feature.web.localhost:6767",
labelUrl: "http://feature.web.localhost:6767",
openUrl: "http://web.feature.paseo.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767",
});
});
@@ -58,7 +58,7 @@ describe("resolveWorkspaceScriptLink", () => {
resolveLink({ type: "relay", endpoint: "relay.paseo.sh:443", display: "relay" }),
).toEqual({
openUrl: null,
labelUrl: "http://feature.web.localhost:6767",
labelUrl: "http://web.feature.paseo.localhost:6767",
});
});
});