@flux-control/effect-modbus-rs
    Preparing search index...

    @flux-control/effect-modbus-rs

    Effect-modbus-rs

    Type-safe Modbus communication via Effect-TS, wrapping the modbus-rs npm bindings (Rust napi-rs under the hood).

    For the complete API reference, see the GitHub Pages documentation.

    Provides scoped Effect.Service constructors for RTU (serial), TCP, and ASCII Modbus transports. Clients expose a typed Effect-based API for all standard Modbus function codes.

    This project is under active development. Its API may change before the 1.0 release.

    bun add @flux-control/effect-modbus-rs
    

    TypeScript only while prototyping (JS consumers will be supported before 1.0).

    import { Console, Effect } from 'effect';
    import { RtuTransportService } from '@flux-control/effect-modbus-rs';

    const program = Effect.gen(function* () {
    const transport = yield* RtuTransportService;
    const client = yield* transport.withClient(1);

    const registers = yield* client.readHoldingRegisters({
    address: 0,
    quantity: 10,
    });
    console.log('Holding registers:', registers);
    });

    program.pipe(
    Effect.catchTags({
    ModbusTimeoutError: (err) => Console.log(`Timeout: ${err.message}`),
    ModbusTransportError: (err) => Console.log(`Transport error: ${err.message}`),
    ModbusConnectionClosedError: (err) => Console.log(`Connection lost: ${err.message}`),
    ModbusExceptionError: (err) => Console.log(`Modbus exception ${err.exception}: ${err.message}`),
    ModbusInvalidArgumentError: (err) => Console.log(`Invalid argument: ${err.message}`),
    }),
    Effect.catchAll((err) => Console.log(`Unhandled error: ${err.message}`)),
    Effect.provide(RtuTransportService.Default({ portPath: '/dev/ttyUSB0', baudRate: 9600 })),
    Effect.scoped,
    Effect.runPromise,
    );
    import { Effect } from 'effect';
    import { TcpTransportService } from '@flux-control/effect-modbus-rs';

    const program = Effect.gen(function* () {
    const transport = yield* TcpTransportService;
    const client = yield* transport.withClient(1);
    const coils = yield* client.readCoils({ address: 0, quantity: 8 });
    console.log('Coils:', coils);
    });

    program.pipe(
    Effect.provide(TcpTransportService.Default({ host: '192.168.1.100', port: 502 })),
    Effect.scoped,
    Effect.runPromise,
    );
    import { Effect } from 'effect';
    import { AsciiTransportService } from '@flux-control/effect-modbus-rs';

    const program = Effect.gen(function* () {
    const transport = yield* AsciiTransportService;
    const client = yield* transport.withClient(1);
    const registers = yield* client.readInputRegisters({
    address: 0,
    quantity: 5,
    });
    console.log('Input registers:', registers);
    });

    program.pipe(
    Effect.provide(AsciiTransportService.Default({ portPath: '/dev/ttyUSB0', baudRate: 9600 })),
    Effect.scoped,
    Effect.runPromise,
    );

    modbus-rs ships its browser bindings through the modbus-rs/web WASM module. This package loads that module dynamically and exposes the same scoped, typed Effect client API as its native transports. Since browsers can't open raw TCP or serial connections directly, there are two browser-specific transports:

    • WasmWsTransportService — Modbus TCP over a WebSocket-to-TCP gateway (e.g. the modbus-gateway application).
    • WasmRtuTransportService / WasmAsciiTransportService — Modbus RTU/ASCII over the Web Serial API, via WasmSerialTransportService.fromRtu / .fromAscii.
    import { Console, Effect } from 'effect';
    import { WasmWsTransportService } from '@flux-control/effect-modbus-rs';

    const program = Effect.gen(function* () {
    const transport = yield* WasmWsTransportService;
    const client = yield* transport.withClient(1);
    const registers = yield* client.readHoldingRegisters({ address: 0, quantity: 10 });
    console.log('Holding registers:', registers);
    });

    program.pipe(
    Effect.provide(WasmWsTransportService.Default({ wsUrl: 'ws://localhost:8080' })),
    Effect.scoped,
    Effect.runPromise,
    );

    Web Serial requires a user-granted port handle. requestSerialPort() must be called synchronously from within a user-gesture event handler (e.g. a button click) — this is a Web Serial API / browser security requirement, not a library restriction:

    import { Effect, Layer } from 'effect';
    import { requestSerialPort, WasmRtuTransportService } from '@flux-control/effect-modbus-rs';

    connectButton.addEventListener('click', () => {
    Effect.runPromise(
    Effect.gen(function* () {
    const port = yield* requestSerialPort();
    yield* program.pipe(
    Effect.provide(WasmRtuTransportService.Default({ port, baudRate: 19200 })),
    Effect.scoped,
    );
    }),
    );
    });

    See examples/wasm/ for a real, runnable Vite app exercising both transports in an actual browser (cd examples/wasm && npm install && npm run dev).

    wasmWsServerLayer and wasmSerialRtuServerLayer / wasmSerialAsciiServerLayer wrap modbus-rs's experimental browser server bindings — same ServerHandlers callback shape as the native servers below. Two things differ from native:

    • Unlike native servers, the WASM server doesn't start serving on bind — these layers fork the required serve() loop into the layer's scope automatically, so usage looks the same as the native tcpServerLayer.
    • For the serial variants, options.serialPort comes from your own app's navigator.serial.requestPort() call (not from this package's requestSerialPort(), which returns a different wrapper type used only by the client transports).

    Not demonstrated in examples/wasm/ (see that app's README) — the same import { wasmWsServerLayer } from "@flux-control/effect-modbus-rs" pattern applies.

    Each transport is a scoped Effect.Service. You provide it with Effect.provide, and the connection is opened on service access and closed when the scope ends.

    Service Options Connection
    RtuTransportService { portPath, baudRate, ... } AsyncRtuTransport.open()
    TcpTransportService { host, port, ... } AsyncTcpTransport.connect()
    AsciiTransportService { portPath, baudRate, ... } AsyncAsciiTransport.open()
    WasmWsTransportService (browser) { wsUrl, requestTimeoutMs? } WasmWsTransport.connect()
    WasmRtuTransportService (browser) { port, baudRate, ... } WasmRtuTransport.open()
    WasmAsciiTransportService (browser) { port, baudRate, ... } WasmAsciiTransport.open()

    Browser transport option types are re-exported from modbus-rs/web unchanged. The native ones are narrowed — RtuTransportOpenOptions, AsciiTransportOpenOptions, and TcpTransportOpenOptions are their modbus-rs counterparts minus the retry knobs, for the reasons in Why the upstream retry knobs are withheld.

    SerialTransportService is a transport-agnostic tag that can be backed by either RTU or ASCII framing — useful when writing code that doesn't need to commit to a specific serial protocol. Provide it with fromRtu or fromAscii:

    import { Console, Effect } from 'effect';
    import { SerialTransportService } from '@flux-control/effect-modbus-rs';

    const program = Effect.gen(function* () {
    const transport = yield* SerialTransportService;
    const client = yield* transport.withClient(1);
    const coils = yield* client.readCoils({ address: 0, quantity: 2 });
    console.log('Coils:', coils);
    });

    // RTU framing
    program.pipe(
    Effect.provide(SerialTransportService.fromRtu({ portPath: '/dev/ttyUSB0', baudRate: 9600 })),
    Effect.scoped,
    Effect.runPromise,
    );

    // Or ASCII framing
    // program.pipe(
    // Effect.provide(SerialTransportService.fromAscii({ portPath: "/dev/ttyUSB0", baudRate: 9600 })),
    // Effect.scoped,
    // Effect.runPromise,
    // );

    WasmSerialTransportService is the browser equivalent. Provide it with fromRtu or fromAscii, and give it a port handle from requestSerialPort.

    Both abstract tags also have makeMockTransport for tests. The option set is the same as the option set of the concrete tags. Thus a test that keeps the framing abstract can also set retry, reconnect, and the mock fault hooks:

    import {
    ModbusTimeoutError,
    RetryPolicies,
    SerialTransportService,
    } from '@flux-control/effect-modbus-rs';

    let attempts = 0;

    const layer = SerialTransportService.makeMockTransport([device])({
    portPath: '/dev/ttyUSB0',
    baudRate: 9600,
    retry: RetryPolicies.serial(),
    // The first two attempts of each operation fail. The policy retries them.
    fault: () =>
    attempts++ < 2
    ? new ModbusTimeoutError({ message: 'no response', cause: new Error('timeout') })
    : undefined,
    });

    See Testing with mocks for the fault hook and the reconnectFault hook.

    transport.withClient(unitId) returns an EffectModbusClient — a typed wrapper around the raw modbus-rs client. All methods return Effect.Effect<T, ModbusError>.

    Method Returns
    readHoldingRegisters({ address, quantity }) number[]
    readInputRegisters({ address, quantity }) number[]
    writeSingleRegister({ address, value }) void
    writeMultipleRegisters({ address, values }) void
    readWriteMultipleRegisters({ readAddress, readQuantity, writeAddress, writeValues }) number[]
    Method Returns
    readCoils({ address, quantity }) boolean[]
    writeSingleCoil({ address, value }) void
    writeMultipleCoils({ address, values }) void
    readDiscreteInputs({ address, quantity }) boolean[]
    Method Returns
    readExceptionStatus() number
    diagnostics({ subFunction, data }) DiagnosticsResponse
    readFifoQueue({ address }) FifoQueueResponse
    readFileRecord({ requests }) number[][]
    writeFileRecord({ requests }) void
    readDeviceIdentification({ readDeviceIdCode, objectId }) DeviceIdentificationResponse

    Errors from the underlying Rust layer are mapped to typed Effect errors via Data.TaggedError:

    Error class Meaning
    ModbusExceptionError Modbus protocol exception (contains exception code)
    ModbusTimeoutError Request timed out
    ModbusTransportError Transport-level failure
    ModbusInvalidArgumentError Invalid parameters
    ModbusConnectionClosedError Connection lost
    ModbusNotConnectedError Operation attempted before connection
    ModbusInternalError Unclassified error

    Handle with Effect.catchTags. The ModbusError union type covers all seven variants.

    These are application-level policies, and they are opt-in.

    They are also the only retries in play. The transport-level knobs the underlying modbus-rs library offers — retryAttempts, retryDelayMs, and retryBackoffStrategy — are not accepted by any transport constructor here. Passing one is a type error. See Why the upstream retry knobs are withheld.

    Resilience belongs to the transport, not to call sites. Attach a policy where the transport is created and every client derived from it carries it:

    const layer = TcpTransportService.Default({
    host: '192.168.1.50',
    port: 502,
    retry: RetryPolicies.tcp(), // applied to every operation
    reconnect: {}, // supervised reconnect + circuit breaker
    });

    // call sites never mention retries
    const client = yield * transport.withClient(1);
    yield * client.readHoldingRegisters({ address: 0, quantity: 10 });

    With neither option set, a transport behaves exactly as it always has: one attempt per operation, reconnection only when you ask for it.

    Template Shape For
    RetryPolicies.none() 1 attempt Opting out of a wider policy
    RetryPolicies.serial() 3 retries, 50 ms base, ×2, 1 s ceiling RS-232/485 — collisions, noise bursts
    RetryPolicies.tcp() 4 retries, 100 ms base, ×2, 5 s ceiling Modbus/TCP — sockets and gateways
    RetryPolicies.persistent() 10 retries, 250 ms base, ×2, 30 s ceiling, 5 min budget Long-running background polling

    All four jitter their delays — see Backoff and jitter — and none retry ModbusInvalidArgumentError or a deterministic exception code.

    Every template is a factory taking overrides, so it doubles as a starting point. Overrides merge into the template rather than replacing it wholesale:

    RetryPolicies.serial({
    maxRetries: 6,
    errors: { ModbusTimeoutError: { baseDelay: '80 millis' } },
    });

    makeRetryPolicy(options) builds one from scratch with the same options.

    One bus often hosts device types that need different logic. A per-client policy replaces the transport's, so overrides can never multiply attempt counts:

    const meter = yield * transport.withClient(1, { retry: RetryPolicies.serial() });
    const plc = yield * transport.withClient(2, { retry: RetryPolicies.serial({ maxRetries: 8 }) });
    const legacy = yield * transport.withClient(3, { retry: RetryPolicies.none() });

    Clients built for the same unit ID under different policies share one underlying connection.

    client.withRetry(policy) does the same for a single operation:

    yield * client.withRetry(RetryPolicies.none()).writeSingleCoil({ address: 0, value });
    

    Resolution order is per-operation → per-client → transport → none. First match wins; the others are discarded, not combined.

    Two things look alike at a call site — both read as "attach a policy here" — but behave differently, and the difference is worth internalising:

    Form Effect on the policy already in force
    withClient(unitId, { retry }) Replaces it
    client.withRetry(policy) Replaces it
    .pipe(retryModbus(policy)) Wraps it — the two nest

    The deciding factor is whether the policy goes through the client or around it. The first two are resolved inside the client when it is built, so the previous policy is never applied. retryModbus is a free function piped around an effect the client has already wrapped in its own retry — nothing in that path can see the inner policy, so both run and the attempt counts multiply.

    Concretely, against a transport policy of maxRetries: 2 (3 attempts):

    transport.withClient(1, { retry: fast(3) })          // 4 attempts  (replaced)
    client.withRetry(fast(4)).readHoldingRegisters(...) // 5 attempts (replaced)
    client.readHoldingRegisters(...).pipe(retryModbus(fast(3))) // 12 attempts (3 × 4)

    That last form is only correct over a RetryPolicies.none() client — see Retrying a transaction.

    Retrying is only correct for failures that can plausibly resolve themselves, so the policy decides per error:

    Error Retried by default
    ModbusTimeoutError yes — slow turnaround, bus contention
    ModbusTransportError yes — framing/CRC corruption
    ModbusConnectionClosedError yes — and hands the link to the supervisor
    ModbusCircuitOpenError yes — refused without touching the bus, so it is cheap to wait out
    ModbusExceptionError only for codes 5, 6, 10, 11 (busy / gateway)
    ModbusInvalidArgumentError no — the answer will not change
    ModbusNotConnectedError no
    ModbusInternalError no

    Any of these can be switched off (errors: { ModbusTimeoutError: false }), switched on, or given their own backoff curve (errors: { ModbusConnectionClosedError: { baseDelay: '250 millis' } }). The retry budget is shared across categories — only the delay curve is per-error — so a mixed failure sequence still stops after maxRetries.

    Delays follow min(maxDelay, baseDelay × factor ** retryIndex), then get jittered.

    Jitter is on by default — for makeRetryPolicy() and for every template, none of which opts out. Each delay is multiplied by a random factor so a fleet of pollers does not re-hit a recovering device in lockstep:

    jitter Delay
    omitted, or true (default) ±20% — Effect's 0.8 – 1.2 multiplier range
    false exact, unrandomised delays — useful for assertable tests
    { min: 0.5, max: 1.5 } custom multiplier range

    So RetryPolicies.tcp() waits roughly 80–120 ms before its first retry, not exactly 100 ms.

    Passing reconnect hands reconnection to a supervisor fiber owned by the transport — one reconnect for the whole application, however many fibers were in flight when the link dropped:

    TcpTransportService.Default({
    host,
    port,
    reconnect: {
    policy: RetryPolicies.tcp(), // how reconnect attempts are spaced
    resetAfter: '30 seconds', // how long the circuit stays open before probing
    triggerOn: ['ModbusConnectionClosedError', 'ModbusTransportError'],
    },
    });

    While the link is being re-established, operations are refused with ModbusCircuitOpenError instead of queueing requests onto a dead bus. Because that error is retryable by default and costs nothing on the wire, a polling loop with a generous policy simply rides out the outage; one with a short budget fails fast and lets the caller decide.

    State transitions are published on transport.connectionState:

    State Meaning
    Disconnected Never opened, or closed. The next operation opens it lazily.
    Connected Usable.
    Reconnecting Supervisor is re-establishing the link. Operations refused.
    Down Attempts exhausted; waiting out resetAfter before probing again. Operations refused.
    yield *
    Stream.runForEach(transport.connectionState.changes, (state) =>
    Console.log(`link: ${state._tag}`),
    );

    retryModbus(policy) remains exported for the one case the transport cannot express: driving a compound operation as a unit, where retrying individual frames would be wrong.

    const client = yield * transport.withClient(1, { retry: RetryPolicies.none() });

    yield *
    Effect.gen(function* () {
    const current = yield* client.readHoldingRegisters({ address: 0, quantity: 2 });
    yield* client.writeMultipleRegisters({ address: 0, values: bump(current) });
    }).pipe(retryModbus(RetryPolicies.tcp()));

    Take a RetryPolicies.none() client first. Unlike withClient({ retry }) and client.withRetry(), which replace the policy in force, retryModbus wraps whatever the client is already doing — so over a policied client the two nest and the attempt counts multiply. See Replacing vs. wrapping.

    See examples/retry-policies.ts for a runnable walkthrough.

    modbus-rs exposes retryAttempts, retryDelayMs, and retryBackoffStrategy on its transport options. This package removes all three from every transport constructor, so setting one is a compile error rather than a documented hazard:

    TcpTransportService.Default({ host, port, retryAttempts: 3 });
    // ^^^^^^^^^^^^^ Object literal may only specify
    // known properties, and 'retryAttempts' does not
    // exist in type 'TcpTransportOpenOptions & …'

    They are withheld rather than merely discouraged because enabling them is never the right call under this design:

    • They retry below the Effect boundary. A failure they paper over never reaches your policy, the circuit breaker, or your logs. The caller sees one slow success instead of several failures and a recovery, and any caller-side Effect.timeout is measuring inflated time.
    • They reconnect. Upstream re-establishes the link inline and replays in-flight requests after it, which races the single supervisor fiber that is supposed to own reconnection for the whole transport.
    • They multiply. Neither layer knows about the other, so attempt counts compound and the two backoff curves interleave.
    • retryDelayMs is flat and unjittered — exactly the lockstep-collision pattern RetryPolicies.serial() exists to break up on a shared RS-485 segment.
    • retryBackoffStrategy does nothing. It is documented upstream as inert and reserved for future implementation, so 'exponential' silently gets you a flat delay.

    Use retry and reconnect on the transport instead. If you genuinely need frame-level resends, construct a raw modbus-rs client directly, where that trade-off is explicit rather than hidden under an Effect service.

    The narrowed option types are exported as RtuTransportOpenOptions, AsciiTransportOpenOptions, and TcpTransportOpenOptions, alongside the generic WithoutUpstreamRetry<T> and the UpstreamRetryOptionKey union.

    Each transport service provides a makeMockTransport(devices) static method that returns an in-memory mock Layer — no serial port or network required.

    import { Console, Effect } from 'effect';
    import { RtuTransportService } from '@flux-control/effect-modbus-rs';

    const device = {
    unitId: 1,
    coils: [
    { address: 0, default: true },
    { address: 1, default: false },
    ],
    discreteInputs: [],
    holdingRegisters: [
    { address: 0, default: 100 },
    { address: 1, default: 200 },
    ],
    inputRegisters: [],
    };

    const program = Effect.gen(function* () {
    const transport = yield* RtuTransportService;
    const client = yield* transport.withClient(1);
    const coils = yield* client.readCoils({ address: 0, quantity: 2 });
    console.log('Coils:', coils);
    });

    const mockLayer = RtuTransportService.makeMockTransport([device])({
    portPath: '/dev/ttyUSB0',
    baudRate: 9600,
    });

    program.pipe(Effect.provide(mockLayer), Effect.scoped, Effect.runPromise);

    The mock factory is the same for every transport. Each tag has a static makeMockTransport method, and each accepts the same options: the open options of that transport, the resilience options (retry and reconnect), and the two fault hooks below. To change transport, use a different tag and adjust the shape of the open options.

    See examples/rtu-mock.ts, examples/tcp-mock.ts, and examples/ascii-mock.ts for full walkthroughs covering read, write, multi-device access, and error-case testing.

    Two mock-only hooks make a policy testable without hardware:

    Hook When it runs Return value
    fault Before every operation attempt A ModbusError fails that attempt. undefined lets it through.
    reconnectFault Before every reconnect attempt A ModbusError keeps the link down. undefined lets the reconnect succeed.

    Because fault runs before each attempt, an error from it is the same as a device that refused that attempt. A retry policy, the backoff, and the circuit breaker therefore behave as they do on a real bus:

    import {
    ModbusTimeoutError,
    RetryPolicies,
    RtuTransportService,
    } from '@flux-control/effect-modbus-rs';

    let attempts = 0;

    const mockLayer = RtuTransportService.makeMockTransport([device])({
    portPath: '/dev/ttyUSB0',
    baudRate: 9600,
    retry: RetryPolicies.serial(),
    fault: () =>
    attempts++ < 2
    ? new ModbusTimeoutError({ message: 'no response', cause: new Error('timeout') })
    : undefined,
    });
    Property Type Default
    unitId number required
    coils { address, default }[] []
    discreteInputs { address, default }[] []
    holdingRegisters { address, default }[] []
    inputRegisters { address, default }[] []

    Coil/default values default to false if omitted at the address level; register values default to 0. Reads beyond the highest configured address produce a ModbusInvalidArgumentError.

    Action Command
    Install bun install
    Type-check bun run typecheck
    Test bun test
    Run example bun run examples/<name>.ts

    No build step — noEmit is on; Bun runs .ts directly.

    src/
    errors.tsData.TaggedError types + toModbusError converter
    modbus-client.tsEffectModbusClient interface + factory (native + WASM)
    mocks.tsSchema-validated mock transport + slave device definitions
    connection.tsConnection state machine, reconnect supervisor, circuit breaker
    retry.tsOpt-in retry policies (backoff, jitter, per-error rules)
    shared-transport.tsGeneric scoped transport lifecycle management, WithoutUpstreamRetry
    RtuTransportService.tsScoped Effect.Service wrapping AsyncRtuTransport
    TcpTransportService.tsScoped Effect.Service wrapping AsyncTcpTransport
    AsciiTransportService.tsScoped Effect.Service wrapping AsyncAsciiTransport
    SerialTransportService.tsAbstract serial transport (RTU/ASCII) tag
    TcpModbusServerService.tstcpServerLayer
    SerialModbusServerService.tsserialRtuServerLayer / serialAsciiServerLayer
    TcpGatewayService.tstcpGatewayLayer
    WasmSerialPort.tsrequestSerialPort() Effect helper (browser, user-gesture gated)
    WasmWsTransportService.tsScoped Effect.Service wrapping WasmWsTransport (browser, WS gateway)
    WasmRtuTransportService.tsScoped Effect.Service wrapping WasmRtuTransport (browser, Web Serial RTU)
    WasmAsciiTransportService.tsScoped Effect.Service wrapping WasmAsciiTransport (browser, Web Serial ASCII)
    WasmSerialTransportService.tsAbstract browser serial transport (RTU/ASCII) tag
    WasmTcpServerService.tswasmWsServerLayer (experimental)
    WasmSerialModbusServerService.tswasmSerialRtuServerLayer / wasmSerialAsciiServerLayer (experimental)
    examples/
    rtu-basic.tsRTU usage pattern
    tcp-basic.tsTCP usage pattern
    ascii-basic.tsASCII usage pattern
    serial-abstract.tsAbstract serial transport (RTU or ASCII)
    rtu-mock.tsRTU with in-memory mock
    tcp-mock.tsTCP with in-memory mock (multi-device)
    ascii-mock.tsASCII with in-memory mock (error-case)
    retry-policies.tsTransport-owned resilience: policies, overrides, transactions
    tcp-polling-stream.tsTCP polling, reconnect, and stream
    tcp-finalizer-reset.tsTCP scope finalizer reset demo
    tcp-server.tsTCP server example
    serial-server.tsSerial RTU server example
    wasm/ — Standalone runnable Vite app for the browser transports (own README, own npm project)
    index.tsRe-exports public API

    GPL-3.0