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-nodedefineDevCli
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[]) => voidReturns a function — call it with process.argv.
| Field | Type | Description |
|---|---|---|
name | string | CLI program name, passed to cli.Command.make |
dir | string | Project root. Sessions live under <dir>/.data/sessions/; the running-signal file lives at <dir>/.data/running.json |
options | CommandConfig (optional) | Flag/argument config — the second parameter type of effect/unstable/cli's Command.make |
run | see above | Script 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:
- Builds a layer from
makeDevSessionsLayer(join(dir, '.data/sessions'))merged withNodeServices.layer. - Runs
runviaNodeRuntime.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. - 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.
| Member | Type |
|---|---|
session | Effect.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
}| Member | Description |
|---|---|
name | Session slug — a single noun, e.g. walrus |
lastModifiedAt | Directory 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') {}| Member | Type | Description |
|---|---|---|
dir | string | Root sessions directory this instance was built with |
path(relativePath) | (relativePath: string) => string | Joins relativePath onto dir |
getSessions | Effect.Effect<Array<DevSession>, PlatformError> | Lists session subdirectories with their mtimes. [] if the root doesn't exist yet — not an error |
createSession | Effect.Effect<DevSession, PlatformError> | Creates a new session directory with a fresh slug |
getLatestOrCreate | Effect.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.writeshallow-mergesdatainto the existing JSON, so distinct slots (distinct schemas) coexist by key — but a key collision between two slots silently overwrites. readresolvesnull, notOption— both when the file is missing and when decoding fails.writecreates 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,writefails withSessionStateError(_tag: 'SessionStateError') instead of merging into it —readis the one that degrades corrupt content tonull, notwrite.
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.