Skip to content

Nodejs – service-plane

Goal: run Service Plane across normal HTTPS services outside Cloudflare.

Use the same ability definitions as Cloudflare services. The main difference is transport and caller auth.

A self-hosted Hono service exposes discovery and one RPC endpoint per ability.

import { serve } from '@hono/node-server';
import { ServicePlaneService, jwksFromUrl } from 'service-plane/service';
import { asanaTasks } from './abilities';
import { capabilities } from './capabilities';
const service = new ServicePlaneService({
id: 'asana',
title: 'Asana Service',
version: '0.2.0',
auth: {
issuer: 'control-plane',
jwks: jwksFromUrl('https://plane.example.com/.well-known/service-plane/jwks.json'),
},
capabilities,
abilities: [asanaTasks],
});
serve({ fetch: service.fetch, port: 8787 });

The service exposes:

GET /.well-known/service-plane/service.json
POST /rpc/asana.tasks

HTTP-batch is the default self-hosted request/response transport.

import {
abilitySession,
controlPlaneJwkTokenRequester,
httpBatchRpc,
type AbilityRpc,
} from 'service-plane/service';
import { asanaTasks } from './abilities';
const asana = await abilitySession<AbilityRpc<typeof asanaTasks>>({
abilityId: 'asana.tasks',
callerServiceId: 'workflow-runner',
targetServiceId: 'asana',
scopes: ['asana.tasks.write'],
requestToken: controlPlaneJwkTokenRequester({
clientId: 'workflow-runner',
controlPlaneUrl: 'https://plane.example.com',
keyId: 'workflow-runner-2026-01',
privateJwk,
}),
transport: httpBatchRpc('https://asana.example.com'),
});
await asana.createTask({
connectionId: 'conn_123',
name: 'Follow up',
projectId: 'proj_456',
});

This local-development example deliberately leaves ingress disabled. Production services should enable ingress: {} and expose the ability through the control-plane broker instead of calling the service URL directly. Direct HTTP-batch calls with ordinary tokens are rejected when ingress is enabled.

Use HMAC caller auth when a private JWK is not practical.

controlPlaneHmacTokenRequester({
clientId: 'workflow-runner',
controlPlaneUrl: 'https://plane.example.com',
clientSecret: process.env.WORKFLOW_RUNNER_SECRET,
});

JWK is preferable for distributed services because the private key stays with the caller and the public key can be discovered or configured by the plane.

Use WebSocket only when the session is long-lived, interactive, or chatty.

transport: websocketRpc('wss://asana.example.com/rpc/asana.tasks')

If the Node runtime does not provide a global WebSocket, inject the standards-compatible client you already use. The factory receives the final URL, including Service Plane’s propagated request id:

transport: websocketRpc('wss://asana.example.com/rpc/asana.tasks', {
createWebSocket, // (url: string) => WebSocket from your client adapter
});

The control-plane broker and MCP projection use the same factory through the service endpoint:

import { httpsService } from 'service-plane/control-plane';
httpsService({
id: 'asana',
baseUrl: 'https://asana.example.com',
createWebSocket,
});

This keeps WebSocket construction runtime-owned and does not require application code to install a persistent global. Cap’n Web still reads WebSocket.CONNECTING from the runtime global when a socket instance is supplied, so Service Plane temporarily supplies that constant only during synchronous session construction and restores the previous global immediately.

For normal request/response calls, prefer HTTP-batch. It is easier to deploy, cache, observe, and retry. Streaming ability methods require a session transport; wire upgradeWebSocket from @hono/node-ws into the service shell as shown in Streaming. On long-running Node processes WebSockets are essentially free, so chatty service pairs should hold a session — the full decision guide is Choosing A Transport.

Next: auth, OpenAPI and MCP, and reference.

  • TypeScript100%