Skip to content
devsess

devsess

The Effect-based core: defineDevCli scaffolds a persistent dev CLI backed by a session store, a sticky port, managed subprocesses, and cross-package running signals. See defineDevCli for sessions and the CLI scaffold, and Running your dev server for subprocess lifetime.

Peers: effect (^4.0.0-beta.101), @effect/platform-node (^4.0.0-beta.101).

bun add devsess effect @effect/platform-node

defineDevCli

function defineDevCli(config: {
	name: string
	dir: string
	options?: CommandConfig
	run: (ctx: RunContext, opts: Record<string, unknown>) => Effect.Effect<
		void,
		unknown,
		DevSessions | NodeServices.NodeServices | Scope.Scope
	>
}): (argv: string[]) => void

Returns a function — call it with process.argv.

FieldTypeDescription
namestringCLI program name, passed to cli.Command.make
dirstringProject root. Sessions live under <dir>/.data/sessions/; the running-signal file lives at <dir>/.data/running.json
optionsCommandConfig (optional)Flag/argument config — the second parameter type of effect/unstable/cli's Command.make
runsee aboveScript body. opts is the parsed CLI options

CommandConfig is typeof cli.Command.make extends (name: string, config: infer C, ...rest) => unknown ? C : never — in practice, the flag/argument record you'd pass as Command.make's second argument.

On invocation:

  1. Builds a layer from makeDevSessionsLayer(join(dir, '.data/sessions')) merged with NodeServices.layer.
  2. Runs run via NodeRuntime.runMain(Effect.scoped(...)) — the entire run is one Effect scope, which is why managed subprocesses and running-signal files clean up on exit or interruption.
  3. Always resolves the session with getLatestOrCreate — there's no flag or option to target a specific session.

--version always prints 0.0.0, regardless of the installed package version — it's hardcoded in the shared CLI scaffolding, not derived from package.json.

RunContext

The ctx object passed to run.

MemberType
sessionEffect.Effect<DevSession, PlatformError>
getStickyPort()() => Effect.Effect<number, PlatformError | SessionStateError, FileSystem | Path>
runManagedSubprocess(cmd, args, opts?)(cmd: string, args: string[], opts?: { env?: Record<string, string> }) => Effect.Effect<ExitCode, PlatformError, Scope.Scope | ChildProcessSpawner>
publishRunning(data)(data: unknown) => Effect.Effect<void, PlatformError, Scope.Scope | FileSystem | Path>
awaitRunning<T>(pkg)(pkg: string) => Effect.Effect<T, PlatformError, FileSystem | Path>

ExitCode (effect/unstable/process) is a branded number. FileSystem, Path, and ChildProcessSpawner come from NodeServices.layer, already provided by defineDevCli — you never supply them yourself.

session — a value, not a function. Built once per run as Effect.cached(sessions.getLatestOrCreate), so every yield* ctx.session after the first is free and resolves to the identical DevSession.

getStickyPort() — unlike session, this is not cached: every call re-reads the port from sess.json and re-asks get-port. Reuses the session's remembered port when it's still free — not a guarantee; a different free port is chosen (and persisted) if the remembered one is taken. Call it once per run and reuse the returned number. It's built on SessionState.slot internally, so a sess.json corrupted by something outside devsess surfaces here as SessionStateError too.

runManagedSubprocess(cmd, args, opts?) — resolves to the child's exit code. stdio is inherit for stdin/stdout/stderr, so child output goes straight to your terminal. opts.env is merged over process.env, not a replacement. The child is killed when the enclosing scope closes — script exit or Ctrl-C; a failed kill is logged, not thrown.

publishRunning(data) — writes data as JSON to <dir>/.data/running.json, atomically (temp file, then rename, so readers never see a partial write). Deletes that file when its scope closes, so a crashed or exited publisher leaves no stale signal behind.

awaitRunning<T>(pkg) — reads another package's running-signal file, not this project's own. pkg starting with . or / resolves as a path relative to dir; anything else is resolved as a package name via require.resolve('<pkg>/package.json'). Creates the sibling's .data directory if it doesn't exist yet (fs.watch would otherwise throw synchronously on a missing directory), then waits using fs.watch on it (no polling), plus one immediate read to catch the already-published case. No timeout — waits indefinitely.

Running signals are not session-scoped — they coordinate across sibling packages in a monorepo, keyed by project directory. See Wiring services together.

DevSession

type DevSession = {
	name: string
	lastModifiedAt: Date | null
	path: (relativePath: string) => Effect.Effect<string, never, never>
	toString: () => string
}
MemberDescription
nameSession slug — a single noun, e.g. walrus
lastModifiedAtDirectory mtime as of the last listing; null for a session just created in this call
path(relativePath)Joins relativePath onto the session directory. Pure path math — does not create the directory or touch the filesystem
toString()Returns name

DevSessions

Context.Service tag for the session store.

class DevSessions extends Context.Service<
	DevSessions,
	{
		readonly dir: string
		readonly path: (relativePath: string) => string
		readonly getSessions: Effect.Effect<Array<DevSession>, PlatformError>
		readonly createSession: Effect.Effect<DevSession, PlatformError>
		readonly getLatestOrCreate: Effect.Effect<DevSession, PlatformError>
	}
>()('devsess/DevSessions') {}
MemberTypeDescription
dirstringRoot sessions directory this instance was built with
path(relativePath)(relativePath: string) => stringJoins relativePath onto dir
getSessionsEffect.Effect<Array<DevSession>, PlatformError>Lists session subdirectories with their mtimes. [] if the root doesn't exist yet — not an error
createSessionEffect.Effect<DevSession, PlatformError>Creates a new session directory with a fresh slug
getLatestOrCreateEffect.Effect<DevSession, PlatformError>Ensures the root exists, then returns the session with the newest directory mtime — or creates one if none exist

getSessions, createSession, and getLatestOrCreate are Effects already bound to the resolved service instance, not functions — read them off the service, e.g. (yield* DevSessions).getLatestOrCreate.

defineDevCli always calls getLatestOrCreate internally. There's no built-in way to target a specific session from the CLI — use DevSessions (or createDevSessions from devsess/async) directly if you need to manage more than one session yourself.

makeDevSessionsLayer

function makeDevSessionsLayer(rootDir: string): Layer.Layer<DevSessions, never, FileSystem | Path>

Builds the DevSessions layer scoped to rootDir — the sessions root itself (e.g. <dir>/.data/sessions), not the project dir passed to defineDevCli. No directory I/O happens when the layer is built, only when its methods run.

SessionState.slot

namespace SessionState {
	function slot<T extends Schema.Top>(schema: T): {
		read: (session: DevSession) => Effect.Effect<T['Type'] | null, PlatformError, FileSystem | T['DecodingServices']>
		write: (session: DevSession, data: T['Type']) => Effect.Effect<void, PlatformError | SessionStateError, FileSystem | Path>
	}
}
 
class SessionStateError extends Data.TaggedError('SessionStateError')<{
	message: string
	cause?: unknown
}> {}

devsess doesn't re-export Schema — build schema from effect directly (import { Schema } from 'effect').

  • All slots for a session share one file, <session>/sess.json. write shallow-merges data into the existing JSON, so distinct slots (distinct schemas) coexist by key — but a key collision between two slots silently overwrites.
  • read resolves null, not Option — both when the file is missing and when decoding fails.
  • write creates the parent directory before writing, but the write itself is not atomic (no temp-file-then-rename, unlike the running-signal file). If the on-disk JSON is corrupt, write fails with SessionStateError (_tag: 'SessionStateError') instead of merging into it — read is the one that degrades corrupt content to null, not write.

cli

Re-export of effect/unstable/cli. Use it to declare options for defineDevCli without adding effect as a direct import, e.g. cli.Flag.boolean('lite'). See defineDevCli.