Node.js only — HTTP tunnels (server exposure via nodeExposure) require Node.js. Clients can run in any environment (Node, browser, edge).
Tunnels let you execute a known task id in another process. Your task id stays the same, Runner behavior stays the same (validation, middleware, typed errors, async context) — only execution location changes.
Important Boundary
Tunnels are designed for inter-runner communication (service-to-service RPC). They are not designed to be a public web API surface for browsers or untrusted internet clients.
If you expose nodeExposure over the network, put it behind an API gateway/reverse proxy and enforce strict auth, rate limiting, and network controls.
How It Works
Server opens HTTP entrypoints via nodeExposure (Node-only)
Server allow-lists tasks via a tunnel resource tagged with globals.tags.tunnel in mode: "server"
Client routes task calls via a tunnel resource in mode: "client"
Task executes on server, result returns to client
Quick Start
1. Shared Tasks (both runtimes)
import { r } from "@bluelibs/runner" ;
export const add = r
. task ( "app.tasks.add" )
. run ( async ( input : { a : number ; b : number }) => input . a + input . b )
. build ();
export const compute = r
. task ( "app.tasks.compute" )
. dependencies ({ add })
. run ( async ( input : { a : number ; b : number }, { add }) => add ( input ))
. build ();
2. Unified Tunnel Resource
import { r , globals } from "@bluelibs/runner" ;
enum EnvVar {
TunnelMode = "RUNNER_TUNNEL_MODE" , // "server" | "client" | "none"
TunnelBaseUrl = "RUNNER_TUNNEL_BASE_URL" ,
TunnelToken = "RUNNER_TUNNEL_TOKEN" ,
}
export const httpTunnel = r
. resource ( "app.tunnels.http" )
. tags ([ globals . tags . tunnel ])
. dependencies ({ clientFactory: globals . resources . httpClientFactory })
. init ( async ( _cfg , { clientFactory }) => {
const mode = process . env [ EnvVar . TunnelMode ] as "server" | "client" | "none" ;
const baseUrl = process . env [ EnvVar . TunnelBaseUrl ];
const token = process . env [ EnvVar . TunnelToken ] ?? "dev-secret" ;
if ( mode === "client" && ! baseUrl ) {
throw new Error ( "RUNNER_TUNNEL_BASE_URL is required in client mode" );
}
const client =
mode === "client"
? clientFactory ({ baseUrl , auth: { token } })
: undefined ;
return {
transport: "http" ,
mode ,
tasks: [ add . id ],
run : async ( task , input ) =>
mode === "client" ? client ?. task ( task . id , input ) : task ( input ),
};
})
. build ();
3. Server Runtime (Node-only)
import { r , run } from "@bluelibs/runner" ;
import { nodeExposure } from "@bluelibs/runner/node" ;
const app = r
. resource ( "app" )
. register ([
add ,
compute ,
httpTunnel ,
nodeExposure . with ({
http: {
basePath: "/__runner" ,
listen: { port: 7070 },
auth: { token: process . env . RUNNER_TUNNEL_TOKEN ?? "dev-secret" },
},
}),
])
. build ();
await run ( app );
4. Client Runtime
import { r , run } from "@bluelibs/runner" ;
const app = r . resource ( "app" ). register ([ add , compute , httpTunnel ]). build ();
const { runTask , logger } = await run ( app );
const sum = await runTask ( compute , { a: 1 , b: 2 });
logger . info ( sum ); // 3 (computed remotely if routed)
Environment Variables
# Server process
RUNNER_TUNNEL_MODE = server
RUNNER_TUNNEL_TOKEN = dev-secret
# Client process
RUNNER_TUNNEL_MODE = client
RUNNER_TUNNEL_TOKEN = dev-secret
RUNNER_TUNNEL_BASE_URL = http://127.0.0.1:7070/__runner
Explicit Client Calls
For explicit RPC boundaries at call sites, call the client directly:
import { Serializer } from "@bluelibs/runner" ;
import { createHttpMixedClient } from "@bluelibs/runner/node" ;
const client = createHttpMixedClient ({
baseUrl: "http://127.0.0.1:7070/__runner" ,
auth: { token: "dev-secret" },
serializer: new Serializer (),
});
const sum = await client . task <{ a : number ; b : number }, number >(
"app.tasks.add" ,
{ a: 1 , b: 2 }
);
console . log ( sum ); // 3
Client Types
Client Use Case Platform createHttpMixedClientDefault for Node (JSON + streams) Node createHttpSmartClientNode streams/duplex Node createHttpClientUniversal fetch client All httpClientFactoryMinimal JSON-only All (global)
Authentication
Static Token
nodeExposure . with ({
http: {
auth: {
token: "my-secret-token" ,
},
},
});
Multiple Tokens (Rotation)
nodeExposure . with ({
http: {
auth: {
token: [ "key-v1" , "key-v2" ],
},
},
});
nodeExposure . with ({
http: {
auth: {
header: "x-api-key" ,
token: [ "key-v1" , "key-v2" ],
},
},
});
Dynamic Validation Task
import { r , globals } from "@bluelibs/runner" ;
const authValidator = r
. task ( "app.tasks.auth.validate" )
. tags ([ globals . tags . authValidator ])
. run ( async ({ headers }) => ({
ok: headers [ "x-tenant" ] === "acme" ,
}))
. build ();
Anonymous Access (Not Recommended)
nodeExposure . with ({
http: {
auth: {
allowAnonymous: true ,
},
},
});
Typed Errors Over Tunnels
If the server throws a Runner registered error, the client can rethrow local typed errors:
import { createHttpClient , Serializer , r } from "@bluelibs/runner" ;
const AppError = r . error <{ code : string }>( "app.errors.AppError" ). build ();
const client = createHttpClient ({
baseUrl: "http://127.0.0.1:7070/__runner" ,
serializer: new Serializer (),
errorRegistry: new Map ([[ AppError . id , AppError ]]),
});
Files and Uploads (Multipart)
const file = {
$runnerFile: "File" as const ,
id: "F1" ,
meta: { name: "a.bin" },
_web: { blob: new Blob ([ 1 , 2 , 3 ]) },
};
await client . task ( "app.tasks.upload" , { file });
import { Readable } from "stream" ;
import { createHttpMixedClient , createNodeFile } from "@bluelibs/runner/node" ;
import { Serializer } from "@bluelibs/runner" ;
const client = createHttpMixedClient ({
baseUrl: "http://127.0.0.1:7070/__runner" ,
auth: { token: "dev-secret" },
serializer: new Serializer (),
});
await client . task ( "app.tasks.upload" , {
file: createNodeFile (
{ name: "a.bin" , type: "application/octet-stream" },
{ stream: Readable . from ([ Buffer . from ( "hello" )]) },
"F1"
),
});
Streaming (Node Only)
Use Node Smart (or Mixed forced to Smart) for stream request/response:
import { r } from "@bluelibs/runner" ;
import { useExposureContext } from "@bluelibs/runner/node" ;
const streamTask = r
. task ( "app.tasks.stream" )
. run ( async () => {
const { req , res , signal } = useExposureContext ();
if ( signal . aborted ) return ;
req . pipe ( res );
})
. build ();
Events and Delivery Modes
Tunnel resources can route events:
const httpTunnel = r
. resource ( "app.tunnels.http" )
. tags ([ globals . tags . tunnel ])
. init ( async () => ({
mode: "client" ,
transport: "http" ,
events: [ someEvent . id ],
eventDeliveryMode: "remote-only" , // or "local-only", "remote-first", "mirror"
emit : async ( event , payload ) => {
// emit logic
},
}))
. build ();
Async Context Propagation
Active async contexts are serialized into x-runner-context header:
const serverTunnel = r
. resource ( "app.tunnel.server" )
. tags ([ globals . tags . tunnel ])
. init ( async () => ({
mode: "server" ,
transport: "http" ,
tasks: [ createOrder . id ],
events: [ orderCreated . id ],
allowAsyncContext: false , // Reject cross-process context
}))
. build ();
Request Correlation
Automatic Correlation ID
nodeExposure generates x-runner-request-id automatically:
import { useExposureContext } from "@bluelibs/runner/node" ;
const { headers } = useExposureContext ();
const requestId = headers [ "x-runner-request-id" ];
Custom Correlation ID
import { createHttpMixedClient } from "@bluelibs/runner/node" ;
const client = createHttpMixedClient ({
baseUrl: "http://127.0.0.1:7070/__runner" ,
auth: { token: "dev-secret" },
onRequest : ({ headers }) => {
const requestId = getRequestIdFromYourContext ();
if ( requestId ) headers [ "x-runner-request-id" ] = requestId ;
},
});
CORS Configuration
nodeExposure . with ({
http: {
cors: {
origin: "*" , // or string[] or RegExp or function
methods: [ "POST" , "OPTIONS" ],
credentials: true ,
maxAge: 86400 ,
},
},
});
If credentials: true, you must set an explicit origin (not "*").
Body Size Limits
nodeExposure . with ({
http: {
limits: {
json: { maxSize: 5 * 1024 * 1024 }, // 5 MB
multipart: {
maxFileSize: 50 * 1024 * 1024 , // 50 MB per file
maxFiles: 20 ,
maxFields: 100 ,
maxFieldSize: 2 * 1024 * 1024 , // 2 MB per field
},
},
},
});
Production Checklist
Configure auth
Use token and/or validator tasks for http.auth
Server allow-list
Set RUNNER_TUNNEL_MODE=server and allow-list ids in tunnel resource tasks/events
Secure discovery
Keep /discovery behind auth or disable with disableDiscovery: true
Set body limits
Configure http.limits for your payload sizes
Disable async context if not needed
Set allowAsyncContext: false on server tunnel resources
Rate limiting
Enforce rate limiting at your edge/proxy/API gateway
Request correlation
Forward and index x-runner-request-id in logs
Header Direction Required Purpose x-runner-tokenclient → server Yes (unless allowAnonymous: true) Tunnel authentication token x-runner-request-idclient ↔ server Optional Request correlation id x-runner-contextclient → server Optional Serialized async-context map content-typeclient → server Yes Request mode selection
Error Codes
HTTP Code Description 400 INVALID_JSONMalformed JSON body 400 INVALID_MULTIPARTInvalid multipart payload 401 UNAUTHORIZEDInvalid token or failed auth 403 FORBIDDENExposure not enabled or not allowed 404 NOT_FOUNDTask/event not found 413 PAYLOAD_TOO_LARGEBody exceeded configured limits 499 REQUEST_ABORTEDClient aborted request 500 INTERNAL_ERRORTask exception or server error 500 AUTH_NOT_CONFIGUREDNo auth configured
Versioning Strategy
Treat task ids as RPC surface. For breaking changes, publish a new task id suffix:
app.tasks.invoice.create
app.tasks.invoice.create.2
Keep both during migration and remove old ids after consumers migrate.
Testing
1. Client Contract Test (Mocked Fetch)
const client = createHttpClient ({
baseUrl: "http://example.test/__runner" ,
auth: { token: "dev-secret" },
serializer: new Serializer (),
fetchImpl : async () =>
new Response ( '{"ok":true,"result":3}' , {
headers: { "content-type" : "application/json" },
}),
});
await expect ( client . task ( "app.tasks.add" , { a: 1 , b: 2 })). resolves . toBe ( 3 );
2. Routing Behavior Test (No HTTP)
const add = r
. task ( "app.tasks.add" )
. run ( async () => 1 )
. build ();
const tunnel = r
. resource ( "tests.tunnel" )
. tags ([ globals . tags . tunnel ])
. init ( async () => ({ mode: "client" , tasks: [ add . id ], run : async () => 99 }))
. build ();
const rt = await run ( r . resource ( "app" ). register ([ add , tunnel ]). build ());
await expect ( rt . runTask ( add )). resolves . toBe ( 99 );
await rt . dispose ();
3. Real HTTP Smoke Test
Start app with nodeExposure + server-mode allow-list, build client with valid token, assert allow-listed task succeeds and unknown task/wrong token fails.
Troubleshooting
Token missing/wrong, or no validator approves.
Server-mode tunnel allow-list missing, or task id not listed.
Task id not registered in the server runtime.
Task runs local instead of remote
Client-mode tunnel did not select that task id. Verify client tunnel tasks includes the task id. Use a phantom task for fail-fast remote-only behavior.
Use Node Mixed/Smart for Node streams.
See Also