npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

localgate

v0.6.1

Published

Stable names for local dev servers: one port 80 multiplexed by Host, plus an API that can restart a dev server owned by your editor.

Readme

localgate

Stable names for local dev servers.

CI License: Apache-2.0

One reverse proxy multiplexes every dev server on the machine by Host header, so each app gets a name instead of a port: myapp.localhost locally, optionally myapp.<label>.<your-domain> on the LAN, and optionally <public-prefix>-myapp.<your-domain> through a tunnel.

It also owns the dev process it starts, which is the part editors cannot give you: localgate restart restarts the server running inside your editor's debug terminal, without stealing the route or the terminal, and localgate status --json answers where and how an app is running - the answer a coding agent needs before it touches anything.

localgate running a dev server, answering on its name, and restarting it from another terminal

localgate run npm run dev     # in your editor's launch config
localgate list                # what is running
localgate restart             # restart the app in this directory
localgate status --json       # machine-readable state

How it routes

Every dev server binds an ephemeral loopback port and registers its name. The proxy routes by the Host header, so it is the only thing that has to listen on a fixed port.

flowchart LR
    local["this PC"] -->|"local: myapp.localhost"| proxy
    lan["the LAN"] -->|"lan: myapp.dev.example.com"| proxy
    net["the internet"] -->|"internet: pub-myapp.example.com"| tunnel["shared tunnel"]
    tunnel --> proxy

    proxy["localgate proxy"]

    proxy --> app1["myapp :41000"]
    proxy --> app2["docs :41001"]
    proxy --> alias["alias :8001"]

The tunnel is not localgate's. It is whatever you already run, pointed at this machine's LAN address on the proxy's port. It keeps the Host header, so a request arriving through it is routed like a LAN request. Localgate only needs the public prefix to register the exact public name.

The table lives in the proxy's memory. There is no config file listing apps, no port assignment to keep in sync, and nothing to clean up after a crash.

How restart and stop work

The runner sits in your editor's debug terminal and owns the dev server as its child. That is what lets a restart keep the terminal and debugger while swapping the process underneath. The restart also reloads package.json and the machine config, then updates the route names before the new child starts.

With no argument, localgate restart asks the proxy which route owns the current directory, and posts to that runner's own control endpoint, which listens on loopback and nowhere else.

sequenceDiagram
    participant Cli as localgate restart
    participant Runner as localgate run
    participant Dev as npm run dev

    Cli->>Runner: POST /restart
    Runner->>Dev: kill the tree
    Runner->>Dev: spawn on the same port
    Note over Cli,Dev: same route, same port, the browser keeps its URL

A restart reports success only once the runner has seen both halves of it: the old tree gone with its port free, and a replacement child running. If the kill is refused or the port stays bound, the runner answers 409 with what the kill said, localgate restart prints that and exits non-zero, and the old process is left named rather than reported as replaced. See docs/architecture/restart-verification.md.

localgate stop is decided the same way, on the same fact: the runner kills the tree first and answers with what that achieved, so a stop the system refused prints the reason and exits non-zero instead of stopped. A refused stop keeps its route and its runner, because the process behind that route is still the one serving it. Ctrl+C is the other way round and ends the runner whatever the kill said. See docs/architecture/stop-verification.md.

While the dev server is down the proxy answers with a readable page instead of a connection error. Websocket upgrades are proxied, and on Next.js dev endpoints (/_next/*, /__nextjs*) the Origin and Referer headers are mapped back to the route's .localhost name. The rewrite requires the header hostname to equal the incoming Host, so it works for LAN and public names without accepting a foreign origin or requiring an allowedDevOrigins entry in the project.

Install

Node 24+.

npm install -g localgate

On Windows the proxy binds port 80, so a name stands alone: http://myapp.localhost. On Linux and macOS everything below 1024 belongs to root, and a tool that starts itself on demand has no business asking for that, so it binds 8080 and the port rides along: http://myapp.localhost:8080. Every URL localgate prints already carries it. To have 80 there too, give the node binary the capability (sudo setcap cap_net_bind_service=+ep $(which node) on Linux) and set "proxyPort": 80 in ~/.localgate/config.json.

Or from a clone, which is also how you develop on it:

git clone https://github.com/ludekvodicka/LocalGate.git
cd LocalGate
pnpm install
pnpm link --global      # puts `localgate` on your PATH

Then, in your editor's launch configuration, run the dev server through it:

// .vscode/launch.json
{
  "name": "dev",
  "type": "node-terminal",
  "request": "launch",
  "command": "localgate run npm run dev"
}

The debugger attaches to the child as usual, and the terminal stays where it is across a restart.

Starting it again while it is already running

Press the editor's restart button and you start a second localgate run for the same project, which used to fail twice over: the name moved to the new runner and left the old one unreachable, and the dev server then refused the port because of its own lock. So the collision is settled before anything is claimed - the new run finds its predecessor, prints what it is about to stop in the debug terminal it was started from, and takes over without asking. A compound launch of several projects therefore restarts all of them, with no terminal left waiting for an answer:

  localgate   myapp.localhost is already running

    command    npm run dev
    upstream   127.0.0.1:54382
    started    2026-08-12 09:04:11
    pids       runner 45568, child 4544
    debugger   attached

localgate: stopping myapp.localhost (runner 45568)

The old one is stopped the good way: its own runner kills the child tree, releases the port and deregisters itself, so its terminal ends cleanly and the new run takes the name. Killing by pid is only the fallback for a runner that no longer answers.

A route whose runner is already gone is cleared too. Its dev server usually outlived it and still holds the port - that is the orphan a framework lock trips over - so it is killed and the name reused.

The same takeover happens if the name is taken between finding it free and claiming it. The route table lives in the proxy, so a proxy that has just started has an empty one, and the runners that were already running refill it from their heartbeats a few seconds later - a window in which the check sees a free name that is not free.

An alias is never taken over: run refuses the name and exits 1.

The start page

http://start.localhost is every route in a browser. One card per app: click it anywhere and it opens. It needs no setup - the proxy serves the page from itself, and the name is reserved, so no project or alias can take it.

the localgate start page: a card per route with its name, state, addresses and upstream port

The card opens the address that works from where you opened the page, and the route's other addresses - the shared name, the public one - are chips next to it. The line at the bottom is the upstream port, the command and the directory. The page refreshes itself every few seconds, so a dev server that has just started appears without a reload, and a state changes while you watch.

The badge is measured, not remembered: the page opens a connection to each route's port as it renders, so a route nobody has visited says running rather than sitting at "starting", and a route with nothing behind it says not running and dims. A wedged app - one that accepts connections but answers nothing - still reads not responding, because only forwarded traffic can tell you that.

A stop chip ends a dev server: the first click turns that card into a question, the second asks the runner to kill the child tree and exit, exactly as localgate stop does. It is on the loopback page only, and it takes a same-origin POST, so no other site the browser has open can reach it.

An alias has no runner, so it gets that button only once you say how to stop what it points at:

localgate alias wagtail 8003 --stop "docker stop wagtail"

The command lives in ~/.localgate/aliases.json, which is machine-local and never committed, and the question names it before it runs. Without one, an alias card has no stop button - localgate will not guess how to end a process it did not start.

With a machine config it also answers on start.<label>.<your-domain>, which is the address to send a colleague - and that answer is narrower than the local one. A route in mode local is absent from it, its name included, and the routes that remain show only the addresses that work off this machine, without the upstream port, the command or the directory. Details in docs/architecture/start-page.md.

Commands

| Command | What it does | |---|---| | localgate run [--force] <cmd...> | Run a dev server behind its name. One already running for the project is stopped first; without a terminal, only with --force. | | localgate list [--json] | Show active routes. | | localgate status [app] [--json] | State of one route, with a tail of its output in --json. | | localgate restart [app] | Restart a route's dev server. | | localgate stop [app] | Stop a route's dev server. | | localgate logs [app] [--lines N] | Tail a route's captured output. | | localgate alias <name> <port> [--stop "<cmd>"] | Register a static route, e.g. a docker service. Remembered across proxy restarts. --stop records what ends it, which gives it a stop button on the start page. | | localgate alias --remove <name> | Drop a static route and forget it. | | localgate config [dir] [--json] | Machine and project config, the names it implies, and the alias file. | | localgate cloudflare-info | Print the DNS and ingress entries to paste. | | localgate prune | Drop routes whose runner is gone. |

With no [app] argument, a command acts on the route owning the current directory. start is not a name a project or an alias can use: it belongs to the start page above.

Working with a coding agent

An agent editing your project has a directory and no idea what is running in it. These three answer that without it having to find, read or guess a port:

localgate status --json     # state, port, command, pids, whether a debugger is attached, log tail
localgate restart           # restart this project's dev server, keeping its name and port
localgate list --json       # everything running on the machine

status --json includes the last lines the dev server printed, so an agent can check whether its change compiled without owning the terminal it compiled in.

The takeover above is the one place this matters for safety. With no terminal - an agent, a CI job - localgate run prints what is already running and exits 1 instead of taking it over. It never ends the developer's session by accident; localgate run --force takes over deliberately. To pick up a change, use localgate restart, which keeps the runner, the terminal and the debugger.

Names, and who can reach them

A project gets its name from package.json, and nothing else is needed to work locally:

| Mode | Names | Reachable from | |---|---|---| | local (default) | myapp.localhost | this machine | | lan | + myapp.<label>.<your-domain> | any PC on the LAN that resolves your domain | | internet | + <public-prefix>-myapp.<your-domain> | local machine, LAN and the configured tunnel |

The name is the package name, with a scope dropped (@org/web becomes web), and it has to be a usable hostname label. When the package name is long or is not what you want to type, override it - that plus the mode is everything a repository ever carries:

// package.json
{
  "name": "@acme/myapp-frontend",
  "localgate": {
    "mode": "lan",       // local (default) | lan | internet
    "name": "myapp"      // optional: the hostname label, instead of the package name
  }
}

Everything machine-specific stays out of the repository, in ~/.localgate/config.json. This is where your own domain is configured, and it is per machine, so two developers can serve the same project on their own names without either name being in the repository:

{
  "label": "dev",              // your machine, as a DNS label
  "baseDomain": "example.com", // a domain whose wildcard resolves to this LAN
  "lanIp": "192.0.2.10",       // the address the proxy also listens on
  "publicPrefix": "pub",        // prefix owned by this machine on the public domain
  "autoRestart": false         // see "When a dev server stops answering"
}

With that file present, myapp in mode lan also answers on myapp.dev.example.com, while mode internet additionally answers on pub-myapp.example.com. Browser-facing environment variables (NEXT_PUBLIC_*, *_PUBLIC_URL) are rewritten from .localhost to the name selected by the current mode on the way into the dev server. Public URLs use HTTPS and never carry the proxy's internal LAN port. Server-side variables stay on .localhost. Node children get a lookup hook for that reserved suffix, so Fetch and the standard HTTP clients reach the local proxy even when the operating system resolver does not expand nested localhost names.

For Auth.js applications that serve several origins, omit AUTH_URL and NEXTAUTH_URL from the application's development env files and launch environment, and configure trustHost: true or AUTH_TRUST_HOST=true. Authentication can then follow each request's origin. Localgate sets X-Forwarded-Host to the routed Host, including its port, and defaults X-Forwarded-Proto to http, preserving a tunnel's single http or https value. Each origin keeps its own session cookie.

Localgate does not delete auth settings, set trust flags or intercept env-file loading. Explicit auth URLs keep their configured hostname; as with other server URLs, a .localhost value receives the proxy port. This replaces the earlier behavior that rewrote auth URLs to the LAN or public hostname. Custom auth paths, cookie prefixes and older NextAuth server helpers remain application configuration. See docs/architecture/auth-origin.md for compatibility and restart steps.

For reaching an app from outside the LAN, localgate cloudflare-info prints the exact prefixed DNS record and tunnel ingress entry. It prints them and stops: localgate holds no API token and sends nothing anywhere. In Atomix projects, use axtoolsv2 expose <dir> off|network|internet; AtomixToolsV2 owns the API calls, project state, and Localgate reload. It retains an existing managed CNAME when leaving internet and removes only ingress, so the public hostname reaches the tunnel's 404 catch-all without DNS recreation delay. Anyone holding an enabled public URL reaches the dev server, which is why internet is a mode you switch back from.

When a dev server stops answering

Detection is free: the proxy forwards every request anyway, so it sees the answer that never came. You get a readable page instead of a browser error, and it says what to do next:

| State | What the browser gets | |---|---| | starting | 503, "still starting, reload in a moment" - a refused connection right after launch is normal | | dead | 503, "nothing is listening" plus the prune hint - the route outlived its dev server | | unresponsive | 504, after three requests in a row went unanswered, with the exact localgate restart line to run |

Acting on it is the risky half, so it is opt-in: "autoRestart": true in ~/.localgate/config.json lets the proxy restart an unresponsive app by itself, bounded by two attempts and a five-minute cooldown. It never fires while a debugger is attached - a process paused at a breakpoint is externally indistinguishable from a wedged one, and killing it would destroy the session you are standing in. Reporting always happens; only the restart is behind the switch.

If the proxy itself dies, every running app notices within ten seconds and re-registers its route, so the names come back without touching a single dev server. An alias has no process to notice, so the starting proxy replays the aliases from ~/.localgate/aliases.json instead.

Design rules

  • Nothing machine-specific is committed. The label, public prefix, domain and LAN IP live in ~/.localgate/config.json; a repository carries at most a package.json key holding a mode (local / lan / internet) and an optional name. Absent key means local, which is names on .localhost only.
  • Nothing is persisted except alias intent. The route table is live state; after a reboot nothing runs until the first command. The one exception is localgate alias: it records the name and the port in ~/.localgate/aliases.json, and a starting proxy serves those names again. An alias has no process of its own, so a file is the only thing that can bring it back. Drop it with localgate alias --remove <name>.
  • The proxy is not a service. The first run or alias starts it, and it exits once the last route disappears.
  • No TLS, no tunnels of its own. Plain http; put Cloudflare in front when something must be reachable from the internet.

Security

The control API - register, restart, stop, enumerate - is served only on the loopback listener, and each runner's restart endpoint listens on 127.0.0.1 alone. Nothing on the LAN can register a route or restart a process.

The mode is enforced by the listener, not just by which names exist. A .localhost name means "the machine that resolved it", so the LAN listener never answers one, whatever Host header a request carries and whether it is a plain request or a websocket upgrade. An app in the default local mode has no other name, which is what keeps it on this machine.

The start page follows the same rule rather than working around it: on the LAN it lists only what that listener would serve. An app in mode local does not appear on it at all - not its name, its port, its command or its directory.

Those dev servers are the real exposure: in lan mode anyone on the network reaches them with no authentication, and in internet mode anyone holding the URL does. Use lan on networks you trust, and switch back from internet when you are done. There is no TLS - it is plain http by design.

Development

pnpm run verify      # type-check + lint + tests
pnpm test            # vitest

The CLI runs the TypeScript sources directly through tsx, so a working copy is always runnable with no build step.

Status

Early. Windows, Linux and macOS: the kill that makes restart reliable goes through taskkill /T on Windows and the child's process group elsewhere, and the port-holder lookup is PowerShell or lsof.

Browsers and Node processes started through Localgate resolve *.localhost to loopback. Other command-line programs still use the operating system resolver, so on a system that does not expand nested localhost names they need an explicit resolver option or a hosts entry.

Bug reports and ideas are welcome - see CONTRIBUTING.md, and SECURITY.md for anything security-related. Licensed under Apache-2.0.