Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions development/relay-receiver-napi-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Embedded native relay receiver (napi-rs) — implementation plan

**Status:** proposed / in progress. **Date:** 2026-08-12.
**Scope:** a **new, separate** TypeScript package that embeds the relay receiver
as a **napi-rs** native addon, reusing the same Rust engine that the Java SDK
uses (`restate-sdk-shared-core`, branch `relay`, `tunnel` feature —
`relay::{Config, Engine, Handle}`). The FFI boundary is control-plane only; every
forwarded request rides a **loopback socket** into the SDK's own node:http2
server, so request dispatch — and therefore **both the codegen SDK and the
default promise API** — is untouched.

This is the TS sibling of `sdk-java`'s `RelayTunnel` / `RestateRelayServer`
(FFM) and of `sdk-shared-core/docs/relay-receiver-in-shared-core.md` (the engine).

## Why a new module (not the existing `restate-sdk-tunnel`)

`packages/libs/restate-sdk-tunnel` is a **pure-JS** reimplementation of the
receiver protocol targeting Restate Cloud's `/_/start-tunnel`. This work is the
**native** path (single-sourced Rust protocol via napi) targeting the generic
relay's `/whoami` mode. They are complementary; reconciling them is deferred.
We build a fresh package and leave `restate-sdk-tunnel` alone.

## The two layers

```
packages/libs/restate-sdk-tunnel-native/ @restatedev/restate-sdk-tunnel-native
├── Cargo.toml # napi crate: napi + napi-derive + restate-sdk-shared-core(relay,tunnel) + serde_json
├── build.rs # napi_build::setup()
├── src/lib.rs # #[napi] RelayTunnel: start(configJson)->self, status()->string, stop()
├── index.js / index.d.ts # GENERATED by `napi build` (loads the .node); gitignored
├── *.node # GENERATED host artifact; gitignored (prebuild matrix deferred)
├── restate/ # the TS wrapper (public API), bundled by tsdown like every lib
│ └── index.ts # serveTunnel({ services, relay, env, tunnel, apiKey, ... })
├── package.json # napi config + build scripts + exports
└── README.md
```

### 1. napi crate (`src/lib.rs`) — the thin binding

Mirrors the Java C ABI, but idiomatic napi. A `#[napi]` class over the engine:

```rust
#[napi]
pub struct RelayTunnel { handle: Option<Handle> }

#[napi]
impl RelayTunnel {
#[napi(factory)]
pub fn start(config_json: String) -> napi::Result<RelayTunnel> { … Engine::start … }
#[napi] pub fn status(&self) -> String { … handle.status_json() … }
#[napi] pub fn stop(&mut self) { … handle.stop() … }
}
```

- Config crosses as a JSON string (the same `relay::Config` serde shape the Java
side builds); `status()` returns the `{"running","last_error"}` JSON.
- `Handle`'s `Drop` also stops the engine, so a GC'd `RelayTunnel` cleans up; the
explicit `stop()` (idempotent) is for deterministic shutdown.
- No `ThreadsafeFunction`, no per-request marshalling — data rides the loopback
socket, exactly like the FFM path.

### 2. TS wrapper (`restate/index.ts`) — the node:http2 wiring

The equivalent of Java's `RestateRelayServer.listen`. Works with **both** the
codegen SDK and the promise API because it wires at the shared `RestateEndpoint`
layer (what `endpoint().bind(...)` accepts from either model):

```ts
import http2 from "node:http2";
import { endpoint } from "@restatedev/restate-sdk/node";
import { RelayTunnel } from "../index.js"; // the napi binding

export async function serveTunnel(opts: ServeTunnelOptions): Promise<TunnelHandle> {
const ep = endpoint();
for (const s of opts.services) ep.bind(s);
const server = http2.createServer(ep.http2Handler({ bidirectional: true }));
const port: number = await new Promise((res, rej) => {
server.once("error", rej);
server.listen(0, "127.0.0.1", () => res((server.address() as any).port));
});
const tunnel = RelayTunnel.start(JSON.stringify({
relay_addr: opts.relayAddress, env: opts.env, tunnel: opts.tunnel,
api_key: opts.apiKey, local_port: port,
connections: opts.connections, instance_id: opts.instanceId,
}));
// handle: status()/stop() + close the server; register on process signals.
}
```

- Binds `127.0.0.1` only (loopback) via a manual `http2.createServer` +
`listen(0, "127.0.0.1")` — `endpoint.listen()` binds all interfaces, so we use
`http2Handler()` directly to keep the server loopback-private.
- `bidirectional: true` → BIDI_STREAM, matching what the relay forwards.

## napi-rs setup (from scratch — no existing precedent here)

The repo builds Rust→TS via `wasm-pack` (the VM `sdk-shared-core-wasm-bindings`);
there is **no** napi-rs yet. This package adds:

- `@napi-rs/cli` as a devDependency; `napi build --platform --release` produces
the host `.node` + `index.{js,d.ts}` loader.
- `napi`, `napi-derive`, `napi-build` crates in `Cargo.toml`.
- **For now:** host-target build only, `.node` gitignored (like wasm `pkg/`).
The multi-platform **prebuild matrix + optionalDependencies + CI**
(`napi create-npm-dirs` / `napi artifacts`) is **deferred** — that is the "deal
with it later" packaging problem.

## Verification (v1)

1. `napi build` produces a loadable `.node`; `RelayTunnel.start/status/stop` are
callable from Node.
2. The wrapper compiles (`tsc --noEmit`) + type-checks against `restate-sdk/node`.
3. A round-trip smoke test against a fake relay (mirrors shared-core's
`tests/relay_loopback.rs`): dial-in, `/whoami`, forwarded request →
loopback → node:http2 server → back. (The engine round-trip is already proven
in shared-core; here we prove the napi binding + node wiring.)

## Deferred (documented, not in v1)

- Multi-platform prebuild matrix + CI + `optionalDependencies` publishing.
Until that lands, the package deliberately defines **no** turbo task scripts
(`_build`/`_check:types`/`_test`/`lint`), so it is skipped by the repo-wide
`turbo run` (build/verify) — CI can't run `napi build`, so it must not try to
`tsc`/`tsdown` a wrapper whose native binding isn't present. Build it manually
with `build:native` then `build:wrapper`.
- Restate Cloud `/_/start-tunnel` mode (the engine is `/whoami`-only, same as Java).
- Reconciling with the pure-JS `restate-sdk-tunnel` package.
- Swapping the shared-core git dep for a crates.io release once `tunnel` ships.
12 changes: 12 additions & 0 deletions packages/libs/restate-sdk-tunnel-native/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Rust build output
/target

# napi-rs generated host artifacts (built locally via `napi build`; the
# multi-platform prebuild matrix + publishing is deferred — see the plan doc).
*.node
/index.cjs
/index.d.cts

# tsdown output
/dist
*.tsbuildinfo
Loading
Loading