Shared Effect utilities for the overeng ecosystem.
bun add @overeng/utils- Workspace-aware command helpers (
cmd,cmdText) with optional logging and retention - Effect-native access to current working directory via
CurrentWorkingDirectory - Workspace root service backed by
WORKSPACE_ROOTviaEffectUtilsWorkspace - File system-backed distributed locks with TTL expiration and atomic operations
This package re-exports and extends effect-distributed-lock with additional backing implementations.
For single-machine scenarios where you need to coordinate locks across multiple Node.js processes (e.g., parallel builds, CLI tools), use the file-based backing:
import { FileSystemBacking } from '@overeng/utils/node'
import { DistributedSemaphore } from '@overeng/utils'
import { Effect, Duration } from 'effect'
import { NodeContext } from '@effect/platform-node'
const program = Effect.gen(function* () {
const semaphore = yield* DistributedSemaphore.make({
key: 'my-resource',
limit: 1,
ttl: Duration.seconds(30),
})
yield* semaphore.withPermit(
Effect.gen(function* () {
// Critical section - only one process can execute this at a time
yield* Effect.log('Acquired lock, doing work...')
yield* Effect.sleep(Duration.seconds(5))
}),
)
})
const backingLayer = FileSystemBacking.layer({
lockDir: '/tmp/my-app-locks',
})
program.pipe(Effect.provide(backingLayer), Effect.provide(NodeContext.layer), Effect.runPromise){lockDir}/
{key}/
{holderId-1}.lock # Contains { permits: N, expiresAt: timestamp }
{holderId-2}.lock
- Each semaphore key gets its own subdirectory
- Each holder gets its own lock file with permit count and expiry
- Lock files are written atomically using write-to-temp-then-rename
- TTL-based expiration handles crashed processes with automatic cleanup
- File system watching provides push-based notifications when permits are released
The file-system backing provides additional functions for forcibly revoking locks, useful for recovering from dead processes or administrative intervention:
import { FileSystemBacking } from '@overeng/utils/node'
import { Effect } from 'effect'
const options = { lockDir: '/tmp/my-app-locks' }
// List all active holders for a key
const holders = yield * FileSystemBacking.listHolders(options, 'my-resource')
// Returns: [{ holderId: 'abc', permits: 1, expiresAt: 1234567890 }, ...]
// Force revoke a specific holder's permits
const revokedPermits =
yield * FileSystemBacking.forceRevoke(options, 'my-resource', 'holder-id-to-revoke')
// Nuclear option: revoke all holders for a key
const allRevoked = yield * FileSystemBacking.forceRevokeAll(options, 'my-resource')
// Returns: [{ holderId: 'abc', permits: 1 }, { holderId: 'def', permits: 2 }]After a force revoke, the victim holder's next TTL refresh will fail, triggering LockLostError in their withPermits scope.
See upstream feature request: ethanniser/effect-distributed-lock#9
- Eventually consistent permit counting: While individual lock file operations are atomic, the total permit count across holders is read from multiple files
- Single machine only: For distributed systems across multiple machines, use the Redis backing from
effect-distributed-lock
Re-exports from effect-distributed-lock:
DistributedSemaphore- Main semaphore interfaceDistributedSemaphoreBacking- Backing store service interfaceSemaphoreBackingError- Error type for backing operationsLockLostError- Error when lock TTL expires unexpectedlyBacking- Backing module namespace
Debug utilities:
withScopeDebug- Enable scope/finalizer tracing for an effectaddTracedFinalizer- Register a finalizer with debug loggingwithTracedScope- Run an effect in a traced scopetraceFinalizer- Wrap a finalizer effect with tracing
makeFileLogger- Pretty-printed file logger with span supportdumpActiveHandles- Inspect active Node.js handles preventing exitmonitorActiveHandles- Periodic monitoring of active handleslogActiveHandles- Log active handles to Effect loggerFileSystemBacking- File-based backing implementation for Node.jslayer- Create the backing layerforceRevoke- Forcibly revoke a holder's permitsforceRevokeAll- Revoke all holders for a keylistHolders- List active holders with their infoHolderInfo- Type for holder informationHolderNotFoundError- Error when target holder doesn't exist
CurrentWorkingDirectory- Service capturing process CWD, with test overridesEffectUtilsWorkspace- Workspace root service backed byWORKSPACE_ROOTcmd- Command runner returning exit codes with optional logging/retentioncmdText- Command runner returning stdout as textTaskRunner- Concurrent task execution with structured state managementprintFinalSummary- Print task summary and fail if any task failedrenderLoop- Background render loop for TUI outputTaskState- Schema for individual task stateTaskRunnerState- Schema for overall runner stateTasksFailedError- Error type when tasks fail
makeFileLogger creates a Layer that writes pretty-printed logs to a file. Useful for debugging long-running processes or capturing logs from background workers.
import { Effect } from 'effect'
import { makeFileLogger } from '@overeng/utils/node'
const program = Effect.gen(function* () {
yield* Effect.log('Application started')
yield* Effect.logDebug('Debug details', { config: { port: 3000 } })
yield* Effect.gen(function* () {
yield* Effect.log('Inside operation')
}).pipe(Effect.withSpan('my-operation'))
})
program.pipe(
Effect.provide(makeFileLogger({ logFilePath: '/tmp/app.log', threadName: 'main' })),
Effect.runPromise,
)Output in /tmp/app.log:
[14:23:45.123 main] INFO (#0): Application started
[14:23:45.124 main] DEBUG (#0): Debug details
{ config: { port: 3000 } }
[14:23:45.125 main] INFO (#0) my-operation (2ms): Inside operation
Options:
threadName- Label to identify the log source (e.g., 'main', 'worker-1')colors- Include ANSI color codes (default: false, useful when tailing withless -R)
CurrentWorkingDirectory exposes the current working directory through the Effect
environment so it can be overridden in tests or nested executions. Utilities like
cmd and cmdText read from this service.
import { NodeContext } from '@effect/platform-node'
import { Effect, Layer } from 'effect'
import { cmd, CurrentWorkingDirectory } from '@overeng/utils/node'
const program = Effect.gen(function* () {
yield* cmd(['echo', 'hello'])
})
program.pipe(
Effect.provide(Layer.mergeAll(NodeContext.layer, CurrentWorkingDirectory.live)),
Effect.runPromise,
)Use EffectUtilsWorkspace when you want a workspace root derived from WORKSPACE_ROOT:
import { Effect, Layer } from 'effect'
import { EffectUtilsWorkspace } from '@overeng/utils/node'
const WorkspaceLayer = Layer.mergeAll(
EffectUtilsWorkspace.live,
EffectUtilsWorkspace.toCwd('packages'),
)TaskRunner provides concurrent task execution with structured state management. It decouples task execution from output rendering, enabling clean TUI output without interleaved logs.
import { NodeContext } from '@effect/platform-node'
import { Effect, Layer, Stream } from 'effect'
import { CurrentWorkingDirectory, printFinalSummary, TaskRunner } from '@overeng/utils/node'
const program = Effect.gen(function* () {
const runner = yield* TaskRunner
// Register tasks upfront
yield* runner.register({ id: 'build', name: 'Build project' })
yield* runner.register({ id: 'lint', name: 'Lint code' })
yield* runner.register({ id: 'test', name: 'Run tests' })
// Optional: Start render loop for real-time TUI output
yield* runner.changes.pipe(
Stream.debounce('50 millis'),
Stream.runForEach(() =>
Effect.gen(function* () {
const output = yield* runner.render()
process.stdout.write('\x1B[2J\x1B[H' + output + '\n')
}),
),
Effect.fork,
)
// Run tasks concurrently - tasks update state but never fail
yield* runner.runAll([
runner.runTask({ id: 'build', command: 'npm', args: ['run', 'build'] }),
runner.runTask({ id: 'lint', command: 'npm', args: ['run', 'lint'] }),
])
// Run dependent tasks after
yield* runner.runTask({ id: 'test', command: 'npm', args: ['test'] })
// Print summary and fail if any task failed
yield* printFinalSummary
}).pipe(
Effect.provide(TaskRunner.live),
Effect.provide(Layer.mergeAll(NodeContext.layer, CurrentWorkingDirectory.live)),
)Key features:
- Tasks update state but never fail the Effect - use
checkForFailures()at the end - Output (stdout/stderr) is captured and buffered per-task
- State changes available via
SubscriptionReffor real-time rendering - Built-in render function produces terminal-friendly output with status icons
Debug utilities for browser environments:
BroadcastLoggerLive- Logger layer that broadcasts via BroadcastChannel (worker side)makeBroadcastLogger- Create a broadcast logger for SharedWorkersmakeLogBridgeLive- Effect-native log bridge with source filtering (tab side)logStream- Stream of broadcast log entries for custom processingformatLogEntry- Format a log entry for display
Other browser utilities:
base64- Base64 encoding/decodingOPFS- Origin Private File System utilitiesWebLock- Web Locks API utilitiesprettyBytes- Byte formatting
Capture logs from SharedWorkers and display them in a tab. Supports multiple workers via the source identifier.
// ═══════════════════════════════════════════════════════════════════════════
// SharedWorker side (sync-worker.ts)
// ═══════════════════════════════════════════════════════════════════════════
import { Effect } from 'effect'
import { BroadcastLoggerLive } from '@overeng/utils/browser'
const workerProgram = Effect.gen(function* () {
yield* Effect.log('Sync worker initialized')
yield* Effect.logDebug('Connecting to database...')
yield* Effect.gen(function* () {
yield* Effect.log('Syncing records')
}).pipe(Effect.withSpan('sync-operation'))
}).pipe(
// All Effect.log calls broadcast to connected tabs
Effect.provide(BroadcastLoggerLive('sync-worker')),
)
// ═══════════════════════════════════════════════════════════════════════════
// Tab side (main.ts) - Option 1: Effect-native bridge (recommended)
// ═══════════════════════════════════════════════════════════════════════════
import { Effect } from 'effect'
import { makeLogBridgeLive } from '@overeng/utils/browser'
const app = Effect.gen(function* () {
yield* Effect.log('App started')
// Worker logs appear through Effect's logger with annotations
}).pipe(
Effect.provide(makeLogBridgeLive()),
// Or filter to specific workers:
// Effect.provide(makeLogBridgeLive({ sources: ['sync-worker'] })),
Effect.scoped,
)
// ═══════════════════════════════════════════════════════════════════════════
// Tab side - Option 2: Stream-based processing
// ═══════════════════════════════════════════════════════════════════════════
import { Effect, Stream } from 'effect'
import { logStream, formatLogEntry } from '@overeng/utils/browser'
const logViewer = logStream.pipe(
Stream.filter((entry) => entry.source === 'sync-worker'),
Stream.runForEach((entry) => Effect.sync(() => console.log(formatLogEntry(entry)))),
)The FileSystemBacking implementation in this package has been proposed for inclusion in the upstream effect-distributed-lock package.