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
8 changes: 5 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
LOCAL_VERSION=$(node -p "require('./packages/portless/package.json').version")
echo "Local version: $LOCAL_VERSION"

NPM_VERSION=$(npm view portless version 2>/dev/null || echo "0.0.0")
NPM_VERSION=$(npm view @variablelab/portless version 2>/dev/null || echo "0.0.0")
echo "npm version: $NPM_VERSION"

if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
Expand Down Expand Up @@ -96,6 +96,8 @@ jobs:
- name: Publish to npm
run: npm publish --provenance
working-directory: packages/portless
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

github-release:
name: Create GitHub Release
Expand All @@ -115,11 +117,11 @@ jobs:
- name: Extract changelog entry
run: |
VERSION="${{ needs.check-release.outputs.version }}"
awk '/<!-- release:start -->/{found=1; next} /<!-- release:end -->/{found=0} found{print}' CHANGELOG.md > /tmp/release-notes.md
awk '/<!-- release:start -->/{found=1; next} /<!-- release:end -->/{found=0} found{print}' CHANGELOG.fork.md > /tmp/release-notes.md

LINES=$(wc -l < /tmp/release-notes.md | tr -d ' ')
if [ "$LINES" -lt 2 ]; then
echo "Error: No release notes found between <!-- release:start --> and <!-- release:end --> markers in CHANGELOG.md"
echo "Error: No release notes found between <!-- release:start --> and <!-- release:end --> markers in CHANGELOG.fork.md"
exit 1
fi
echo "Extracted release notes for $VERSION ($LINES lines)"
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Agent Rules

> This repository is a fork of [vercel-labs/portless](https://github.com/vercel-labs/portless) published as `@variablelab/portless`. The rules below are upstream's and still apply, with two exceptions: releases follow [docs/RELEASING.md](./docs/RELEASING.md) (fork changelog in `CHANGELOG.fork.md`, no docs-site changelog entry), and upstream changes are pulled in per [docs/SYNCING.md](./docs/SYNCING.md). Keep the diff against upstream small.

## Package Manager

Use `pnpm` for all package management commands (not npm or yarn).
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.fork.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Changelog (fork)

Release notes for `@variablelab/portless`. Upstream's own history is in [CHANGELOG.md](./CHANGELOG.md).

## 0.0.1

<!-- release:start -->

First release of the fork, based on upstream [portless v0.15.6](https://github.com/vercel-labs/portless/releases/tag/v0.15.6) (upstream `main` at `1ad573b`).

### Added

- **Path-based routing**: `--path <prefix>` on `portless run` and `portless <name>`, the `PORTLESS_PATH` environment variable, and the `path` field in `portless.json` and in the `"portless"` key of `package.json`. Several apps can share one hostname; the proxy dispatches by the longest prefix that matches at a `/` boundary and forwards the full path to the backend. `alias`, `get`, and `list` understand prefixes, and the 404 page lists them. Proposed upstream in [vercel-labs/portless#165](https://github.com/vercel-labs/portless/pull/165).

### Changed

- Published as `@variablelab/portless`. The installed command is still `portless`.
- The npm tarball now includes `LICENSE` and `NOTICE`.

<!-- release:end -->
27 changes: 27 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
@variablelab/portless
Fork maintained by Ricardo Q. Bazan (rcrd) <ricardo@variable.land>
https://github.com/variableland/portless

This product is a fork of portless, created by Vercel Labs.

portless
Copyright 2025 Vercel Inc.
https://github.com/vercel-labs/portless
Licensed under the Apache License, Version 2.0

The original LICENSE file is preserved unchanged in this repository and in
the published npm package. All credit for portless itself belongs to Vercel
Labs and the upstream contributors.

Changes made in this fork, licensed under the same Apache License, Version 2.0:

- Path-based routing: the `--path <prefix>` flag, the `PORTLESS_PATH`
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.
- Packaging metadata so the fork can be published to npm as
@variablelab/portless, plus fork-specific documentation, changelog, and
release workflow.

The exact set of modified files is visible with
`git diff upstream/main...main` in this repository (see docs/SYNCING.md).
123 changes: 118 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# portless

> **This is a fork.** `@variablelab/portless` is [vercel-labs/portless](https://github.com/vercel-labs/portless) plus path-based routing: the `--path` flag, `PORTLESS_PATH`, and the `path` config field, so several apps can share one hostname and be dispatched by URL prefix. That feature is proposed upstream in [PR #165](https://github.com/vercel-labs/portless/pull/165) and is pending review; this package exists so it can be used in the meantime. Everything else is upstream portless, created and maintained by Vercel Labs (see [NOTICE](./NOTICE)). The fork tracks upstream `main` (see [docs/SYNCING.md](./docs/SYNCING.md)) and keeps its own [changelog](./CHANGELOG.fork.md).
>
> ```bash
> npm install -g @variablelab/portless
> ```
>
> The installed command is `portless`, the same as upstream, so run `npm uninstall -g portless` first if you have the upstream package installed globally. Jump to [Path-based routing](#path-based-routing) for the fork-specific docs.

Replace port numbers with stable, named .localhost URLs for local development. For humans and agents.

```diff
Expand All @@ -12,13 +20,13 @@ Replace port numbers with stable, named .localhost URLs for local development. F
**Global (recommended):**

```bash
npm install -g portless
npm install -g @variablelab/portless
```

**Or as a project dev dependency:**

```bash
npm install -D portless
npm install -D @variablelab/portless
```

> portless is pre-1.0. When installed per-project, different contributors may run different versions. The state directory format may change between releases, which can require re-running `portless trust`.
Expand Down Expand Up @@ -90,9 +98,21 @@ Without an `apps` map, hostnames follow the `<package>.<project>.localhost` conv
| `script` | string | `"dev"` | Name of a `package.json` script to run. |
| `appPort` | number | auto | Fixed port for the child process. |
| `proxy` | boolean | auto | Whether to route through the proxy. Auto-detected. |
| `path` | string | | URL path prefix for path-based routing (e.g. `/api`). |
| `apps` | object | | Overrides for workspace packages, keyed by relative path. |
| `turbo` | boolean | `true` | Set `false` to use direct spawning instead of turborepo. |

Apps sharing a `name` with different `path` values are served under one hostname and dispatched by longest prefix:

```json
{
"apps": {
"apps/web": { "name": "myapp" },
"apps/api": { "name": "myapp", "path": "/api" }
}
}
```

### package.json "portless" key

Instead of a separate `portless.json`, you can add a `"portless"` key to your `package.json`. A string value is shorthand for setting the name:
Expand All @@ -104,7 +124,7 @@ Instead of a separate `portless.json`, you can add a `"portless"` key to your `p
}
```

An object supports all per-app fields (`name`, `script`, `appPort`, `proxy`):
An object supports all per-app fields (`name`, `script`, `appPort`, `proxy`, `path`):

```json
{
Expand Down Expand Up @@ -204,6 +224,97 @@ portless run --name myapp next dev # -> https://fix-ui.myapp.localhost

Put `portless run` in your `package.json` once and it works everywhere. The main checkout uses the plain name, each worktree gets a unique subdomain. No collisions, no `--force`.

## Path-based routing

This is the feature added by this fork (upstream [PR #165](https://github.com/vercel-labs/portless/pull/165)).

Route several apps under one hostname by URL path prefix. Each app registers the same name with a different `--path`, and the proxy picks the app whose prefix matches the request:

```bash
portless myapp vite dev # https://myapp.localhost serves /
portless myapp --path /api pnpm start # https://myapp.localhost/api serves /api/*
portless myapp --path /docs next dev # https://myapp.localhost/docs serves /docs/*
```

Useful for local API gateways, microfrontends, and monorepos where services share a domain in production and route by path.

### How requests are matched

1. The proxy first selects the routes whose hostname matches the request, exactly as before.
2. Among those, it keeps the routes whose prefix matches the request path and picks the longest one.
3. A route registered without `--path` is the root catch-all for that hostname. If there is no root route and nothing else matches, the proxy answers 404 and lists the active routes with their prefixes.

A prefix only matches at a `/` boundary: `/api` matches `/api` and `/api/users` but not `/api-v2` or `/apiary`. The check is `pathname === prefix || pathname.startsWith(prefix + "/")` (`matchesPathPrefix` in `packages/portless/src/proxy.ts`).

Given these routes on `myapp.localhost`:

| Registered with | Request | Served by |
| --------------------------- | ---------------------- | ------------------------------------------------ |
| no `--path` | `/`, `/about` | the root app |
| `--path /settings` | `/settings` | the settings app |
| `--path /settings` | `/settings/profile` | the settings app |
| `--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.

### The path is forwarded unchanged

`--path /api` does not strip `/api` before proxying. The backend receives `/api/users`, not `/users`, so it must serve its routes under that prefix. Most frameworks have a setting for this (`basePath` in Next.js, `base` in Vite). This mirrors how the app would be mounted behind a real gateway, so nothing changes between local and production.

### Prefix syntax

Prefixes are normalized by `normalizePathPrefix` in `packages/portless/src/utils.ts`:

- A leading `/` is added and a trailing `/` removed: `settings` and `/settings/` both become `/settings`.
- `/` and an empty value mean no prefix, which is the root route.
- Allowed characters are letters, digits, `/`, `.`, `_`, and `-`. `/api/v2.0` is valid; `/set tings` is rejected.
- Empty segments (`/api//v1`) and `..` segments (`/api/../etc`) are rejected.

An invalid value makes portless exit with an error before the command starts.

### Where to set it

The prefix can come from three places, from highest to lowest precedence:

1. The `--path` flag on `portless run` or `portless <name>`.
2. The `PORTLESS_PATH` environment variable, for example `PORTLESS_PATH=/api portless run pnpm start`.
3. The `path` field in `portless.json`, or in the `"portless"` key of `package.json`.

When bare `portless` starts every workspace package from a monorepo root, only per-app `path` entries apply. `PORTLESS_PATH` is ignored there because one global prefix would be ambiguous across apps.

Monorepo example with one hostname for the web app and the API:

```json
{
"apps": {
"apps/web": { "name": "myapp" },
"apps/api": { "name": "myapp", "path": "/api" }
}
}
```

```bash
portless # from the repo root: https://myapp.localhost and https://myapp.localhost/api
```

Two apps with the same name and the same prefix conflict, just as two apps with the same name did before. Use `--force` to take over the route.

### Static routes and lookups

`alias`, `get`, and `list` understand prefixes, so services outside portless (a Docker container, for example) can join a shared hostname:

```bash
portless alias myapp 8080 --path /api # static route for myapp.localhost/api
portless alias --remove myapp --path /api # remove only that prefix
portless get myapp --path /api # prints https://myapp.localhost/api
portless list # shows myapp.localhost/api next to myapp.localhost
```

### Tunnels

Tailscale and ngrok tunnels dial the app's port directly, bypassing the proxy's path dispatch. Since the path is never stripped, the shared URL printed for a `--path` app includes the prefix, for example `https://devbox.ts.net/api`.

## Custom TLD

By default, portless uses `.localhost` which auto-resolves to `127.0.0.1` in most browsers. If you prefer a different TLD (e.g. `.test`), use `--tld`:
Expand Down Expand Up @@ -379,8 +490,8 @@ Requires the ngrok CLI to be installed and authenticated. If ngrok reports an au
```bash
portless # Run dev script through proxy
portless # From monorepo root: run all workspace packages
portless run [--name <name>] [cmd] [args...] # Infer name, run through proxy
portless <name> <cmd> [args...] # Run app at https://<name>.localhost
portless run [--name <name>] [--path <prefix>] [cmd] [args...] # Infer name, run through proxy
portless <name> [--path <prefix>] <cmd> [args...] # Run app at https://<name>.localhost
portless alias <name> <port> # Register a static route (e.g. for Docker)
portless alias <name> <port> --force # Overwrite an existing route
portless alias --remove <name> # Remove a static route
Expand Down Expand Up @@ -428,6 +539,7 @@ portless service uninstall # Remove the startup service
--state-dir <path> Use a custom state directory with service install
--script <name> Run a specific package.json script (default: dev)
--app-port <number> Use a fixed port for the app (skip auto-assignment)
--path <prefix> URL path prefix for path-based routing (e.g. /api)
--tailscale Share the app on your Tailscale network (tailnet)
--funnel Share the app publicly via Tailscale Funnel
--ngrok Share the app publicly via ngrok
Expand All @@ -447,6 +559,7 @@ PORTLESS_LAN_IP=<address> Pin a specific LAN IP for LAN mode
PORTLESS_TLD=<tld>[,<tld>] Use one or more TLDs (e.g. localhost,test)
PORTLESS_WILDCARD=1 Allow unregistered subdomains to fall back to parent route
PORTLESS_SYNC_HOSTS=0 Disable auto-sync of /etc/hosts (on by default)
PORTLESS_PATH=<path> Path prefix for path-based routing (e.g. /api)
PORTLESS_TAILSCALE=1 Share apps on your Tailscale network (same as --tailscale)
PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel)
PORTLESS_NGROK=1 Share apps publicly via ngrok (same as --ngrok)
Expand Down
14 changes: 9 additions & 5 deletions apps/docs/src/app/commands/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Use a `portless.json` to override defaults. See [Configuration](/configuration).
## Run an app

```bash
portless run [--name <name>] [cmd] [args...]
portless <name> <cmd> [args...]
portless run [--name <name>] [--path <prefix>] [cmd] [args...]
portless <name> [--path <prefix>] <cmd> [args...]
```

`portless run` infers the project name from `portless.json`, `package.json`, git root, or directory name. When no command is given, runs the configured script (default: `"dev"`) from `package.json`. Use `--name` to override the inferred name while still applying worktree prefixes.
Expand Down Expand Up @@ -56,6 +56,10 @@ portless docs.myapp next dev
<td>`--app-port <number>`</td>
<td>Use a fixed port for the app instead of auto-assignment. Also configurable via `PORTLESS_APP_PORT` or `portless.json`.</td>
</tr>
<tr>
<td>`--path <prefix>`</td>
<td>URL path prefix for path-based routing (e.g. `/api`). Also configurable via `PORTLESS_PATH`.</td>
</tr>
<tr>
<td>`--force`</td>
<td>Override an existing route registered by another process</td>
Expand Down Expand Up @@ -93,14 +97,14 @@ Print the URL for a service. Useful for wiring services together in scripts or e
BACKEND_URL=$(portless get backend)
```

Applies worktree prefix detection by default. Use `--no-worktree` to skip it.
Applies worktree prefix detection by default. Use `--no-worktree` to skip it. Use `--path <prefix>` to include a path prefix in the URL.

## Alias (static routes)

```bash
portless alias <name> <port>
portless alias <name> <port> [--path <prefix>]
portless alias <name> <port> --force
portless alias --remove <name>
portless alias --remove <name> [--path <prefix>]
```

Register a route for a service not managed by portless (e.g. a Docker container). Aliases persist across stale-route cleanup.
Expand Down
16 changes: 15 additions & 1 deletion apps/docs/src/app/configuration/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ The name is inferred from `package.json` if not set in config. The script defaul
non-server scripts.
</td>
</tr>
<tr>
<td>`path`</td>
<td>string</td>
<td></td>
<td>
URL path prefix for path-based routing (e.g. `/api`). Apps sharing a `name` with different
`path` values are dispatched by longest prefix under one hostname.
</td>
</tr>
<tr>
<td>`apps`</td>
<td>object</td>
Expand All @@ -74,7 +83,7 @@ The name is inferred from `package.json` if not set in config. The script defaul
</tbody>
</table>

Each `apps` entry has the same shape (`name`, `script`, `appPort`, `proxy`). When `apps` is present, top-level fields apply only in single-app mode.
Each `apps` entry has the same shape (`name`, `script`, `appPort`, `proxy`, `path`). When `apps` is present, top-level fields apply only in single-app mode.

### package.json "portless" key

Expand Down Expand Up @@ -185,6 +194,11 @@ For `appPort`: CLI `--app-port` flag > `PORTLESS_APP_PORT` env var > `package.js
<td>Use a fixed port for the app (skip auto-assignment)</td>
<td>random 4000 to 4999</td>
</tr>
<tr>
<td>`PORTLESS_PATH`</td>
<td>Path prefix for path-based routing (e.g. `/api`)</td>
<td>none</td>
</tr>
<tr>
<td>`PORTLESS_SYNC_HOSTS`</td>
<td>Set to `0` to disable auto-sync of `/etc/hosts`</td>
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/src/app/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ portless docs.myapp next dev
# -> https://docs.myapp.localhost
```

## Path-based routing

Route multiple apps under one hostname by URL path:

```bash
portless myapp vite dev # serves /
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 to the backend unchanged — `--path /api` does not strip `/api` before proxying, so your app must serve its routes under that prefix (or you can set its base path accordingly). Useful for local API gateways, microfrontends, monorepos, or any setup where services share a domain. Also available via `PORTLESS_PATH=/api`.

## Git Worktrees

`portless run` automatically detects git worktrees. In a linked worktree, the branch name is prepended as a subdomain so each worktree gets its own URL without any config changes:
Expand Down
Loading
Loading