Edit this page on GitHub

Runtime Deployment

This guide covers deploying Setu-TS applications to different runtime environments: Node.js, Deno, Bun, and Cloudflare Workers.

Runtime Overview

RuntimePackage ManagerHTTP ModelBest For
Denodenofetch / listenModern TypeScript, security-first
Node.jsnpm / pnpm / yarnfetch / listenLegacy compatibility, ecosystem
Bunbunfetch / listenPerformance, npm compatibility
Cloudflare Workersnpm / denofetch onlyEdge computing, global scale

Common Patterns

Fetch vs Listen

Setu-TS applications can run in two modes:

  1. Fetch mode: Exports a fetch handler (Workers, testing)
  2. Listen mode: Binds to a TCP port (Node, Deno, Bun)

createApp() builds a fresh, unstarted application; scaffolded projects export it from setu.config.ts.

import type { IKernelApplication } from '@setu-ts/kernel';
import { createApp } from './setu.config.ts';

// Fetch mode (testing; the Workers section below supplies the binding-aware factory).
// Share startup across concurrent requests. After plugin registration begins,
// a failed application cannot start again, so every retry builds a fresh one.
let starting: Promise<IKernelApplication> | undefined;
function started(): Promise<IKernelApplication> {
  starting ??= (async () => {
    const app = await createApp();
    await app.start();
    return app;
  })().catch((error: unknown) => {
    starting = undefined;
    throw error;
  });
  return starting;
}
export default {
  async fetch(request: Request): Promise<Response> {
    return (await started()).fetch(request);
  },
};

// Listen mode (Node, Deno, Bun)
const app = await createApp();
await app.start({ port: 3000 });

Streaming Responses

All runtimes support streaming responses via IResponse.stream():

import { CAPABILITIES } from '@setu-ts/common';
import type { IRuntimeServices } from '@setu-ts/common';

const runtime = app.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
const delay = (ms: number) =>
  new Promise<void>((resolve) => {
    runtime.setTimeout(resolve, ms);
  });

app.router.get('/stream', async (ctx) => {
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(new TextEncoder().encode(`Line ${i}\n`));
        await delay(100);
      }
      controller.close();
    },
  });
  return ctx.response.stream(stream);
});

SSE (Server-Sent Events)

import { SsePlugin } from '@setu-ts/sse-plugin';
import { CAPABILITIES, type ISseService } from '@setu-ts/common';

app.register(SsePlugin({ heartbeatMs: 15_000, retryMs: 3_000 }));

app.router.get('/events', async (ctx) => {
  const sse = ctx.services.get<ISseService>(CAPABILITIES.SSE);
  const conn = sse.open(ctx);
  conn.send({ id: '1', data: 'hello world' });
  return conn.result;
});

Node.js Deployment

Prerequisites

  • Node.js 22.23.4+, 24.22.1+ or 26.12.1+ (Promise.withResolvers is used in five packages and shipped in Node 22; those releases carry the Node.js security fixes of 2026-10-21; CI verifies on Node 24)
  • npm, pnpm, or yarn

Setup

# Create a new Node.js project
mkdir my-app && cd my-app
npm init -y
npm pkg set type=module

# Add Setu-TS packages from npm
npm install @setu-ts/kernel @setu-ts/runtime @setu-ts/common
npm install --save-dev tsx

Or let setu new my-app --runtime node scaffold all of this.

Application

// main.ts
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';

const app = createApplication();

// RuntimePlugin() auto-detects Node and selects NodeHttpAdapter.
app.register(RuntimePlugin());

app.router.get('/', async (ctx) => {
  return ctx.response.json({ message: 'Hello from Node.js!' });
});

await app.start({ port: 3000, hostname: '0.0.0.0' });

console.log('Server running on http://localhost:3000');

package.json

{
  "type": "module",
  "scripts": {
    "start": "tsx main.ts",
    "dev": "tsx watch main.ts"
  },
  "dependencies": {
    "@setu-ts/common": "^0.11.0",
    "@setu-ts/kernel": "^0.11.0",
    "@setu-ts/runtime": "^0.11.0"
  },
  "devDependencies": {
    "tsx": "^4.20.0"
  }
}

Node needs a transform, not just type stripping. --experimental-strip-types erases types without transforming code, so it cannot run a decorator or a constructor parameter property (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX). That applies to TC39 standard decorators too, which is what this framework uses: V8 has not shipped them, so node answers a decorated class with a bare SyntaxError: Invalid or unexpected token. tsx transforms both and needs no compiler option, which is why setu new --runtime node emits exactly this. Compiling ahead of time with tsc and running the JavaScript works equally well.

Deployment

Docker

# Node 22.23.4+, 24.22.1+ or 26.12.1+ (see Prerequisites); rebuild with --pull to pick up patches.
FROM node:24-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

# Numeric, not `USER node`: Kubernetes' runAsNonRoot refuses an image whose user is a name.
USER 1000:1000

EXPOSE 3000
CMD ["npm", "start"]

PM2

npm install -g pm2
# Run the `start` script, so PM2 launches the same tsx entry point npm does.
pm2 start npm --name my-app -- start

Serverless

AWS Lambda (Web Functions and classic event-driven functions) and Azure Functions each take a short entry file rather than an adapter package: see Serverless: AWS Lambda and Azure Functions.

Limitations

  • Raw TCP sockets available, which the Redis, RabbitMQ, NATS and Kafka brokers need (Deno and Bun have them too; only Workers does not)
  • Worker threads available (worker-pool-plugin)
  • File system fully available

Deno Deployment

Prerequisites

  • Deno 2.x

Setup

# Install Deno
curl -fsSL https://deno.land/install.sh | sh

# Create a new project. `deno add` creates deno.json; skip `deno init`, whose main_test.ts
# imports a `handler` export your main.ts will not have, which breaks `deno test`.
mkdir my-app && cd my-app

# Add Setu-TS packages
deno add npm:@setu-ts/kernel npm:@setu-ts/runtime npm:@setu-ts/common

Application

// main.ts
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';

const app = createApplication();

app.register(RuntimePlugin()); // DenoHttpAdapter is default

app.router.get('/', async (ctx) => {
  return ctx.response.json({ message: 'Hello from Deno!' });
});

await app.start({ port: 3000, hostname: '0.0.0.0' });

console.log('Server running on http://localhost:3000');

deno.json

{
  "tasks": {
    "start": "deno run --allow-net --allow-env main.ts",
    "dev": "deno run --watch --allow-net --allow-env main.ts"
  },
  "imports": {
    "@setu-ts/kernel": "npm:@setu-ts/kernel@^0.11.0",
    "@setu-ts/runtime": "npm:@setu-ts/runtime@^0.11.0",
    "@setu-ts/common": "npm:@setu-ts/common@^0.11.0"
  }
}

Permissions

# Network access
deno run --allow-net main.ts

# Environment variables
deno run --allow-env main.ts

# File system
deno run --allow-read --allow-write main.ts

# All permissions (development only)
deno run -A main.ts

Deployment

Deno Deploy

  1. Push code to GitHub
  2. Connect repository at dash.deno.com
  3. Deploy automatically

Docker

# Pin the tag: a base older than the Deno that wrote your deno.lock fails with
# "Unsupported lockfile version".
FROM denoland/deno:alpine-2.9.5

WORKDIR /app

COPY . .
RUN deno cache main.ts

# Numeric, not `USER deno`: Kubernetes' runAsNonRoot refuses an image whose user is a name.
USER 1000:1000

EXPOSE 3000
CMD ["run", "--allow-net", "--allow-env", "main.ts"]

This snippet is for your own project, where dependencies come from npm. To build an image of an example in this repository, use the parameterized docker/Dockerfile instead — it builds from the repository root, which the workspace requires. See Deployment.

Compiled Binary

deno compile --allow-net --allow-env --output my-app main.ts
./my-app

Limitations

  • None significant - Deno is the reference implementation

Local diagnostics listener (Deno and Node)

The RuntimePlugin provides CAPABILITIES.LOCAL_DIAGNOSTICS_LISTENER — a factory that binds exactly ONE additional IPv4 loopback (127.0.0.1) listener on a port you choose (1024–65535), consumed by @setu-ts/diagnostics-plugin for the authenticated local diagnostics connection on Deno and Node. On Bun and Cloudflare Workers the factory refuses every listen call before any bind. This listener is not a generic second HTTP server API: the connector supplies its protocol handler and never receives an adapter or server handle. See docs/diagnostics-protocol.md for the wire protocol and its trust limits.

The Node listener uses node:http directly, preserves the original request-target and refuses duplicate singleton header lines before mapping. It has a 16 KiB native header limit, 5 s header timeout, 10 s request timeout, 5 s keep-alive timeout and a 32-connection cap. Native parser errors answer a fixed bare 400 with no body; protocol refusals retain their fixed JSON body. A bind conflict rejects startup, and shutdown closes active connections immediately. Without an onListen callback, both runtimes announce Listening on http://127.0.0.1:<port>/ to stderr.

Bun Deployment

Prerequisites

  • Bun 1.x

Setup

# Install Bun
curl -fsSL https://bun.sh/install | bash

# Create a new project
mkdir my-app && cd my-app
bun init -y

# Add Setu-TS packages from npm
bun add @setu-ts/kernel @setu-ts/runtime @setu-ts/common

Application

// main.ts
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';

const app = createApplication();

// RuntimePlugin() auto-detects Bun and selects BunHttpAdapter.
app.register(RuntimePlugin());

app.router.get('/', async (ctx) => {
  return ctx.response.json({ message: 'Hello from Bun!' });
});

await app.start({ port: 3000, hostname: '0.0.0.0' });

console.log('Server running on http://localhost:3000');

package.json

{
  "type": "module",
  "scripts": {
    "start": "bun run main.ts",
    "dev": "bun run --watch main.ts"
  }
}

Deployment

Docker

FROM oven/bun:latest

WORKDIR /app

COPY . .
RUN bun install

EXPOSE 3000
CMD ["bun", "run", "start"]

Compiled Binary

bun build --compile --outfile my-app main.ts
./my-app

Limitations

  • Some npm packages may not be compatible
  • Worker threads available

Cloudflare Workers Deployment

Prerequisites

  • Wrangler CLI
  • Cloudflare account

Setup

# Scaffold a Worker: wrangler.toml, package.json and the entry module below
setu new my-app --runtime cloudflare-workers
cd my-app && npm install

# Or add Setu-TS to an existing Worker
npm install @setu-ts/kernel @setu-ts/runtime @setu-ts/cloudflare-plugin @setu-ts/common

# Log in to Cloudflare once, before the first deploy
npx wrangler login

Application

// src/index.ts
import { createApplication, type IKernelApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { CloudflarePlugin } from '@setu-ts/cloudflare-plugin';
import { env, waitUntil } from 'cloudflare:workers';

// `env` (bindings + variables) and `waitUntil` are imported from `cloudflare:workers`
// and passed to the plugins. RuntimePlugin auto-detects Workers and selects
// CloudflareWorkersHttpAdapter; `env` populates `runtime.env`.
function createApp(): IKernelApplication {
  const app = createApplication({
    plugins: [RuntimePlugin({ env }), CloudflarePlugin({ env, waitUntil })],
  });

  app.router.get('/', async (ctx) => {
    return ctx.response.json({ message: 'Hello from Workers!' });
  });
  return app;
}

// Start once, on the first request, and share that start across concurrent
// requests. A FAILED attempt is forgotten, so the next request constructs a
// fresh application: the kernel cannot retry start() once registration began.
let application: Promise<IKernelApplication> | undefined;

function app(): Promise<IKernelApplication> {
  application ??= (async () => {
    const created = createApp();
    await created.start();
    return created;
  })().catch((error: unknown) => {
    application = undefined;
    throw error;
  });
  return application;
}

// Export the fetch handler — Workers invokes this per request.
export default {
  async fetch(request: Request): Promise<Response> {
    return (await app()).fetch(request);
  },
};

The entry setu new --runtime cloudflare-workers generates goes further: it takes env from each request rather than from the module, keeps one application per binding set so a deploy that changes only bindings is served with the new ones even when Cloudflare reuses the isolate, and stops a superseded application only once no request or queue batch still uses it.

wrangler.toml

name = "my-app"
main = "src/index.ts"
compatibility_date = "2025-08-08"

# Bind KV namespaces
[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id"

# Bind D1 databases
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-database-id"

# Bind Queues
[[queues.producers]]
queue = "my-queue"
binding = "QUEUE"

# Cron triggers
[triggers]
crons = ["*/5 * * * *"]

Deployment

# Deploy
wrangler deploy

# View logs
wrangler tail

# Open in browser
wrangler open

Limitations

FeatureStatusNotes
TCP sockets❌Use HTTP-based services
File system❌Use R2 or KV for storage
Worker threads❌worker-pool-plugin registers, but run() throws WorkerPoolUnavailableError
Raw sockets (WebSocket)✅Via WebSocket upgrade
Cron✅Via Wrangler triggers
Queues✅Via Workers Queues
Messaging (pub/sub)✅Via Workers Queues
Messaging (request/reply)✅Via a Durable Object reply inbox
KV✅Via KV bindings
D1✅Via D1 bindings
R2✅Via R2 bindings
Durable Objects✅Via DO bindings

Messaging on Workers

@setu-ts/messaging-plugin cannot run here — every broker but the in-memory default needs a raw socket. CloudflarePlugin registers CAPABILITIES.MESSAGING from the platform instead, so publish/subscribe/request/respond work at the edge with no code change at the call site.

Two things about it are structural rather than incidental. First, delivery arrives through a module-level queue export, not through fetch — subscribe() registers a handler into a dispatch table, and the handler createMessagingHandler(app) builds is what routes a delivered batch into it:

import type { IApplication } from '@setu-ts/common';
import { createMessagingHandler } from '@setu-ts/cloudflare-plugin';

// A Worker's src/index.ts exports both entry points from one application.
export function workerEntry(application: IApplication) {
  return {
    fetch: (request: Request) => application.fetch(request),
    queue: createMessagingHandler(application),
  };
}

Second, the consuming queue must set max_batch_timeout = 0 in wrangler.toml. The platform default of 5s alone exhausts the default request/reply budget, so a nonzero value makes every RPC time out. A queue also has exactly one active consumer, so cross-service fan-out over one topic is not available.

setu new --template microservice --runtime cloudflare-workers scaffolds this wiring, including the wrangler.toml stanzas — see the CLI Guide.

Runtime Environment

Access platform bindings via CloudflarePlugin, which publishes an ICloudflareBindings service under CAPABILITIES.CLOUDFLARE. Each binding is reached through a named accessor — kv('KV'), d1('DB'), r2('BUCKET'), queue('QUEUE') — rather than property access, and a missing binding throws CloudflareBindingMissingError naming what was requested and what is present. The plugin requires the Worker’s env (and optionally waitUntil):

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import {
  CloudflarePlugin,
  type ICloudflareBindings,
  type ID1Database,
  type IKvNamespace,
  type IQueueProducer,
  type IR2Bucket,
} from '@setu-ts/cloudflare-plugin';
import { CAPABILITIES } from '@setu-ts/common';

// Deployment glue: at runtime `env` and `waitUntil` come from
// `import { env, waitUntil } from 'cloudflare:workers'`. That specifier is
// unresolvable off a Worker toolchain, so this block declares a minimal,
// explicitly typed binding record compatible with `CloudflarePluginOptions`
// rather than importing it — the real Worker passes the platform's `env`, which
// satisfies this shape structurally. Do not invent members the Worker does not
// carry; name only the bindings your wrangler.toml declares.
//
// `CloudflareWorkerEnv` is `Readonly<Record<string, unknown>>`, so a named-only
// interface (which lacks the string index signature) is NOT assignable to it.
// An intersection with `Record<string, unknown>` keeps the named accessors for
// type-safe binding use AND satisfies the index signature the plugin requires.
type WorkerEnv = Readonly<Record<string, unknown>> & {
  readonly KV: IKvNamespace;
  readonly DB: ID1Database;
  readonly BUCKET: IR2Bucket;
  readonly QUEUE: IQueueProducer;
  readonly API_KEY: string;
};

// `waitUntil` is the platform's background-work sink; `env` is the binding record.
declare const env: WorkerEnv;
declare const waitUntil: (promise: Promise<unknown>) => void;

const app = createApplication({
  plugins: [RuntimePlugin({ env }), CloudflarePlugin({ env, waitUntil })],
});

async function reportUsage(_value: string | null): Promise<void> {
  // ...report to your metrics backend...
}

app.router.get('/', async (ctx) => {
  const cf = ctx.services.get<ICloudflareBindings>(CAPABILITIES.CLOUDFLARE);

  // KV — `kv('KV')` resolves the KV namespace bound as `KV` in wrangler.toml.
  await cf.kv('KV').put('key', 'value');
  const value = await cf.kv('KV').get('key');

  // D1 — `d1('DB')` resolves the D1 database bound as `DB`.
  const result = await cf.d1('DB').prepare('SELECT * FROM items').all();

  // R2 — `r2('BUCKET')` resolves the R2 bucket bound as `BUCKET`.
  await cf.r2('BUCKET').put('file.txt', new ArrayBuffer(0));

  // Queues — `queue('QUEUE')` resolves the Queues producer bound as `QUEUE`.
  await cf.queue('QUEUE').send({ type: 'event' });

  // waitUntil keeps the invocation alive for background work past the response.
  cf.waitUntil(reportUsage(value));

  return ctx.response.json({ ok: true, rows: result.results.length });
});

Runtime Comparison

FeatureNode.jsDenoBunWorkers
Startup SpeedMediumFastFastInstant
Cold StartSlowMediumFast< 1ms
File System✅✅✅❌ (R2/KV)
TCP Sockets✅✅✅❌
Worker Threads✅✅✅❌
npm Compatibility✅✅✅✅ (limited)
Securityopt-inopt-inopt-insandboxed
Edge Deploy❌✅❌✅

Best Practices

1. Use Runtime Detection

Runtime services live in the registry under CAPABILITIES.RUNTIME, so a request handler resolves them there (only a plugin’s register context carries them as ctx.runtime):

import { CAPABILITIES, type IRuntimeServices } from '@setu-ts/common';

app.router.get('/platform', (ctx) => {
  const runtime = ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
  if (runtime.platform() === 'cloudflare-workers') {
    // Workers-specific logic
  }
  return ctx.response.json({ platform: runtime.platform() });
});

2. Handle Missing Services Gracefully

Optional runtime members such as fs are absent where the platform cannot provide them, so check before use:

import { CAPABILITIES, type ICacheStore, type IRuntimeServices } from '@setu-ts/common';

app.router.get('/notice', async (ctx) => {
  const runtime = ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME);
  const content = runtime.fs
    ? await runtime.fs.readFile('notice.txt')
    : // Workers has no file system: read from a store instead
      await ctx.services.get<ICacheStore>(CAPABILITIES.CACHE).get<string>('notice');
  return ctx.response.json({ content });
});

3. Know the Workers Execution Limits

An HTTP-triggered Worker has no wall-clock limit while the client stays connected, but CPU time is capped: 10 ms per request on the free plan, and 30 seconds by default on paid plans, configurable up to 5 minutes. Background work passed to waitUntil() gets at most 30 seconds after the response. Long CPU-bound work belongs in a queue consumer or on a socket runtime.

4. Use Platform-Specific Storage

// Workers: KV, R2, D1
// Node/Deno/Bun: File system, databases

5. Test on Target Runtime

# Test locally with Wrangler
wrangler dev

# Test with Deno
deno task start

# Test with Bun
bun run start

Next Steps