diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40d108fc..7047e201 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -115,11 +117,11 @@ jobs: - name: Extract changelog entry run: | VERSION="${{ needs.check-release.outputs.version }}" - awk '//{found=1; next} //{found=0} found{print}' CHANGELOG.md > /tmp/release-notes.md + awk '//{found=1; next} //{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 and markers in CHANGELOG.md" + echo "Error: No release notes found between and markers in CHANGELOG.fork.md" exit 1 fi echo "Extracted release notes for $VERSION ($LINES lines)" diff --git a/AGENTS.md b/AGENTS.md index 4719ad14..4693fb0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/CHANGELOG.fork.md b/CHANGELOG.fork.md new file mode 100644 index 00000000..b13c6930 --- /dev/null +++ b/CHANGELOG.fork.md @@ -0,0 +1,20 @@ +# Changelog (fork) + +Release notes for `@variablelab/portless`. Upstream's own history is in [CHANGELOG.md](./CHANGELOG.md). + +## 0.0.1 + + + +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 ` on `portless run` and `portless `, 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`. + + diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..b4bdde57 --- /dev/null +++ b/NOTICE @@ -0,0 +1,27 @@ +@variablelab/portless +Fork maintained by Ricardo Q. Bazan (rcrd) +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 ` 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). diff --git a/README.md b/README.md index 0f35d7d2..2bed03b4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. @@ -90,9 +98,21 @@ Without an `apps` map, hostnames follow the `..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: @@ -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 { @@ -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 `. +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`: @@ -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 ] [cmd] [args...] # Infer name, run through proxy -portless [args...] # Run app at https://.localhost +portless run [--name ] [--path ] [cmd] [args...] # Infer name, run through proxy +portless [--path ] [args...] # Run app at https://.localhost portless alias # Register a static route (e.g. for Docker) portless alias --force # Overwrite an existing route portless alias --remove # Remove a static route @@ -428,6 +539,7 @@ portless service uninstall # Remove the startup service --state-dir Use a custom state directory with service install --script Run a specific package.json script (default: dev) --app-port Use a fixed port for the app (skip auto-assignment) +--path 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 @@ -447,6 +559,7 @@ PORTLESS_LAN_IP=
Pin a specific LAN IP for LAN mode PORTLESS_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 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) diff --git a/apps/docs/src/app/commands/page.mdx b/apps/docs/src/app/commands/page.mdx index 18d900cc..d05960b5 100644 --- a/apps/docs/src/app/commands/page.mdx +++ b/apps/docs/src/app/commands/page.mdx @@ -19,8 +19,8 @@ Use a `portless.json` to override defaults. See [Configuration](/configuration). ## Run an app ```bash -portless run [--name ] [cmd] [args...] -portless [args...] +portless run [--name ] [--path ] [cmd] [args...] +portless [--path ] [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. @@ -56,6 +56,10 @@ portless docs.myapp next dev `--app-port ` Use a fixed port for the app instead of auto-assignment. Also configurable via `PORTLESS_APP_PORT` or `portless.json`. + + `--path ` + URL path prefix for path-based routing (e.g. `/api`). Also configurable via `PORTLESS_PATH`. + `--force` Override an existing route registered by another process @@ -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 ` to include a path prefix in the URL. ## Alias (static routes) ```bash -portless alias +portless alias [--path ] portless alias --force -portless alias --remove +portless alias --remove [--path ] ``` Register a route for a service not managed by portless (e.g. a Docker container). Aliases persist across stale-route cleanup. diff --git a/apps/docs/src/app/configuration/page.mdx b/apps/docs/src/app/configuration/page.mdx index 7b2c6aab..5a3a0ec6 100644 --- a/apps/docs/src/app/configuration/page.mdx +++ b/apps/docs/src/app/configuration/page.mdx @@ -59,6 +59,15 @@ The name is inferred from `package.json` if not set in config. The script defaul non-server scripts. + + `path` + string + + + 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. + + `apps` object @@ -74,7 +83,7 @@ The name is inferred from `package.json` if not set in config. The script defaul -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 @@ -185,6 +194,11 @@ For `appPort`: CLI `--app-port` flag > `PORTLESS_APP_PORT` env var > `package.js Use a fixed port for the app (skip auto-assignment) random 4000 to 4999 + + `PORTLESS_PATH` + Path prefix for path-based routing (e.g. `/api`) + none + `PORTLESS_SYNC_HOSTS` Set to `0` to disable auto-sync of `/etc/hosts` diff --git a/apps/docs/src/app/page.mdx b/apps/docs/src/app/page.mdx index 92701304..04870cb7 100644 --- a/apps/docs/src/app/page.mdx +++ b/apps/docs/src/app/page.mdx @@ -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: diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..96f88b08 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,29 @@ +# Releasing @variablelab/portless + +Releases are driven by the version in `packages/portless/package.json`, the same mechanism upstream uses. The workflow in `.github/workflows/release.yml` runs on every push to `main` and on manual dispatch: + +1. It compares the `version` in `packages/portless/package.json` with the latest version on npm. +2. If they differ, it builds and runs `npm publish --provenance` from `packages/portless`. +3. It then creates the GitHub release `v` with the notes found between `` and `` in `CHANGELOG.fork.md`. + +Pushing a new version to `main` is what publishes. There is no separate approval step, so treat a merge to `main` that changes the version as a release. + +## One-time setup + +- Create an npm granular access token with publish rights on the `@variablelab` scope and "bypass two-factor authentication" enabled (npm requires this for publishes from CI). Add it to the repository as the `NPM_TOKEN` secret under Settings, Secrets and variables, Actions. +- The publish job runs in the `Release` GitHub environment. GitHub creates it on the first run. Add required reviewers to that environment if you want a manual gate before every publish. +- Alternative: configure [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) for `variableland/portless` with the `release.yml` workflow, then delete the `NODE_AUTH_TOKEN` line from the publish step. Provenance works with either method. + +## Cut a release + +1. Branch from `main`: `git checkout -b release/v0.0.2`. +2. Bump `version` in `packages/portless/package.json`. +3. Add an entry at the top of `CHANGELOG.fork.md` wrapped in `` and ``, and remove the markers from the previous entry. Record which upstream version the release is based on. +4. Run `pnpm build && pnpm test`, then `cd packages/portless && npm publish --dry-run` to review the tarball. +5. Open a PR, get CI green, merge to `main`. The release workflow publishes and creates the tag and release. + +`prepublishOnly` copies `README.md`, `LICENSE`, and `NOTICE` into `packages/portless/`. The copies are gitignored; delete them after a local dry run so they are not confused with the root files. + +## Versioning + +Fork versions are independent from upstream's. `0.0.1` is the first release, based on upstream `v0.15.6` plus path-based routing. Each changelog entry records the upstream version it tracks. diff --git a/docs/SYNCING.md b/docs/SYNCING.md new file mode 100644 index 00000000..6d4d31d5 --- /dev/null +++ b/docs/SYNCING.md @@ -0,0 +1,77 @@ +# Syncing with upstream + +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)) +- `chore: rename package to @variablelab/portless` (packaging) +- fork docs, `CHANGELOG.fork.md`, and the release workflow + +Everything else should be identical to upstream. Keep it that way: the smaller the fork's diff, the cheaper each sync. + +## One-time setup + +```bash +git clone git@github.com:variableland/portless.git +cd portless +git remote add upstream https://github.com/vercel-labs/portless.git +git fetch upstream --tags +``` + +## Pull in upstream changes + +Merge, do not rebase: `main` is public and its history must stay stable. + +```bash +git checkout main +git pull origin main +git fetch upstream --tags +git checkout -b sync/upstream-$(date +%Y%m%d) +git merge upstream/main +``` + +Resolve conflicts (see below), then run the full pipeline before opening a PR: + +```bash +pnpm install --frozen-lockfile +pnpm format:check && pnpm lint && pnpm type-check +pnpm build && pnpm test && pnpm test:e2e +``` + +Open a PR against `main`. CI runs the same steps. Merging the PR does not publish anything unless the version changed; see [RELEASING.md](./RELEASING.md). + +## Where conflicts show up + +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 | + +Do not modify `CHANGELOG.md` or `LICENSE`: both stay upstream's. The fork's release notes live in `CHANGELOG.fork.md`. + +To see exactly what the fork changes relative to upstream at any time: + +```bash +git diff upstream/main...main --stat +git diff upstream/main...main -- packages/portless/src +``` + +## If upstream merges PR #165 + +```bash +gh pr view 165 --repo vercel-labs/portless --json state,mergedAt +``` + +If it was merged, the feature commit is redundant. Resolve the merge in favor of upstream for the feature files, keep only the packaging and docs commits, and consider deprecating the package on npm with a pointer to upstream. + +## Rules + +- Never force-push `main`. +- Never push to `upstream`. Changes for upstream go through PRs on vercel-labs/portless. +- Keep upstream's `LICENSE` and copyright intact (Apache-2.0, Vercel Inc.). diff --git a/packages/portless/.gitignore b/packages/portless/.gitignore index b43bf86b..70319792 100644 --- a/packages/portless/.gitignore +++ b/packages/portless/.gitignore @@ -1 +1,3 @@ README.md +LICENSE +NOTICE diff --git a/packages/portless/package.json b/packages/portless/package.json index 35c72934..213681ba 100644 --- a/packages/portless/package.json +++ b/packages/portless/package.json @@ -1,7 +1,7 @@ { - "name": "portless", - "version": "0.15.6", - "description": "Replace port numbers with stable, named .localhost URLs. For humans and agents.", + "name": "@variablelab/portless", + "version": "0.0.1", + "description": "Fork of vercel-labs/portless with path-based routing (--path). Replace port numbers with stable, named .localhost URLs and route several apps under one hostname by URL prefix.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -12,11 +12,15 @@ } }, "bin": { - "portless": "./dist/cli.js" + "portless": "dist/cli.js" }, "files": [ - "dist" + "dist", + "NOTICE" ], + "publishConfig": { + "access": "public" + }, "engines": { "node": ">=24" }, @@ -30,7 +34,7 @@ "dev": "tsup --watch", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", - "prepublishOnly": "cp ../../README.md . && pnpm build", + "prepublishOnly": "cp ../../README.md ../../LICENSE ../../NOTICE . && pnpm build", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", @@ -40,17 +44,31 @@ "local", "development", "proxy", - "localhost" + "localhost", + "path-based-routing", + "reverse-proxy", + "dev-server", + "https", + "monorepo", + "portless" ], "author": "Vercel Labs", + "contributors": [ + { + "name": "Ricardo (rcrd)", + "email": "ricardo@variable.land", + "url": "https://github.com/variableland/portless" + } + ], "license": "Apache-2.0", "repository": { "type": "git", - "url": "https://github.com/vercel-labs/portless.git" + "url": "git+https://github.com/variableland/portless.git", + "directory": "packages/portless" }, - "homepage": "https://portless.sh", + "homepage": "https://github.com/variableland/portless#readme", "bugs": { - "url": "https://github.com/vercel-labs/portless/issues" + "url": "https://github.com/variableland/portless/issues" }, "devDependencies": { "@types/node": "^24.12.4", diff --git a/packages/portless/src/cli.test.ts b/packages/portless/src/cli.test.ts index 44b2e802..c0f8aa09 100644 --- a/packages/portless/src/cli.test.ts +++ b/packages/portless/src/cli.test.ts @@ -1609,6 +1609,12 @@ describe("CLI", () => { expect(stdout.trim()).toMatch(/^https?:\/\/backend\.localhost(:\d+)?$/); }); + it("appends the prefix with --path", () => { + const { status, stdout } = run(["get", "backend", "--path", "/api"], { env: getEnv() }); + expect(status).toBe(0); + expect(stdout.trim()).toMatch(/^https?:\/\/backend\.localhost(:\d+)?\/api$/); + }); + it("exits 1 for invalid hostname", () => { const { status, stderr } = run(["get", "my@app"]); expect(status).toBe(1); @@ -1646,6 +1652,47 @@ describe("CLI", () => { }); }); + describe("--path flag", () => { + it("shows --path in run --help output", () => { + const { status, stdout } = run(["run", "--help"]); + expect(status).toBe(0); + expect(stdout).toContain("--path"); + }); + + it("shows --path in main help output", () => { + const { status, stdout } = run(["--help"]); + expect(status).toBe(0); + expect(stdout).toContain("--path"); + }); + + it("rejects --path without a value in named mode", () => { + const { status, stderr } = run(["myapp", "--path"]); + expect(status).not.toBe(0); + expect(stderr).toContain("--path requires a path value"); + }); + + it("rejects --path without a value in run mode", () => { + const { status, stderr } = run(["run", "--path"]); + expect(status).not.toBe(0); + expect(stderr).toContain("--path requires a path value"); + }); + + it("prints a friendly error for an invalid --path value", () => { + const { status, stderr } = run(["get", "backend", "--path", "/api?x=1"]); + expect(status).toBe(1); + expect(stderr).toContain("Invalid path prefix"); + expect(stderr).not.toContain("at "); + }); + + it("prints a friendly error for an invalid PORTLESS_PATH", () => { + const { status, stderr } = run(["myapp"], { + env: { PORTLESS_PATH: "/a//b" }, + }); + expect(status).toBe(1); + expect(stderr).toContain("Invalid PORTLESS_PATH"); + }); + }); + describe("run --name flag", () => { it("shows --name in run help", () => { const { status, stdout } = run(["run", "--help"]); diff --git a/packages/portless/src/cli.ts b/packages/portless/src/cli.ts index 3bc9f525..b2258c33 100644 --- a/packages/portless/src/cli.ts +++ b/packages/portless/src/cli.ts @@ -19,6 +19,7 @@ import { formatUrl, isErrnoException, isProcessAlive as isPidAlive, + normalizePathPrefix, parseHostname, parseHostnames, } from "./utils.js"; @@ -130,6 +131,9 @@ import { import type { ManifestEntry } from "./turbo.js"; import { buildServiceUninstallSudoArgs, handleService, tryUninstallService } from "./service.js"; +/** npm package name of this fork. The installed command is still `portless`. */ +const PACKAGE_NAME = "@variablelab/portless"; + const chalk = colors; // --------------------------------------------------------------------------- @@ -394,13 +398,13 @@ function getEntryScript(): string { /** * Check whether portless is installed as a project dependency by walking - * up from cwd looking for node_modules/portless. Used to distinguish a + * up from cwd looking for the package under node_modules. Used to distinguish a * local `npx portless` (allowed) from a one-off download (blocked). */ function isLocallyInstalled(): boolean { let dir = process.cwd(); for (;;) { - if (fs.existsSync(path.join(dir, "node_modules", "portless", "package.json"))) { + if (fs.existsSync(path.join(dir, "node_modules", ...PACKAGE_NAME.split("/"), "package.json"))) { return true; } const parent = path.dirname(dir); @@ -481,8 +485,13 @@ function buildHostnames(name: string, tlds: readonly string[]): string[] { return parseHostnames(name, normalizeTlds(tlds)); } -function formatUrls(hostnames: readonly string[], proxyPort: number, tls: boolean): string[] { - return hostnames.map((hostname) => formatUrl(hostname, proxyPort, tls)); +function formatUrls( + hostnames: readonly string[], + proxyPort: number, + tls: boolean, + pathPrefix?: string +): string[] { + return hostnames.map((hostname) => formatUrl(hostname, proxyPort, tls, pathPrefix)); } function formatViteAllowedHosts(tlds: readonly string[]): string { @@ -502,13 +511,14 @@ function addRoutes( hostnames: readonly string[], port: number, pid: number, - force = false + force = false, + pathPrefix?: string ): number[] { const registered: string[] = []; const killedPids: number[] = []; try { for (const hostname of hostnames) { - const killedPid = store.addRoute(hostname, port, pid, force); + const killedPid = store.addRoute(hostname, port, pid, force, pathPrefix); registered.push(hostname); if (killedPid !== undefined) { killedPids.push(killedPid); @@ -517,7 +527,7 @@ function addRoutes( } catch (err) { for (const hostname of registered) { try { - store.removeRoute(hostname, pid); + store.removeRoute(hostname, pid, pathPrefix); } catch { // Non-fatal rollback cleanup. } @@ -527,10 +537,15 @@ function addRoutes( return [...new Set(killedPids)]; } -function removeRoutes(store: RouteStore, hostnames: readonly string[], ownerPid?: number): void { +function removeRoutes( + store: RouteStore, + hostnames: readonly string[], + ownerPid?: number, + pathPrefix?: string +): void { for (const hostname of hostnames) { try { - store.removeRoute(hostname, ownerPid); + store.removeRoute(hostname, ownerPid, pathPrefix); } catch { // Non-fatal cleanup. } @@ -601,10 +616,25 @@ function startProxyServer( const onMdnsError = (msg: string) => console.warn(chalk.yellow(msg)); + // Path routes can share a hostname on different ports, so hostname-keyed + // consumers (hosts file, mDNS) must dedupe. The mDNS change-detection key + // maps each hostname to its sorted route ports; a same-hostname set of path + // routes yields one stable key instead of thrashing publish/unpublish. + const uniqueHostnames = () => [...new Set(cachedRoutes.map((r) => r.hostname))]; + const routePortKeys = (routes: { hostname: string; port: number }[]) => { + const ports = new Map(); + for (const r of routes) { + const list = ports.get(r.hostname) ?? []; + list.push(r.port); + ports.set(r.hostname, list); + } + return new Map([...ports].map(([h, list]) => [h, list.sort((a, b) => a - b).join(",")])); + }; + const publishCachedRoutes = () => { if (!activeLanIp) return; - for (const route of cachedRoutes) { - publish(route.hostname, proxyPort, activeLanIp, onMdnsError); + for (const hostname of uniqueHostnames()) { + publish(hostname, proxyPort, activeLanIp, onMdnsError); } }; @@ -654,27 +684,28 @@ function startProxyServer( } catch { hostnames = cachedRoutes.map((route) => route.hostname); } - syncHostsAndLatch(hostnames); + // Path-based routes share a hostname; the hosts block wants each once. + syncHostsAndLatch([...new Set(hostnames)]); return "acted"; }; const reloadRoutes = () => { try { - const previousRoutes = new Map(cachedRoutes.map((r) => [r.hostname, r.port])); + const previousRoutes = routePortKeys(cachedRoutes); cachedRoutes = store.loadRoutes(); if (autoSyncHosts) { - syncHostsAndLatch(cachedRoutes.map((r) => r.hostname)); + syncHostsAndLatch(uniqueHostnames()); } // Sync mDNS records with current routes if (activeLanIp) { - const currentRoutes = new Map(cachedRoutes.map((r) => [r.hostname, r.port])); - for (const route of cachedRoutes) { - const previousPort = previousRoutes.get(route.hostname); - if (previousPort === undefined) { - publish(route.hostname, proxyPort, activeLanIp, onMdnsError); - } else if (previousPort !== route.port) { - unpublish(route.hostname); - publish(route.hostname, proxyPort, activeLanIp, onMdnsError); + const currentRoutes = routePortKeys(cachedRoutes); + for (const [hostname, portsKey] of currentRoutes) { + const previousKey = previousRoutes.get(hostname); + if (previousKey === undefined) { + publish(hostname, proxyPort, activeLanIp, onMdnsError); + } else if (previousKey !== portsKey) { + unpublish(hostname); + publish(hostname, proxyPort, activeLanIp, onMdnsError); } } for (const hostname of previousRoutes.keys()) { @@ -700,7 +731,7 @@ function startProxyServer( } if (autoSyncHosts) { - syncHostsAndLatch(cachedRoutes.map((r) => r.hostname)); + syncHostsAndLatch(uniqueHostnames()); } // Publish mDNS for routes that already exist at startup @@ -1030,6 +1061,11 @@ async function stopProxy(store: RouteStore, proxyPort: number, _tls: boolean): P } } +/** Display label for a route: hostname plus its path prefix when present. */ +function formatRouteLabel(route: { hostname: string; pathPrefix?: string }): string { + return `${route.hostname}${route.pathPrefix ?? ""}`; +} + function listRoutes(store: RouteStore, proxyPort: number, tls: boolean): void { const routes = store.loadRoutes(); @@ -1041,17 +1077,20 @@ function listRoutes(store: RouteStore, proxyPort: number, tls: boolean): void { console.log(colors.blue.bold("\nActive routes:\n")); for (const route of routes) { - const url = formatUrl(route.hostname, proxyPort, tls); + const url = formatUrl(route.hostname, proxyPort, tls, route.pathPrefix); const label = route.pid === 0 ? "(alias)" : `(pid ${route.pid})`; console.log( ` ${colors.cyan(url)} ${colors.gray("->")} ${colors.white(`localhost:${route.port}`)} ${colors.gray(label)}` ); if (route.tailscaleUrl) { const tsLabel = route.tailscaleFunnel ? "funnel" : "tailscale"; - console.log(` ${colors.gray(tsLabel + ":")} ${colors.green(route.tailscaleUrl)}`); + const tsUrl = `${route.tailscaleUrl}${route.pathPrefix ?? ""}`; + console.log(` ${colors.gray(tsLabel + ":")} ${colors.green(tsUrl)}`); } if (route.ngrokUrl) { - console.log(` ${colors.gray("ngrok:")} ${colors.green(route.ngrokUrl)}`); + console.log( + ` ${colors.gray("ngrok:")} ${colors.green(`${route.ngrokUrl}${route.pathPrefix ?? ""}`)}` + ); } } console.log(); @@ -1219,6 +1258,7 @@ async function runApp( force: boolean, autoInfo?: { nameSource: string; prefix?: string; prefixSource?: string }, desiredPort?: number, + pathPrefix?: string, lanMode = false, lanIp?: string | null ) { @@ -1324,10 +1364,11 @@ async function runApp( ); } + const displayHostnames = pathPrefix ? hostnames.map((h) => `${h}${pathPrefix}`) : hostnames; if (lanIp) { - console.log(chalk.gray(`-- ${hostnames.join(", ")} (LAN: ${lanIp})`)); + console.log(chalk.gray(`-- ${displayHostnames.join(", ")} (LAN: ${lanIp})`)); } else { - console.log(chalk.gray(`-- ${hostnames.join(", ")} (auto-resolves to 127.0.0.1)`)); + console.log(chalk.gray(`-- ${displayHostnames.join(", ")} (auto-resolves to 127.0.0.1)`)); } if (autoInfo) { const baseName = autoInfo.prefix ? name.slice(autoInfo.prefix.length + 1) : name; @@ -1336,6 +1377,9 @@ async function runApp( console.log(chalk.gray(`-- Prefix "${autoInfo.prefix}" (from ${autoInfo.prefixSource})`)); } } + if (pathPrefix) { + console.log(chalk.gray(`-- Path "${pathPrefix}"`)); + } const port = desiredPort ?? (await findFreePort()); if (desiredPort) { @@ -1347,7 +1391,7 @@ async function runApp( // Register route (--force kills the existing owner if any) let killedPids: number[] = []; try { - killedPids = addRoutes(store, hostnames, port, process.pid, force); + killedPids = addRoutes(store, hostnames, port, process.pid, force, pathPrefix); await reportHostsSyncHere(hostnames, proxyPort, tls, lanMode); } catch (err) { if (err instanceof RouteConflictError) { @@ -1360,8 +1404,8 @@ async function runApp( console.log(colors.yellow(`Killed existing process(es): ${killedPids.join(", ")}`)); } - const finalUrl = formatUrl(hostname, proxyPort, tls); - const allUrls = formatUrls(hostnames, proxyPort, tls); + const finalUrl = formatUrl(hostname, proxyPort, tls, pathPrefix); + const allUrls = formatUrls(hostnames, proxyPort, tls, pathPrefix); console.log(chalk.cyan.bold(`\n -> ${finalUrl}\n`)); for (const extraUrl of allUrls.slice(1)) { console.log(chalk.cyan(` also -> ${extraUrl}`)); @@ -1402,10 +1446,14 @@ async function runApp( ) ); try { - store.updateRoute(hostname, { - ngrokUrl: null, - ngrokPid: null, - }); + store.updateRoute( + hostname, + { + ngrokUrl: null, + ngrokPid: null, + }, + pathPrefix + ); } catch { // Best-effort cleanup; non-fatal } @@ -1436,7 +1484,9 @@ async function runApp( // a successful register or exits the process on final failure. tailscaleUrl = formatTailscaleUrl(tsBaseUrl, tailscaleHttpsPort!); const label = wantsFunnel ? "Funnel (public)" : "Tailscale"; - console.log(chalk.green(` ${label} -> ${tailscaleUrl}`)); + // The tunnel dials the app port directly and the path is never stripped, + // so the app's routes live under the prefix on the tunnel too. + console.log(chalk.green(` ${label} -> ${tailscaleUrl}${pathPrefix ?? ""}`)); if (wantsFunnel) { console.log(chalk.gray(" (accessible from the public internet via Tailscale Funnel)\n")); } else { @@ -1444,11 +1494,15 @@ async function runApp( } try { - store.updateRoute(hostname, { - tailscaleUrl: tailscaleUrl, - tailscaleHttpsPort, - tailscaleFunnel: wantsFunnel || undefined, - }); + store.updateRoute( + hostname, + { + tailscaleUrl: tailscaleUrl, + tailscaleHttpsPort, + tailscaleFunnel: wantsFunnel || undefined, + }, + pathPrefix + ); } catch { // Non-fatal: the local hostname keeps routing without it, but the // proxy needs tailscaleUrl to route requests arriving with the @@ -1464,14 +1518,18 @@ async function runApp( onExit: handleNgrokExit, }); ngrokUrl = ngrokProcess.url; - console.log(chalk.green(` ngrok -> ${ngrokUrl}`)); + console.log(chalk.green(` ngrok -> ${ngrokUrl}${pathPrefix ?? ""}`)); console.log(chalk.gray(" (accessible from the public internet via ngrok)\n")); try { - store.updateRoute(hostname, { - ngrokUrl, - ngrokPid: ngrokProcess.pid, - }); + store.updateRoute( + hostname, + { + ngrokUrl, + ngrokPid: ngrokProcess.pid, + }, + pathPrefix + ); } catch { // Non-fatal: route display metadata only } finally { @@ -1499,7 +1557,7 @@ async function runApp( // Best-effort cleanup; non-fatal } try { - removeRoutes(store, hostnames, process.pid); + removeRoutes(store, hostnames, process.pid, pathPrefix); } catch { // Best-effort cleanup; non-fatal } @@ -1581,7 +1639,7 @@ async function runApp( // Best-effort cleanup; non-fatal } try { - removeRoutes(store, hostnames, process.pid); + removeRoutes(store, hostnames, process.pid, pathPrefix); } catch { // Lock acquisition may fail during cleanup; non-fatal } @@ -1599,6 +1657,8 @@ interface ParsedRunArgs { appPort?: number; /** Override the inferred base name (from --name flag). */ name?: string; + /** URL path prefix for path-based routing (e.g. "/api"). */ + pathPrefix?: string; /** The child command and its arguments, passed through untouched. */ commandArgs: string[]; } @@ -1632,6 +1692,29 @@ function appPortFromEnv(): number | undefined { return port; } +/** Normalize a user-supplied path prefix, printing a friendly error on invalid input. */ +function parsePathPrefixOrExit(value: string): string | undefined { + try { + return normalizePathPrefix(value); + } catch (err) { + console.error(colors.red(`Error: ${(err as Error).message}`)); + process.exit(1); + } +} + +function pathPrefixFromEnv(): string | undefined { + const envVal = process.env.PORTLESS_PATH; + if (!envVal) return undefined; + try { + return normalizePathPrefix(envVal); + } catch (err) { + console.error( + colors.red(`Error: Invalid PORTLESS_PATH="${envVal}". ${(err as Error).message}`) + ); + process.exit(1); + } +} + function applySharingFlag(flag: string): boolean { if (flag === "--tailscale") { process.env.PORTLESS_TAILSCALE = "1"; @@ -1660,6 +1743,7 @@ function parseRunArgs(args: string[]): ParsedRunArgs { let force = false; let appPort: number | undefined; let name: string | undefined; + let pathPrefix: string | undefined; let i = 0; while (i < args.length && args[i].startsWith("-")) { @@ -1683,6 +1767,7 @@ ${colors.bold("Options:")} --name Override the inferred base name (worktree prefix still applies) --force Kill the existing process and take over its route --app-port Use a fixed port for the app (skip auto-assignment) + --path 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 @@ -1719,13 +1804,21 @@ ${colors.bold("Examples:")} process.exit(1); } name = args[i]; + } else if (args[i] === "--path") { + i++; + if (!args[i] || args[i].startsWith("-")) { + console.error(colors.red("Error: --path requires a path value.")); + console.error(colors.cyan(" portless run --path /api ")); + process.exit(1); + } + pathPrefix = parsePathPrefixOrExit(args[i]); } else if (applySharingFlag(args[i])) { // handled } else { console.error(colors.red(`Error: Unknown flag "${args[i]}".`)); console.error( colors.blue( - "Known flags: --name, --force, --app-port, --tailscale, --funnel, --ngrok, --help" + "Known flags: --name, --force, --app-port, --path, --tailscale, --funnel, --ngrok, --help" ) ); process.exit(1); @@ -1734,8 +1827,9 @@ ${colors.bold("Examples:")} } if (!appPort) appPort = appPortFromEnv(); + if (!pathPrefix) pathPrefix = pathPrefixFromEnv(); - return { force, appPort, name, commandArgs: args.slice(i) }; + return { force, appPort, name, pathPrefix, commandArgs: args.slice(i) }; } /** @@ -1748,6 +1842,7 @@ ${colors.bold("Examples:")} function parseAppArgs(args: string[]): ParsedAppArgs { let force = false; let appPort: number | undefined; + let pathPrefix: string | undefined; let i = 0; // Consume leading flags before name @@ -1760,12 +1855,20 @@ function parseAppArgs(args: string[]): ParsedAppArgs { } else if (args[i] === "--app-port") { i++; appPort = parseAppPort(args[i]); + } else if (args[i] === "--path") { + i++; + if (!args[i] || args[i].startsWith("-")) { + console.error(colors.red("Error: --path requires a path value.")); + console.error(colors.cyan(" portless --path /api ")); + process.exit(1); + } + pathPrefix = parsePathPrefixOrExit(args[i]); } else if (applySharingFlag(args[i])) { // handled } else { console.error(colors.red(`Error: Unknown flag "${args[i]}".`)); console.error( - colors.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok") + colors.blue("Known flags: --force, --app-port, --path, --tailscale, --funnel, --ngrok") ); process.exit(1); } @@ -1786,12 +1889,20 @@ function parseAppArgs(args: string[]): ParsedAppArgs { } else if (args[i] === "--app-port") { i++; appPort = parseAppPort(args[i]); + } else if (args[i] === "--path") { + i++; + if (!args[i] || args[i].startsWith("-")) { + console.error(colors.red("Error: --path requires a path value.")); + console.error(colors.cyan(" portless --path /api ")); + process.exit(1); + } + pathPrefix = parsePathPrefixOrExit(args[i]); } else if (applySharingFlag(args[i])) { // handled } else { console.error(colors.red(`Error: Unknown flag "${args[i]}".`)); console.error( - colors.blue("Known flags: --force, --app-port, --tailscale, --funnel, --ngrok") + colors.blue("Known flags: --force, --app-port, --path, --tailscale, --funnel, --ngrok") ); process.exit(1); } @@ -1799,8 +1910,9 @@ function parseAppArgs(args: string[]): ParsedAppArgs { } if (!appPort) appPort = appPortFromEnv(); + if (!pathPrefix) pathPrefix = pathPrefixFromEnv(); - return { force, appPort, name, commandArgs: args.slice(i) }; + return { force, appPort, name, pathPrefix, commandArgs: args.slice(i) }; } // --------------------------------------------------------------------------- @@ -1815,8 +1927,8 @@ Eliminates port conflicts, memorizing port numbers, and cookie/storage clashes by giving each dev server a stable .localhost URL. ${colors.bold("Install:")} - ${colors.cyan("npm install -g portless")} Global (recommended) - ${colors.cyan("npm install -D portless")} Project dev dependency + ${colors.cyan(`npm install -g ${PACKAGE_NAME}`)} Global (recommended) + ${colors.cyan(`npm install -D ${PACKAGE_NAME}`)} Project dev dependency ${colors.bold("Requirements:")} Node.js 24+ @@ -1829,6 +1941,7 @@ ${colors.bold("Usage:")} ${colors.cyan("portless ")} Run with an explicit app name ${colors.cyan("portless proxy start")} Start the proxy (HTTPS on port 443, daemon); rarely needed since it auto-starts on first run ${colors.cyan("portless proxy stop")} Stop the proxy + ${colors.cyan("portless --path /prefix ")} Route by URL path prefix ${colors.cyan("portless service install")} Start proxy automatically when the OS starts ${colors.cyan("portless get ")} Print URL for a service (for cross-service refs) ${colors.cyan("portless alias ")} Register a static route (e.g. for Docker) @@ -1856,6 +1969,8 @@ ${colors.bold("Examples:")} portless myapp --tailscale next dev # -> also https://.ts.net (tailnet) portless myapp --funnel next dev # -> also https://.ts.net (public) portless myapp --ngrok next dev # -> also https://.ngrok.app (public) + portless myapp --path /api pnpm start # -> https://myapp.localhost/api + portless myapp --path /docs next dev # -> https://myapp.localhost/docs ${colors.bold("Configuration (portless.json):")} Optional. Portless works out of the box by running the "dev" script @@ -1865,6 +1980,14 @@ ${colors.bold("Configuration (portless.json):")} Override script: { "name": "myapp", "script": "start" } Monorepo: { "apps": { "apps/web": { "name": "myapp" } } } +${colors.bold("Path-based routing:")} + Route multiple apps under one hostname by URL path: + 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 to dispatch requests. + In portless.json: { "apps": { "apps/api": { "name": "myapp", "path": "/api" } } } + ${colors.bold("In package.json:")} { "scripts": { @@ -1976,6 +2099,7 @@ ${colors.bold("Environment variables:")} PORTLESS_FUNNEL=1 Share apps publicly via Tailscale Funnel (same as --funnel) PORTLESS_NGROK=1 Share apps publicly via ngrok (same as --ngrok) PORTLESS_STATE_DIR= Override the state directory + PORTLESS_PATH= Path prefix for path-based routing (e.g. /api) PORTLESS=0 Run command directly without proxy ${colors.bold("Child process environment:")} @@ -2238,7 +2362,9 @@ ${colors.bold("Options:")} for (const route of stale) { const pids = findPidsOnPort(route.port); if (pids.length === 0) { - console.log(` ${route.hostname} :${route.port} - route removed (port already free)`); + console.log( + ` ${formatRouteLabel(route)} :${route.port} - route removed (port already free)` + ); continue; } const signal = forceKill ? "SIGKILL" : "SIGTERM"; @@ -2246,9 +2372,9 @@ ${colors.bold("Options:")} try { process.kill(pid, signal); killed++; - console.log(` ${route.hostname} :${route.port} - killed PID ${pid} (${signal})`); + console.log(` ${formatRouteLabel(route)} :${route.port} - killed PID ${pid} (${signal})`); } catch { - console.log(` ${route.hostname} :${route.port} - PID ${pid} already exited`); + console.log(` ${formatRouteLabel(route)} :${route.port} - PID ${pid} already exited`); } } } @@ -2286,6 +2412,7 @@ together: ${colors.bold("Options:")} --no-worktree Skip worktree prefix detection + --path Include a path prefix in the URL --help, -h Show this help ${colors.bold("Examples:")} @@ -2297,14 +2424,22 @@ ${colors.bold("Examples:")} } let skipWorktree = false; + let pathPrefix: string | undefined; const positional: string[] = []; for (let i = 1; i < args.length; i++) { if (args[i] === "--no-worktree") { skipWorktree = true; + } else if (args[i] === "--path") { + i++; + if (!args[i] || args[i].startsWith("-")) { + console.error(colors.red("Error: --path requires a path value.")); + process.exit(1); + } + pathPrefix = parsePathPrefixOrExit(args[i]); } else if (args[i].startsWith("-")) { console.error(colors.red(`Error: Unknown flag "${args[i]}".`)); - console.error(colors.blue("Known flags: --no-worktree, --help")); + console.error(colors.blue("Known flags: --no-worktree, --path, --help")); process.exit(1); } else { positional.push(args[i]); @@ -2326,7 +2461,7 @@ ${colors.bold("Examples:")} const { port, tls, tlds } = await discoverState(); const hostname = buildHostnames(effectiveName, tlds)[0]!; - const url = formatUrl(hostname, port, tls); + const url = formatUrl(hostname, port, tls, pathPrefix); // Print bare URL to stdout so it works in $(portless get ) process.stdout.write(url + "\n"); } @@ -2361,15 +2496,30 @@ ${colors.bold("Examples:")} console.error(colors.cyan(" portless alias --remove ")); process.exit(1); } + let removePathPrefix: string | undefined; + for (let i = 3; i < args.length; i++) { + if (args[i] === "--path") { + i++; + if (!args[i] || args[i].startsWith("-")) { + console.error(colors.red("Error: --path requires a path value.")); + process.exit(1); + } + removePathPrefix = parsePathPrefixOrExit(args[i]); + } + } const hostnames = buildHostnames(aliasName, tlds); const routes = store.loadRoutes(); - const existing = routes.find((r) => hostnames.includes(r.hostname) && r.pid === 0); + const existing = routes.find( + (r) => hostnames.includes(r.hostname) && r.pid === 0 && r.pathPrefix === removePathPrefix + ); if (!existing) { - console.error(colors.red(`Error: No alias found for "${hostnames.join(", ")}".`)); + console.error( + colors.red(`Error: No alias found for "${hostnames.join(", ")}${removePathPrefix || ""}".`) + ); process.exit(1); } - removeRoutes(store, hostnames); - console.log(colors.green(`Removed alias: ${hostnames.join(", ")}`)); + removeRoutes(store, hostnames, undefined, removePathPrefix); + console.log(colors.green(`Removed alias: ${hostnames.join(", ")}${removePathPrefix || ""}`)); return; } @@ -2392,9 +2542,24 @@ ${colors.bold("Examples:")} process.exit(1); } + let pathPrefix: string | undefined; + for (let i = 3; i < args.length; i++) { + if (args[i] === "--path") { + i++; + if (!args[i] || args[i].startsWith("-")) { + console.error(colors.red("Error: --path requires a path value.")); + process.exit(1); + } + pathPrefix = parsePathPrefixOrExit(args[i]); + } + } const force = args.includes("--force"); - addRoutes(store, hostnames, port, 0, force); - console.log(colors.green(`Alias registered: ${hostnames.join(", ")} -> 127.0.0.1:${port}`)); + addRoutes(store, hostnames, port, 0, force, pathPrefix); + console.log( + colors.green( + `Alias registered: ${hostnames.map((h) => `${h}${pathPrefix || ""}`).join(", ")} -> 127.0.0.1:${port}` + ) + ); // Awaited: this command exits, and a warning printed after exit reaches nobody. await reportHostsSyncHere(hostnames, proxyPort, tls, lanMode); } @@ -2485,7 +2650,7 @@ ${colors.bold("Usage: portless hosts ")} return; } } - const hostnames = routes.map((r) => r.hostname); + const hostnames = [...new Set(routes.map((r) => r.hostname))]; if (syncHostsFile(hostnames)) { console.log(colors.green(`Synced ${hostnames.length} hostname(s) to ${HOSTS_DISPLAY}:`)); for (const h of hostnames) { @@ -2822,7 +2987,7 @@ ${colors.bold("Options:")} } for (const route of staleRoutes.slice(0, 5)) { - add("warn", `Stale route ${route.hostname} is owned by exited PID ${route.pid}.`); + add("warn", `Stale route ${formatRouteLabel(route)} is owned by exited PID ${route.pid}.`); } if (staleRoutes.length > 5) { add("warn", `${staleRoutes.length - 5} additional stale routes hidden.`); @@ -2842,7 +3007,7 @@ ${colors.bold("Options:")} if (invalidPort) { add( "warn", - `Route ${route.hostname} has invalid port ${route.port}.`, + `Route ${formatRouteLabel(route)} has invalid port ${route.port}.`, route.pid === 0 ? "Remove or recreate the alias." : "Run: portless prune" ); continue; @@ -2850,7 +3015,7 @@ ${colors.bold("Options:")} if (listening) continue; add( "warn", - `Route ${route.hostname} points to port ${route.port}, but nothing is listening there.`, + `Route ${formatRouteLabel(route)} points to port ${route.port}, but nothing is listening there.`, route.pid === 0 ? "Remove the alias or start that service." : "The app may still be starting." ); } @@ -2869,11 +3034,13 @@ ${colors.bold("Options:")} } } else if (liveRoutes.length > 0) { const managedHosts = new Set(getManagedHostnames()); + // Path routes can share a hostname; resolution is per-hostname, so dedupe. + const uniqueLiveHostnames = [...new Set(liveRoutes.map((route) => route.hostname))]; const resolutionChecks = await Promise.all( - liveRoutes.map(async (route) => ({ - hostname: route.hostname, - resolves: await checkHostResolution(route.hostname), - managed: managedHosts.has(route.hostname), + uniqueLiveHostnames.map(async (hostname) => ({ + hostname, + resolves: await checkHostResolution(hostname), + managed: managedHosts.has(hostname), })) ); const unresolved = resolutionChecks.filter((result) => !result.resolves); @@ -3556,6 +3723,10 @@ async function handleDefaultSingle( const worktree = detectWorktreePrefix(cwd); const effectiveName = applyWorktreePrefix(baseName, worktree); + // PORTLESS_PATH wins over the config "path", mirroring appPort precedence. + const pathPrefix = + pathPrefixFromEnv() ?? (appConfig?.path ? parsePathPrefixOrExit(appConfig.path) : undefined); + const { dir, port, tls, tlds, lanMode, lanIp } = await discoverState(); const store = new RouteStore(dir, { onWarning: (msg) => console.warn(colors.yellow(msg)), @@ -3571,6 +3742,7 @@ async function handleDefaultSingle( false, { nameSource, prefix: worktree?.prefix, prefixSource: worktree?.source }, appConfig?.appPort, + pathPrefix, lanMode, lanIp ); @@ -3588,6 +3760,8 @@ interface MultiAppEntry { label: string; commandArgs: string[]; appPort?: number; + /** URL path prefix for path-based routing (e.g. "/api"). */ + pathPrefix?: string; proxied: boolean; } @@ -3643,7 +3817,7 @@ async function spawnProxiedApp( ): Promise<{ child: ReturnType; displayUrl: string; - route: { store: RouteStore; hostnames: string[] } | null; + route: { store: RouteStore; hostnames: string[]; pathPrefix?: string } | null; }> { const usesPortless = app.commandArgs[0] === "portless"; @@ -3665,11 +3839,11 @@ async function spawnProxiedApp( const appPort = app.appPort ?? (await findFreePort()); hostnames = buildHostnames(app.name, tlds); - const urls = formatUrls(hostnames, proxyPort, tls); + const urls = formatUrls(hostnames, proxyPort, tls, app.pathPrefix); const url = urls[0]!; displayUrl = url; - addRoutes(store, hostnames, appPort, process.pid); + addRoutes(store, hostnames, appPort, process.pid, false, app.pathPrefix); await reportHostsSyncHere(hostnames, proxyPort, tls, lanMode); env = { @@ -3701,11 +3875,12 @@ async function spawnProxiedApp( console.error(colors.yellow(`[${app.name}] killed by ${signal}`)); } if (capturedStore && capturedHostnames.length > 0) { - removeRoutes(capturedStore, capturedHostnames, process.pid); + removeRoutes(capturedStore, capturedHostnames, process.pid, app.pathPrefix); } }); - const route = store && hostnames.length > 0 ? { store, hostnames } : null; + const route = + store && hostnames.length > 0 ? { store, hostnames, pathPrefix: app.pathPrefix } : null; return { child, displayUrl, route }; } @@ -3844,7 +4019,12 @@ async function handleDefaultMulti( name = applyWorktreePrefix(name, worktree); - apps.push({ pkg, name, label, commandArgs, appPort: appOverride.appPort, proxied }); + // Per-app path prefixes let several apps share one hostname (e.g. web at + // "/" and api at "/api"); PORTLESS_PATH is ignored here because a single + // global prefix is ambiguous across apps. + const pathPrefix = appOverride.path ? parsePathPrefixOrExit(appOverride.path) : undefined; + + apps.push({ pkg, name, label, commandArgs, appPort: appOverride.appPort, pathPrefix, proxied }); } if (apps.length === 0) { @@ -3922,7 +4102,7 @@ async function runWithTurbo( }); const manifest: Record = {}; - const routes: { hostnames: string[] }[] = []; + const routes: { hostnames: string[]; pathPrefix?: string }[] = []; const appUrls: { label: string; url: string }[] = []; for (const app of proxiedApps) { @@ -3934,13 +4114,13 @@ async function runWithTurbo( const appPort = app.appPort ?? (await findFreePort()); const hostnames = buildHostnames(app.name, tlds); - const urls = formatUrls(hostnames, proxyPort, tls); + const urls = formatUrls(hostnames, proxyPort, tls, app.pathPrefix); const url = urls[0]!; appUrls.push({ label: app.label, url }); - addRoutes(store, hostnames, appPort, process.pid); + addRoutes(store, hostnames, appPort, process.pid, false, app.pathPrefix); await reportHostsSyncHere(hostnames, proxyPort, tls, lanMode); - routes.push({ hostnames }); + routes.push({ hostnames, pathPrefix: app.pathPrefix }); const entry: ManifestEntry = { PORT: String(appPort), @@ -4003,8 +4183,8 @@ async function runWithTurbo( } }, SIGKILL_TIMEOUT_MS).unref(); - for (const { hostnames } of routes) { - removeRoutes(store, hostnames, process.pid); + for (const { hostnames, pathPrefix } of routes) { + removeRoutes(store, hostnames, process.pid, pathPrefix); } removeManifest(); }; @@ -4035,7 +4215,7 @@ async function runWithDirectSpawn( const children: ReturnType[] = []; const exitCodes = new Map(); const appUrls: { label: string; url: string }[] = []; - const routeEntries: { store: RouteStore; hostnames: string[] }[] = []; + const routeEntries: { store: RouteStore; hostnames: string[]; pathPrefix?: string }[] = []; // Sequential: each spawnProxiedApp calls findFreePort() which binds/releases // a port, so parallel spawning could cause port collisions. @@ -4088,8 +4268,8 @@ async function runWithDirectSpawn( } }, SIGKILL_TIMEOUT_MS).unref(); - for (const { store, hostnames } of routeEntries) { - removeRoutes(store, hostnames, process.pid); + for (const { store, hostnames, pathPrefix } of routeEntries) { + removeRoutes(store, hostnames, process.pid, pathPrefix); } }; @@ -4164,6 +4344,9 @@ async function handleRunMode(args: string[], globalScript?: string): Promise { process.exit(1); } - if (!parsed.appPort) { + if (!parsed.appPort || !parsed.pathPrefix) { const appConfig = loadAppConfig(); - if (appConfig?.appPort) { + if (!parsed.appPort && appConfig?.appPort) { parsed.appPort = appConfig.appPort; } + if (!parsed.pathPrefix && appConfig?.path) { + parsed.pathPrefix = parsePathPrefixOrExit(appConfig.path); + } } // Truncate individual labels that exceed the DNS limit, same as handleRunMode. @@ -4229,6 +4416,7 @@ async function handleNamedMode(args: string[]): Promise { parsed.force, undefined, parsed.appPort, + parsed.pathPrefix, lanMode, lanIp ); @@ -4260,8 +4448,8 @@ async function main() { if ((isNpx || isPnpmDlx) && !isLocallyInstalled()) { console.error(colors.red("Error: portless should not be run via npx or pnpm dlx.")); console.error(colors.blue("Install globally or as a project dependency:")); - console.error(colors.cyan(" npm install -g portless")); - console.error(colors.cyan(" npm install -D portless")); + console.error(colors.cyan(` npm install -g ${PACKAGE_NAME}`)); + console.error(colors.cyan(` npm install -D ${PACKAGE_NAME}`)); process.exit(1); } diff --git a/packages/portless/src/config.test.ts b/packages/portless/src/config.test.ts index 7bfad601..903cb402 100644 --- a/packages/portless/src/config.test.ts +++ b/packages/portless/src/config.test.ts @@ -281,6 +281,42 @@ describe("loadConfig validation", () => { expect(() => loadConfig(tmpDir)).toThrow(ConfigValidationError); }); + it("accepts a valid path prefix", () => { + fs.writeFileSync( + path.join(tmpDir, "portless.json"), + JSON.stringify({ name: "myapp", path: "/api" }) + ); + const result = loadConfig(tmpDir); + expect(result?.config.path).toBe("/api"); + }); + + it("accepts path in apps entries", () => { + fs.writeFileSync( + path.join(tmpDir, "portless.json"), + JSON.stringify({ apps: { "apps/api": { name: "myapp", path: "/api" } } }) + ); + const result = loadConfig(tmpDir); + expect(result?.config.apps?.["apps/api"].path).toBe("/api"); + }); + + it("throws when path is not a string", () => { + fs.writeFileSync(path.join(tmpDir, "portless.json"), JSON.stringify({ path: 42 })); + expect(() => loadConfig(tmpDir)).toThrow(ConfigValidationError); + }); + + it("throws when path contains invalid characters", () => { + fs.writeFileSync(path.join(tmpDir, "portless.json"), JSON.stringify({ path: "/api?x=1" })); + expect(() => loadConfig(tmpDir)).toThrow(ConfigValidationError); + }); + + it("throws when an apps entry path is invalid", () => { + fs.writeFileSync( + path.join(tmpDir, "portless.json"), + JSON.stringify({ apps: { "apps/web": { name: "web", path: "/a//b" } } }) + ); + expect(() => loadConfig(tmpDir)).toThrow(ConfigValidationError); + }); + it("warns on unknown top-level keys", () => { const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); fs.writeFileSync( @@ -311,6 +347,12 @@ describe("resolveAppConfig", () => { expect(result).toEqual({ name: "myapp", script: "dev", appPort: undefined, proxy: undefined }); }); + it("returns the top-level path when no apps key", () => { + const config = { name: "myapp", path: "/api" }; + const result = resolveAppConfig(config, "/repo", "/repo"); + expect(result.path).toBe("/api"); + }); + it("returns proxy field from top-level config", () => { const config = { name: "myapp", proxy: false }; const result = resolveAppConfig(config, "/repo", "/repo"); diff --git a/packages/portless/src/config.ts b/packages/portless/src/config.ts index 99e8d2b0..d4b056d4 100644 --- a/packages/portless/src/config.ts +++ b/packages/portless/src/config.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { normalizePathPrefix } from "./utils.js"; export class ConfigValidationError extends Error { constructor(message: string) { @@ -13,6 +14,8 @@ export interface AppConfig { script?: string; appPort?: number; proxy?: boolean; + /** URL path prefix for path-based routing (e.g. "/api"). */ + path?: string; } export interface PortlessConfig extends AppConfig { @@ -125,7 +128,13 @@ export function resolveAppConfig( } return {}; } - return { name: config.name, script: config.script, appPort: config.appPort, proxy: config.proxy }; + return { + name: config.name, + script: config.script, + appPort: config.appPort, + proxy: config.proxy, + path: config.path, + }; } /** @@ -311,8 +320,19 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException { return err instanceof Error && "code" in err; } -const KNOWN_TOP_KEYS = new Set(["name", "script", "appPort", "proxy", "apps", "turbo"]); -const KNOWN_APP_KEYS = new Set(["name", "script", "appPort", "proxy"]); +const KNOWN_TOP_KEYS = new Set(["name", "script", "appPort", "proxy", "path", "apps", "turbo"]); +const KNOWN_APP_KEYS = new Set(["name", "script", "appPort", "proxy", "path"]); + +function validatePathValue(value: unknown, label: string, configPath: string): void { + if (typeof value !== "string" || !value.trim()) { + throw new ConfigValidationError(`"${label}" in ${configPath} must be a non-empty string.`); + } + try { + normalizePathPrefix(value); + } catch (err) { + throw new ConfigValidationError(`"${label}" in ${configPath}: ${(err as Error).message}`); + } +} function validateConfig(config: unknown, configPath: string): asserts config is PortlessConfig { if (typeof config !== "object" || config === null || Array.isArray(config)) { @@ -352,6 +372,10 @@ function validateConfig(config: unknown, configPath: string): asserts config is } } + if (obj.path !== undefined) { + validatePathValue(obj.path, "path", configPath); + } + if (obj.turbo !== undefined) { if (typeof obj.turbo !== "boolean") { throw new ConfigValidationError(`"turbo" in ${configPath} must be a boolean.`); @@ -405,6 +429,9 @@ function validateAppConfig(obj: Record, prefix: string, configP throw new ConfigValidationError(`"${prefix}.proxy" in ${configPath} must be a boolean.`); } } + if (obj.path !== undefined) { + validatePathValue(obj.path, `${prefix}.path`, configPath); + } warnUnknownKeys(obj, KNOWN_APP_KEYS, configPath, prefix); } diff --git a/packages/portless/src/proxy.test.ts b/packages/portless/src/proxy.test.ts index 4aa24079..801cffbe 100644 --- a/packages/portless/src/proxy.test.ts +++ b/packages/portless/src/proxy.test.ts @@ -118,6 +118,22 @@ describe("createProxyServer", () => { expect(res.body).toContain("api.localhost"); }); + it("shows path prefixes in 404 page route list", async () => { + const routes: RouteInfo[] = [ + { hostname: "app.localhost", port: 4001 }, + { hostname: "app.localhost", port: 4002, pathPrefix: "/settings" }, + ]; + const server = trackServer( + createProxyServer({ getRoutes: () => routes, proxyPort: TEST_PROXY_PORT }) + ); + await listen(server); + + const res = await request(server, { host: "other.localhost" }); + expect(res.status).toBe(404); + expect(res.body).toContain("app.localhost"); + expect(res.body).toContain("/settings"); + }); + it("includes correct port in 404 page links", async () => { const routes: RouteInfo[] = [{ hostname: "myapp.localhost", port: 4001 }]; const server = trackServer(createProxyServer({ getRoutes: () => routes, proxyPort: 8080 })); @@ -591,6 +607,217 @@ describe("createProxyServer", () => { expect(parentRes.status).toBe(200); expect(parentRes.body).toBe("parent"); }); + + describe("path-based routing", () => { + it("routes to correct backend based on path prefix", async () => { + const rootBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("root"); + }) + ); + await listen(rootBackend); + const rootAddr = rootBackend.address() as net.AddressInfo; + + const settingsBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("settings"); + }) + ); + await listen(settingsBackend); + const settingsAddr = settingsBackend.address() as net.AddressInfo; + + const routes: RouteInfo[] = [ + { hostname: "app.localhost", port: rootAddr.port }, + { hostname: "app.localhost", port: settingsAddr.port, pathPrefix: "/settings" }, + ]; + const server = trackServer( + createProxyServer({ getRoutes: () => routes, proxyPort: TEST_PROXY_PORT }) + ); + await listen(server); + + const rootRes = await request(server, { host: "app.localhost", path: "/" }); + expect(rootRes.body).toBe("root"); + + const settingsRes = await request(server, { host: "app.localhost", path: "/settings" }); + expect(settingsRes.body).toBe("settings"); + + const settingsSubRes = await request(server, { + host: "app.localhost", + path: "/settings/profile", + }); + expect(settingsSubRes.body).toBe("settings"); + }); + + it("does not match path prefix at non-boundary", async () => { + const settingsBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("settings"); + }) + ); + await listen(settingsBackend); + const settingsAddr = settingsBackend.address() as net.AddressInfo; + + const routes: RouteInfo[] = [ + { hostname: "app.localhost", port: settingsAddr.port, pathPrefix: "/settings" }, + ]; + const server = trackServer( + createProxyServer({ getRoutes: () => routes, proxyPort: TEST_PROXY_PORT }) + ); + await listen(server); + + // /settings-v2 should NOT match /settings + const res = await request(server, { host: "app.localhost", path: "/settings-v2" }); + expect(res.status).toBe(404); + }); + + it("selects longest matching path prefix", async () => { + const settingsBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("settings"); + }) + ); + await listen(settingsBackend); + const settingsAddr = settingsBackend.address() as net.AddressInfo; + + const advancedBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("advanced"); + }) + ); + await listen(advancedBackend); + const advancedAddr = advancedBackend.address() as net.AddressInfo; + + const routes: RouteInfo[] = [ + { hostname: "app.localhost", port: settingsAddr.port, pathPrefix: "/settings" }, + { hostname: "app.localhost", port: advancedAddr.port, pathPrefix: "/settings/advanced" }, + ]; + const server = trackServer( + createProxyServer({ getRoutes: () => routes, proxyPort: TEST_PROXY_PORT }) + ); + await listen(server); + + const res = await request(server, { + host: "app.localhost", + path: "/settings/advanced/theme", + }); + expect(res.body).toBe("advanced"); + }); + + it("preserves full request path to backend", async () => { + let receivedPath = ""; + const backend = trackServer( + http.createServer((req, res) => { + receivedPath = req.url || ""; + res.writeHead(200); + res.end("ok"); + }) + ); + await listen(backend); + const addr = backend.address() as net.AddressInfo; + + const routes: RouteInfo[] = [ + { hostname: "app.localhost", port: addr.port, pathPrefix: "/settings" }, + ]; + const server = trackServer( + createProxyServer({ getRoutes: () => routes, proxyPort: TEST_PROXY_PORT }) + ); + await listen(server); + + await request(server, { host: "app.localhost", path: "/settings/profile?tab=general" }); + expect(receivedPath).toBe("/settings/profile?tab=general"); + }); + + it("falls back to root route when no path prefix matches", async () => { + const rootBackend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("root"); + }) + ); + await listen(rootBackend); + const rootAddr = rootBackend.address() as net.AddressInfo; + + const routes: RouteInfo[] = [ + { hostname: "app.localhost", port: rootAddr.port }, + { hostname: "app.localhost", port: 9999, pathPrefix: "/settings" }, + ]; + const server = trackServer( + createProxyServer({ getRoutes: () => routes, proxyPort: TEST_PROXY_PORT }) + ); + await listen(server); + + const res = await request(server, { host: "app.localhost", path: "/unknown" }); + expect(res.body).toBe("root"); + }); + + it("works with wildcard subdomain + path prefix", async () => { + const backend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("wildcard-path"); + }) + ); + await listen(backend); + const addr = backend.address() as net.AddressInfo; + + const routes: RouteInfo[] = [ + { hostname: "app.localhost", port: addr.port, pathPrefix: "/settings" }, + ]; + const server = trackServer( + createProxyServer({ + getRoutes: () => routes, + proxyPort: TEST_PROXY_PORT, + strict: false, + }) + ); + await listen(server); + + const res = await request(server, { host: "tenant.app.localhost", path: "/settings/foo" }); + expect(res.body).toBe("wildcard-path"); + }); + + it("does not crash on a malformed request-target", async () => { + const backend = trackServer( + http.createServer((_req, res) => { + res.writeHead(200); + res.end("root"); + }) + ); + await listen(backend); + const addr = backend.address() as net.AddressInfo; + + const routes: RouteInfo[] = [{ hostname: "app.localhost", port: addr.port }]; + const server = trackServer( + createProxyServer({ getRoutes: () => routes, proxyPort: TEST_PROXY_PORT }) + ); + await listen(server); + const proxyAddr = server.address() as net.AddressInfo; + + // "http://[" is not parseable by `new URL()`; the proxy must fall back + // to "/" for path matching instead of throwing. + const response = await new Promise((resolve, reject) => { + const socket = net.connect(proxyAddr.port, "127.0.0.1", () => { + socket.write( + "GET http://[ HTTP/1.1\r\nHost: app.localhost\r\nConnection: close\r\n\r\n" + ); + }); + let data = ""; + socket.on("data", (chunk) => (data += chunk)); + socket.on("end", () => resolve(data)); + socket.on("error", reject); + }); + expect(response).toMatch(/^HTTP\/1\.1 \d{3}/); + + // The proxy must still be alive and routing. + const res = await request(server, { host: "app.localhost", path: "/" }); + expect(res.body).toBe("root"); + }); + }); }); describe("missing Host header", () => { diff --git a/packages/portless/src/proxy.ts b/packages/portless/src/proxy.ts index ea8b27fc..a69342ad 100644 --- a/packages/portless/src/proxy.ts +++ b/packages/portless/src/proxy.ts @@ -2,7 +2,7 @@ import * as crypto from "node:crypto"; import * as http from "node:http"; import * as http2 from "node:http2"; import * as net from "node:net"; -import type { ProxyServerOptions } from "./types.js"; +import type { ProxyServerOptions, RouteInfo } from "./types.js"; import { createLoopbackConnection, escapeHtml, formatUrl } from "./utils.js"; import { ARROW_SVG, renderPage } from "./pages.js"; import { @@ -160,6 +160,12 @@ const PORTLESS_HOPS_HEADER = "x-portless-hops"; */ const MAX_PROXY_HOPS = 5; +/** Test whether a request path matches a route's path prefix at a `/` boundary. */ +function matchesPathPrefix(prefix: string | undefined, pathname: string): boolean { + if (!prefix || prefix === "/") return true; + return pathname === prefix || pathname.startsWith(prefix + "/"); +} + /** Authority (hostname, plus port when non-default) of a route's tailscaleUrl, or undefined if unset or invalid. */ function tailscaleAuthority(tailscaleUrl: string | undefined): string | undefined { if (!tailscaleUrl) return undefined; @@ -187,27 +193,51 @@ function normalizeAuthority(host: string): string { } /** - * Find the route matching a request's host, which may include a port. Match - * order: local hostname, tailscale authority (hostname and port), tailscale - * hostname ignoring port, then wildcard subdomain. The authority tier - * disambiguates apps sharing a `.ts.net` hostname on different ports; the - * hostname tier keeps other-port requests resolving. `strict` drops the wildcard. - * All comparisons run against the normalized authority so they are - * case-insensitive and treat an explicit `:443` as the default HTTPS port. + * 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. */ function findRoute( - routes: { hostname: string; port: number; tailscaleUrl?: string }[], + routes: RouteInfo[], host: string, + url: string, strict?: boolean -): { hostname: string; port: number } | undefined { +): RouteInfo | undefined { const authority = normalizeAuthority(host); const hostname = authority.split(":")[0]; - return ( - routes.find((r) => r.hostname.toLowerCase() === hostname) || - routes.find((r) => tailscaleAuthority(r.tailscaleUrl) === authority) || - routes.find((r) => tailscaleAuthority(r.tailscaleUrl)?.split(":")[0] === hostname) || - (strict ? undefined : routes.find((r) => hostname.endsWith("." + r.hostname.toLowerCase()))) + // A malformed request-target must not crash the proxy; fall back to "/" so + // no-prefix (catch-all) routes still match. + let pathname = "/"; + try { + pathname = new URL(url, "http://localhost").pathname; + } catch { + // keep "/" + } + + const pickByPath = (candidates: RouteInfo[]): RouteInfo | undefined => { + const [match] = candidates + .filter((r) => matchesPathPrefix(r.pathPrefix, pathname)) + .sort((a, b) => (b.pathPrefix || "/").length - (a.pathPrefix || "/").length); + return match; + }; + + const exact = routes.filter((r) => r.hostname.toLowerCase() === hostname); + if (exact.length > 0) return pickByPath(exact); + + const tsAuthorityMatch = routes.find((r) => tailscaleAuthority(r.tailscaleUrl) === authority); + if (tsAuthorityMatch) return tsAuthorityMatch; + const tsHostnameMatch = routes.find( + (r) => tailscaleAuthority(r.tailscaleUrl)?.split(":")[0] === hostname ); + if (tsHostnameMatch) return tsHostnameMatch; + + if (strict) return undefined; + return pickByPath(routes.filter((r) => hostname.endsWith("." + r.hostname.toLowerCase()))); } /** Server type returned by createProxyServer (plain HTTP/1.1 or net.Server TLS wrapper). */ @@ -275,6 +305,7 @@ export function createProxyServer(options: ProxyServerOptions): ProxyServer { } const host = rawHost.split(":")[0]; + const url = req.url || "/"; if (!host) { res.writeHead(400, { "Content-Type": "text/plain" }); @@ -305,7 +336,7 @@ export function createProxyServer(options: ProxyServerOptions): ProxyServer { return; } - const route = findRoute(routes, rawHost, strict); + const route = findRoute(routes, rawHost, url, strict); if (!route) { const safeHost = escapeHtml(host); @@ -319,7 +350,14 @@ export function createProxyServer(options: ProxyServerOptions): ProxyServer { const safeSuggestion = escapeHtml(strippedHost); const routesList = routes.length > 0 - ? `` + ? `

Active apps

` : '

No apps running.

'; res.writeHead(404, { "Content-Type": "text/html" }); res.end( @@ -355,7 +393,7 @@ export function createProxyServer(options: ProxyServerOptions): ProxyServer { { // Dial via createLoopbackConnection so ::1-only backends work too. createConnection: () => createLoopbackConnection(route.port), - path: req.url, + path: url, method: req.method, headers: proxyReqHeaders, }, @@ -438,7 +476,8 @@ export function createProxyServer(options: ProxyServerOptions): ProxyServer { } const routes = getRoutes(); - const route = findRoute(routes, getRequestHost(req), strict); + const url = req.url || "/"; + const route = findRoute(routes, getRequestHost(req), url, strict); if (!route) { socket.destroy(); @@ -467,7 +506,7 @@ export function createProxyServer(options: ProxyServerOptions): ProxyServer { const proxyReq = http.request({ // Dial via createLoopbackConnection so ::1-only backends work too. createConnection: () => createLoopbackConnection(route.port), - path: req.url, + path: url, method: req.method, headers: proxyReqHeaders, }); @@ -587,7 +626,7 @@ export function createProxyServer(options: ProxyServerOptions): ProxyServer { return; } - const route = findRoute(getRoutes(), getRequestHost(compatReq), strict); + const route = findRoute(getRoutes(), getRequestHost(compatReq), req.url || "/", strict); if (!route) { res.writeHead(404, { "content-type": "text/plain" }); res.end(`No app registered for ${getRequestHost(compatReq)}\n`); diff --git a/packages/portless/src/routes.test.ts b/packages/portless/src/routes.test.ts index 381294cb..6049520c 100644 --- a/packages/portless/src/routes.test.ts +++ b/packages/portless/src/routes.test.ts @@ -418,6 +418,128 @@ describe("RouteStore", () => { }, 10_000); }); + describe("pathPrefix support", () => { + it("adds route with pathPrefix", () => { + store.addRoute("app.localhost", 4001, process.pid, false, "/settings"); + const routes = store.loadRoutes(); + expect(routes).toHaveLength(1); + expect(routes[0]).toEqual({ + hostname: "app.localhost", + port: 4001, + pid: process.pid, + pathPrefix: "/settings", + }); + }); + + it("allows same hostname with different pathPrefixes", () => { + store.addRoute("app.localhost", 4001, process.pid, false); + store.addRoute("app.localhost", 4002, process.pid, false, "/settings"); + store.addRoute("app.localhost", 4003, process.pid, false, "/metrics"); + const routes = store.loadRoutes(); + expect(routes).toHaveLength(3); + const ports = routes.map((r) => r.port).sort(); + expect(ports).toEqual([4001, 4002, 4003]); + }); + + it("detects conflict on same hostname + pathPrefix", () => { + store.addRoute("app.localhost", 4001, process.pid, false, "/settings"); + // Same PID re-registering replaces the route + store.addRoute("app.localhost", 4099, process.pid, false, "/settings"); + const routes = store.loadRoutes(); + const settingsRoute = routes.find((r) => r.pathPrefix === "/settings"); + expect(settingsRoute?.port).toBe(4099); + }); + + it("removes route by hostname + pathPrefix", () => { + store.addRoute("app.localhost", 4001, process.pid, false); + store.addRoute("app.localhost", 4002, process.pid, false, "/settings"); + store.removeRoute("app.localhost", undefined, "/settings"); + const routes = store.loadRoutes(); + expect(routes).toHaveLength(1); + expect(routes[0].port).toBe(4001); + expect(routes[0].pathPrefix).toBeUndefined(); + }); + + it("removeRoute without pathPrefix only removes root route", () => { + store.addRoute("app.localhost", 4001, process.pid, false); + store.addRoute("app.localhost", 4002, process.pid, false, "/settings"); + store.removeRoute("app.localhost"); + const routes = store.loadRoutes(); + expect(routes).toHaveLength(1); + expect(routes[0].pathPrefix).toBe("/settings"); + }); + + it("updateRoute targets the entry matching hostname + pathPrefix", () => { + store.addRoute("app.localhost", 4001, process.pid, false); + store.addRoute("app.localhost", 4002, process.pid, false, "/api"); + store.updateRoute( + "app.localhost", + { ngrokUrl: "https://x.ngrok.app", ngrokPid: 123 }, + "/api" + ); + const routes = store.loadRoutes(); + const root = routes.find((r) => r.pathPrefix === undefined); + const api = routes.find((r) => r.pathPrefix === "/api"); + expect(root?.ngrokUrl).toBeUndefined(); + expect(api?.ngrokUrl).toBe("https://x.ngrok.app"); + expect(api?.ngrokPid).toBe(123); + }); + + it("removeRoute with ownerPid respects ownership for path routes", () => { + store.addRoute("app.localhost", 4002, process.pid, false, "/api"); + // A different pid must not be able to deregister the route (post-takeover guard). + store.removeRoute("app.localhost", process.pid + 1, "/api"); + expect(store.loadRoutes()).toHaveLength(1); + store.removeRoute("app.localhost", process.pid, "/api"); + expect(store.loadRoutes()).toHaveLength(0); + }); + + it("RouteConflictError message includes the path prefix", () => { + const err = new RouteConflictError("app.localhost", 123, "/api"); + expect(err.message).toContain('"app.localhost/api"'); + }); + + it("loads routes.json with pathPrefix field from disk", () => { + store.ensureDir(); + const routes = [ + { hostname: "app.localhost", port: 4001, pid: process.pid }, + { hostname: "app.localhost", port: 4002, pid: process.pid, pathPrefix: "/settings" }, + ]; + fs.writeFileSync(store.getRoutesPath(), JSON.stringify(routes)); + const loaded = store.loadRoutes(); + expect(loaded).toHaveLength(2); + expect(loaded[0].pathPrefix).toBeUndefined(); + expect(loaded[1].pathPrefix).toBe("/settings"); + }); + + it("throws RouteConflictError when same (hostname, pathPrefix) is owned by another live process", () => { + const child = spawn("node", ["-e", "setTimeout(()=>{},60000)"], { + detached: true, + stdio: "ignore", + }); + child.unref(); + const otherPid = child.pid!; + try { + store.addRoute("app.localhost", 4001, otherPid, false, "/settings"); + // Different hostname or different prefix should not conflict. + expect(() => store.addRoute("app.localhost", 4002, process.pid)).not.toThrow(); + expect(() => + store.addRoute("app.localhost", 4003, process.pid, false, "/other") + ).not.toThrow(); + // Same (hostname, pathPrefix) owned by the other live PID conflicts. + expect(() => + store.addRoute("app.localhost", 4099, process.pid, false, "/settings") + ).toThrow(RouteConflictError); + } finally { + try { + process.kill(otherPid, "SIGTERM"); + } catch { + // already dead + } + } + }); + }); + describe("tailscale metadata", () => { it("persists and loads tailscale fields via updateRoute", () => { store.addRoute("myapp.localhost", 4123, process.pid); diff --git a/packages/portless/src/routes.ts b/packages/portless/src/routes.ts index fbe0812d..412c131b 100644 --- a/packages/portless/src/routes.ts +++ b/packages/portless/src/routes.ts @@ -40,12 +40,14 @@ type RouteMetadataPatch = { /** Runtime check that a parsed JSON value is a valid RouteMapping. */ function isValidRoute(value: unknown): value is RouteMapping { + const r = value as RouteMapping; return ( typeof value === "object" && value !== null && - typeof (value as RouteMapping).hostname === "string" && - typeof (value as RouteMapping).port === "number" && - typeof (value as RouteMapping).pid === "number" + typeof r.hostname === "string" && + typeof r.port === "number" && + typeof r.pid === "number" && + (r.pathPrefix === undefined || typeof r.pathPrefix === "string") ); } @@ -57,9 +59,9 @@ export class RouteConflictError extends Error { readonly hostname: string; readonly existingPid: number; - constructor(hostname: string, existingPid: number) { + constructor(hostname: string, existingPid: number, pathPrefix?: string) { super( - `"${hostname}" is already registered by a running process (PID ${existingPid}). ` + + `"${hostname}${pathPrefix ?? ""}" is already registered by a running process (PID ${existingPid}). ` + `Use --force to override.` ); this.name = "RouteConflictError"; @@ -153,6 +155,10 @@ export class RouteStore { } } + private matchesRoute(r: RouteMapping, hostname: string, pathPrefix?: string): boolean { + return r.hostname === hostname && r.pathPrefix === pathPrefix; + } + // Route I/O // --------------------------------------------------------------------------- @@ -219,7 +225,13 @@ export class RouteStore { * replaced. Returns the PID of the killed process (if any) so the caller can * log it. */ - addRoute(hostname: string, port: number, pid: number, force = false): number | undefined { + addRoute( + hostname: string, + port: number, + pid: number, + force = false, + pathPrefix?: string + ): number | undefined { this.ensureDir(); if (!this.acquireLock()) { throw new Error("Failed to acquire route lock"); @@ -227,10 +239,10 @@ export class RouteStore { let killedPid: number | undefined; try { const routes = this.loadRoutes(true); - const existing = routes.find((r) => r.hostname === hostname); + const existing = routes.find((r) => this.matchesRoute(r, hostname, pathPrefix)); if (existing && existing.pid !== pid && this.isProcessAlive(existing.pid)) { if (!force) { - throw new RouteConflictError(hostname, existing.pid); + throw new RouteConflictError(hostname, existing.pid, pathPrefix); } // --force: kill the existing process before taking over try { @@ -240,8 +252,9 @@ export class RouteStore { // Process may have exited between the check and the kill; non-fatal } } - const filtered = routes.filter((r) => r.hostname !== hostname); + const filtered = routes.filter((r) => !this.matchesRoute(r, hostname, pathPrefix)); const entry: RouteMapping = { hostname, port, pid }; + if (pathPrefix) entry.pathPrefix = pathPrefix; filtered.push(entry); this.saveRoutes(filtered); } finally { @@ -311,16 +324,16 @@ export class RouteStore { /** * Update metadata on an existing route entry. Only provided fields are - * merged; the route must already exist (matched by hostname). + * merged; the route must already exist (matched by hostname + pathPrefix). */ - updateRoute(hostname: string, fields: RouteMetadataPatch): void { + updateRoute(hostname: string, fields: RouteMetadataPatch, pathPrefix?: string): void { this.ensureDir(); if (!this.acquireLock()) { throw new Error("Failed to acquire route lock"); } try { const routes = this.loadRoutes(true); - const route = routes.find((r) => r.hostname === hostname); + const route = routes.find((r) => this.matchesRoute(r, hostname, pathPrefix)); if (!route) return; if ("tailscaleUrl" in fields) { if (fields.tailscaleUrl === null) delete route.tailscaleUrl; @@ -351,19 +364,22 @@ export class RouteStore { } /** - * Remove a route by hostname. When `ownerPid` is provided, the entry is - * only removed while it is still owned by that pid. Exit cleanups must - * pass their own pid: after a `--force` takeover the killed process would - * otherwise deregister the route the new owner just registered. + * Remove a route by hostname (+ pathPrefix). When `ownerPid` is provided, + * the entry is only removed while it is still owned by that pid. Exit + * cleanups must pass their own pid: after a `--force` takeover the killed + * process would otherwise deregister the route the new owner just + * registered. */ - removeRoute(hostname: string, ownerPid?: number): void { + removeRoute(hostname: string, ownerPid?: number, pathPrefix?: string): void { this.ensureDir(); if (!this.acquireLock()) { throw new Error("Failed to acquire route lock"); } try { const routes = this.loadRoutes(true).filter( - (r) => r.hostname !== hostname || (ownerPid !== undefined && r.pid !== ownerPid) + (r) => + !this.matchesRoute(r, hostname, pathPrefix) || + (ownerPid !== undefined && r.pid !== ownerPid) ); this.saveRoutes(routes); } finally { diff --git a/packages/portless/src/types.ts b/packages/portless/src/types.ts index 54fcf6cf..05d61fa8 100644 --- a/packages/portless/src/types.ts +++ b/packages/portless/src/types.ts @@ -2,6 +2,11 @@ export interface RouteInfo { hostname: string; port: number; + /** + * URL path prefix for path-based routing (e.g. "/api"). Routes without a + * pathPrefix act as the root catch-all for their hostname. + */ + pathPrefix?: string; /** * Public Tailscale Serve/Funnel URL for this route, when one is active * (e.g. "https://my-device.tail1234.ts.net"). Requests whose Host header diff --git a/packages/portless/src/utils.test.ts b/packages/portless/src/utils.test.ts index fd6859fd..6a2db8f8 100644 --- a/packages/portless/src/utils.test.ts +++ b/packages/portless/src/utils.test.ts @@ -4,6 +4,7 @@ import { formatUrl, isErrnoException, isProcessAlive, + normalizePathPrefix, parseHostname, parseHostnames, resolveUserHome, @@ -113,6 +114,58 @@ describe("isErrnoException", () => { }); }); +describe("normalizePathPrefix", () => { + it("returns undefined for undefined input", () => { + expect(normalizePathPrefix(undefined)).toBeUndefined(); + }); + + it("returns undefined for '/'", () => { + expect(normalizePathPrefix("/")).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(normalizePathPrefix("")).toBeUndefined(); + }); + + it("adds leading slash if missing", () => { + expect(normalizePathPrefix("settings")).toBe("/settings"); + }); + + it("strips trailing slash", () => { + expect(normalizePathPrefix("/settings/")).toBe("/settings"); + }); + + it("preserves valid path", () => { + expect(normalizePathPrefix("/settings")).toBe("/settings"); + }); + + it("handles nested paths", () => { + expect(normalizePathPrefix("/app/settings")).toBe("/app/settings"); + }); + + it("allows dots in paths", () => { + expect(normalizePathPrefix("/api/v2.0")).toBe("/api/v2.0"); + }); + + it("throws for paths with invalid characters", () => { + expect(() => normalizePathPrefix("/set tings")).toThrow("Invalid path prefix"); + }); + + it("throws for paths with empty segments (//)", () => { + expect(() => normalizePathPrefix("/api//v1")).toThrow("empty path segments"); + }); + + it("throws for paths containing .. segments", () => { + expect(() => normalizePathPrefix("/..")).toThrow('".." segments'); + expect(() => normalizePathPrefix("/api/../etc")).toThrow('".." segments'); + }); + + it("allows dots within a segment (not standalone ..)", () => { + expect(normalizePathPrefix("/v1.2.3")).toBe("/v1.2.3"); + expect(normalizePathPrefix("/...")).toBe("/..."); + }); +}); + describe("isProcessAlive", () => { it("returns true when signal 0 succeeds", () => { vi.spyOn(process, "kill").mockImplementation(() => true); @@ -158,6 +211,22 @@ describe("formatUrl", () => { expect(formatUrl("myapp.localhost", 8080)).toBe("http://myapp.localhost:8080"); expect(formatUrl("myapp.localhost", 3000)).toBe("http://myapp.localhost:3000"); }); + + it("appends path prefix to URL", () => { + expect(formatUrl("myapp.localhost", 1355, false, "/settings")).toBe( + "http://myapp.localhost:1355/settings" + ); + }); + + it("appends path prefix with HTTPS", () => { + expect(formatUrl("myapp.localhost", 443, true, "/metrics")).toBe( + "https://myapp.localhost/metrics" + ); + }); + + it("ignores undefined path prefix", () => { + expect(formatUrl("myapp.localhost", 1355, false)).toBe("http://myapp.localhost:1355"); + }); }); describe("parseHostname", () => { diff --git a/packages/portless/src/utils.ts b/packages/portless/src/utils.ts index 1d744d14..3e18321d 100644 --- a/packages/portless/src/utils.ts +++ b/packages/portless/src/utils.ts @@ -136,16 +136,47 @@ export function escapeHtml(str: string): string { .replace(/'/g, "'"); } +/** + * Normalize a path prefix for storage: ensure leading slash, strip trailing + * slash. Returns `undefined` for root path ("/") or empty input — callers + * should treat `undefined` as "match all paths" (root catch-all). + */ +export function normalizePathPrefix(input: string | undefined): string | undefined { + if (input === undefined || input === "" || input === "/") return undefined; + let p = input; + if (!p.startsWith("/")) p = "/" + p; + if (p.endsWith("/") && p.length > 1) p = p.slice(0, -1); + if (!/^\/[a-zA-Z0-9/._-]+$/.test(p)) { + throw new Error( + `Invalid path prefix "${input}": only letters, digits, hyphens, underscores, dots, and slashes are allowed` + ); + } + if (p.includes("//")) { + throw new Error(`Invalid path prefix "${input}": empty path segments are not allowed`); + } + if (p.split("/").some((seg) => seg === "..")) { + throw new Error(`Invalid path prefix "${input}": ".." segments are not allowed`); + } + return p; +} + /** * Format a URL for the given hostname. Omits the port when it matches the - * protocol default (80 for HTTP, 443 for HTTPS). + * protocol default (80 for HTTP, 443 for HTTPS). Appends path prefix when + * provided. */ -export function formatUrl(hostname: string, proxyPort: number, tls = false): string { +export function formatUrl( + hostname: string, + proxyPort: number, + tls = false, + pathPrefix?: string +): string { const proto = tls ? "https" : "http"; const defaultPort = tls ? 443 : 80; - return proxyPort === defaultPort - ? `${proto}://${hostname}` - : `${proto}://${hostname}:${proxyPort}`; + const base = + proxyPort === defaultPort ? `${proto}://${hostname}` : `${proto}://${hostname}:${proxyPort}`; + if (!pathPrefix) return base; + return `${base}${pathPrefix}`; } /** diff --git a/skills/portless/SKILL.md b/skills/portless/SKILL.md index a8aed422..4e5b41ed 100644 --- a/skills/portless/SKILL.md +++ b/skills/portless/SKILL.md @@ -25,10 +25,10 @@ Install globally (recommended) or as a project dev dependency. Do NOT use `npx` ```bash # Global (available everywhere) -npm install -g portless +npm install -g @variablelab/portless # Or per-project dev dependency -npm install -D portless +npm install -D @variablelab/portless ``` When installed per-project, invoke via package.json scripts or `npx portless` (since the package is local, npx will not download anything). @@ -37,7 +37,7 @@ When installed per-project, invoke via package.json scripts or `npx portless` (s ```bash # Install globally (or add -D to a project) -npm install -g portless +npm install -g @variablelab/portless # Run your app (auto-starts the HTTPS proxy on port 443) portless run next dev @@ -147,6 +147,18 @@ portless run next dev # -> https://fix-ui.myapp.localhost No config changes needed. Put `portless run` in `package.json` once and it works in all worktrees. +### 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 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 Set `PORTLESS=0` to run the command directly without the proxy: @@ -190,6 +202,7 @@ Portless stores its state (routes, PID file, port file) in `~/.portless`. When t | `PORTLESS_LAN_IP` | Pin a specific LAN IP for LAN mode | | `PORTLESS_TLD` | Use one or more TLDs, single or multi-segment (e.g. localhost,dev.example.com) | | `PORTLESS_WILDCARD` | Set to `1` to allow unregistered subdomains to fall back to parent | +| `PORTLESS_PATH` | Path prefix for path-based routing (e.g. /api) | | `PORTLESS_SYNC_HOSTS` | Set to `0` to disable auto-sync of /etc/hosts (on by default) | | `PORTLESS_TAILSCALE` | Set to `1` to share apps on your Tailscale network (same as `--tailscale`) | | `PORTLESS_FUNNEL` | Set to `1` to share apps publicly via Tailscale Funnel (same as `--funnel`) | @@ -319,6 +332,7 @@ The chosen service configuration is written into launchd, systemd, or Task Sched | `portless alias --remove ` | Remove a static route | | `portless hosts sync` | Add routes to /etc/hosts (fixes Safari) | | `portless hosts clean` | Remove portless entries from /etc/hosts | +| `portless --path /prefix ` | Route by URL path prefix (path-based routing) | | `portless --app-port ` | Use a fixed port for the app instead of auto-assignment | | `portless --tailscale ` | Share the app on your Tailscale network (tailnet) | | `portless --funnel ` | Share the app publicly via Tailscale Funnel | @@ -342,10 +356,11 @@ Optional config file. Portless looks for it in the current directory. | `script` | string | `"dev"` | Name of a package.json script to run | | `appPort` | number | auto-assigned | Fixed port for the child process | | `proxy` | boolean | auto-detected | Whether to route through the proxy (`false` for tasks) | +| `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 | -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. Apps sharing a `name` with different `path` values are served under one hostname and dispatched by longest prefix. ### package.json "portless" key diff --git a/turbo.json b/turbo.json index dab248bd..9f916317 100644 --- a/turbo.json +++ b/turbo.json @@ -13,7 +13,7 @@ "dependsOn": ["build"] }, "test:e2e": { - "dependsOn": ["portless#build"], + "dependsOn": ["@variablelab/portless#build"], "cache": false }, "test:coverage": {