# Ports that survive restarts

Restart your dev script and the server usually comes up somewhere new. The OAuth redirect URI you registered against `localhost:4321` stops matching. A tunnel you pointed at that port now points at nothing. Anywhere you hardcoded the URL goes stale the moment the port does.

`getStickyPort(session)` gives you a port with three properties, and the third is the one worth building on.

## Free

Ask for a port and you get one nothing else on the machine is using — `getStickyPort` is a thin wrapper around [`get-port`](https://github.com/sindresorhus/get-port), which checks with the OS before handing one back.

## Reusable

Restart the same session and you tend to get the same port back:

```ts
// inside a handler, with CurrentSession.layer provided
const session = yield* CurrentSession
const port = yield* getStickyPort(session)
// 4321 — the same port as last run, if nothing else has taken it
```

```ts
getStickyPort(session: DevSession, options?: { name?: string }): Effect<number>
```

It takes the session, plus an optional name — nothing else. It always reads and writes that session, not any other; omit the name (or the whole options object) and it resolves the entry named `default`, which is what every example above does. Internally it pulls whatever port is stored under that name in `sess.json`, passes it to `get-port` as a preference, and writes back whichever port that call actually returns.

:::warning\[Best-effort, not guaranteed]
The remembered port is a preference, not a reservation. If something else has taken it since your last run, you get a different free port instead — and that new one is what gets remembered from now on. Don't build anything that assumes the port can never change.
:::

## Named ports

A session that runs one server only ever needs the `default` name — everything above this section already uses it, implicitly. A session running two servers, say a web app and a mock API it talks to, needs two remembered ports instead:

```ts
const port = yield* getStickyPort(session)
const apiPort = yield* getStickyPort(session, { name: 'api' })
```

`sess.json` grows a slot per name — `{ ports: { default: 4321, api: 4322 } }` — instead of one top-level port. Resolving one name excludes the ports already remembered under the others, so allocating `api`'s port can't land on the one `default` is holding. Reach for a second name when you actually have a second server; most scripts never need more than `default`.

## Discoverable

Free and reusable only help the process that owns the port. What makes it worth building on: another service that depends on this one can find out which port it landed on, without you hardcoding it anywhere — see [Wiring services together](/recipes/wiring-services).

## Write the port wherever it's needed

A handler is ordinary TypeScript. Once you have the port, you can put it anywhere something else expects to find it — before you start the server that's actually going to bind to it.

Here it keeps an MCP client config pointed at the right URL:

```ts
// scripts/dev.ts
import { CurrentSession, DevSessions, getStickyPort, runManagedSubprocess } from 'devsess'
import { NodeRuntime, NodeServices } from '@effect/platform-node'
import { Effect } from 'effect'
import { FileSystem } from 'effect/FileSystem'
import { Command } from 'effect/unstable/cli'

const web = Command.make('web', {}, () =>
	Effect.gen(function* () {
		const sessions = yield* DevSessions
		const session = yield* CurrentSession
		const port = yield* getStickyPort(session)
		const fs = yield* FileSystem

		const mcpConfigPath = sessions.path('.mcp.json')
		const raw = yield* fs.readFileString(mcpConfigPath)
		const config = JSON.parse(raw)
		config.mcpServers.web.url = `http://localhost:${port}/mcp`
		yield* fs.writeFileString(mcpConfigPath, JSON.stringify(config, null, '\t'))
		// .mcp.json now points at this run's port, before vite ever starts

		yield* runManagedSubprocess('bunx', ['vite'], {
			env: { PORT: String(port) },
		})
	}).pipe(Effect.provide(CurrentSession.layer)),
)

Command.run(web, { version: '0.1.0' }).pipe(
	Effect.provide(DevSessions.layer),
	Effect.provide(NodeServices.layer),
	Effect.scoped,
	NodeRuntime.runMain,
)
```

No hook, no plugin — reading a JSON file, editing a field, and writing it back is the whole mechanism. `sessions.path('.mcp.json')` resolves against your project root (not the session), the same way `join(import.meta.dirname, '../.mcp.json')` used to. An `.env` file, a proxy config, a second `dev.ts` in a sibling package: the same mechanism works anywhere a port needs to end up.

Starting the process that actually binds to this port is its own recipe — see [Running your dev server](/recipes/dev-server).
