@flux-control/effect-modbus-rs
v0.3.1
Published
Type-safe Modbus communication via Effect-TS, wrapping the modbus-rs npm bindings.
Downloads
650
Readme
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.
Install
bun add @flux-control/effect-modbus-rsTypeScript only while prototyping (JS consumers will be supported before 1.0).
Quick start
RTU (serial)
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,
);TCP
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,
);ASCII
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,
);Browser / WASM (modbus-rs/web)
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. themodbus-gatewayapplication).WasmRtuTransportService/WasmAsciiTransportService— Modbus RTU/ASCII over the Web Serial API, viaWasmSerialTransportService.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).
Browser server (experimental)
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 nativetcpServerLayer. - For the serial variants,
options.serialPortcomes from your own app'snavigator.serial.requestPort()call (not from this package'srequestSerialPort(), 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.
Transports
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.
Abstract serial transport
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.
Client API
transport.withClient(unitId) returns an EffectModbusClient — a typed wrapper around the raw modbus-rs client. All methods return Effect.Effect<T, ModbusError>.
Registers
| Method | Returns |
| -------------------------------------------------------------------------------------- | ---------- |
| readHoldingRegisters({ address, quantity }) | number[] |
| readInputRegisters({ address, quantity }) | number[] |
| writeSingleRegister({ address, value }) | void |
| writeMultipleRegisters({ address, values }) | void |
| readWriteMultipleRegisters({ readAddress, readQuantity, writeAddress, writeValues }) | number[] |
Coils / discrete inputs
| Method | Returns |
| ------------------------------------------- | ----------- |
| readCoils({ address, quantity }) | boolean[] |
| writeSingleCoil({ address, value }) | void |
| writeMultipleCoils({ address, values }) | void |
| readDiscreteInputs({ address, quantity }) | boolean[] |
Diagnostics & file access
| Method | Returns |
| ---------------------------------------------------------- | ------------------------------ |
| readExceptionStatus() | number |
| diagnostics({ subFunction, data }) | DiagnosticsResponse |
| readFifoQueue({ address }) | FifoQueueResponse |
| readFileRecord({ requests }) | number[][] |
| writeFileRecord({ requests }) | void |
| readDeviceIdentification({ readDeviceIdCode, objectId }) | DeviceIdentificationResponse |
Error handling
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.
Resilience: retries, reconnection, and circuit breaking
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-rslibrary offers —retryAttempts,retryDelayMs, andretryBackoffStrategy— 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.
Templates
| 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.
Overriding per client and per operation
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.
Replacing vs. wrapping
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.
Error-aware by construction
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.
Backoff and jitter
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.
Supervised reconnection and the circuit breaker
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}`),
);Retrying a transaction
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.
Why the upstream retry knobs are withheld
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.timeoutis 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.
retryDelayMsis flat and unjittered — exactly the lockstep-collision patternRetryPolicies.serial()exists to break up on a shared RS-485 segment.retryBackoffStrategydoes 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.
Testing with mocks
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.
Fault injection
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,
});Slave device schema
| 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.
Development
| 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.
Source layout
src/
errors.ts — Data.TaggedError types + toModbusError converter
modbus-client.ts — EffectModbusClient interface + factory (native + WASM)
mocks.ts — Schema-validated mock transport + slave device definitions
connection.ts — Connection state machine, reconnect supervisor, circuit breaker
retry.ts — Opt-in retry policies (backoff, jitter, per-error rules)
shared-transport.ts — Generic scoped transport lifecycle management, WithoutUpstreamRetry
RtuTransportService.ts — Scoped Effect.Service wrapping AsyncRtuTransport
TcpTransportService.ts — Scoped Effect.Service wrapping AsyncTcpTransport
AsciiTransportService.ts — Scoped Effect.Service wrapping AsyncAsciiTransport
SerialTransportService.ts — Abstract serial transport (RTU/ASCII) tag
TcpModbusServerService.ts — tcpServerLayer
SerialModbusServerService.ts — serialRtuServerLayer / serialAsciiServerLayer
TcpGatewayService.ts — tcpGatewayLayer
WasmSerialPort.ts — requestSerialPort() Effect helper (browser, user-gesture gated)
WasmWsTransportService.ts — Scoped Effect.Service wrapping WasmWsTransport (browser, WS gateway)
WasmRtuTransportService.ts — Scoped Effect.Service wrapping WasmRtuTransport (browser, Web Serial RTU)
WasmAsciiTransportService.ts — Scoped Effect.Service wrapping WasmAsciiTransport (browser, Web Serial ASCII)
WasmSerialTransportService.ts — Abstract browser serial transport (RTU/ASCII) tag
WasmTcpServerService.ts — wasmWsServerLayer (experimental)
WasmSerialModbusServerService.ts — wasmSerialRtuServerLayer / wasmSerialAsciiServerLayer (experimental)
examples/
rtu-basic.ts — RTU usage pattern
tcp-basic.ts — TCP usage pattern
ascii-basic.ts — ASCII usage pattern
serial-abstract.ts — Abstract serial transport (RTU or ASCII)
rtu-mock.ts — RTU with in-memory mock
tcp-mock.ts — TCP with in-memory mock (multi-device)
ascii-mock.ts — ASCII with in-memory mock (error-case)
retry-policies.ts — Transport-owned resilience: policies, overrides, transactions
tcp-polling-stream.ts — TCP polling, reconnect, and stream
tcp-finalizer-reset.ts — TCP scope finalizer reset demo
tcp-server.ts — TCP server example
serial-server.ts — Serial RTU server example
wasm/ — Standalone runnable Vite app for the browser transports (own README, own npm project)
index.ts — Re-exports public APILicense
GPL-3.0
