npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

duplex-rpc

v0.1.1

Published

A small RPC protocol for running untrusted WebAssembly programs behind a narrow, message-based interface. This is an experimental prototype and not intended for production use.

Readme

Sandboxed WebAssembly RPC

A small RPC protocol for running untrusted WebAssembly programs behind a narrow, message-based interface.

⚠️ This is an experimental prototype and not intended for production use. ⚠️

Rationale

Agentic AI is increasingly used for software development, and code review is becoming a bottleneck. In many projects it is no longer practical to manually review every line of implementation code.

Unit tests can help verify that code does what it is supposed to do. However, they are much less effective at proving that code does not do additional unintended things. Such unintended behavior could range from harmless side effects to scanning the filesystem, exfiltrating credentials, or installing malicious software.

This project explores a safer architecture:

  • Keep the trusted host small.
  • Execute the larger implementation inside a WebAssembly sandbox.
  • Treat the WebAssembly binary as untrusted.
  • Allow interaction only through a strict, validated RPC protocol.
  • The host process decides which capabilities are available.
  • The WebAssembly side cannot directly access host resources such as the filesystem, network, environment variables, process execution, or application internals.

This makes it possible to run complex logic inside WebAssembly while keeping external effects under host control.

⚠️ Security Note: The WebAssembly module is sandboxed, but every RPC function exposed by the host is a capability granted to untrusted code. Keep exposed functions narrow, validate all inputs and outputs, and avoid providing generic access such as raw HTTP, filesystem, or database operations.

High-level architecture

The system consists of two sides:

  • A trusted host process
  • An untrusted WebAssembly process

The two sides communicate over stdin/stdout or another byte stream.

flowchart LR
  subgraph NODE["node"]
    DR["duplex-rpc<br/><i>(TypeScript)</i>"]
  end

  subgraph WT["wasmtime"]
    DRR["Duplex PRC implementation<br/><i>(any language that can compile to WASM)</i>"]
  end

  DR -- "stdin" --> DRR
  DRR -- "stdout" --> DR

The protocol is bi-directional:

  • The host can request that the WebAssembly side executes a function.
  • The WebAssembly side can return results or errors.
  • The WebAssembly side can request that the host executes an allowed callback function.
  • Both sides can exchange stream events.

This allows the WebAssembly program to perform asynchronous workflows without receiving broad host access. For example, instead of giving WebAssembly raw HTTP access, the host may expose a narrow callback such as getProductDetails(productId).

Wire format

Messages are encoded as MessagePack on the wire.

For readability, this README shows messages as JSON-like examples. These examples describe the logical message structure, not the textual wire representation.

The version byte allows the protocol to evolve in the future. Receivers must reject frames with an unrecognized version.

Each frame contains exactly one protocol message.

packet
  title Frame layout
  0-7: "Version (always 1)"
  8-39: "Payload Length in bytes"
  40-95: "MessagePack payload"

Protocol concepts

IDs

Every function call and every stream is identified by a unique string id. The caller generates the ID before sending a message.

  • For function calls, the id links a FunctionCall message to its corresponding FunctionResponse or FunctionError. This ID is generated by the caller and must be unique over the lifetime of a WebAssembly application instance.
  • For streams, the id links all StreamChunk messages to their final StreamEnd or StreamError. The stream ID is chosen by the caller of the function from which the stream originates.

IDs must be unique within a session. The receiver uses the id to route incoming messages to the correct pending call or stream handler.

sequenceDiagram
  participant Caller
  participant Callee

  Caller->>Callee: FunctionCall(id = "a1")
  Callee-->>Caller: FunctionResponse(id = "a1")

  Caller->>Callee: FunctionCall(id = "b1")
  Callee-->>Caller: FunctionError(id = "b1")

Function executions can interleave:

sequenceDiagram
  participant Caller
  participant Callee

  Caller->>Callee: FunctionCall(id = "a1")
  Caller->>Callee: FunctionCall(id = "b1")

  Callee-->>Caller: FunctionResponse(id = "a1")
  Callee-->>Caller: FunctionError(id = "b1")

Every message has a numeric type field that identifies its kind.

| Type | Name | Direction | Purpose | | ---- | ------------------ | --------------- | ------------------------------------------------ | | 0 | FunctionCall | both directions | Request execution of a named function. | | 1 | FunctionResponse | both directions | Report successful completion of a function call. | | 2 | FunctionError | both directions | Report failed completion of a function call. | | 3 | StreamChunk | both directions | Deliver one stream item. | | 4 | StreamEnd | both directions | Signal successful stream completion. | | 5 | StreamError | both directions | Signal failed stream completion. |

FunctionCall (type: 0)

Requests execution of a named function.

| Field | Type | Required | Description | | ----------------- | ------- | -------- | ------------------------------------------------------------------------------------------------- | | type | 0 | yes | Message type discriminator. | | id | string | yes | Unique identifier for this call. | | functionName | string | yes | Name of the function to invoke. | | params | JSON | no | Arguments for the function. | | expectsResponse | boolean | no | Set to false for fire-and-forget calls. Omit when a response is expected, which is the default. |

Example:

{
  "type": 0,
  "id": "a1",
  "functionName": "add",
  "params": [1, 2],
}

FunctionResponse (type: 1)

Indicates that a function call completed successfully.

| Field | Type | Required | Description | | -------- | ------ | -------- | --------------------------------------- | | type | 1 | yes | Message type discriminator. | | id | string | yes | ID of the corresponding FunctionCall. | | result | JSON | no | Return value of the function, if any. |

Example:

{
  "type": 1,
  "id": "a1",
  "result": 3,
}

FunctionError (type: 2)

Indicates that a function call failed.

| Field | Type | Required | Description | | ------- | ------ | -------- | --------------------------------------- | | type | 2 | yes | Message type discriminator. | | id | string | yes | ID of the corresponding FunctionCall. | | error | string | yes | Human-readable error message. |

Example:

{
  "type": 2,
  "id": "b1",
  "error": "Division by zero",
}

StreamChunk (type: 3)

Delivers one chunk of data within a stream.

| Field | Type | Required | Description | | ------- | ------ | -------- | --------------------------------------- | | type | 3 | yes | Message type discriminator. | | id | string | yes | ID of the stream this chunk belongs to. | | chunk | JSON | yes | The data for this chunk. |

Example:

{
  "type": 3,
  "id": "stream-1",
  "chunk": {
    "name": "Hammer",
  },
}

StreamEnd (type: 4)

Signals that a stream has completed successfully.

A StreamEnd is not sent when a stream ends with an error. Use StreamError instead.

| Field | Type | Required | Description | | ------ | ------ | -------- | --------------------------- | | type | 4 | yes | Message type discriminator. | | id | string | yes | ID of the completed stream. |

Example:

{
  "type": 4,
  "id": "stream-1",
}

StreamError (type: 5)

Signals that a stream has ended with an error.

| Field | Type | Required | Description | | ------- | ------ | -------- | ----------------------------- | | type | 5 | yes | Message type discriminator. | | id | string | yes | ID of the failed stream. | | error | string | yes | Human-readable error message. |

Example:

{
  "type": 5,
  "id": "stream-1",
  "error": "Connection lost",
}

Examples

Simple function call and response

The host asks WebAssembly to run a function and receives a result:

// Host → WebAssembly
{ "type": 0, "id": "a1", "functionName": "add", "params": [1, 2] }

// WebAssembly → Host
{ "type": 1, "id": "a1", "result": 3 }

Note: params and result can be any JSON value, including primitive values, objects, and arrays.

Function call that returns an error

// Host → WebAssembly
{ "type": 0, "id": "b1", "functionName": "divide", "params": [10, 0] }

// WebAssembly → Host
{ "type": 2, "id": "b1", "error": "Division by zero" }

Fire-and-forget call

The caller sets expectsResponse to false. The callee must not send a response or error for this ID.

// Host → WebAssembly
{
  "type": 0,
  "id": "c1",
  "functionName": "logEvent",
  "params": { "event": "started" },
  "expectsResponse": false,
}

WebAssembly callback to the host

WebAssembly can call back into the host using the same message types. The host decides which callbacks are allowed.

// WebAssembly → Host
{ "type": 0, "id": "d1", "functionName": "getProductDetails", "params": { "productId": "p-42" } }

// Host → WebAssembly
{ "type": 1, "id": "d1", "result": { "name": "Broccoli", "price": 6.99 } }

The host must validate that getProductDetails is allowed before executing it.

Streaming data

A stream delivers multiple chunks followed by a StreamEnd. The stream ID is chosen by the caller of the function that returns or consumes the stream. The stream data can flow before and after the creating function returned.

sequenceDiagram
  participant Host
  participant WASM as WebAssembly

  Host->>WASM: FunctionCall(id = "e1", functionName = "listItems", params = { toolStreamId: "t1" })
  WASM-->>Host: FunctionResponse(id = "e1")
  WASM-->>Host: StreamChunk(id = "t1", chunk = "Hammer")
  WASM-->>Host: StreamChunk(id = "t1", chunk = "Wrench")
  WASM-->>Host: StreamEnd(id = "t1")
// Host → WebAssembly  (call a function that produces a stream)
{ "type": 0, "id": "e1", "functionName": "listItems", "params": { "category": "tools", "toolStreamId": "t1" } }

// The function might return before, while, or after the stream is active.
// WebAssembly → Host  (function returns without a result, but the stream is active)
{ "type": 1, "id": "e1" }

// WebAssembly → Host  (stream chunks sharing the same id)
{ "type": 3, "id": "t1", "chunk": { "name": "Hammer" } }
{ "type": 3, "id": "t1", "chunk": { "name": "Wrench" } }

// WebAssembly → Host  (stream completed)
{ "type": 4, "id": "t1" }

If something goes wrong mid-stream, a StreamError is sent instead of StreamEnd:

{ "type": 5, "id": "t1", "error": "Connection lost" }

Failure handling

The host should terminate the WebAssembly process and discard its state when:

  • A frame cannot be decoded.
  • A frame uses an unrecognized protocol version.
  • A message does not match the protocol schema.
  • WebAssembly requests an unauthorized callback.
  • WebAssembly references an unknown id.
  • A call exceeds its timeout.
  • The process exits unexpectedly.
  • The output stream contains non-protocol data.

After termination, the host should start a fresh WebAssembly process for subsequent work.

flowchart TD
  message["Receive frame"]
  decode["Decode frame and MessagePack payload"]
  validate["Validate protocol message"]
  authorized["Authorized operation?"]
  execute["Execute or route message"]
  terminate["Terminate WebAssembly process<br/>discard state<br/>start fresh process if needed"]

  message --> decode
  decode -->|"decode failed"| terminate
  decode -->|"ok"| validate
  validate -->|"invalid schema"| terminate
  validate -->|"ok"| authorized
  authorized -->|"no"| terminate
  authorized -->|"yes"| execute

Why an external Wasmtime process and not node's built-in WASM APIs?

This project intentionally communicates with WebAssembly through an external Wasmtime process instead of embedding WebAssembly directly through Node's WebAssembly APIs.

The goal is not to make every function call as cheap as possible. The goal is to create a simple, inspectable, process-isolated boundary for larger untrusted components.

Key reasons:

  • Stronger isolation boundary
    The WebAssembly program runs in a separate process. If it hangs, crashes, violates the protocol, or exceeds a timeout, the host can terminate the process and start fresh.

  • Simpler communication model
    Direct WebAssembly embedding usually requires manual memory management: allocating memory inside the WebAssembly instance, writing bytes into linear memory, passing pointer/length pairs, and reading results back out. With an external process, communication is just framed messages over stdin/stdout.

  • Language-independent protocol
    The protocol is not tied to TypeScript or Node. Any host language that can start a subprocess and read/write byte streams can speak the same protocol.

  • Runtime flexibility
    Although this project targets Wasmtime, the protocol itself is just MessagePack frames over a byte stream. In principle, any external command-line application could implement the same protocol, whether or not it is backed by WebAssembly.

  • Good fit for coarse-grained logic
    This RPC boundary is not intended for tiny high-frequency helper functions. Simple logic should usually stay in the host application. The model is better suited for larger components such as parsers, analyzers, planners, validators, transformers, ranking algorithms, or other logic where the call overhead is small compared with the work performed.

  • Testable black-box behavior
    The WebAssembly component can be treated as a black box. The host can run integration tests and property-based tests against the protocol boundary without needing to trust or inspect the internal implementation.

  • Capability-oriented design
    The WebAssembly side cannot simply perform arbitrary host actions. If it needs external data or effects, it must request them through explicit RPC calls that the host can validate, authorize, deny, log, or simulate in tests.

In short: this approach favors a clear trust boundary, protocol validation, process isolation, and implementation flexibility over ultra-low-latency in-process calls.