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.
mkdir todos && cd todos
bun init -y
bun add @knead-js/core @knead-js/http valibot
bun add -d @knead-js/testingValibot 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):
{
"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.
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.
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 injectsTodosServicegets the same instance.- The handler receives the request context.
Ctx<{ body: ... }>typesctx.bodyas the schema's output: the title is already trimmed, andparams.idis already a number, because theTodoIdschema transforms it. - Returning an object or an array sends JSON with status
200(or the route'sstatus). Returning nothing sends204 No Content.
#Group it in a module
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:
import { Module } from '@knead-js/core';
import { TodosModule } from './todos.module.js';
@Module({ imports: [TodosModule] })
export class AppModule {}#Start the server
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' });bun src/main.ts[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
curl -i -X POST localhost:3000/todos -H 'content-type: application/json' -d '{"title":"Buy flour"}'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:
curl -X POST localhost:3000/todos -H 'content-type: application/json' -d '{"title":""}'{
"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:
curl localhost:3000/todos/7{
"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.
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);
});bun testOverrides, 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:
NODE_ENV=development bun src/main.tsDevelopment 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
- Modules and dependency injection explain imports, exports, tokens and every provider form.
- Controllers, request context and validation go deeper into request handling.
- Configuration and logging prepare an app for production.