Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'

This commit is contained in:
-Puter
2026-07-19 03:25:18 +05:30
parent dd1071cc10
commit 2daf979036
2214 changed files with 673090 additions and 0 deletions
@@ -0,0 +1,722 @@
/**
* Shared Node.js implementation of the child process spawner service.
*
* This module adapts `node:child_process.spawn` to the Effect
* `ChildProcessSpawner` service. Provide {@link layer} to run `ChildProcess`
* commands in Node-compatible runtimes: commands get scoped process handles
* with stdin sinks, stdout and stderr streams, exit-code waiting,
* interruption-time cleanup, process killing, and custom file-descriptor pipes.
*
* The implementation sits below the command-building API. It validates and
* resolves `cwd` through the Effect `FileSystem` and `Path` services,
* translates Node errno failures to `PlatformError`, and uses scopes to
* terminate referenced children when the owning effect is interrupted or
* finalized. Pipelines are flattened by {@link flattenCommand} and spawned one
* process at a time, wiring the selected source stream (`stdout`, `stderr`,
* `all`, or `fdN`) to the destination `stdin` or `fdN`.
*
* @since 4.0.0
*/
import type * as Arr from "effect/Array"
import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Path from "effect/Path"
import type * as PlatformError from "effect/PlatformError"
import * as Predicate from "effect/Predicate"
import type * as Scope from "effect/Scope"
import * as Sink from "effect/Sink"
import * as Stream from "effect/Stream"
import * as ChildProcess from "effect/unstable/process/ChildProcess"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import {
ChildProcessSpawner,
ExitCode,
make as makeSpawner,
makeHandle,
ProcessId
} from "effect/unstable/process/ChildProcessSpawner"
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import { handleErrnoException } from "./internal/utils.ts"
import * as NodeSink from "./NodeSink.ts"
import * as NodeStream from "./NodeStream.ts"
const toError = (error: unknown): Error =>
error instanceof globalThis.Error
? error
: new globalThis.Error(String(error))
const toPlatformError = (
method: string,
error: NodeJS.ErrnoException,
command: ChildProcess.Command
): PlatformError.PlatformError => {
const { commands } = flattenCommand(command)
const commandStr = commands.reduce((acc, curr) => {
const cmd = `${curr.command} ${curr.args.join(" ")}`
return acc.length === 0 ? cmd : `${acc} | ${cmd}`
}, "")
return handleErrnoException("ChildProcess", method)(error, [commandStr])
}
type ExitCodeWithSignal = readonly [code: number | null, signal: NodeJS.Signals | null]
type ExitSignal = Deferred.Deferred<ExitCodeWithSignal>
const make = Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const resolveWorkingDirectory = Effect.fnUntraced(
function*(options: ChildProcess.CommandOptions) {
if (Predicate.isUndefined(options.cwd)) return undefined
// Validate that the specified directory is accessible
yield* fs.access(options.cwd)
return path.resolve(options.cwd)
}
)
const resolveEnvironment = (options: ChildProcess.CommandOptions) => {
return options.extendEnv
? { ...globalThis.process.env, ...options.env }
: options.env
}
const inputToStdioOption = (input: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined =>
Stream.isStream(input) ? "pipe" : input
const outputToStdioOption = (input: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined =>
Sink.isSink(input) ? "pipe" : input
const resolveStdinOption = (options: ChildProcess.CommandOptions): ChildProcess.StdinConfig => {
const defaultConfig: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true }
if (Predicate.isUndefined(options.stdin)) {
return defaultConfig
}
if (typeof options.stdin === "string") {
return { ...defaultConfig, stream: options.stdin }
}
if (Stream.isStream(options.stdin)) {
return { ...defaultConfig, stream: options.stdin }
}
return {
stream: options.stdin.stream,
encoding: options.stdin.encoding ?? defaultConfig.encoding,
endOnDone: options.stdin.endOnDone ?? defaultConfig.endOnDone
}
}
const resolveOutputOption = (
options: ChildProcess.CommandOptions,
streamName: "stdout" | "stderr"
): ChildProcess.StdoutConfig => {
const option = options[streamName]
if (Predicate.isUndefined(option)) {
return { stream: "pipe" }
}
if (typeof option === "string") {
return { stream: option }
}
if (Sink.isSink(option)) {
return { stream: option }
}
return { stream: option.stream }
}
interface ResolvedAdditionalFd {
readonly fd: number
readonly config: ChildProcess.AdditionalFdConfig
}
const resolveAdditionalFds = (
options: ChildProcess.CommandOptions
): ReadonlyArray<ResolvedAdditionalFd> => {
if (Predicate.isUndefined(options.additionalFds)) {
return []
}
const result: Array<ResolvedAdditionalFd> = []
for (const [name, config] of Object.entries(options.additionalFds)) {
const fd = ChildProcess.parseFdName(name)
if (Predicate.isNotUndefined(fd)) {
result.push({ fd, config })
}
}
// Sort by fd number to ensure correct ordering
return result.sort((a, b) => a.fd - b.fd)
}
const buildStdioArray = (
stdinConfig: ChildProcess.StdinConfig,
stdoutConfig: ChildProcess.StdoutConfig,
stderrConfig: ChildProcess.StderrConfig,
additionalFds: ReadonlyArray<ResolvedAdditionalFd>
): NodeChildProcess.StdioOptions => {
const stdio: Array<NodeChildProcess.IOType | undefined> = [
inputToStdioOption(stdinConfig.stream),
outputToStdioOption(stdoutConfig.stream),
outputToStdioOption(stderrConfig.stream)
]
if (additionalFds.length === 0) {
return stdio as NodeChildProcess.StdioOptions
}
// Find the maximum fd number to size the array correctly
const maxFd = additionalFds.reduce((max, { fd }) => Math.max(max, fd), 2)
// Fill gaps with "ignore"
for (let i = 3; i <= maxFd; i++) {
stdio[i] = "ignore"
}
// Set up additional fds as "pipe"
for (const { fd } of additionalFds) {
stdio[fd] = "pipe"
}
return stdio as NodeChildProcess.StdioOptions
}
const setupAdditionalFds = Effect.fnUntraced(function*(
command: ChildProcess.StandardCommand,
childProcess: NodeChildProcess.ChildProcess,
additionalFds: ReadonlyArray<ResolvedAdditionalFd>
) {
if (additionalFds.length === 0) {
return {
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty
}
}
const inputSinks = new Map<number, Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError>>()
const outputStreams = new Map<number, Stream.Stream<Uint8Array, PlatformError.PlatformError>>()
for (const { config, fd } of additionalFds) {
const nodeStream = childProcess.stdio[fd]
switch (config.type) {
case "input": {
// Create a sink to write to for input file descriptors
let sink: Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError> = Sink.drain
if (nodeStream && "write" in nodeStream) {
sink = NodeSink.fromWritable({
evaluate: () => nodeStream,
onError: (error) => toPlatformError(`fromWritable(fd${fd})`, toError(error), command)
})
}
// If user provided a stream, pipe it into the sink
if (config.stream) {
yield* Effect.forkScoped(Stream.run(config.stream, sink))
}
inputSinks.set(fd, sink)
break
}
case "output": {
// Create a stream to read from for output file descriptors
let stream: Stream.Stream<Uint8Array, PlatformError.PlatformError> = Stream.empty
if (nodeStream && "read" in nodeStream) {
const passThrough = new PassThrough()
nodeStream.on("error", (error) => passThrough.destroy(error))
nodeStream.pipe(passThrough)
stream = NodeStream.fromReadable({
evaluate: () => passThrough,
onError: (error) => toPlatformError(`fromReadable(fd${fd})`, toError(error), command)
})
}
// If user provided a sink, transduce the stream through it
if (config.sink) {
stream = Stream.transduce(stream, config.sink)
}
outputStreams.set(fd, stream)
break
}
}
}
return {
getInputFd: (fd: number) => inputSinks.get(fd) ?? Sink.drain,
getOutputFd: (fd: number) => outputStreams.get(fd) ?? Stream.empty
}
})
const setupChildStdin = (
command: ChildProcess.StandardCommand,
childProcess: NodeChildProcess.ChildProcess,
config: ChildProcess.StdinConfig
) =>
Effect.suspend(() => {
// If the child process has a standard input stream, connect it to the
// sink that will attached to the process handle
let sink: Sink.Sink<void, unknown, never, PlatformError.PlatformError> = Sink.drain
if (Predicate.isNotNull(childProcess.stdin)) {
sink = NodeSink.fromWritable({
evaluate: () => childProcess.stdin!,
onError: (error) => toPlatformError("fromWritable(stdin)", toError(error), command),
endOnDone: config.endOnDone,
encoding: config.encoding
})
}
// If the user provided a `Stream`, run it into the stdin sink
if (Stream.isStream(config.stream)) {
return Effect.as(Effect.forkScoped(Stream.run(config.stream, sink)), sink)
}
return Effect.succeed(sink)
})
const setupChildOutputStreams = (
command: ChildProcess.StandardCommand,
childProcess: NodeChildProcess.ChildProcess,
stdoutConfig: ChildProcess.StdoutConfig,
stderrConfig: ChildProcess.StderrConfig
): {
stdout: Stream.Stream<Uint8Array, PlatformError.PlatformError>
stderr: Stream.Stream<Uint8Array, PlatformError.PlatformError>
all: Stream.Stream<Uint8Array, PlatformError.PlatformError>
} => {
let stdout = childProcess.stdout ?
(() => {
const passThrough = new PassThrough()
childProcess.stdout!.on("error", (error) => passThrough.destroy(error))
childProcess.stdout!.pipe(passThrough)
return NodeStream.fromReadable({
evaluate: () => passThrough,
onError: (error) => toPlatformError("fromReadable(stdout)", toError(error), command)
})
})() :
Stream.empty
let stderr = childProcess.stderr ?
(() => {
const passThrough = new PassThrough()
childProcess.stderr!.on("error", (error) => passThrough.destroy(error))
childProcess.stderr!.pipe(passThrough)
return NodeStream.fromReadable({
evaluate: () => passThrough,
onError: (error) => toPlatformError("fromReadable(stderr)", toError(error), command)
})
})() :
Stream.empty
if (Sink.isSink(stdoutConfig.stream)) {
stdout = Stream.transduce(stdout, stdoutConfig.stream)
}
if (Sink.isSink(stderrConfig.stream)) {
stderr = Stream.transduce(stderr, stderrConfig.stream)
}
const all = Stream.merge(stdout, stderr)
return { stdout, stderr, all }
}
const spawn = (
command: ChildProcess.StandardCommand,
spawnOptions: NodeChildProcess.SpawnOptions
) =>
Effect.callback<
readonly [NodeChildProcess.ChildProcess, ExitSignal],
PlatformError.PlatformError
>((resume) => {
const deferred = Deferred.makeUnsafe<ExitCodeWithSignal>()
const handle = NodeChildProcess.spawn(
command.command,
command.args,
spawnOptions
)
handle.on("error", (error) => {
resume(Effect.fail(toPlatformError("spawn", error, command)))
})
handle.on("exit", (...args) => {
Deferred.doneUnsafe(deferred, Exit.succeed(args))
})
handle.on("spawn", () => {
resume(Effect.succeed([handle, deferred]))
})
return Effect.sync(() => {
handle.kill("SIGTERM")
})
})
const killProcessGroup = (
command: ChildProcess.StandardCommand,
childProcess: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals
) => {
if (globalThis.process.platform === "win32") {
return Effect.callback<void, PlatformError.PlatformError>((resume) => {
NodeChildProcess.exec(`taskkill /pid ${childProcess.pid} /T /F`, (error) => {
if (error) {
resume(Effect.fail(toPlatformError("kill", toError(error), command)))
} else {
resume(Effect.void)
}
})
})
}
return Effect.try({
try: () => {
globalThis.process.kill(-childProcess.pid!, signal)
},
catch: (error) => toPlatformError("kill", toError(error), command)
})
}
const killProcessGroupOnExit = (
childProcess: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals
): void => {
if (globalThis.process.platform === "win32") {
NodeChildProcess.exec(`taskkill /pid ${childProcess.pid} /T /F`, () => {
// ignore errors during best-effort cleanup
})
return
}
try {
globalThis.process.kill(-childProcess.pid!, signal)
} catch {
// ignore errors during best-effort cleanup
}
}
const killProcess = (
command: ChildProcess.StandardCommand,
childProcess: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals
) =>
Effect.suspend(() => {
const killed = childProcess.kill(signal)
if (!killed) {
const error = new globalThis.Error("Failed to kill child process")
return Effect.fail(toPlatformError("kill", error, command))
}
return Effect.void
})
const withTimeout = (
childProcess: NodeChildProcess.ChildProcess,
command: ChildProcess.StandardCommand,
options: ChildProcess.KillOptions | undefined
) =>
<A, E, R>(
kill: (
command: ChildProcess.StandardCommand,
childProcess: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals
) => Effect.Effect<A, E, R>
) => {
const killSignal = options?.killSignal ?? "SIGTERM"
return Predicate.isUndefined(options?.forceKillAfter)
? kill(command, childProcess, killSignal)
: Effect.timeoutOrElse(kill(command, childProcess, killSignal), {
duration: options.forceKillAfter,
orElse: () => kill(command, childProcess, "SIGKILL")
})
}
/**
* Get the appropriate source stream from a process handle based on the
* `from` pipe option.
*/
const getSourceStream = (
handle: ChildProcessHandle,
from: ChildProcess.PipeFromOption | undefined
): Stream.Stream<Uint8Array, PlatformError.PlatformError> => {
const fromOption = from ?? "stdout"
switch (fromOption) {
case "stdout":
return handle.stdout
case "stderr":
return handle.stderr
case "all":
return handle.all
default: {
// Handle fd3, fd4, etc.
const fd = ChildProcess.parseFdName(fromOption)
if (Predicate.isNotUndefined(fd)) {
return handle.getOutputFd(fd)
}
// Fallback to stdout for invalid fd names
return handle.stdout
}
}
}
const spawnCommand: (
command: ChildProcess.Command
) => Effect.Effect<
ChildProcessHandle,
PlatformError.PlatformError,
Scope.Scope
> = Effect.fnUntraced(function*(cmd) {
switch (cmd._tag) {
case "StandardCommand": {
const stdinConfig = resolveStdinOption(cmd.options)
const stdoutConfig = resolveOutputOption(cmd.options, "stdout")
const stderrConfig = resolveOutputOption(cmd.options, "stderr")
const resolvedAdditionalFds = resolveAdditionalFds(cmd.options)
let isReferenced = true
const cwd = yield* resolveWorkingDirectory(cmd.options)
const env = resolveEnvironment(cmd.options)
const stdio = buildStdioArray(stdinConfig, stdoutConfig, stderrConfig, resolvedAdditionalFds)
const [childProcess, exitSignal] = yield* Effect.acquireRelease(
spawn(cmd, {
cwd,
env,
stdio,
detached: cmd.options.detached ?? process.platform !== "win32",
shell: cmd.options.shell
}),
Effect.fnUntraced(function*([childProcess, exitSignal]) {
const exited = yield* Deferred.isDone(exitSignal)
const killWithTimeout = withTimeout(childProcess, cmd, cmd.options)
if (exited) {
// Process already exited, check if children need cleanup
const [code] = yield* Deferred.await(exitSignal)
if (code !== 0 && Predicate.isNotNull(code)) {
// Non-zero exit code ,attempt to clean up process group
return yield* Effect.ignore(killWithTimeout(killProcessGroup))
}
return yield* Effect.void
}
if (!isReferenced) {
return yield* Effect.void
}
// Process is still running, kill it
return yield* killWithTimeout((command, childProcess, signal) =>
killProcessGroup(command, childProcess, signal).pipe(
Effect.catch(() => killProcess(command, childProcess, signal)),
Effect.andThen(Deferred.await(exitSignal))
)
).pipe(
Effect.ignore
)
})
)
const pid = ProcessId(childProcess.pid!)
childProcess.on("exit", (code) => {
if (code !== 0 && Predicate.isNotNull(code)) {
killProcessGroupOnExit(childProcess, cmd.options.killSignal ?? "SIGTERM")
}
})
const reref = Effect.sync(() => {
if (!isReferenced) {
childProcess.ref()
isReferenced = true
}
})
const unref = Effect.sync(() => {
if (isReferenced) {
childProcess.unref()
isReferenced = false
}
return reref
})
const stdin = yield* setupChildStdin(cmd, childProcess, stdinConfig)
const { all, stderr, stdout } = setupChildOutputStreams(cmd, childProcess, stdoutConfig, stderrConfig)
const { getInputFd, getOutputFd } = yield* setupAdditionalFds(cmd, childProcess, resolvedAdditionalFds)
const isRunning = Effect.map(Deferred.isDone(exitSignal), (done) => !done)
const exitCode = Effect.flatMap(Deferred.await(exitSignal), ([code, signal]) => {
if (Predicate.isNotNull(code)) {
return Effect.succeed(ExitCode(code))
}
// If code is `null`, then `signal` must be defined. See the NodeJS
// documentation for the `"exit"` event on a `child_process`.
// https://nodejs.org/api/child_process.html#child_process_event_exit
const error = new globalThis.Error(`Process interrupted due to receipt of signal: '${signal}'`)
return Effect.fail(toPlatformError("exitCode", error, cmd))
})
const kill = (options?: ChildProcess.KillOptions | undefined) => {
const killWithTimeout = withTimeout(childProcess, cmd, options)
return killWithTimeout((command, childProcess, signal) =>
killProcessGroup(command, childProcess, signal).pipe(
Effect.catch(() => killProcess(command, childProcess, signal)),
Effect.andThen(Deferred.await(exitSignal))
)
).pipe(
Effect.asVoid
)
}
return makeHandle({
pid,
exitCode,
isRunning,
kill,
stdin,
stdout,
stderr,
all,
getInputFd,
getOutputFd,
unref
})
}
case "PipedCommand": {
const { commands, pipeOptions } = flattenCommand(cmd)
const [root, ...pipeline] = commands
const handles = [yield* spawnCommand(root)]
for (let i = 0; i < pipeline.length; i++) {
const command = pipeline[i]
const options = pipeOptions[i] ?? {}
const stdinConfig = resolveStdinOption(command.options)
// Get the appropriate stream from the source based on `from` option
const sourceStream = Stream.unwrap(
Effect.succeed(getSourceStream(handles[handles.length - 1], options.from))
)
// Determine where to pipe: stdin or custom fd
const toOption = options.to ?? "stdin"
if (toOption === "stdin") {
// Pipe to stdin (default behavior)
handles.push(
yield* spawnCommand(ChildProcess.make(command.command, command.args, {
...command.options,
stdin: { ...stdinConfig, stream: sourceStream }
}))
)
} else {
// Pipe to custom fd (fd3, fd4, etc.)
const fd = ChildProcess.parseFdName(toOption)
if (Predicate.isNotUndefined(fd)) {
const fdName = ChildProcess.fdName(fd) as `fd${number}`
const existingFds = command.options.additionalFds ?? {}
handles.push(
yield* spawnCommand(ChildProcess.make(command.command, command.args, {
...command.options,
additionalFds: {
...existingFds,
[fdName]: { type: "input" as const, stream: sourceStream }
}
}))
)
} else {
// Invalid fd name, fall back to stdin
handles.push(
yield* spawnCommand(ChildProcess.make(command.command, command.args, {
...command.options,
stdin: { ...stdinConfig, stream: sourceStream }
}))
)
}
}
}
const handle = handles[handles.length - 1]
const unref = Effect.gen(function*() {
const rerefs: Array<Effect.Effect<void, PlatformError.PlatformError>> = []
for (const handle of handles) {
rerefs.push(yield* handle.unref)
}
return Effect.forEach([...rerefs].reverse(), (reref) => reref, { discard: true })
})
return makeHandle({
pid: handle.pid,
exitCode: handle.exitCode,
isRunning: handle.isRunning,
kill: handle.kill,
stdin: handle.stdin,
stdout: handle.stdout,
stderr: handle.stderr,
all: handle.all,
getInputFd: handle.getInputFd,
getOutputFd: handle.getOutputFd,
unref
})
}
}
})
return makeSpawner(spawnCommand)
})
/**
* Layer that provides the `NodeChildProcessSpawner` implementation.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<
ChildProcessSpawner,
never,
FileSystem.FileSystem | Path.Path
> = Layer.effect(ChildProcessSpawner, make)
// =============================================================================
// Internal Helpers
// =============================================================================
/**
* Result of flattening a pipeline of commands.
*
* @category models
* @since 4.0.0
*/
export interface FlattenedPipeline {
readonly commands: Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>
readonly pipeOptions: ReadonlyArray<ChildProcess.PipeOptions>
}
/**
* Flattens a `Command` into an array of `StandardCommand`s along with pipe
* options for each connection.
*
* @category transforming
* @since 4.0.0
*/
export const flattenCommand = (
command: ChildProcess.Command
): FlattenedPipeline => {
const commands: Array<ChildProcess.StandardCommand> = []
const pipeOptions: Array<ChildProcess.PipeOptions> = []
const flatten = (cmd: ChildProcess.Command): void => {
switch (cmd._tag) {
case "StandardCommand": {
commands.push(cmd)
break
}
case "PipedCommand": {
// Recursively flatten left side first
flatten(cmd.left)
// Store the pipe options for this connection
pipeOptions.push(cmd.options)
// Then flatten right side
flatten(cmd.right)
break
}
}
}
flatten(command)
// The commands array is guaranteed to be non-empty since we always have at
// least one command in the input. We validate this at runtime and return a
// properly typed tuple.
if (commands.length === 0) {
// This should never happen given a valid Command input
throw new Error("flattenCommand produced empty commands array")
}
const [first, ...rest] = commands
const nonEmptyCommands: Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand> = [first, ...rest]
return { commands: nonEmptyCommands, pipeOptions }
}
@@ -0,0 +1,70 @@
/**
* Node TCP socket transport for Effect Cluster runner-to-runner RPC.
*
* This module provides the shared Node layers used by socket-based cluster
* transports. `layerClientProtocol` opens TCP sockets to peer runner addresses
* and wraps them in the current RPC serialization protocol. `layerSocketServer`
* exposes the socket server that receives incoming runner RPC traffic.
*
* @since 4.0.0
*/
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import * as Runners from "effect/unstable/cluster/Runners"
import * as ShardingConfig from "effect/unstable/cluster/ShardingConfig"
import * as RpcClient from "effect/unstable/rpc/RpcClient"
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
import { Socket } from "effect/unstable/socket/Socket"
import type * as SocketServer from "effect/unstable/socket/SocketServer"
import * as NodeSocket from "./NodeSocket.ts"
import * as NodeSocketServer from "./NodeSocketServer.ts"
/**
* Provides the cluster `RpcClientProtocol` by opening TCP sockets to runner
* addresses and using the current RPC serialization service.
*
* @category layers
* @since 4.0.0
*/
export const layerClientProtocol: Layer.Layer<
Runners.RpcClientProtocol,
never,
RpcSerialization.RpcSerialization
> = Layer.effect(Runners.RpcClientProtocol)(
Effect.gen(function*() {
const serialization = yield* RpcSerialization.RpcSerialization
return Effect.fnUntraced(function*(address) {
const socket = yield* NodeSocket.makeNet({
openTimeout: 1000,
timeout: 5500,
host: address.host,
port: address.port
})
return yield* RpcClient.makeProtocolSocket().pipe(
Effect.provideService(Socket, socket),
Effect.provideService(RpcSerialization.RpcSerialization, serialization)
)
}, Effect.orDie)
})
)
/**
* Provides the socket server used by cluster runners, listening on
* `ShardingConfig.runnerListenAddress` or `runnerAddress`.
*
* @category layers
* @since 4.0.0
*/
export const layerSocketServer: Layer.Layer<
SocketServer.SocketServer,
SocketServer.SocketServerError,
ShardingConfig.ShardingConfig
> = Effect.gen(function*() {
const config = yield* ShardingConfig.ShardingConfig
const listenAddress = Option.orElse(config.runnerListenAddress, () => config.runnerAddress)
if (Option.isNone(listenAddress)) {
return yield* Effect.die("layerSocketServer: ShardingConfig.runnerListenAddress is None")
}
return NodeSocketServer.layer(listenAddress.value)
}).pipe(Layer.unwrap)
@@ -0,0 +1,60 @@
/**
* Node-compatible implementation of Effect's `Crypto` service.
*
* This module builds the service from `node:crypto`, using `randomBytes` for
* random data and `createHash` for supported digest algorithms. It exports
* `make` as the concrete service value and `layer` for providing it through
* Effect context.
*
* @since 1.0.0
*/
import * as EffectCrypto from "effect/Crypto"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as PlatformError from "effect/PlatformError"
import * as NodeCrypto from "node:crypto"
const toHashAlgorithm = (algorithm: EffectCrypto.DigestAlgorithm): string => {
switch (algorithm) {
case "SHA-1":
return "sha1"
case "SHA-256":
return "sha256"
case "SHA-384":
return "sha384"
case "SHA-512":
return "sha512"
}
}
const digest: EffectCrypto.Crypto["digest"] = (algorithm, data) =>
Effect.try({
try: () => Uint8Array.from(NodeCrypto.createHash(toHashAlgorithm(algorithm)).update(data).digest()),
catch: (cause) =>
PlatformError.systemError({
module: "Crypto",
method: "digest",
_tag: "Unknown",
description: "Could not compute digest",
cause
})
})
/**
* The default Node.js Crypto service implementation.
*
* @category constructors
* @since 1.0.0
*/
export const make: EffectCrypto.Crypto = EffectCrypto.make({
randomBytes: NodeCrypto.randomBytes,
digest
})
/**
* Layer that provides the Node.js Crypto service implementation.
*
* @category layers
* @since 1.0.0
*/
export const layer: Layer.Layer<EffectCrypto.Crypto> = Layer.succeed(EffectCrypto.Crypto, make)
@@ -0,0 +1,668 @@
/**
* Shared Node-compatible implementation of Effect's `FileSystem` service.
*
* This module adapts Node's `node:fs`, `node:os`, and `node:path` APIs into a
* `FileSystem` layer for Effect programs running on Node-compatible runtimes.
* Platform packages use it to provide file and directory I/O, permissions,
* links, metadata, temporary files and directories, and file watching through
* the shared `FileSystem` service.
*
* @since 4.0.0
*/
import * as Cause from "effect/Cause"
import * as Effect from "effect/Effect"
import { effectify } from "effect/Effect"
import * as FileSystem from "effect/FileSystem"
import { pipe } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import * as Error from "effect/PlatformError"
import * as Queue from "effect/Queue"
import * as Stream from "effect/Stream"
import * as Crypto from "node:crypto"
import * as NFS from "node:fs"
import * as OS from "node:os"
import * as Path from "node:path"
import { handleErrnoException } from "./internal/utils.ts"
const handleBadArgument = (method: string) => (err: unknown) =>
Error.badArgument({
module: "FileSystem",
method,
description: (err as Error).message ?? String(err)
})
// == access
const access = ((): FileSystem.FileSystem["access"] => {
const nodeAccess = effectify(
NFS.access,
handleErrnoException("FileSystem", "access"),
handleBadArgument("access")
)
return (path, options) => {
let mode = NFS.constants.F_OK
if (options?.readable) {
mode |= NFS.constants.R_OK
}
if (options?.writable) {
mode |= NFS.constants.W_OK
}
return nodeAccess(path, mode)
}
})()
// == copy
const copy = ((): FileSystem.FileSystem["copy"] => {
const nodeCp = effectify(
NFS.cp,
handleErrnoException("FileSystem", "copy"),
handleBadArgument("copy")
)
return (fromPath, toPath, options) =>
nodeCp(fromPath, toPath, {
force: options?.overwrite ?? false,
preserveTimestamps: options?.preserveTimestamps ?? false,
recursive: true
})
})()
// == copyFile
const copyFile = (() => {
const nodeCopyFile = effectify(
NFS.copyFile,
handleErrnoException("FileSystem", "copyFile"),
handleBadArgument("copyFile")
)
return (fromPath: string, toPath: string) => nodeCopyFile(fromPath, toPath)
})()
// == chmod
const chmod = (() => {
const nodeChmod = effectify(
NFS.chmod,
handleErrnoException("FileSystem", "chmod"),
handleBadArgument("chmod")
)
return (path: string, mode: number) => nodeChmod(path, mode)
})()
// == chown
const chown = (() => {
const nodeChown = effectify(
NFS.chown,
handleErrnoException("FileSystem", "chown"),
handleBadArgument("chown")
)
return (path: string, uid: number, gid: number) => nodeChown(path, uid, gid)
})()
// == glob
const glob = ((): FileSystem.FileSystem["glob"] => {
const nodeGlob = effectify(
NFS.glob,
handleErrnoException("FileSystem", "glob"),
handleBadArgument("glob")
)
return (pattern: string, options) =>
nodeGlob(pattern, {
cwd: options?.root,
exclude: options?.exclude
})
})()
// == link
const link = (() => {
const nodeLink = effectify(
NFS.link,
handleErrnoException("FileSystem", "link"),
handleBadArgument("link")
)
return (existingPath: string, newPath: string) => nodeLink(existingPath, newPath)
})()
// == makeDirectory
const makeDirectory = ((): FileSystem.FileSystem["makeDirectory"] => {
const nodeMkdir = effectify(
NFS.mkdir,
handleErrnoException("FileSystem", "makeDirectory"),
handleBadArgument("makeDirectory")
)
return (path, options) =>
nodeMkdir(path, {
recursive: options?.recursive ?? false,
mode: options?.mode
})
})()
// == makeTempDirectory
const makeTempDirectoryFactory = (method: string): FileSystem.FileSystem["makeTempDirectory"] => {
const nodeMkdtemp = effectify(
NFS.mkdtemp,
handleErrnoException("FileSystem", method),
handleBadArgument(method)
)
return (options) =>
Effect.suspend(() => {
const prefix = options?.prefix ?? ""
const directory = typeof options?.directory === "string"
? Path.join(options.directory, ".")
: OS.tmpdir()
return nodeMkdtemp(prefix ? Path.join(directory, prefix) : directory + "/")
})
}
const makeTempDirectory = makeTempDirectoryFactory("makeTempDirectory")
// == remove
const removeFactory = (method: string): FileSystem.FileSystem["remove"] => {
const nodeRm = effectify(
NFS.rm,
handleErrnoException("FileSystem", method),
handleBadArgument(method)
)
return (path, options) =>
nodeRm(
path,
{ recursive: options?.recursive ?? false, force: options?.force ?? false }
)
}
const remove = removeFactory("remove")
// == makeTempDirectoryScoped
const makeTempDirectoryScoped = ((): FileSystem.FileSystem["makeTempDirectoryScoped"] => {
const makeDirectory = makeTempDirectoryFactory("makeTempDirectoryScoped")
const removeDirectory = removeFactory("makeTempDirectoryScoped")
return (options) =>
Effect.acquireRelease(
makeDirectory(options),
(directory) => Effect.orDie(removeDirectory(directory, { recursive: true }))
)
})()
// == open
const openFactory = (method: string): FileSystem.FileSystem["open"] => {
const nodeOpen = effectify(
NFS.open,
handleErrnoException("FileSystem", method),
handleBadArgument(method)
)
const nodeClose = effectify(
NFS.close,
handleErrnoException("FileSystem", method),
handleBadArgument(method)
)
return (path, options) =>
pipe(
Effect.acquireRelease(
nodeOpen(path, options?.flag ?? "r", options?.mode),
(fd) => Effect.orDie(nodeClose(fd))
),
Effect.map((fd) => makeFile(FileSystem.FileDescriptor(fd), options?.flag?.startsWith("a") ?? false))
)
}
const open = openFactory("open")
const makeFile = (() => {
const nodeReadFactory = (method: string) =>
effectify(
NFS.read,
handleErrnoException("FileSystem", method),
handleBadArgument(method)
)
const nodeRead = nodeReadFactory("read")
const nodeReadAlloc = nodeReadFactory("readAlloc")
const nodeStat = effectify(
NFS.fstat,
handleErrnoException("FileSystem", "stat"),
handleBadArgument("stat")
)
const nodeTruncate = effectify(
NFS.ftruncate,
handleErrnoException("FileSystem", "truncate"),
handleBadArgument("truncate")
)
const nodeSync = effectify(
NFS.fsync,
handleErrnoException("FileSystem", "sync"),
handleBadArgument("sync")
)
const nodeWriteFactory = (method: string) =>
effectify(
NFS.write,
handleErrnoException("FileSystem", method),
handleBadArgument(method)
)
const nodeWrite = nodeWriteFactory("write")
const nodeWriteAll = nodeWriteFactory("writeAll")
class FileImpl implements FileSystem.File {
readonly [FileSystem.FileTypeId]: typeof FileSystem.FileTypeId
readonly fd: FileSystem.File.Descriptor
private readonly append: boolean
private position: bigint = BigInt(0)
constructor(
fd: FileSystem.File.Descriptor,
append: boolean
) {
this[FileSystem.FileTypeId] = FileSystem.FileTypeId
this.fd = fd
this.append = append
}
get stat() {
return Effect.map(nodeStat(this.fd), makeFileInfo)
}
get sync() {
return nodeSync(this.fd)
}
seek(offset: FileSystem.SizeInput, from: FileSystem.SeekMode) {
const offsetSize = FileSystem.Size(offset)
return Effect.sync(() => {
if (from === "start") {
this.position = offsetSize
} else if (from === "current") {
this.position = this.position + offsetSize
}
return this.position
})
}
read(buffer: Uint8Array) {
return Effect.suspend(() => {
const position = this.position
return Effect.map(
nodeRead(this.fd, { buffer, position }),
(bytesRead) => {
const sizeRead = FileSystem.Size(bytesRead)
this.position = position + sizeRead
return sizeRead
}
)
})
}
readAlloc(size: FileSystem.SizeInput) {
const sizeNumber = Number(size)
return Effect.suspend(() => {
const buffer = Buffer.allocUnsafeSlow(sizeNumber)
const position = this.position
return Effect.map(
nodeReadAlloc(this.fd, { buffer, position }),
(bytesRead): Option.Option<Buffer> => {
if (bytesRead === 0) {
return Option.none()
}
this.position = position + BigInt(bytesRead)
if (bytesRead === sizeNumber) {
return Option.some(buffer)
}
const dst = Buffer.allocUnsafeSlow(bytesRead)
buffer.copy(dst, 0, 0, bytesRead)
return Option.some(dst)
}
)
})
}
truncate(length?: FileSystem.SizeInput) {
return Effect.map(nodeTruncate(this.fd, length ? Number(length) : undefined), () => {
if (!this.append) {
const len = BigInt(length ?? 0)
if (this.position > len) {
this.position = len
}
}
})
}
write(buffer: Uint8Array) {
return Effect.suspend(() => {
const position = this.position
return Effect.map(
nodeWrite(this.fd, buffer, undefined, undefined, this.append ? undefined : Number(position)),
(bytesWritten) => {
const sizeWritten = FileSystem.Size(bytesWritten)
if (!this.append) {
this.position = position + sizeWritten
}
return sizeWritten
}
)
})
}
private writeAllChunk(buffer: Uint8Array): Effect.Effect<void, Error.PlatformError> {
return Effect.suspend(() => {
const position = this.position
return Effect.flatMap(
nodeWriteAll(this.fd, buffer, undefined, undefined, this.append ? undefined : Number(position)),
(bytesWritten) => {
if (bytesWritten === 0) {
return Effect.fail(
Error.systemError({
module: "FileSystem",
method: "writeAll",
_tag: "WriteZero",
pathOrDescriptor: this.fd,
description: "write returned 0 bytes written"
})
)
}
if (!this.append) {
this.position = position + BigInt(bytesWritten)
}
return bytesWritten < buffer.length ? this.writeAllChunk(buffer.subarray(bytesWritten)) : Effect.void
}
)
})
}
writeAll(buffer: Uint8Array) {
return this.writeAllChunk(buffer)
}
}
return (fd: FileSystem.File.Descriptor, append: boolean): FileSystem.File => new FileImpl(fd, append)
})()
// == makeTempFile
const makeTempFileFactory = (method: string): FileSystem.FileSystem["makeTempFile"] => {
const makeDirectory = makeTempDirectoryFactory(method)
return Effect.fnUntraced(function*(options) {
const directory = yield* makeDirectory(options)
const random = Crypto.randomBytes(6).toString("hex")
const name = Path.join(directory, options?.suffix ? `${random}${options.suffix}` : random)
yield* writeFile(name, new Uint8Array(0))
return name
})
}
const makeTempFile = makeTempFileFactory("makeTempFile")
// == makeTempFileScoped
const makeTempFileScoped = ((): FileSystem.FileSystem["makeTempFileScoped"] => {
const makeFile = makeTempFileFactory("makeTempFileScoped")
const removeDirectory = removeFactory("makeTempFileScoped")
return (options) =>
Effect.acquireRelease(
makeFile(options),
(file) => Effect.orDie(removeDirectory(Path.dirname(file), { recursive: true }))
)
})()
// == readDirectory
const readDirectory: FileSystem.FileSystem["readDirectory"] = (path, options) =>
Effect.tryPromise({
try: () => NFS.promises.readdir(path, options),
catch: (err) => handleErrnoException("FileSystem", "readDirectory")(err as any, [path])
})
// == readFile
const readFile = (path: string) =>
Effect.callback<Uint8Array, Error.PlatformError>((resume, signal) => {
try {
NFS.readFile(path, { signal }, (err, data) => {
if (err) {
resume(Effect.fail(handleErrnoException("FileSystem", "readFile")(err, [path])))
} else {
resume(Effect.succeed(data))
}
})
} catch (err) {
resume(Effect.fail(handleBadArgument("readFile")(err)))
}
})
// == readLink
const readLink = (() => {
const nodeReadLink = effectify(
NFS.readlink,
handleErrnoException("FileSystem", "readLink"),
handleBadArgument("readLink")
)
return (path: string) => nodeReadLink(path)
})()
// == realPath
const realPath = (() => {
const nodeRealPath = effectify(
NFS.realpath,
handleErrnoException("FileSystem", "realPath"),
handleBadArgument("realPath")
)
return (path: string) => nodeRealPath(path)
})()
// == rename
const rename = (() => {
const nodeRename = effectify(
NFS.rename,
handleErrnoException("FileSystem", "rename"),
handleBadArgument("rename")
)
return (oldPath: string, newPath: string) => nodeRename(oldPath, newPath)
})()
// == stat
const makeFileInfo = (stat: NFS.Stats): FileSystem.File.Info => ({
type: stat.isFile() ?
"File" :
stat.isDirectory() ?
"Directory" :
stat.isSymbolicLink() ?
"SymbolicLink" :
stat.isBlockDevice() ?
"BlockDevice" :
stat.isCharacterDevice() ?
"CharacterDevice" :
stat.isFIFO() ?
"FIFO" :
stat.isSocket() ?
"Socket" :
"Unknown",
mtime: Option.fromNullishOr(stat.mtime),
atime: Option.fromNullishOr(stat.atime),
birthtime: Option.fromNullishOr(stat.birthtime),
dev: stat.dev,
rdev: Option.fromNullishOr(stat.rdev),
ino: Option.fromNullishOr(stat.ino),
mode: stat.mode,
nlink: Option.fromNullishOr(stat.nlink),
uid: Option.fromNullishOr(stat.uid),
gid: Option.fromNullishOr(stat.gid),
size: FileSystem.Size(stat.size),
blksize: stat.blksize !== undefined ? Option.some(FileSystem.Size(stat.blksize)) : Option.none(),
blocks: Option.fromNullishOr(stat.blocks)
})
const stat = (() => {
const nodeStat = effectify(
NFS.stat,
handleErrnoException("FileSystem", "stat"),
handleBadArgument("stat")
)
return (path: string) => Effect.map(nodeStat(path), makeFileInfo)
})()
// == symlink
const symlink = (() => {
const nodeSymlink = effectify(
NFS.symlink,
handleErrnoException("FileSystem", "symlink"),
handleBadArgument("symlink")
)
return (target: string, path: string) => nodeSymlink(target, path)
})()
// == truncate
const truncate = (() => {
const nodeTruncate = effectify(
NFS.truncate,
handleErrnoException("FileSystem", "truncate"),
handleBadArgument("truncate")
)
return (path: string, length?: FileSystem.SizeInput) =>
nodeTruncate(path, length !== undefined ? Number(length) : undefined)
})()
// == utimes
const utimes = (() => {
const nodeUtimes = effectify(
NFS.utimes,
handleErrnoException("FileSystem", "utime"),
handleBadArgument("utime")
)
return (path: string, atime: number | Date, mtime: number | Date) => nodeUtimes(path, atime, mtime)
})()
// == watch
const watchNode = (path: string) =>
Stream.callback<FileSystem.WatchEvent, Error.PlatformError>((queue) =>
Effect.acquireRelease(
Effect.sync(() => {
const watcher = NFS.watch(path, {
recursive: true
}, (event, path) => {
if (!path) return
switch (event) {
case "rename": {
Effect.runFork(Effect.matchEffect(stat(path), {
onSuccess: (_) => Queue.offer(queue, { _tag: "Create", path }),
onFailure: (_) => Queue.offer(queue, { _tag: "Remove", path })
}))
return
}
case "change": {
Queue.offerUnsafe(queue, { _tag: "Update", path })
return
}
}
})
watcher.on("error", (error) => {
Queue.failCauseUnsafe(
queue,
Cause.fail(
Error.systemError({
module: "FileSystem",
_tag: "Unknown",
method: "watch",
pathOrDescriptor: path,
cause: error
})
)
)
})
watcher.on("close", () => {
Queue.endUnsafe(queue)
})
return watcher
}),
(watcher) => Effect.sync(() => watcher.close())
)
)
const watch = (backend: Option.Option<FileSystem.WatchBackend["Service"]>, path: string) =>
stat(path).pipe(
Effect.map((stat) =>
backend.pipe(
Option.flatMap((_) => _.register(path, stat)),
Option.getOrElse(() => watchNode(path))
)
),
Stream.unwrap
)
// == writeFile
const writeFile: FileSystem.FileSystem["writeFile"] = (path, data, options) =>
Effect.callback<void, Error.PlatformError>((resume, signal) => {
try {
NFS.writeFile(path, data, {
signal,
flag: options?.flag,
mode: options?.mode
}, (err) => {
if (err) {
resume(Effect.fail(handleErrnoException("FileSystem", "writeFile")(err, [path])))
} else {
resume(Effect.void)
}
})
} catch (err) {
resume(Effect.fail(handleBadArgument("writeFile")(err)))
}
})
const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), (backend) =>
FileSystem.make({
access,
chmod,
chown,
copy,
copyFile,
glob,
link,
makeDirectory,
makeTempDirectory,
makeTempDirectoryScoped,
makeTempFile,
makeTempFileScoped,
open,
readDirectory,
readFile,
readLink,
realPath,
remove,
rename,
stat,
symlink,
truncate,
utimes,
watch(path) {
return watch(backend, path)
},
writeFile
}))
/**
* Provides the `FileSystem` service backed by Node filesystem APIs, including
* file operations, directory operations, links, metadata, and file watching.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<FileSystem.FileSystem> = Layer.effect(FileSystem.FileSystem)(makeFileSystem)
@@ -0,0 +1,80 @@
/**
* Node-backed provider for Effect's `Path` service.
*
* This module turns Node's `node:path` and `node:url` APIs into `Path` layers.
* `layer` uses the host platform path implementation, while `layerPosix` and
* `layerWin32` provide fixed POSIX and Windows variants. All three layers also
* include helpers for converting between file paths and file URLs.
*
* @since 4.0.0
*/
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import { Path, TypeId } from "effect/Path"
import { BadArgument } from "effect/PlatformError"
import * as NodePath from "node:path"
import * as NodeUrl from "node:url"
const fromFileUrl = (url: URL): Effect.Effect<string, BadArgument> =>
Effect.try({
try: () => NodeUrl.fileURLToPath(url),
catch: (cause) =>
new BadArgument({
module: "Path",
method: "fromFileUrl",
cause
})
})
const toFileUrl = (path: string): Effect.Effect<URL, BadArgument> =>
Effect.try({
try: () => NodeUrl.pathToFileURL(path),
catch: (cause) =>
new BadArgument({
module: "Path",
method: "toFileUrl",
cause
})
})
/**
* Provides the `Path` service using Node's POSIX path implementation plus
* file URL conversion helpers.
*
* @category layers
* @since 4.0.0
*/
export const layerPosix: Layer.Layer<Path> = Layer.succeed(Path)({
[TypeId]: TypeId,
...NodePath.posix,
fromFileUrl,
toFileUrl
})
/**
* Provides the `Path` service using Node's Windows path implementation plus
* file URL conversion helpers.
*
* @category layers
* @since 4.0.0
*/
export const layerWin32: Layer.Layer<Path> = Layer.succeed(Path)({
[TypeId]: TypeId,
...NodePath.win32,
fromFileUrl,
toFileUrl
})
/**
* Provides the default `Path` service using the host platform's Node path
* implementation plus file URL conversion helpers.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<Path> = Layer.succeed(Path)({
[TypeId]: TypeId,
...NodePath,
fromFileUrl,
toFileUrl
})
@@ -0,0 +1,59 @@
/**
* Node-compatible process runner for Effect programs.
*
* This module provides the shared `runMain` implementation used by
* Node-compatible platform packages. It runs one Effect as the main process
* fiber, interrupts that fiber on `SIGINT` or `SIGTERM`, and delegates final
* exit-code handling to the configured teardown.
*
* @since 4.0.0
*/
import type { Effect } from "effect/Effect"
import * as Runtime from "effect/Runtime"
/**
* Runs an Effect as the Node process main program, interrupting the fiber on
* `SIGINT` or `SIGTERM` and invoking the configured teardown to determine the
* process exit code.
*
* @category running
* @since 4.0.0
*/
export const runMain: {
(
options?: {
readonly disableErrorReporting?: boolean | undefined
readonly teardown?: Runtime.Teardown | undefined
}
): <E, A>(effect: Effect<A, E>) => void
<E, A>(
effect: Effect<A, E>,
options?: {
readonly disableErrorReporting?: boolean | undefined
readonly teardown?: Runtime.Teardown | undefined
}
): void
} = Runtime.makeRunMain(({
fiber,
teardown
}) => {
let receivedSignal = false
fiber.addObserver((exit) => {
process.removeListener("SIGINT", onSigint)
process.removeListener("SIGTERM", onSigint)
teardown(exit, (code) => {
if (receivedSignal || code !== 0) {
process.exit(code)
}
})
})
function onSigint() {
receivedSignal = true
fiber.interruptUnsafe(fiber.id)
}
process.on("SIGINT", onSigint)
process.on("SIGTERM", onSigint)
})
@@ -0,0 +1,116 @@
/**
* Sink adapters for writing Effect chunks into Node writable streams.
*
* `fromWritable` creates a `Sink`, `fromWritableChannel` creates a lower-level
* `Channel`, and `pullIntoWritable` writes from an existing pull loop. All
* three adapters respect writable-stream backpressure, map writable errors with
* the supplied `onError` function, and can end the writable when the upstream
* data is done.
*
* @since 4.0.0
*/
import type { NonEmptyReadonlyArray } from "effect/Array"
import * as Cause from "effect/Cause"
import * as Channel from "effect/Channel"
import * as Effect from "effect/Effect"
import { identity, type LazyArg } from "effect/Function"
import * as Pull from "effect/Pull"
import * as Sink from "effect/Sink"
import type { Writable } from "node:stream"
/**
* Creates a `Sink` that writes chunks to a Node writable stream, respecting
* backpressure, mapping writable errors with `onError`, and ending the stream
* on completion unless `endOnDone` is `false`.
*
* @category constructors
* @since 4.0.0
*/
export const fromWritable = <E, A = Uint8Array | string>(
options: {
readonly evaluate: LazyArg<Writable | NodeJS.WritableStream>
readonly onError: (error: unknown) => E
readonly endOnDone?: boolean | undefined
readonly encoding?: BufferEncoding | undefined
}
): Sink.Sink<void, A, never, E> =>
Sink.fromChannel(Channel.mapDone(fromWritableChannel<never, E, A>(options), (_) => [_]))
/**
* Creates a `Channel` that pulls chunks from upstream and writes them to a
* Node writable stream, respecting backpressure and optionally ending the
* writable when upstream is done.
*
* @category constructors
* @since 4.0.0
*/
export const fromWritableChannel = <IE, E, A = Uint8Array | string>(
options: {
readonly evaluate: LazyArg<Writable | NodeJS.WritableStream>
readonly onError: (error: unknown) => E
readonly endOnDone?: boolean | undefined
readonly encoding?: BufferEncoding | undefined
}
): Channel.Channel<never, IE | E, void, NonEmptyReadonlyArray<A>, IE> =>
Channel.fromTransform((pull: Pull.Pull<NonEmptyReadonlyArray<A>, IE, unknown>) => {
const writable = options.evaluate() as Writable
return Effect.succeed(pullIntoWritable({ ...options, writable, pull }))
})
/**
* Writes Effect chunks into a Node writable stream.
*
* **When to use**
*
* Use to implement custom Node stream adapters that already have an upstream
* pull and need direct control over a writable stream.
*
* **Details**
*
* The loop waits for `drain` when needed, fails on writable errors, and ends
* the writable on upstream completion unless `endOnDone` is `false`.
*
* @category converting
* @since 4.0.0
*/
export const pullIntoWritable = <A, IE, E>(options: {
readonly pull: Pull.Pull<NonEmptyReadonlyArray<A>, IE, unknown>
readonly writable: Writable
readonly onError: (error: unknown) => E
readonly endOnDone?: boolean | undefined
readonly encoding?: BufferEncoding | undefined
}): Pull.Pull<never, IE | E, unknown> =>
options.pull.pipe(
Effect.flatMap((chunk) => {
let i = 0
return Effect.callback<void, E>(function loop(resume) {
for (; i < chunk.length;) {
const success = options.writable.write(chunk[i++], options.encoding as any)
if (!success) {
options.writable.once("drain", () => (loop as any)(resume))
return
}
}
resume(Effect.void)
})
}),
Effect.forever({ disableYield: true }),
Effect.raceFirst(Effect.callback<never, E>((resume) => {
const onError = (error: unknown) => resume(Effect.fail(options.onError(error)))
options.writable.once("error", onError)
return Effect.sync(() => {
options.writable.off("error", onError)
})
})),
options.endOnDone !== false ?
Pull.catchDone((_) => {
if ("closed" in options.writable && options.writable.closed) {
return Cause.done(_)
}
return Effect.callback<never, E | Cause.Done<unknown>>((resume) => {
options.writable.once("finish", () => resume(Cause.done(_)))
options.writable.end()
})
}) :
identity
)
@@ -0,0 +1,269 @@
/**
* Node socket adapters for Effect sockets.
*
* This module opens `node:net` connections or wraps existing Node `Duplex`
* streams and presents them as `Socket.Socket` values, socket channels, or
* layers. It also exposes the current underlying `NetSocket` service for code
* running inside a socket handler and re-exports the `ws` package namespace.
*
* @since 4.0.0
*/
import type { Array } from "effect"
import * as Channel from "effect/Channel"
import * as Context from "effect/Context"
import * as Deferred from "effect/Deferred"
import type * as Duration from "effect/Duration"
import * as Effect from "effect/Effect"
import * as FiberSet from "effect/FiberSet"
import * as Function from "effect/Function"
import { identity } from "effect/Function"
import * as Latch from "effect/Latch"
import * as Layer from "effect/Layer"
import * as Scope from "effect/Scope"
import * as Socket from "effect/unstable/socket/Socket"
import * as Net from "node:net"
import type { Duplex } from "node:stream"
/**
* @category re-exports
* @since 4.0.0
*/
export * as NodeWS from "ws"
/**
* Service tag for the underlying Node `net.Socket` associated with the current
* socket connection.
*
* @category services
* @since 4.0.0
*/
export class NetSocket extends Context.Service<NetSocket, Net.Socket>()(
"@effect/platform-node/NodeSocket/NetSocket"
) {}
/**
* Opens a Node TCP connection as an Effect socket.
*
* **When to use**
*
* Use to create a scoped `Socket.Socket` from Node `net.createConnection`.
*
* **Details**
*
* Supports `openTimeout` and closes or destroys the underlying socket when the
* enclosing scope is finalized.
*
* @category constructors
* @since 4.0.0
*/
export const makeNet = (
options: Net.NetConnectOpts & {
readonly openTimeout?: Duration.Input | undefined
}
): Effect.Effect<Socket.Socket> =>
fromDuplex(
Effect.contextWith((context: Context.Context<Scope.Scope>) => {
let conn: Net.Socket | undefined
return Effect.flatMap(
Scope.addFinalizer(
Context.get(context, Scope.Scope),
Effect.sync(() => {
if (!conn) return
if (conn.closed === false) {
if ("destroySoon" in conn) {
conn.destroySoon()
} else {
;(conn as Net.Socket).destroy()
}
}
})
),
() =>
Effect.callback<Net.Socket, Socket.SocketError, never>((resume) => {
conn = Net.createConnection(options)
conn.once("connect", () => {
resume(Effect.succeed(conn!))
})
conn.on("error", (cause: Error) => {
resume(Effect.fail(
new Socket.SocketError({
reason: new Socket.SocketOpenError({ kind: "Unknown", cause })
})
))
})
})
)
}),
options
)
/**
* Adapts a Node `Duplex` into a `Socket.Socket`, wiring data events to socket
* handlers, providing a scoped writer, and mapping open, read, write, and close
* failures to `SocketError`.
*
* @category constructors
* @since 4.0.0
*/
export const fromDuplex = <RO>(
open: Effect.Effect<Duplex, Socket.SocketError, RO>,
options?: {
readonly openTimeout?: Duration.Input | undefined
}
): Effect.Effect<Socket.Socket, never, Exclude<RO, Scope.Scope>> =>
Effect.withFiber<Socket.Socket, never, Exclude<RO, Scope.Scope>>((fiber) => {
let currentSocket: Duplex | undefined
const latch = Latch.makeUnsafe(false)
const openServices = fiber.context as Context.Context<RO>
const run = <R, E, _>(handler: (_: Uint8Array) => Effect.Effect<_, E, R> | void, opts?: {
readonly onOpen?: Effect.Effect<void> | undefined
}) =>
Effect.scopedWith(Effect.fnUntraced(function*(scope) {
const fiberSet = yield* FiberSet.make<any, E | Socket.SocketError>().pipe(
Scope.provide(scope)
)
let conn: Duplex | undefined = undefined
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
if (!conn) return
conn.off("data", onData)
conn.off("end", onEnd)
conn.off("error", onError)
conn.off("close", onClose)
})
)
conn = yield* Scope.provide(open, scope).pipe(
options?.openTimeout ?
Effect.timeoutOrElse({
duration: options.openTimeout,
orElse: () =>
Effect.fail(
new Socket.SocketError({
reason: new Socket.SocketOpenError({ kind: "Timeout", cause: new Error("Connection timed out") })
})
)
}) :
identity
)
conn.on("end", onEnd)
conn.on("error", onError)
conn.on("close", onClose)
const run = yield* Effect.provideService(FiberSet.runtime(fiberSet)<R>(), NetSocket, conn as Net.Socket)
conn.on("data", onData)
currentSocket = conn
latch.openUnsafe()
if (opts?.onOpen) {
yield* opts.onOpen
}
return yield* FiberSet.join(fiberSet)
function onData(chunk: Uint8Array) {
const result = handler(chunk)
if (Effect.isEffect(result)) {
run(result)
}
}
function onEnd() {
Deferred.doneUnsafe(fiberSet.deferred, Effect.void)
}
function onError(cause: Error) {
Deferred.doneUnsafe(
fiberSet.deferred,
Effect.fail(
new Socket.SocketError({
reason: new Socket.SocketReadError({ cause })
})
)
)
}
function onClose(hadError: boolean) {
Deferred.doneUnsafe(
fiberSet.deferred,
Effect.fail(
new Socket.SocketError({
reason: new Socket.SocketCloseError({ code: hadError ? 1006 : 1000 })
})
)
)
}
})).pipe(
Effect.updateContext((input: Context.Context<R>) => Context.merge(openServices, input)),
Effect.onExit(() =>
Effect.sync(() => {
latch.closeUnsafe()
currentSocket = undefined
})
)
)
const write = (chunk: Uint8Array | string | Socket.CloseEvent) =>
latch.whenOpen(Effect.callback<void, Socket.SocketError>((resume) => {
const conn = currentSocket!
if (Socket.isCloseEvent(chunk)) {
conn.destroy(chunk.code > 1000 ? new Error(`closed with code ${chunk.code}`) : undefined)
return resume(Effect.void)
}
currentSocket!.write(chunk, (cause) => {
resume(
cause
? Effect.fail(
new Socket.SocketError({
reason: new Socket.SocketWriteError({ cause: cause! })
})
)
: Effect.void
)
})
}))
const writer = Effect.acquireRelease(
Effect.succeed(write),
() =>
Effect.sync(() => {
if (!currentSocket || currentSocket.writableEnded) return
currentSocket.end()
})
)
return Effect.succeed(Socket.make({
run,
runRaw: run,
writer
}))
})
/**
* Creates a `Channel` over a TCP socket, reading arrays of `Uint8Array`
* chunks and writing arrays of bytes, strings, or socket close events.
*
* @category constructors
* @since 4.0.0
*/
export const makeNetChannel = <IE = never>(
options: Net.NetConnectOpts
): Channel.Channel<
Array.NonEmptyReadonlyArray<Uint8Array>,
Socket.SocketError | IE,
void,
Array.NonEmptyReadonlyArray<Uint8Array | string | Socket.CloseEvent>,
IE
> =>
Channel.unwrap(
Effect.map(makeNet(options), Socket.toChannelWith<IE>())
)
/**
* Provides a `Socket.Socket` by opening a TCP connection with the supplied
* Node `net` connection options.
*
* @category layers
* @since 4.0.0
*/
export const layerNet: (options: Net.NetConnectOpts) => Layer.Layer<
Socket.Socket,
Socket.SocketError
> = Function.flow(makeNet, Layer.effect(Socket.Socket))
@@ -0,0 +1,301 @@
/**
* Node socket server adapters for Effect's unstable socket server API.
*
* This module turns `node:net` TCP or Unix-domain servers and `ws` WebSocket
* servers into scoped `SocketServer.SocketServer` services. Use the TCP
* constructors when handlers should receive a `Socket.Socket` backed by a Node
* `net.Socket`; use the WebSocket constructors when handlers should receive a
* `Socket.Socket` backed by `ws` and have access to the per-connection
* WebSocket and `IncomingMessage` services.
*
* @since 4.0.0
*/
import type { Cause } from "effect/Cause"
import * as Context from "effect/Context"
import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as Fiber from "effect/Fiber"
import { pipe } from "effect/Function"
import * as Function from "effect/Function"
import * as Layer from "effect/Layer"
import * as References from "effect/References"
import * as Scope from "effect/Scope"
import * as Socket from "effect/unstable/socket/Socket"
import * as SocketServer from "effect/unstable/socket/SocketServer"
import type * as Http from "node:http"
import * as Net from "node:net"
import * as NodeSocket from "./NodeSocket.ts"
import { NodeWS } from "./NodeSocket.ts"
/**
* Service tag for the Node `IncomingMessage` associated with the current
* WebSocket server connection.
*
* @category services
* @since 4.0.0
*/
export class IncomingMessage extends Context.Service<
IncomingMessage,
Http.IncomingMessage
>()("@effect/platform-node-shared/NodeSocketServer/IncomingMessage") {}
/**
* Creates a scoped TCP `SocketServer` from a Node `net.Server`, starts
* listening with the supplied options, queues pending connections until `run`
* is called, and closes the server when the scope ends.
*
* @category constructors
* @since 4.0.0
*/
export const make = Effect.fnUntraced(function*(
options: Net.ServerOpts & Net.ListenOptions
) {
const errorDeferred = Deferred.makeUnsafe<never, Error>()
const pending = new Set<Net.Socket>()
function defaultOnConnection(conn: Net.Socket) {
pending.add(conn)
const remove = () => {
pending.delete(conn)
}
conn.on("close", remove)
conn.on("error", remove)
}
let onConnection = defaultOnConnection
// oxlint-disable-next-line prefer-const
let server: Net.Server | undefined
yield* Effect.addFinalizer(() =>
Effect.callback<void>((resume) => {
server?.close(() => resume(Effect.void))
})
)
server = Net.createServer(options, (conn) => onConnection(conn))
server.on("error", (err) => Deferred.doneUnsafe(errorDeferred, Exit.fail(err)))
yield* Effect.callback<void, SocketServer.SocketServerError>((resume) => {
server.listen(options, () => resume(Effect.void))
}).pipe(
Effect.raceFirst(Effect.mapError(Deferred.await(errorDeferred), (err) =>
new SocketServer.SocketServerError({
reason: new SocketServer.SocketServerOpenError({
cause: err
})
})))
)
const run = Effect.fnUntraced(function*<R, E, _>(handler: (socket: Socket.Socket) => Effect.Effect<_, E, R>) {
const scope = yield* Scope.make()
const services = Context.omit(Scope.Scope)(yield* Effect.context<R>()) as Context.Context<R>
const trackFiber = Fiber.runIn(scope)
const prevOnConnection = onConnection
onConnection = function(conn: Net.Socket) {
let error: Error | undefined
conn.on("error", (err) => {
error = err
})
pipe(
NodeSocket.fromDuplex(
Effect.acquireRelease(
Effect.suspend((): Effect.Effect<Net.Socket, Socket.SocketError> => {
if (error) {
return Effect.fail(
new Socket.SocketError({
reason: new Socket.SocketOpenError({
kind: "Unknown",
cause: error
})
})
)
} else if (conn.closed) {
return Effect.fail(
new Socket.SocketError({
reason: new Socket.SocketCloseError({ code: 1000 })
})
)
}
return Effect.succeed(conn)
}),
(conn) =>
Effect.sync(() => {
if (conn.closed === false) {
conn.destroySoon()
}
})
)
),
Effect.flatMap(handler),
Effect.catchCause(reportUnhandledError),
Effect.runForkWith(Context.add(services, NodeSocket.NetSocket, conn)),
trackFiber
)
}
pending.forEach((conn) => {
conn.removeAllListeners("error")
conn.removeAllListeners("close")
onConnection(conn)
})
pending.clear()
return yield* Effect.callback<never>((_resume) => {
return Effect.suspend(() => {
onConnection = prevOnConnection
return Scope.close(scope, Exit.void)
})
})
})
const address = server.address()!
return SocketServer.SocketServer.of({
address: typeof address === "string" ?
{
_tag: "UnixAddress",
path: address
} :
{
_tag: "TcpAddress",
hostname: address.address,
port: address.port
},
run
})
})
/**
* Provides a TCP `SocketServer` by creating and managing a scoped Node
* `net.Server` with the supplied server and listen options.
*
* @category layers
* @since 4.0.0
*/
export const layer: (
options: Net.ServerOpts & Net.ListenOptions
) => Layer.Layer<
SocketServer.SocketServer,
SocketServer.SocketServerError
> = Function.flow(make, Layer.effect(SocketServer.SocketServer))
/**
* Creates a scoped WebSocket `SocketServer` backed by the `ws` package,
* providing the WebSocket and its Node `IncomingMessage` to connection
* handlers and closing the server when the scope ends.
*
* @category constructors
* @since 4.0.0
*/
export const makeWebSocket: (
options: NodeWS.ServerOptions<typeof NodeWS.WebSocket, typeof Http.IncomingMessage>
) => Effect.Effect<
SocketServer.SocketServer["Service"],
SocketServer.SocketServerError,
Scope.Scope
> = Effect.fnUntraced(function*(
options: NodeWS.ServerOptions
) {
const server = yield* Effect.acquireRelease(
Effect.sync(() => new NodeWS.WebSocketServer(options)),
(server) =>
Effect.callback<void>((resume) => {
server.close(() => resume(Effect.void))
})
)
const pendingConnections = new Set<readonly [globalThis.WebSocket, Http.IncomingMessage]>()
function defaultHandler(conn: globalThis.WebSocket, req: Http.IncomingMessage) {
const entry = [conn, req] as const
pendingConnections.add(entry)
conn.addEventListener("close", () => {
pendingConnections.delete(entry)
})
}
let onConnection = defaultHandler
server.on("connection", (conn, req) => onConnection(conn as any, req))
yield* Effect.callback<void, SocketServer.SocketServerError>((resume) => {
server.once("error", (error) => {
resume(Effect.fail(
new SocketServer.SocketServerError({
reason: new SocketServer.SocketServerOpenError({
cause: error
})
})
))
})
server.once("listening", () => {
resume(Effect.void)
})
})
const run = Effect.fnUntraced(function*<R, E, _>(handler: (socket: Socket.Socket) => Effect.Effect<_, E, R>) {
const scope = yield* Scope.make()
const services = Context.omit(Scope.Scope)(yield* Effect.context<R>()) as Context.Context<R>
const trackFiber = Fiber.runIn(scope)
const prevOnConnection = onConnection
onConnection = function(conn: globalThis.WebSocket, req: Http.IncomingMessage) {
const map = new Map(services.mapUnsafe)
map.set(IncomingMessage.key, req)
map.set(Socket.WebSocket.key, conn as any)
pipe(
Socket.fromWebSocket(
Effect.acquireRelease(
Effect.succeed(conn),
(conn) =>
Effect.sync(() => {
conn.close()
})
)
),
Effect.flatMap(handler),
Effect.catchCause(reportUnhandledError),
Effect.runForkWith(Context.makeUnsafe(map)),
trackFiber
)
}
for (const [conn, req] of pendingConnections) {
onConnection(conn, req)
}
pendingConnections.clear()
return yield* Effect.callback<never>((_resume) => {
return Effect.sync(() => {
onConnection = prevOnConnection
})
}).pipe(
Effect.ensuring(Scope.close(scope, Exit.void))
)
})
const address = server.address()!
return SocketServer.SocketServer.of({
address: typeof address === "string" ?
{
_tag: "UnixAddress",
path: address
} :
{
_tag: "TcpAddress",
hostname: address.address,
port: address.port
},
run
})
})
/**
* Provides a WebSocket `SocketServer` backed by the `ws` package and managed
* with the supplied server options.
*
* @category layers
* @since 4.0.0
*/
export const layerWebSocket: (
options: NodeSocket.NodeWS.ServerOptions<typeof NodeSocket.NodeWS.WebSocket, typeof Http.IncomingMessage>
) => Layer.Layer<
SocketServer.SocketServer,
SocketServer.SocketServerError
> = Function.flow(makeWebSocket, Layer.effect(SocketServer.SocketServer))
const reportUnhandledError = <E>(cause: Cause<E>) =>
Effect.withFiber<void>((fiber) => {
const unhandledLogLevel = fiber.getRef(References.UnhandledLogLevel)
if (unhandledLogLevel) {
return Effect.logWithLevel(unhandledLogLevel)(cause, "Unhandled error in SocketServer")
}
return Effect.void
})
@@ -0,0 +1,67 @@
/**
* Shared Node.js implementation of the Effect `Stdio` service.
*
* `NodeStdio` provides {@link Stdio.Stdio} from the current Node process. The
* exported {@link layer} reads command-line arguments from `process.argv`,
* consumes input from `process.stdin`, and writes normal and error output to
* `process.stdout` and `process.stderr`. Standard input remains open, and
* standard output and error output are not ended unless requested.
*
* @since 4.0.0
*/
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import { systemError } from "effect/PlatformError"
import * as Stdio from "effect/Stdio"
import { fromWritable } from "./NodeSink.ts"
import { fromReadable } from "./NodeStream.ts"
/**
* Provides `Stdio` from `process.argv`, `process.stdin`, `process.stdout`,
* and `process.stderr`; stdin remains open and stdout/stderr are not ended by
* default.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<Stdio.Stdio> = Layer.succeed(
Stdio.Stdio,
Stdio.make({
args: Effect.sync(() => process.argv.slice(2)),
stdout: (options) =>
fromWritable({
evaluate: () => process.stdout,
onError: (cause) =>
systemError({
module: "Stdio",
method: "stdout",
_tag: "Unknown",
cause
}),
endOnDone: options?.endOnDone ?? false
}),
stderr: (options) =>
fromWritable({
evaluate: () => process.stderr,
onError: (cause) =>
systemError({
module: "Stdio",
method: "stderr",
_tag: "Unknown",
cause
}),
endOnDone: options?.endOnDone ?? false
}),
stdin: fromReadable({
evaluate: () => process.stdin,
onError: (cause) =>
systemError({
module: "Stdio",
method: "stdin",
_tag: "Unknown",
cause
}),
closeOnDone: false
})
})
)
@@ -0,0 +1,447 @@
/**
* Adapters between Node streams and Effect streams, channels, and readables.
*
* This module is the stream boundary for Node APIs. It wraps `Readable` and
* `Duplex` values as Effect `Stream`s and `Channel`s, pipes Effect streams
* through Node duplex streams, exposes an Effect `Stream` back to Node as a
* `Readable`, and collects readable payloads into strings, array buffers, or
* `Uint8Array`s with optional byte limits.
*
* @since 4.0.0
*/
import * as Arr from "effect/Array"
import * as Cause from "effect/Cause"
import * as Channel from "effect/Channel"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as Fiber from "effect/Fiber"
import type { SizeInput } from "effect/FileSystem"
import { dual, type LazyArg } from "effect/Function"
import * as Latch from "effect/Latch"
import * as MutableRef from "effect/MutableRef"
import * as Pull from "effect/Pull"
import * as Scope from "effect/Scope"
import * as Stream from "effect/Stream"
import type { Duplex } from "node:stream"
import { Readable } from "node:stream"
import { pullIntoWritable } from "./NodeSink.ts"
/**
* Converts a Node readable stream into an Effect `Stream`, reading chunks with
* an optional chunk size, mapping stream errors with `onError`, and destroying
* the readable on completion unless `closeOnDone` is `false`.
*
* @category constructors
* @since 4.0.0
*/
export const fromReadable = <A = Uint8Array, E = Cause.UnknownError>(options: {
readonly evaluate: LazyArg<Readable | NodeJS.ReadableStream>
readonly onError?: (error: unknown) => E
readonly chunkSize?: number | undefined
readonly bufferSize?: number | undefined
readonly closeOnDone?: boolean | undefined
}): Stream.Stream<A, E> => Stream.fromChannel(fromReadableChannel<A, E>(options))
/**
* Creates a `Channel` that pulls chunks from a Node readable stream, mapping
* errors with `onError` and destroying the readable on completion unless
* `closeOnDone` is `false`.
*
* @category constructors
* @since 4.0.0
*/
export const fromReadableChannel = <A = Uint8Array, E = Cause.UnknownError>(options: {
readonly evaluate: LazyArg<Readable | NodeJS.ReadableStream>
readonly onError?: (error: unknown) => E
readonly chunkSize?: number | undefined
readonly closeOnDone?: boolean | undefined
}): Channel.Channel<Arr.NonEmptyReadonlyArray<A>, E> =>
Channel.fromTransform((_, scope) =>
readableToPullUnsafe({
scope,
readable: options.evaluate(),
onError: options.onError ?? defaultOnError as any,
chunkSize: options.chunkSize,
closeOnDone: options.closeOnDone
})
)
/**
* Creates a `Channel` over a Node `Duplex`, writing upstream chunks with
* backpressure while emitting chunks read from the duplex and optionally ending
* the writable side when upstream completes.
*
* @category constructors
* @since 4.0.0
*/
export const fromDuplex = <IE, I = Uint8Array, O = Uint8Array, E = Cause.UnknownError>(
options: {
readonly evaluate: LazyArg<Duplex>
readonly onError?: (error: unknown) => E
readonly chunkSize?: number | undefined
readonly bufferSize?: number | undefined
readonly endOnDone?: boolean | undefined
readonly encoding?: BufferEncoding | undefined
}
): Channel.Channel<Arr.NonEmptyReadonlyArray<O>, IE | E, void, Arr.NonEmptyReadonlyArray<I>, IE> =>
Channel.fromTransform((upstream, scope) => {
const duplex = options.evaluate()
const exit = MutableRef.make<Exit.Exit<never, IE | E | Cause.Done> | undefined>(undefined)
return pullIntoWritable({
pull: upstream,
writable: duplex,
onError: options.onError ?? defaultOnError as any,
endOnDone: options.endOnDone,
encoding: options.encoding
}).pipe(
Effect.catchCause((cause) => {
if (Pull.isDoneCause(cause)) return Effect.void
exit.current = Exit.failCause(cause as Cause.Cause<IE | E | Cause.Done>)
return Effect.void
}),
Effect.forkIn(scope),
Effect.flatMap(() =>
readableToPullUnsafe({
scope,
exit,
readable: duplex,
onError: options.onError ?? defaultOnError as any,
chunkSize: options.chunkSize
})
)
)
})
/**
* Pipes an Effect `Stream` through a Node `Duplex`, writing the stream's
* chunks to the duplex and emitting chunks read back from it.
*
* @category combinators
* @since 4.0.0
*/
export const pipeThroughDuplex: {
<B = Uint8Array, E2 = Cause.UnknownError>(
options: {
readonly evaluate: LazyArg<Duplex>
readonly onError?: (error: unknown) => E2
readonly chunkSize?: number | undefined
readonly bufferSize?: number | undefined
readonly endOnDone?: boolean | undefined
readonly encoding?: BufferEncoding | undefined
}
): <R, E, A>(self: Stream.Stream<A, E, R>) => Stream.Stream<B, E2 | E, R>
<R, E, A, B = Uint8Array, E2 = Cause.UnknownError>(
self: Stream.Stream<A, E, R>,
options: {
readonly evaluate: LazyArg<Duplex>
readonly onError?: (error: unknown) => E2
readonly chunkSize?: number | undefined
readonly bufferSize?: number | undefined
readonly endOnDone?: boolean | undefined
readonly encoding?: BufferEncoding | undefined
}
): Stream.Stream<B, E | E2, R>
} = dual(2, <R, E, A, B = Uint8Array, E2 = Cause.UnknownError>(
self: Stream.Stream<A, E, R>,
options: {
readonly evaluate: LazyArg<Duplex>
readonly onError?: (error: unknown) => E2
readonly chunkSize?: number | undefined
readonly bufferSize?: number | undefined
readonly endOnDone?: boolean | undefined
readonly encoding?: BufferEncoding | undefined
}
): Stream.Stream<B, E | E2, R> =>
Stream.pipeThroughChannelOrFail(
self,
fromDuplex(options)
))
/**
* Pipes a stream of strings or bytes through a Node `Duplex` using default
* options and `Cause.UnknownError` for stream failures.
*
* @category combinators
* @since 4.0.0
*/
export const pipeThroughSimple: {
(
duplex: LazyArg<Duplex>
): <R, E>(self: Stream.Stream<string | Uint8Array, E, R>) => Stream.Stream<Uint8Array, E | Cause.UnknownError, R>
<R, E>(
self: Stream.Stream<string | Uint8Array, E, R>,
duplex: LazyArg<Duplex>
): Stream.Stream<Uint8Array, Cause.UnknownError | E, R>
} = dual(2, <R, E>(
self: Stream.Stream<string | Uint8Array, E, R>,
duplex: LazyArg<Duplex>
): Stream.Stream<Uint8Array, Cause.UnknownError | E, R> => pipeThroughDuplex(self, { evaluate: duplex }))
/**
* Converts an Effect `Stream` into a Node `Readable`, using the caller's
* Effect context to run the stream and destroying the readable if the stream
* fails.
*
* @category converting
* @since 4.0.0
*/
export const toReadable = <E, R>(stream: Stream.Stream<string | Uint8Array, E, R>): Effect.Effect<Readable, never, R> =>
Effect.map(
Effect.context<R>(),
(context) => new StreamAdapter(context, stream)
)
/**
* Converts a service-free Effect `Stream` into a Node `Readable` using an
* empty Effect context.
*
* @category converting
* @since 4.0.0
*/
export const toReadableNever = <E>(stream: Stream.Stream<string | Uint8Array, E, never>): Readable =>
new StreamAdapter(
Context.empty(),
stream
)
/**
* Consumes a Node readable stream into a string using the selected encoding,
* failing through `onError` on stream errors or when `maxBytes` is exceeded
* and destroying the stream on interruption or failure.
*
* @category converting
* @since 4.0.0
*/
export const toString = <E = Cause.UnknownError>(
readable: LazyArg<Readable | NodeJS.ReadableStream>,
options?: {
readonly onError?: (error: unknown) => E
readonly encoding?: BufferEncoding | undefined
readonly maxBytes?: SizeInput | undefined
}
): Effect.Effect<string, E> => {
const maxBytesNumber = options?.maxBytes ? Number(options.maxBytes) : undefined
const onError = options?.onError ?? defaultOnError
const encoding = options?.encoding ?? "utf8"
return Effect.callback((resume) => {
const stream = readable() as Readable
stream.setEncoding(encoding)
stream.once("error", (err) => {
if ("closed" in stream && !stream.closed) {
stream.destroy()
}
resume(Effect.fail(onError(err) as E))
})
stream.once("error", (err) => {
resume(Effect.fail(onError(err) as E))
})
let string = ""
let bytes = 0
stream.once("end", () => {
resume(Effect.succeed(string))
})
stream.on("data", (chunk) => {
string += chunk
bytes += Buffer.byteLength(chunk)
if (maxBytesNumber && bytes > maxBytesNumber) {
resume(Effect.fail(onError(new Error("maxBytes exceeded")) as E))
}
})
return Effect.sync(() => {
if ("closed" in stream && !stream.closed) {
stream.destroy()
}
})
})
}
/**
* Consumes a Node readable stream into an `ArrayBuffer`, failing through
* `onError` on stream errors or when `maxBytes` is exceeded and destroying the
* stream on interruption or failure.
*
* @category converting
* @since 4.0.0
*/
export const toArrayBuffer = <E = Cause.UnknownError>(
readable: LazyArg<Readable | NodeJS.ReadableStream>,
options?: {
readonly onError?: (error: unknown) => E
readonly maxBytes?: SizeInput | undefined
}
): Effect.Effect<ArrayBuffer, E> => {
const maxBytesNumber = options?.maxBytes ? Number(options.maxBytes) : undefined
const onError = options?.onError ?? defaultOnError
return Effect.callback((resume) => {
const stream = readable() as Readable
const buffers: Array<Uint8Array> = []
let bytes = 0
stream.once("error", (err) => {
if ("closed" in stream && !stream.closed) {
stream.destroy()
}
resume(Effect.fail(onError(err) as E))
})
stream.once("end", () => {
const buffer = buffers.length === 1 ? buffers[0] : Buffer.concat(buffers)
if (buffer.byteOffset === 0 && buffer.buffer.byteLength === buffer.byteLength) {
return resume(Effect.succeed(buffer.buffer as ArrayBuffer))
}
resume(
Effect.succeed(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer)
)
})
stream.on("data", (chunk) => {
buffers.push(chunk)
bytes += chunk.length
if (maxBytesNumber && bytes > maxBytesNumber) {
resume(Effect.fail(onError(new Error("maxBytes exceeded")) as E))
}
})
return Effect.sync(() => {
if ("closed" in stream && !stream.closed) {
stream.destroy()
}
})
})
}
/**
* Consumes a Node readable stream into a `Uint8Array`, using the same error
* mapping and `maxBytes` handling as `toArrayBuffer`.
*
* @category converting
* @since 4.0.0
*/
export const toUint8Array = <E = Cause.UnknownError>(
readable: LazyArg<Readable | NodeJS.ReadableStream>,
options?: {
readonly onError?: (error: unknown) => E
readonly maxBytes?: SizeInput | undefined
}
): Effect.Effect<Uint8Array, E> => Effect.map(toArrayBuffer(readable, options), (buffer) => new Uint8Array(buffer))
// ----------------------------------------------------------------------------
// internal
// ----------------------------------------------------------------------------
const readableToPullUnsafe = <A, E>(options: {
readonly scope: Scope.Scope
readonly exit?: MutableRef.MutableRef<Exit.Exit<never, E | Cause.Done> | undefined> | undefined
readonly readable: Readable | NodeJS.ReadableStream
readonly onError: (error: unknown) => E
readonly chunkSize: number | undefined
readonly closeOnDone?: boolean | undefined
}) => {
const readable = options.readable as Readable
const closeOnDone = options.closeOnDone ?? true
const exit = options.exit ?? MutableRef.make(undefined)
const latch = Latch.makeUnsafe(false)
function onReadable() {
latch.openUnsafe()
}
function onError(error: unknown) {
exit.current = Exit.fail(options.onError(error))
latch.openUnsafe()
}
function onEnd() {
exit.current = Exit.fail(Cause.Done())
latch.openUnsafe()
}
readable.on("readable", onReadable)
readable.once("error", onError)
readable.once("end", onEnd)
const pull = Effect.suspend(function loop(): Pull.Pull<Arr.NonEmptyReadonlyArray<A>, E> {
let item = options.readable.read(options.chunkSize) as A | null
if (item === null) {
if (exit.current) {
return exit.current
}
if (readable.readableEnded) {
return Effect.fail(Cause.Done())
}
latch.closeUnsafe()
return Effect.flatMap(latch.await, loop)
}
const chunk = Arr.of(item as A)
while (true) {
item = options.readable.read(options.chunkSize)
if (item === null) break
chunk.push(item)
}
return Effect.succeed(chunk)
})
return Effect.as(
Scope.addFinalizer(
options.scope,
Effect.sync(() => {
readable.off("readable", onReadable)
readable.off("error", onError)
readable.off("end", onEnd)
if (closeOnDone && "closed" in options.readable && !options.readable.closed) {
options.readable.destroy()
}
})
),
pull
)
}
class StreamAdapter<E, R> extends Readable {
private readonly readLatch: Latch.Latch
private fiber: Fiber.Fiber<void, E> | undefined = undefined
constructor(
context: Context.Context<R>,
stream: Stream.Stream<Uint8Array | string, E, R>
) {
super({})
this.readLatch = Latch.makeUnsafe(false)
this.fiber = Stream.runForEachArray(stream, (chunk) =>
this.readLatch.whenOpen(Effect.sync(() => {
this.readLatch.closeUnsafe()
for (let i = 0; i < chunk.length; i++) {
const item = chunk[i]
if (typeof item === "string") {
this.push(item, "utf8")
} else {
this.push(item)
}
}
}))).pipe(
this.readLatch.whenOpen,
Effect.provideContext(context),
Effect.runFork
)
this.fiber.addObserver((exit) => {
this.fiber = undefined
if (Exit.isSuccess(exit)) {
this.push(null)
} else {
this.destroy(Cause.squash(exit.cause) as any)
}
})
}
override _read(_size: number): void {
this.readLatch.openUnsafe()
}
override _destroy(error: Error | null, callback: (error?: Error | null | undefined) => void): void {
if (!this.fiber) {
return callback(error)
}
Effect.runFork(Fiber.interrupt(this.fiber)).addObserver((exit) => {
callback(exit._tag === "Failure" ? Cause.squash(exit.cause) as any : error)
})
}
}
const defaultOnError = (error: unknown): Cause.UnknownError => new Cause.UnknownError(error)
@@ -0,0 +1,129 @@
/**
* Shared Node.js implementation of Effect's `Terminal` service.
*
* `NodeTerminal` adapts Node's `readline` APIs plus the current process
* `stdin` and `stdout` streams into {@link Terminal.Terminal}. The service can
* display output, read a line, stream key input, and read terminal dimensions.
* `make` manages readline and TTY raw mode in a scope, while `layer` provides
* the default service that ends key input on Ctrl+C or Ctrl+D.
*
* @since 4.0.0
*/
import type * as Cause from "effect/Cause"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import { badArgument, type PlatformError } from "effect/PlatformError"
import * as Predicate from "effect/Predicate"
import * as Queue from "effect/Queue"
import * as RcRef from "effect/RcRef"
import type * as Scope from "effect/Scope"
import * as Terminal from "effect/Terminal"
import * as readline from "node:readline"
/**
* Creates a scoped process-backed `Terminal` using Node `readline`, enabling
* TTY raw mode while in scope and using the supplied predicate to decide when
* key input should end.
*
* @category constructors
* @since 4.0.0
*/
export const make: (
shouldQuit?: (input: Terminal.UserInput) => boolean
) => Effect.Effect<Terminal.Terminal, never, Scope.Scope> = Effect.fnUntraced(
function*(shouldQuit: (input: Terminal.UserInput) => boolean = defaultShouldQuit) {
const stdin = process.stdin
const stdout = process.stdout
// Acquire readline interface with TTY setup/cleanup inside the scope
const rlRef = yield* RcRef.make({
acquire: Effect.acquireRelease(
Effect.sync(() => {
const rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 50 })
readline.emitKeypressEvents(stdin, rl)
if (stdin.isTTY) {
stdin.setRawMode(true)
}
return rl
}),
(rl) =>
Effect.sync(() => {
if (stdin.isTTY) {
stdin.setRawMode(false)
}
rl.close()
})
)
})
const columns = Effect.sync(() => stdout.columns ?? 0)
const rows = Effect.sync(() => stdout.rows ?? 0)
const readInput = Effect.gen(function*() {
yield* RcRef.get(rlRef)
const queue = yield* Queue.make<Terminal.UserInput, Cause.Done>()
const handleKeypress = (s: string | undefined, k: readline.Key) => {
const userInput = {
input: Option.fromUndefinedOr(s),
key: { name: k.name ?? "", ctrl: !!k.ctrl, meta: !!k.meta, shift: !!k.shift }
}
Queue.offerUnsafe(queue, userInput)
if (shouldQuit(userInput)) {
Queue.endUnsafe(queue)
}
}
yield* Effect.addFinalizer(() => Effect.sync(() => stdin.off("keypress", handleKeypress)))
stdin.on("keypress", handleKeypress)
return queue as Queue.Dequeue<Terminal.UserInput, Cause.Done>
})
const readLine = Effect.scoped(
Effect.flatMap(RcRef.get(rlRef), (readlineInterface) =>
Effect.callback<string, Terminal.QuitError>((resume) => {
const onLine = (line: string) => resume(Effect.succeed(line))
readlineInterface.once("line", onLine)
return Effect.sync(() => readlineInterface.off("line", onLine))
}))
)
const display = (prompt: string) =>
Effect.uninterruptible(
Effect.callback<void, PlatformError>((resume) => {
stdout.write(prompt, (err) =>
Predicate.isNullish(err)
? resume(Effect.void)
: resume(Effect.fail(
badArgument({
module: "Terminal",
method: "display",
description: "Failed to write prompt to stdout",
cause: err
})
)))
})
)
return Terminal.make({
columns,
rows,
readInput,
readLine,
display
})
}
)
/**
* Provides the default process-backed `Terminal` service, ending key input on
* Ctrl+C or Ctrl+D.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<Terminal.Terminal> = Layer.effect(Terminal.Terminal, make(defaultShouldQuit))
function defaultShouldQuit(input: Terminal.UserInput) {
return input.key.ctrl && (input.key.name === "c" || input.key.name === "d")
}
@@ -0,0 +1,65 @@
/**
* @since 4.0.0
*/
// @barrel: Auto-generated exports. Do not edit manually.
/**
* @since 4.0.0
*/
export * as NodeChildProcessSpawner from "./NodeChildProcessSpawner.ts"
/**
* @since 4.0.0
*/
export * as NodeClusterSocket from "./NodeClusterSocket.ts"
/**
* @since 1.0.0
*/
export * as NodeCrypto from "./NodeCrypto.ts"
/**
* @since 4.0.0
*/
export * as NodeFileSystem from "./NodeFileSystem.ts"
/**
* @since 4.0.0
*/
export * as NodePath from "./NodePath.ts"
/**
* @since 4.0.0
*/
export * as NodeRuntime from "./NodeRuntime.ts"
/**
* @since 4.0.0
*/
export * as NodeSink from "./NodeSink.ts"
/**
* @since 4.0.0
*/
export * as NodeSocket from "./NodeSocket.ts"
/**
* @since 4.0.0
*/
export * as NodeSocketServer from "./NodeSocketServer.ts"
/**
* @since 4.0.0
*/
export * as NodeStdio from "./NodeStdio.ts"
/**
* @since 4.0.0
*/
export * as NodeStream from "./NodeStream.ts"
/**
* @since 4.0.0
*/
export * as NodeTerminal from "./NodeTerminal.ts"
@@ -0,0 +1,51 @@
import type { SystemError, SystemErrorTag } from "effect/PlatformError"
import * as PlatformError from "effect/PlatformError"
import type { PathLike } from "node:fs"
/** @internal */
export const handleErrnoException = (module: SystemError["module"], method: string) =>
(
err: NodeJS.ErrnoException,
[path]: [path: PathLike | number | string | readonly string[], ...args: Array<any>]
): PlatformError.PlatformError => {
let reason: SystemErrorTag = "Unknown"
switch (err.code) {
case "ENOENT":
reason = "NotFound"
break
case "EACCES":
reason = "PermissionDenied"
break
case "EEXIST":
reason = "AlreadyExists"
break
case "EISDIR":
reason = "BadResource"
break
case "ENOTDIR":
reason = "BadResource"
break
case "EBUSY":
reason = "Busy"
break
case "ELOOP":
reason = "BadResource"
break
}
return PlatformError.systemError({
_tag: reason,
module,
method,
pathOrDescriptor: path as string | number,
syscall: err.syscall,
cause: err
})
}