Edit this page on GitHub

Getting Started with Setu-TS

This guide takes you from an empty directory to a running, tested Setu-TS application, then shows how to add the plugins most applications need.

Prerequisites

  • Deno 2.x, Node.js 22.23.4+, 24.22.1+ or 26.12.1+, or Bun 1.x — the framework uses Promise.withResolvers, which Node 22 was the first to ship, and those are the first releases of each line carrying the Node.js security fixes of 2026-10-21; CI verifies Node 24
  • Basic familiarity with TypeScript

Two ways to start

Scaffold a project if you want a working layout, tests, configuration, and a Dockerfile in one step:

# Create a REST application on Node (add --runtime deno, bun, or cloudflare-workers to change target)
npm create @setu-ts my-app -- --template rest
cd my-app && npm install && npm run start:dev

The equivalent create forms are pnpm create @setu-ts my-app --template rest and bun create @setu-ts my-app --template rest (neither needs --). npx @setu-ts/cli new my-app --template rest runs the same generator; npm install --global @setu-ts/cli makes it setu new my-app --template rest.

After cd my-app, a Bun target runs bun install && bun run start:dev; a Deno target runs deno task start:dev; Workers runs npm install && npx wrangler dev. Use the development entry while editing. For a Node production run, use npm run build && npm start.

To start from a runnable example instead, use npm create @setu-ts my-app -- --example rest-api, then cd my-app && npm install. npm run smoke runs that example’s proof and exits; npm start serves it until you stop it. This creates a standalone Node project and downloads from GitHub at the CLI’s release tag; see Examples.

The template-generated project’s README lists its commands. See the CLI guide for templates and code generation.

Build it by hand if you want to see every moving part. The rest of this guide does that — it is three packages and about ten lines of code.

A full-stack project

npm create @setu-ts my-app -- --template full-stack
cd my-app && npm install && npm run start:dev

The full-stack development entry runs Vite in the application process and serves route edits without a production build. Node uses npm run start:dev, Bun bun run start:dev, and Deno deno task start:dev. Workers uses npm run dev: Wrangler builds the client and server first, without the in-process Vite loop or hot module replacement.

Production uses built output: Node runs npm run build then npm start; Bun runs bun run build then bun run start; Deno’s deno task start builds first. Workers ships with npm run deploy. See React Router development for the loop and production builds for the service build.

Installation

Setu-TS is published on npm under the @setu-ts scope. Each runtime installs it with its own tooling:

Node.js

mkdir my-app && cd my-app
npm init -y
npm pkg set type=module
npm install @setu-ts/kernel @setu-ts/runtime @setu-ts/common

pnpm add and yarn add work the same way.

Bun

mkdir my-app && cd my-app
bun init -y
bun add @setu-ts/kernel @setu-ts/runtime @setu-ts/common

Deno

mkdir my-app && cd my-app
deno add npm:@setu-ts/kernel npm:@setu-ts/runtime npm:@setu-ts/common

deno add creates the deno.json for you, mapping each @setu-ts/… import to its npm package.

Earlier releases were published on JSR (jsr:@setu-ts/…). They stay there, but nothing new is published to JSR; see the upgrading guide to move an existing project.

Your First Application

Create main.ts:

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

const app = createApplication();

// Required in every application: RuntimePlugin detects whether you are on Deno,
// Node, Bun, or Cloudflare Workers and supplies the HTTP server, timers, and
// environment for that platform. start() throws if it is missing.
app.register(RuntimePlugin());

// A handler receives the request context and returns a response built
// through ctx.response.
app.router.get('/hello', (ctx) => ctx.response.json({ message: 'Hello, World!' }));

app.router.get('/health', (ctx) => ctx.response.json({ status: 'ok' }));

await app.start({ port: 3000 });

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

A handler may be async when it awaits something; these two do not need to be.

Running the Application

Deno — --allow-net to bind the port, --allow-env because RuntimePlugin reads the process environment:

deno run --allow-net --allow-env main.ts

Node.js:

node main.ts

Bun:

bun run main.ts

Try It

curl http://localhost:3000/hello
# {"message":"Hello, World!"}

Testing Your Application

Choose your runtime’s runner below. Each example puts the same test in test/app.test.ts: create a test application, register /hello, inject a request, check the response, and stop it. createTestApp starts without binding a port. The Node and Bun runners match the CLI-generated test harness.

Node

npm install -D @setu-ts/testing tsx
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { createTestApp, inject } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';

describe('My Application', () => {
  it('handles GET /hello', async () => {
    const app = await createTestApp({ plugins: [RuntimePlugin()] });
    try {
      app.router.get('/hello', (ctx) => ctx.response.json({ message: 'Hello, World!' }));
      const response = await inject(app, { method: 'GET', url: '/hello' });
      assert.equal(response.statusCode, 200);
      assert.deepEqual(response.json(), { message: 'Hello, World!' });
    } finally {
      await app.stop();
    }
  });
});
npx tsx --test test/app.test.ts

Bun

bun add -d @setu-ts/testing
import { describe, it, expect } from 'bun:test';
import { createTestApp, inject } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';

describe('My Application', () => {
  it('handles GET /hello', async () => {
    const app = await createTestApp({ plugins: [RuntimePlugin()] });
    try {
      app.router.get('/hello', (ctx) => ctx.response.json({ message: 'Hello, World!' }));
      const response = await inject(app, { method: 'GET', url: '/hello' });
      expect(response.statusCode).toBe(200);
      expect(response.json()).toEqual({ message: 'Hello, World!' });
    } finally {
      await app.stop();
    }
  });
});
bun test

Deno

deno add npm:@setu-ts/testing jsr:@std/testing jsr:@std/expect
import { describe, it } from '@std/testing/bdd';
import { expect } from '@std/expect';
import { createTestApp, inject } from '@setu-ts/testing';
import { RuntimePlugin } from '@setu-ts/runtime';

describe('My Application', () => {
  it('handles GET /hello', async () => {
    const app = await createTestApp({ plugins: [RuntimePlugin()] });
    try {
      app.router.get('/hello', (ctx) => ctx.response.json({ message: 'Hello, World!' }));
      const response = await inject(app, { method: 'GET', url: '/hello' });
      expect(response.statusCode).toBe(200);
      expect(response.json()).toEqual({ message: 'Hello, World!' });
    } finally {
      await app.stop();
    }
  });
});
deno test --allow-env

Once your application has a real composition root (a createApp() the server and the tests both call), pass it as createTestApp({ app: createApp() }) so tests run against exactly the plugins production runs — see the @setu-ts/testing README.

Adding Plugins

Everything beyond routing is a plugin you register. A plugin publishes a service under a capability token, and code anywhere in the application resolves it by that token. Here are the most common ones.

Logger Plugin

import { LoggerPlugin } from '@setu-ts/logger-plugin';

app.register(LoggerPlugin());

Config Plugin

import { CAPABILITIES } from '@setu-ts/common';
import type { IConfig } from '@setu-ts/common';
import { ConfigPlugin, defineConfigSection, getConfigSection } from '@setu-ts/config-plugin';
import { z } from 'zod';

// A typed slice of configuration: DATABASE_URL, validated as a URL.
const database = defineConfigSection({
  prefix: 'DATABASE_',
  keys: ['URL'],
  schema: z.object({ URL: z.string().url() }),
});

app.register(
  ConfigPlugin({
    // Skip a missing .env; an existing but unreadable file still throws.
    envFilePath: '.env',
    envFileOptional: true,
    sections: [database],
  }),
);

await app.start({ port: 3000 });

// Sections are validated during start(), so a bad DATABASE_URL fails startup
// rather than the first request that reads it.
const config = app.services.get<IConfig>(CAPABILITIES.CONFIG);
const settings = getConfigSection(config, database); // settings.URL: string

On Deno, envFilePath also needs --allow-read.

Database Plugin

import { DatabasePlugin } from '@setu-ts/database-plugin';

app.register(
  DatabasePlugin({
    type: 'memory', // In-memory, for development; swap the arm for a real backend later
  }),
);

The database plugin README lists the other backends.

Auth Plugin

import { AuthPlugin } from '@setu-ts/auth-plugin';

app.register(
  AuthPlugin({
    jwt: {
      // Read this from configuration in a real application — never commit it.
      secret: 'your-secret-key',
      // 'HS256' pairs with `secret`; 'RS256' pairs with `privateKey`/`publicKey`.
      algorithm: 'HS256',
    },
  }),
);

Roles and permissions are an optional rbac arm; the auth plugin README covers guards, sessions, and sign-in providers.

Stopping Cleanly

When a container runtime or Ctrl+C stops the process, it sends SIGTERM/SIGINT. Unless you catch the signal, the process dies immediately and app.stop() never runs — in-flight requests are cut and no plugin gets to disconnect. Add this to main.ts after start():

import { createRuntimeServices } from '@setu-ts/runtime';

const runtime = createRuntimeServices();

for (const signal of ['SIGTERM', 'SIGINT'] as const) {
  // onSignal is absent where there is no signal to catch (Windows, Workers).
  runtime.onSignal?.(signal, () => {
    void app
      .stop()
      .then(() => runtime.exit(0))
      .catch(() => runtime.exit(1));
  });
}

This is the same block setu new writes into every generated main.ts, and it works unchanged on every socket runtime: Deno, Node, and Bun alike.

Running on Different Runtimes

The application above runs unchanged on Deno, Node, and Bun: RuntimePlugin() detects the platform and picks its HTTP server. To override detection, pass platform:

import { RuntimePlugin } from '@setu-ts/runtime';

app.register(RuntimePlugin({ platform: 'node' }));
await app.start({ port: 3000 });

Cloudflare Workers

On Workers there is no socket to bind, so the application exports a fetch handler instead of calling start({ port }). The Worker’s bindings and variables arrive as the env argument to fetch; pass them to both RuntimePlugin (so runtime.env is populated) and CloudflarePlugin (which publishes typed binding accessors under CAPABILITIES.CLOUDFLARE):

import { createApplication } from '@setu-ts/kernel';
import type { IKernelApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { CloudflarePlugin } from '@setu-ts/cloudflare-plugin';

async function boot(env: Record<string, unknown>): Promise<IKernelApplication> {
  const app = createApplication({
    plugins: [RuntimePlugin({ env }), CloudflarePlugin({ env })],
  });
  app.router.get('/', (ctx) => ctx.response.json({ message: 'Hello from Workers!' }));
  await app.start();
  return app;
}

// Start once, on the first request, and share that start across concurrent
// requests. Two cases rebuild instead of reusing:
// - A FAILED start is forgotten rather than cached, so a transient problem at
//   cold start is retried on the next request instead of breaking the isolate
//   for its whole life.
// - A NEW `env` object means the bindings changed. Cloudflare may keep running
//   an isolate across a bindings-only deploy, and an application built from the
//   old `env` would keep using the old bindings. While bindings are unchanged,
//   every request receives the same `env` object, so this check costs nothing.
let booted: Promise<IKernelApplication> | undefined;
let bootedEnv: Record<string, unknown> | undefined;

function ensureBooted(env: Record<string, unknown>): Promise<IKernelApplication> {
  if (booted === undefined || bootedEnv !== env) {
    const previous = booted;
    const attempt = boot(env).catch((error: unknown) => {
      if (booted === attempt) booted = undefined;
      throw error;
    });
    booted = attempt;
    bootedEnv = env;
    // Release the superseded application's resources; a failure here must not
    // affect the new one.
    void previous?.then((app) => app.stop()).catch(() => {});
  }
  return booted;
}

export default {
  async fetch(request: Request, env: Record<string, unknown>): Promise<Response> {
    const app = await ensureBooted(env);
    return app.fetch(request);
  },
};

setu new my-app --runtime cloudflare-workers scaffolds a Workers project with an entry module and its wrangler.toml.

Next Steps

Common Issues

Permission Errors (Deno)

Deno grants nothing by default. The flags an application commonly needs:

FlagNeeded for
--allow-netBinding the server port and any outbound connection
--allow-envRuntimePlugin, which reads the process environment
--allow-readLoading a .env file, serving static files, local storage
--allow-sysThe health plugin’s self check, which reads the hostname

npm error Unsupported URL Type "jsr:"

You ran npm install jsr:@setu-ts/…, an instruction from the JSR era. npm has no jsr: protocol, and from v0.9.0 you do not need one: install from npm directly with npm install @setu-ts/<package> (bun add on Bun, deno add npm:@setu-ts/<package> on Deno).

deno test fails on main_test.ts

If you started with deno init, it created a main_test.ts that imports a handler export your new main.ts no longer has. Delete it.

Port Already in Use

Start on a different port:

await app.start({ port: 3001 });