diff --git a/NOTICE b/NOTICE index b4bdde57..52fba044 100644 --- a/NOTICE +++ b/NOTICE @@ -19,6 +19,10 @@ Changes made in this fork, licensed under the same Apache License, Version 2.0: environment variable, and the `path` field in portless.json. Written by Ricardo Q. Bazan and proposed to the upstream project in https://github.com/vercel-labs/portless/pull/165. + - Wildcard subdomain routing picks the closest registered parent hostname, + then the longest path prefix among that hostname's routes, instead of the + first matching route (upstream issue + https://github.com/vercel-labs/portless/issues/380). - Packaging metadata so the fork can be published to npm as @variablelab/portless, plus fork-specific documentation, changelog, and release workflow. diff --git a/README.md b/README.md index 2bed03b4..48e0d452 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ portless docs.myapp next dev # -> https://docs.myapp.localhost ``` -By default, only explicitly registered subdomains are routed (strict mode). Use `--wildcard` when starting the proxy to allow any subdomain of a registered route to fall back to that app (e.g. `tenant1.myapp.localhost` routes to the `myapp` app without extra registration). +By default, only explicitly registered subdomains are routed (strict mode). Use `--wildcard` when starting the proxy to allow any subdomain of a registered route to fall back to that app (e.g. `tenant1.myapp.localhost` routes to the `myapp` app without extra registration). When several registered routes are parents of the requested subdomain, the closest one wins: with `myapp.localhost` and `fix-ui.myapp.localhost` both running, `tenant1.fix-ui.myapp.localhost` routes to `fix-ui.myapp`, whatever order they were registered in. ## Git Worktrees @@ -256,7 +256,7 @@ Given these routes on `myapp.localhost`: | `--path /settings/advanced` | `/settings/advanced/x` | the advanced app (longest prefix wins) | | `--path /settings` | `/settings-v2` | the root app, or 404 when there is no root route | -Wildcard subdomains (`--wildcard`) go through the same longest-prefix selection. Tailscale URLs skip it, since a tailnet URL identifies exactly one route. +Wildcard subdomains (`--wildcard`) first pick the closest registered parent hostname and then go through the same longest-prefix selection among that hostname's routes only. A prefix registered on a farther parent never serves the request: if the closest parent has no route for the path, the proxy answers 404, as it would for an exact match. Tailscale URLs skip path selection, since a tailnet URL identifies exactly one route. ### The path is forwarded unchanged diff --git a/docs/SYNCING.md b/docs/SYNCING.md index 6d4d31d5..e0f0c102 100644 --- a/docs/SYNCING.md +++ b/docs/SYNCING.md @@ -3,6 +3,7 @@ This fork tracks [vercel-labs/portless](https://github.com/vercel-labs/portless). Its own commits sit on top of upstream `main`: - `feat: add --path flag for path-based routing` (the feature, upstream [PR #165](https://github.com/vercel-labs/portless/pull/165)) +- `fix(proxy): route wildcard subdomains to the closest registered parent` (upstream issue [#380](https://github.com/vercel-labs/portless/issues/380), extended to path prefixes) - `chore: rename package to @variablelab/portless` (packaging) - fork docs, `CHANGELOG.fork.md`, and the release workflow @@ -43,15 +44,15 @@ Open a PR against `main`. CI runs the same steps. Merging the PR does not publis The fork touches a small set of files, so conflicts cluster there: -| File | Why it conflicts | How to resolve | -| --------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | -| `packages/portless/package.json` | upstream bumps `version` on every release | keep the fork's `name`, `version`, and metadata; take upstream's dependencies | -| `packages/portless/src/cli.ts` | flag parsing, help text, `runApp` and route helpers | keep both: upstream's change plus the `pathPrefix` plumbing | -| `packages/portless/src/proxy.ts` | `findRoute` | keep the longest-prefix selection inside whatever matching upstream has | -| `packages/portless/src/routes.ts` | route identity is `(hostname, pathPrefix)` in the fork | thread `pathPrefix` through any new add, remove, or update helper | -| `README.md`, `skills/portless/SKILL.md` | upstream edits docs near the fork's sections | keep the fork banner, install commands, and the path-based routing section | -| `turbo.json` | `test:e2e` depends on `@variablelab/portless#build` | keep the scoped name | -| `.github/workflows/release.yml` | the fork publishes a different package | keep the fork's version | +| File | Why it conflicts | How to resolve | +| --------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `packages/portless/package.json` | upstream bumps `version` on every release | keep the fork's `name`, `version`, and metadata; take upstream's dependencies | +| `packages/portless/src/cli.ts` | flag parsing, help text, `runApp` and route helpers | keep both: upstream's change plus the `pathPrefix` plumbing | +| `packages/portless/src/proxy.ts` | `findRoute` | keep the longest-prefix selection inside whatever matching upstream has, and pick the closest wildcard parent before the path | +| `packages/portless/src/routes.ts` | route identity is `(hostname, pathPrefix)` in the fork | thread `pathPrefix` through any new add, remove, or update helper | +| `README.md`, `skills/portless/SKILL.md` | upstream edits docs near the fork's sections | keep the fork banner, install commands, and the path-based routing section | +| `turbo.json` | `test:e2e` depends on `@variablelab/portless#build` | keep the scoped name | +| `.github/workflows/release.yml` | the fork publishes a different package | keep the fork's version | Do not modify `CHANGELOG.md` or `LICENSE`: both stay upstream's. The fork's release notes live in `CHANGELOG.fork.md`. diff --git a/packages/portless/src/proxy.test.ts b/packages/portless/src/proxy.test.ts index 801cffbe..a87202cc 100644 --- a/packages/portless/src/proxy.test.ts +++ b/packages/portless/src/proxy.test.ts @@ -267,6 +267,49 @@ describe("createProxyServer", () => { expect(res.body).toBe("exact"); }); + it("routes a wildcard subdomain to the most specific registered parent regardless of route order", async () => { + const parentBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("parent"); + }) + ); + await listen(parentBackend); + const parentAddr = parentBackend.address() as net.AddressInfo; + + const nestedBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("nested"); + }) + ); + await listen(nestedBackend); + const nestedAddr = nestedBackend.address() as net.AddressInfo; + + const parent: RouteInfo = { hostname: "acme.localhost", port: parentAddr.port }; + const nested: RouteInfo = { hostname: "api.acme.localhost", port: nestedAddr.port }; + let routes: RouteInfo[] = [parent, nested]; + const server = trackServer( + createProxyServer({ + getRoutes: () => routes, + proxyPort: TEST_PROXY_PORT, + strict: false, + }) + ); + await listen(server); + + const res = await request(server, { host: "admin.api.acme.localhost" }); + expect(res.body).toBe("nested"); + + routes = [nested, parent]; + const reversedRes = await request(server, { host: "admin.api.acme.localhost" }); + expect(reversedRes.body).toBe("nested"); + + // The farther parent still owns the subdomains the nested route does not cover + const parentRes = await request(server, { host: "admin.acme.localhost" }); + expect(parentRes.body).toBe("parent"); + }); + it("returns 404 when subdomain does not match any route", async () => { const routes: RouteInfo[] = [{ hostname: "myapp.localhost", port: 4001 }]; const server = trackServer( @@ -781,6 +824,99 @@ describe("createProxyServer", () => { expect(res.body).toBe("wildcard-path"); }); + it("prefers the most specific wildcard parent over a longer path prefix on a farther parent", async () => { + const parentSettingsBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("parent-settings"); + }) + ); + await listen(parentSettingsBackend); + const parentSettingsAddr = parentSettingsBackend.address() as net.AddressInfo; + + const worktreeBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("worktree"); + }) + ); + await listen(worktreeBackend); + const worktreeAddr = worktreeBackend.address() as net.AddressInfo; + + const routes: RouteInfo[] = [ + { hostname: "app.localhost", port: parentSettingsAddr.port, pathPrefix: "/settings" }, + { hostname: "feat-x.app.localhost", port: worktreeAddr.port }, + ]; + const server = trackServer( + createProxyServer({ + getRoutes: () => routes, + proxyPort: TEST_PROXY_PORT, + strict: false, + }) + ); + await listen(server); + + const res = await request(server, { + host: "tenant.feat-x.app.localhost", + path: "/settings/profile", + }); + expect(res.body).toBe("worktree"); + + const parentRes = await request(server, { + host: "tenant.app.localhost", + path: "/settings/profile", + }); + expect(parentRes.body).toBe("parent-settings"); + }); + + it("returns 404 when the most specific wildcard parent has no route for the path", async () => { + const parentBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("parent"); + }) + ); + await listen(parentBackend); + const parentAddr = parentBackend.address() as net.AddressInfo; + + const worktreeSettingsBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("worktree-settings"); + }) + ); + await listen(worktreeSettingsBackend); + const worktreeSettingsAddr = worktreeSettingsBackend.address() as net.AddressInfo; + + const routes: RouteInfo[] = [ + { hostname: "app.localhost", port: parentAddr.port }, + { + hostname: "feat-x.app.localhost", + port: worktreeSettingsAddr.port, + pathPrefix: "/settings", + }, + ]; + const server = trackServer( + createProxyServer({ + getRoutes: () => routes, + proxyPort: TEST_PROXY_PORT, + strict: false, + }) + ); + await listen(server); + + // Same as an exact match: the closest parent owns the request, so a + // path it does not serve is a 404 instead of the farther parent's app. + const res = await request(server, { host: "tenant.feat-x.app.localhost", path: "/" }); + expect(res.status).toBe(404); + + const settingsRes = await request(server, { + host: "tenant.feat-x.app.localhost", + path: "/settings", + }); + expect(settingsRes.body).toBe("worktree-settings"); + }); + it("does not crash on a malformed request-target", async () => { const backend = trackServer( http.createServer((_req, res) => { diff --git a/packages/portless/src/proxy.ts b/packages/portless/src/proxy.ts index a69342ad..009f5f7f 100644 --- a/packages/portless/src/proxy.ts +++ b/packages/portless/src/proxy.ts @@ -195,12 +195,13 @@ function normalizeAuthority(host: string): string { /** * Find the route matching a request's host (which may include a port) and URL * path. Match order: local hostname, tailscale authority (hostname and port), - * tailscale hostname ignoring port, then wildcard subdomain. `strict` drops - * the wildcard tier. Within the local-hostname and wildcard tiers, several - * routes may share a hostname and differ only by `pathPrefix`; the longest - * matching prefix wins and routes without a `pathPrefix` act as root - * catch-all. Tailscale tiers skip path selection: a tailscale URL identifies - * a single route and its requests are not path-prefixed. + * tailscale hostname ignoring port, then wildcard subdomain, where the closest + * registered parent hostname wins. `strict` drops the wildcard tier. Within + * the matched local or parent hostname, several routes may differ only by + * `pathPrefix`; the longest matching prefix wins and routes without a + * `pathPrefix` act as root catch-all. Tailscale tiers skip path selection: a + * tailscale URL identifies a single route and its requests are not + * path-prefixed. */ function findRoute( routes: RouteInfo[], @@ -237,7 +238,13 @@ function findRoute( if (tsHostnameMatch) return tsHostnameMatch; if (strict) return undefined; - return pickByPath(routes.filter((r) => hostname.endsWith("." + r.hostname.toLowerCase()))); + // Every candidate is a dot-bounded suffix of the request hostname, so the + // longest one is the closest parent (and equal lengths mean the same host). + // It owns the request like an exact match would: path selection never falls + // through to a farther parent, so routes.json order cannot mix apps. + const parents = routes.filter((r) => hostname.endsWith("." + r.hostname.toLowerCase())); + const closest = Math.max(0, ...parents.map((r) => r.hostname.length)); + return pickByPath(parents.filter((r) => r.hostname.length === closest)); } /** Server type returned by createProxyServer (plain HTTP/1.1 or net.Server TLS wrapper). */ diff --git a/skills/portless/SKILL.md b/skills/portless/SKILL.md index 4e5b41ed..382fd2dc 100644 --- a/skills/portless/SKILL.md +++ b/skills/portless/SKILL.md @@ -131,7 +131,7 @@ portless api.myapp pnpm start # https://api.myapp.localhost portless docs.myapp next dev # https://docs.myapp.localhost ``` -By default, only explicitly registered subdomains are routed (strict mode). Start the proxy with `--wildcard` to allow any subdomain of a registered route to fall back to that app (e.g. `tenant1.myapp.localhost` routes to the `myapp` app). Exact matches always take priority over wildcards. +By default, only explicitly registered subdomains are routed (strict mode). Start the proxy with `--wildcard` to allow any subdomain of a registered route to fall back to that app (e.g. `tenant1.myapp.localhost` routes to the `myapp` app). Exact matches always take priority over wildcards. When several registered routes are parents of a subdomain, the closest one wins (e.g. `tenant1.fix-ui.myapp.localhost` routes to `fix-ui.myapp`, not `myapp`), regardless of registration order. ### Git worktrees @@ -157,7 +157,7 @@ portless myapp --path /api pnpm start # serves /api/* portless myapp --path /docs next dev # serves /docs/* ``` -The proxy uses longest-prefix matching. The full request path is forwarded unchanged. Useful for local API gateways, microfrontends, monorepos, or any setup where services share a domain. Also available as `PORTLESS_PATH=/api` or per app in `portless.json` (`"path": "/api"`). Tailscale/ngrok tunnels dial the app's port directly, so shared URLs for a `--path` app include the prefix. +The proxy uses longest-prefix matching. With `--wildcard`, the closest parent hostname is chosen first and the prefix is matched only among its routes (404 if none matches). The full request path is forwarded unchanged. Useful for local API gateways, microfrontends, monorepos, or any setup where services share a domain. Also available as `PORTLESS_PATH=/api` or per app in `portless.json` (`"path": "/api"`). Tailscale/ngrok tunnels dial the app's port directly, so shared URLs for a `--path` app include the prefix. ### Bypassing portless