Knead

Search every guide and package page, down to section headings.

↑↓ move Enter open Esc close

Getting started

Getting started

Build a small REST API with a module, a service, a validated controller and a test, then run it.

On this page 10 sections

This walkthrough builds a todo API: a service that holds the data, a controller that exposes it over HTTP with validated input, a module that ties them together, and a test that runs against a real server.

#Install

Create a project and add the packages. Knead needs Bun 1.4.3 or newer.

Terminal
mkdir todos && cd todos
bun init -y
bun add @knead-js/core @knead-js/http valibot
bun add -d @knead-js/testing

Valibot validates the request body here. Any library that implements Standard Schema works, for example Zod or ArkType.

#Configure TypeScript

Knead uses standard TC39 decorators. Make sure experimentalDecorators is not enabled in tsconfig.json. Bun reads that setting too: with it on, Bun applies legacy decorators and the first decorated class fails at load with TypeError: undefined is not an object (evaluating 'context.metadata').

A configuration that works (the one Knead itself uses):

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ESNext", "ESNext.Decorators"],
    "types": ["bun"],
    "strict": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "noEmit": true
  }
}

ESNext.Decorators provides the type of Symbol.metadata. bun init installs @types/bun, which provides "types": ["bun"].

#Write a service

A provider is a plain class. This one keeps todos in memory. When a todo is missing, it throws NotFoundException, which Knead turns into a 404 response.

src/todos.service.ts
import { Injectable } from '@knead-js/core';
import { NotFoundException } from '@knead-js/http';

export interface Todo {
  id: number;
  title: string;
  done: boolean;
}

@Injectable()
export class TodosService {
  readonly #todos = new Map<number, Todo>();
  #nextId = 1;

  list(): Todo[] {
    return [...this.#todos.values()];
  }

  get(id: number): Todo {
    const todo = this.#todos.get(id);
    if (!todo) throw new NotFoundException(`Todo ${id} does not exist`);
    return todo;
  }

  create(title: string): Todo {
    const todo = { id: this.#nextId++, title, done: false };
    this.#todos.set(todo.id, todo);
    return todo;
  }

  remove(id: number): void {
    this.get(id);
    this.#todos.delete(id);
  }
}

#Write a controller

@Controller('/todos') sets the path prefix. Each route decorator takes a path and an options object. Route options can carry Standard Schema validators for params, query, headers and body, plus a success status.

src/todos.controller.ts
import { inject } from '@knead-js/core';
import { Controller, type Ctx, Delete, Get, Post } from '@knead-js/http';
import * as v from 'valibot';
import { TodosService } from './todos.service.js';

const TodoId = v.object({
  id: v.pipe(v.string(), v.digits(), v.transform(Number)),
});

const CreateTodo = v.object({
  title: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(200)),
});

@Controller('/todos')
export class TodosController {
  readonly #todos = inject(TodosService);

  @Get('/')
  list() {
    return this.#todos.list();
  }

  @Get('/:id', { params: TodoId })
  get({ params }: Ctx<{ params: v.InferOutput<typeof TodoId> }>) {
    return this.#todos.get(params.id);
  }

  @Post('/', { body: CreateTodo, status: 201 })
  create({ body }: Ctx<{ body: v.InferOutput<typeof CreateTodo> }>) {
    return this.#todos.create(body.title);
  }

  @Delete('/:id', { params: TodoId })
  remove({ params }: Ctx<{ params: v.InferOutput<typeof TodoId> }>) {
    this.#todos.remove(params.id);
  }
}

Things to notice:

  • inject(TodosService) in a field initializer asks the injector for the service. Providers are singletons by default, so every class that injects TodosService gets the same instance.
  • The handler receives the request context. Ctx<{ body: ... }> types ctx.body as the schema's output: the title is already trimmed, and params.id is already a number, because the TodoId schema transforms it.
  • Returning an object or an array sends JSON with status 200 (or the route's status). Returning nothing sends 204 No Content.

#Group it in a module

src/todos.module.ts
import { Module } from '@knead-js/core';
import { TodosController } from './todos.controller.js';
import { TodosService } from './todos.service.js';

@Module({
  controllers: [TodosController],
  providers: [TodosService],
})
export class TodosModule {}

The root module imports feature modules:

src/app.module.ts
import { Module } from '@knead-js/core';
import { TodosModule } from './todos.module.js';

@Module({ imports: [TodosModule] })
export class AppModule {}

#Start the server

src/main.ts
import { KneadFactory } from '@knead-js/http';
import { AppModule } from './app.module.js';

const app = await KneadFactory.create(AppModule);
// Close cleanly on SIGTERM and SIGINT: stop accepting requests, finish in-flight ones, run
// shutdown hooks, then exit.
app.enableShutdownHooks();
await app.listen({ port: 3000, hostname: '127.0.0.1' });
Terminal
bun src/main.ts
Output
[knead] Listening on http://127.0.0.1:3000/

listen() binds port 3000 on 0.0.0.0 (every interface) by default. This example passes 127.0.0.1 so the server is reachable only from your machine.

#Try it

Terminal
curl -i -X POST localhost:3000/todos -H 'content-type: application/json' -d '{"title":"Buy flour"}'
HTTP
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8

{"id":1,"title":"Buy flour","done":false}

Invalid input never reaches the handler. Knead answers with an RFC 9457 problem response that lists every issue:

Terminal
curl -X POST localhost:3000/todos -H 'content-type: application/json' -d '{"title":""}'
JSON
{
  "issues": [
    {
      "source": "body",
      "message": "Invalid length: Expected >=1 but received 0",
      "path": ["title"]
    }
  ],
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Request validation failed",
  "instance": "/todos"
}

The response's content-type is application/problem+json; charset=utf-8. A missing todo produces the NotFoundException from the service:

Terminal
curl localhost:3000/todos/7
JSON
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Todo 7 does not exist",
  "instance": "/todos/7"
}

#Test it

@knead-js/testing compiles a module into an application and can start it on a random local port. Requests then go through the real server and pipeline.

src/todos.test.ts
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { Test, type TestApp, type TestingModule } from '@knead-js/testing';
import { TodosModule } from './todos.module.js';

let moduleRef: TestingModule;
let app: TestApp;

beforeEach(async () => {
  moduleRef = await Test.createModule({ imports: [TodosModule] }).compile();
  app = await moduleRef.createApp();
});

afterEach(() => moduleRef.close());

test('creates and reads a todo', async () => {
  const created = await app.request('POST', '/todos', { json: { title: '  Buy flour ' } });
  expect(created.status).toBe(201);
  expect(await created.json()).toEqual({ id: 1, title: 'Buy flour', done: false });

  const read = await app.request('GET', '/todos/1');
  expect(await read.json()).toEqual({ id: 1, title: 'Buy flour', done: false });
});

test('rejects an empty title', async () => {
  const res = await app.request('POST', '/todos', { json: { title: '' } });
  expect(res.status).toBe(400);
});
Terminal
bun test

Overrides, captured logs and GraphQL helpers are covered in testing.

#Development mode

Knead runs in production mode unless NODE_ENV is development, or you pass development: true to KneadFactory.create(). Development mode adds error details to 500 responses. Run locally with:

Terminal
NODE_ENV=development bun src/main.ts

Development mode is meant for your machine only. If you start it on a hostname other than loopback, listen() warns that error details are exposed.

#Next steps