docs/Guides/Javascript

JavaScript / TypeScript

Use Subway from JavaScript and TypeScript via the REST and WebSocket bridge.

Tip

New to Subway? Start with the TypeScript quickstart — you'll be connected in 5 minutes. This guide covers the full protocol, raw WebSocket patterns, and advanced usage.

Subway's bridge exposes the full protocol over HTTP and WebSocket. Any Node.js script, Deno program, or browser app can send messages, make RPC calls, broadcast to topics, and subscribe to events — no Rust required.

All examples include TypeScript types. Drop the type annotations for plain JavaScript.

Prerequisites#

  • Node.js 18+ (or Deno, Bun — all support fetch and WebSocket natively)
  • A running Subway relay with bridge (the public relay at relay.subway.dev works)

The easiest way to use Subway from TypeScript/JavaScript:

npm install subway-sdk

The SDK provides SubwayAgent (persistent WebSocket) and SubwayClient (stateless REST). See the subway-ts README for the full API.

Raw HTTP/WebSocket

No dependencies needed for REST (uses native fetch). For WebSocket in Node.js < 21, install ws:

npm install ws

The examples below use raw HTTP and WebSocket calls to show the protocol directly. For production use, prefer the subway-sdk package.

REST API: fire-and-forget#

The REST bridge is the simplest integration path. No persistent connection, no agent identity — just HTTP.

Send a message

const RELAY = "http://localhost:9002";
 
async function send() {
  const res = await fetch(`${RELAY}/v1/send`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      to: "worker.relay",
      message_type: "task",
      payload: "process this document",
      metadata: { priority: "high" },
    }),
  });
 
  if (!res.ok) throw new Error(`send failed: ${res.status}`);
  const data = await res.json();
  console.log("sent:", data);
}
 
send();

RPC call (request-response)

const RELAY = "http://localhost:9002";
 
interface CallResult {
  success: boolean;
  payload?: string;
  error?: string;
}
 
async function call() {
  const res = await fetch(`${RELAY}/v1/call`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      to: "worker.relay",
      method: "summarize",
      payload: "The quick brown fox jumps over the lazy dog.",
      timeout_ms: 5000,
    }),
  });
 
  if (!res.ok) throw new Error(`call failed: ${res.status}`);
  const result: CallResult = await res.json();
 
  if (result.success) {
    console.log("response:", result.payload);
  } else {
    console.error("error:", result.error);
  }
}
 
call();

Broadcast to a topic

const RELAY = "http://localhost:9002";
 
async function broadcast() {
  const res = await fetch(`${RELAY}/v1/broadcast`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      topic: "metrics.cpu",
      message_type: "metric",
      payload: "usage: 42%",
    }),
  });
 
  if (!res.ok) throw new Error(`broadcast failed: ${res.status}`);
}
 
broadcast();

Subscribe via Server-Sent Events

SSE works natively in browsers and Node.js 18+:

const RELAY = "http://localhost:9002";
 
// Browser or Node.js with native EventSource
const source = new EventSource(`${RELAY}/v1/subscribe?topic=metrics.*`);
 
source.onmessage = (event) => {
  console.log("broadcast:", JSON.parse(event.data));
};
 
source.onerror = () => {
  console.error("SSE connection lost, reconnecting...");
};

For Node.js without EventSource, use fetch with streaming:

const RELAY = "http://localhost:9002";
 
async function subscribe() {
  const res = await fetch(`${RELAY}/v1/subscribe?topic=metrics.*`);
  if (!res.body) throw new Error("no body");
 
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
 
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
 
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? "";
 
    for (const line of lines) {
      if (line.startsWith("data: ")) {
        console.log("broadcast:", JSON.parse(line.slice(6)));
      }
    }
  }
}
 
subscribe();

Resolve a name

const RELAY = "http://localhost:9002";
 
async function resolve(name: string) {
  const res = await fetch(`${RELAY}/v1/resolve/${name}`);
  if (!res.ok) throw new Error(`resolve failed: ${res.status}`);
 
  const data = await res.json();
  console.log(data); // { name: "worker.relay", peer_id: "12D3KooW..." }
}
 
resolve("worker.relay");

WebSocket: persistent agents#

The WebSocket bridge gives your JavaScript process a full agent identity on the mesh. It can send, receive, handle RPCs, and subscribe to topics — all over a single connection.

Tip

The examples below use ws://localhost:9002/ws for local development. For the public relay, use wss://relay.subway.dev/ws.

Types

These types cover every message you'll send or receive:

// Outbound messages (you send these)
type OutboundMessage =
  | { type: "register"; name: string }
  | { type: "send"; to: string; message_type: string; payload: string; metadata?: Record<string, string> }
  | { type: "call"; to: string; method: string; payload: string; correlation_id: string }
  | { type: "call_response"; correlation_id: string; success: boolean; payload: string }
  | { type: "broadcast"; topic: string; message_type: string; payload: string }
  | { type: "subscribe"; topic: string }
  | { type: "unsubscribe"; topic: string }
  | { type: "resolve"; name: string }
  | { type: "ping" };
 
// Inbound messages (you receive these)
type InboundMessage =
  | { type: "registered"; name: string; peer_id: string }
  | { type: "message"; from_peer: string; from_name: string; message_type: string; payload: string; metadata?: Record<string, string> }
  | { type: "inbound_call"; from_peer: string; from_name: string; method: string; payload: string; correlation_id: string }
  | { type: "call_result"; correlation_id: string; success: boolean; payload: string; error?: string }
  | { type: "broadcast_message"; topic: string; from_peer: string; from_name: string; message_type: string; payload: string }
  | { type: "subscribed"; topic: string }
  | { type: "unsubscribed"; topic: string }
  | { type: "resolved"; name: string; peer_id: string }
  | { type: "pong" }
  | { type: "error"; message: string };

Connect and register

const ws = new WebSocket("ws://localhost:9002/ws");
 
ws.onopen = () => {
  ws.send(JSON.stringify({ type: "register", name: "js-agent.relay" }));
};
 
ws.onmessage = (event) => {
  const msg: InboundMessage = JSON.parse(event.data);
 
  switch (msg.type) {
    case "registered":
      console.log(`connected as ${msg.name} (${msg.peer_id})`);
      break;
    case "message":
      console.log(`[msg] ${msg.from_name}: ${msg.payload}`);
      break;
    case "inbound_call":
      console.log(`[rpc] ${msg.from_name}.${msg.method}: ${msg.payload}`);
      break;
    case "broadcast_message":
      console.log(`[${msg.topic}] ${msg.from_name}: ${msg.payload}`);
      break;
  }
};

Node.js with ws (pre-v21)

import WebSocket from "ws";
 
const ws = new WebSocket("ws://localhost:9002/ws");
 
ws.on("open", () => {
  ws.send(JSON.stringify({ type: "register", name: "node-agent.relay" }));
});
 
ws.on("message", (raw: Buffer) => {
  const msg = JSON.parse(raw.toString());
  console.log(`[${msg.type}]`, msg);
});

Send and call

function send(ws: WebSocket, to: string, payload: string) {
  ws.send(JSON.stringify({
    type: "send",
    to,
    message_type: "task",
    payload,
  }));
}
 
function call(
  ws: WebSocket,
  to: string,
  method: string,
  payload: string,
): Promise<string> {
  const correlationId = crypto.randomUUID();
 
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => reject(new Error("rpc timeout")), 10_000);
 
    const handler = (event: MessageEvent) => {
      const msg = JSON.parse(event.data);
      if (msg.type === "call_result" && msg.correlation_id === correlationId) {
        clearTimeout(timeout);
        ws.removeEventListener("message", handler);
        msg.success ? resolve(msg.payload) : reject(new Error(msg.error));
      }
    };
 
    ws.addEventListener("message", handler);
    ws.send(JSON.stringify({
      type: "call",
      to,
      method,
      payload,
      correlation_id: correlationId,
    }));
  });
}
 
// Usage
send(ws, "worker.relay", "hello from javascript");
const result = await call(ws, "worker.relay", "echo", "ping");
console.log("rpc response:", result);

Handle inbound RPCs

ws.onmessage = (event) => {
  const msg: InboundMessage = JSON.parse(event.data);
 
  if (msg.type === "inbound_call") {
    console.log(`rpc from ${msg.from_name}: ${msg.method}(${msg.payload})`);
 
    // Process and respond
    const result = `processed: ${msg.payload}`;
    ws.send(JSON.stringify({
      type: "call_response",
      correlation_id: msg.correlation_id,
      success: true,
      payload: result,
    }));
  }
};

Subscribe to topics

// Subscribe with wildcard
ws.send(JSON.stringify({ type: "subscribe", topic: "events.*" }));
 
ws.onmessage = (event) => {
  const msg: InboundMessage = JSON.parse(event.data);
 
  if (msg.type === "subscribed") {
    console.log(`subscribed to: ${msg.topic}`);
  }
 
  if (msg.type === "broadcast_message") {
    console.log(`[${msg.topic}] ${msg.from_name}: ${msg.payload}`);
  }
};

Patterns#

RPC service with typed handlers

Build a structured service that routes RPCs to handler functions:

type Handler = (payload: string, from: string) => Promise<string>;
 
class SubwayService {
  private ws: WebSocket;
  private handlers = new Map<string, Handler>();
 
  constructor(relayUrl: string, private name: string) {
    this.ws = new WebSocket(relayUrl);
    this.ws.onopen = () => {
      this.ws.send(JSON.stringify({ type: "register", name: this.name }));
    };
    this.ws.onmessage = (event) => this.dispatch(JSON.parse(event.data));
  }
 
  handle(method: string, handler: Handler) {
    this.handlers.set(method, handler);
  }
 
  private async dispatch(msg: InboundMessage) {
    if (msg.type !== "inbound_call") return;
 
    const handler = this.handlers.get(msg.method);
    if (!handler) {
      this.ws.send(JSON.stringify({
        type: "call_response",
        correlation_id: msg.correlation_id,
        success: false,
        payload: `unknown method: ${msg.method}`,
      }));
      return;
    }
 
    try {
      const result = await handler(msg.payload, msg.from_name);
      this.ws.send(JSON.stringify({
        type: "call_response",
        correlation_id: msg.correlation_id,
        success: true,
        payload: result,
      }));
    } catch (err) {
      this.ws.send(JSON.stringify({
        type: "call_response",
        correlation_id: msg.correlation_id,
        success: false,
        payload: String(err),
      }));
    }
  }
}
 
// Usage
const svc = new SubwayService("ws://localhost:9002/ws", "api.relay");
 
svc.handle("echo", async (payload) => payload);
 
svc.handle("uppercase", async (payload) => payload.toUpperCase());
 
svc.handle("json-parse", async (payload) => {
  const data = JSON.parse(payload);
  return JSON.stringify({ keys: Object.keys(data), count: Object.keys(data).length });
});

Reconnecting agent

Production agents need to handle disconnects gracefully:

class ReconnectingAgent {
  private ws: WebSocket | null = null;
  private reconnectDelay = 1000;
  private maxDelay = 30_000;
 
  constructor(
    private relayUrl: string,
    private name: string,
    private onMessage: (msg: InboundMessage) => void,
  ) {
    this.connect();
  }
 
  private connect() {
    this.ws = new WebSocket(this.relayUrl);
 
    this.ws.onopen = () => {
      this.reconnectDelay = 1000;
      this.ws!.send(JSON.stringify({ type: "register", name: this.name }));
    };
 
    this.ws.onmessage = (event) => {
      this.onMessage(JSON.parse(event.data));
    };
 
    this.ws.onclose = () => {
      console.log(`disconnected, reconnecting in ${this.reconnectDelay}ms...`);
      setTimeout(() => this.connect(), this.reconnectDelay);
      this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay);
    };
 
    this.ws.onerror = () => {
      this.ws?.close();
    };
  }
 
  send(msg: OutboundMessage) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(msg));
    }
  }
}
 
// Usage
const agent = new ReconnectingAgent(
  "ws://localhost:9002/ws",
  "resilient.relay",
  (msg) => console.log(`[${msg.type}]`, msg),
);

Multi-agent coordinator

Spin up multiple agents from one process and route work between them:

async function createAgent(name: string): Promise<WebSocket> {
  return new Promise((resolve) => {
    const ws = new WebSocket("ws://localhost:9002/ws");
    ws.onopen = () => {
      ws.send(JSON.stringify({ type: "register", name }));
      ws.onmessage = (event) => {
        const msg = JSON.parse(event.data);
        if (msg.type === "registered") resolve(ws);
      };
    };
  });
}
 
async function main() {
  // Create a coordinator and two workers
  const coordinator = await createAgent("coordinator.relay");
  const worker1 = await createAgent("worker-1.relay");
  const worker2 = await createAgent("worker-2.relay");
 
  // Workers handle tasks
  for (const worker of [worker1, worker2]) {
    worker.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      if (msg.type === "inbound_call") {
        const result = `done: ${msg.payload}`;
        worker.send(JSON.stringify({
          type: "call_response",
          correlation_id: msg.correlation_id,
          success: true,
          payload: result,
        }));
      }
    };
  }
 
  // Coordinator distributes work round-robin
  const workers = ["worker-1.relay", "worker-2.relay"];
  for (let i = 0; i < 6; i++) {
    const target = workers[i % workers.length];
    coordinator.send(JSON.stringify({
      type: "call",
      to: target,
      method: "process",
      payload: `task-${i}`,
      correlation_id: crypto.randomUUID(),
    }));
  }
}
 
main();

Browser usage#

The WebSocket bridge works directly in browsers — no bundler plugins or polyfills needed:

<script type="module">
  const ws = new WebSocket("ws://localhost:9002/ws");
 
  ws.onopen = () => {
    ws.send(JSON.stringify({ type: "register", name: "browser.relay" }));
  };
 
  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    document.getElementById("log").textContent += `${msg.type}: ${JSON.stringify(msg)}\n`;
  };
 
  // Expose to window for console testing
  window.subwaySend = (to, payload) => {
    ws.send(JSON.stringify({ type: "send", to, message_type: "chat", payload }));
  };
</script>
<pre id="log"></pre>

When to use REST vs WebSocket#

Use REST when...Use WebSocket when...
Sending one-off messagesYour agent needs to receive messages
Simple RPC calls from a serverHandling inbound RPCs as a service
Serverless functions (Lambda, Edge)Long-running Node.js processes
No agent identity neededYou need a named agent on the mesh
Browser fetch callsReal-time browser applications

Next steps#