Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
19 changes: 10 additions & 9 deletions docs/SYNCING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`.

Expand Down
136 changes: 136 additions & 0 deletions packages/portless/src/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) => {
Expand Down
21 changes: 14 additions & 7 deletions packages/portless/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down Expand Up @@ -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). */
Expand Down
4 changes: 2 additions & 2 deletions skills/portless/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
Loading