unnbound-events
v3.1.3
Published
Workflow HTTP routing SDK with durable admission through the Temper gateway.
Readme
Unnbound Events SDK
HTTP workflow routing with cohort-specific delivery at the Temper gateway. Restate-admitted environments receive durable Runs over HTTP for wait/ack requests; legacy environments retain direct synchronous proxying and SQS asynchronous delivery. Stored passthrough routes and WebSocket upgrades do not create Runs.
c.request.body is always an ArrayBuffer. HTTP delivery preserves the exact request-content bytes after transport framing without content-type parsing or decompression; legacy SQS delivery retains its envelope re-serialization behavior described below.
Install
bun add unnbound-eventsQuick Start
import { createServer } from 'unnbound-events';
const server = createServer();
// Register route handlers
server.post('/email/:userId', async (c) => {
const { userId } = c.request.params;
const payload = JSON.parse(Buffer.from(c.request.body).toString('utf8'));
return { status: 200, body: { sent: true } };
});
// Start the HTTP listener
server.start();The server automatically:
- Starts HTTP server on
PORT(default:3000) - Provides
/healthcheckendpoint (returns200 { status: 'ok' }) - Handles graceful shutdown on
SIGINT/SIGTERM - Retains SQS polling for legacy queue-backed workflows: enabled by default when
UNNBOUND_SQS_URLis present, with explicitqueue.enabledoverriding detection. Firecracker always disables queue polling, including when a stale queue URL is present.
Handlers
Routes receive a context object with request details. c.request.body is an ArrayBuffer containing the exact request-content bytes after HTTP transport framing, for every content type (zero-length when there is no body) — decode what you need inside the handler:
server.post('/users/:id', async (c) => {
// Access request data
const { id } = c.request.params;
const query = c.request.query;
const headers = c.request.headers;
// Decode the raw bytes for the payload you expect:
// JSON
const payload = JSON.parse(Buffer.from(c.request.body).toString('utf8'));
// Text
const text = new TextDecoder().decode(c.request.body);
// Multipart / form-data (boundary comes from the original content-type header)
const form = await new Response(c.request.body, {
headers: { 'content-type': c.request.headers['content-type'] },
}).formData();
// URL-encoded forms
const params = new URLSearchParams(Buffer.from(c.request.body).toString('utf8'));
// XML, EDI, CSV: use the library the workflow already depends on (xml2js, csv, @ontemper/edi)
// Return response
return {
status: 200,
body: { id },
headers: { 'x-custom': 'value' },
};
});Transport framing and content encoding
c.request.body contains request content, not HTTP transport framing. Content encodings such as gzip are preserved, so decompress them explicitly when needed.
Response format: { status?: number, body?: object | Uint8Array | ArrayBuffer, headers?: Record<string, string> }
Objects are JSON-serialized. Uint8Array/ArrayBuffer bodies are sent verbatim as binary (set content-type explicitly) — required for protocols like AS2 whose responses must not be JSON-mangled.
Return undefined, null, or omit properties to default to 204 No Content.
Upgrading from 2.0.x
c.request.body used to vary by content-type. It is now always an ArrayBuffer of the exact request-content bytes after HTTP transport framing.
| content-type | 2.0.x | Current version |
| ----------------------------------- | ------------- | --------------- |
| application/json | parsed object | ArrayBuffer |
| application/x-www-form-urlencoded | parsed object | ArrayBuffer |
| multipart/form-data | parsed object | ArrayBuffer |
| text/plain | string | ArrayBuffer |
| anything else | ArrayBuffer | ArrayBuffer |
Handlers for the first four must decode explicitly — see Handlers for the per-type recipes. Handlers that already received bytes (XML, EDI, CSV, binary) are unaffected.
Three things to check when upgrading an existing workflow:
- A malformed JSON body used to arrive as
undefined. It now arrives as raw bytes andJSON.parsethrows — handle it where you decode. - A missing body used to arrive as
undefined, soif (!c.request.body)guards fired. Over HTTP it is now a zero-lengthArrayBuffer, which is truthy, and those guards silently stop firing. Checkc.request.body.byteLengthinstead. (For legacy SQS backlog drain during cutover, a bodyless async request replays as the four bytesnulldue to legacy queue envelope re-serialization). tsccatches direct property access on the body, but not a body passed straight to a logger or serializer. Those compile clean and emit{}at runtime. Audit everyserver.post/server.puthandler before deploying.
Middleware
Add logic that runs before/after handlers.
// Global middleware
server.use(async (c, next) => {
console.log('Request:', c.request.method, c.request.path);
return next();
});
// Path-based middleware
server.use('/admin/*', async (c, next) => {
const token = c.request.headers['authorization'];
if (!token?.startsWith('Bearer ')) {
return { status: 401, body: { error: 'Unauthorized' } };
}
return next();
});
// Handler-specific middleware
server.post(
'/email',
async (c, next) => {
// Runs only for this handler
return next();
},
async (c) => {
return { status: 200, body: { ok: true } };
},
);Configuration
HTTP Port
Set via PORT environment variable (default: 3000):
export PORT=8080Durable admission
Use createEnqueuer to submit through the gateway's /async alias. The gateway
acknowledges only after Restate accepts the Run. Payload storage and retries are
platform responsibilities; the workflow SDK receives an ordinary HTTP request.
Logger Integration
Pass a custom logger:
import { logger } from 'unnbound-logger-sdk';
const server = createServer({
logger,
});The logger is used for internal events and traces.
