Appearance
Getting started
This page takes you from an empty folder to a running HTTP API with a service, an interface-based repository and a controller.
Prerequisites
Create a project with the CLI
The quickest way to start is the znight CLI:
sh
npx @znight/cli new my-servicesh
pnpm dlx @znight/cli new my-servicesh
bunx @znight/cli new my-serviceIn a terminal, the CLI asks for the project name, the package manager, the HTTP adapter (the built-in node:http server, Fastify, Express or Hono), the features and whether to install the dependencies and initialise git. --yes takes the defaults (node adapter, configuration and observability); flags answer the questions up front:
sh
npx @znight/cli new my-service --yes --adapter fastify --features all
npx @znight/cli new my-service --yes --features config,observability,schedule,openapi| Feature | Adds |
|---|---|
config | @znight/config: a typed @Config class read from the environment |
observability | @znight/observability: JSON request logs; metrics once prom-client is installed |
messaging | @znight/messaging: a consumer and an in-memory broker |
schedule | @znight/schedule: a cron job and its test on a virtual clock |
openapi | @znight/openapi: /openapi.json, the API reference on /docs |
It creates a small greeting API with its tests:
txt
my-service/
├─ src/
│ ├─ main.ts # Application.create() with the plugins, then start()
│ ├─ api.ts # @OpenApiDocument: title and tags of openapi.json (openapi)
│ └─ app/
│ ├─ app.config.ts # @Config('app'): PORT, APP_GREETING (config)
│ ├─ greeting.schemas.ts # Zod schema of the request body
│ ├─ greeting.service.ts # @Injectable
│ ├─ greeting.controller.ts # GET /greetings/:name, POST /greetings (validated body)
│ ├─ greeting.consumer.ts # @Consumer('greetings') (messaging)
│ ├─ health.controller.ts # GET /health
│ └─ heartbeat.jobs.ts # @Cron('* * * * *'): a job every minute (schedule)
├─ test/ # TestBed (unit), TestApp (in-memory HTTP), jobs on a virtual clock
├─ vite.config.ts # plugins: [znight(options)]
├─ znight.config.ts # compiler options, shared with the Bun preload and znight generate
├─ bunfig.toml # Bun projects: preloads @znight/compiler/bun for bun test
├─ tsconfig.json
└─ package.json # scripts: dev, build, start, generate, typecheck, test (openapi)Bun projects test with bun test (the preload regenerates the container), the others with Vitest (the Vite plugin does). Start it in development mode:
sh
cd my-service
npm run dev # znight dev: rebuilds and restarts on every changePrefer to understand every file? Set the project up by hand instead.
Manual setup
1. Install the packages
sh
npm install @znight/core @znight/http fastify
npm install -D @znight/compiler @znight/cli vite typescript @types/nodesh
pnpm add @znight/core @znight/http fastify
pnpm add -D @znight/compiler @znight/cli vite typescript @types/nodesh
bun add @znight/core @znight/http fastify
bun add -d @znight/compiler @znight/cli vite typescript @types/node| Package | Role |
|---|---|
@znight/core | Runtime: container, application lifecycle, plugins, decorators |
@znight/http | HTTP controllers and the HttpPlugin (Fastify here; Express/Hono work too) |
@znight/compiler | Build time only: Vite plugin (and Bun preload) generating the container |
@znight/cli | Optional: znight dev, znight generate, code generators |
2. Configure TypeScript
Znight decorators use TypeScript's experimentalDecorators (parameter decorators such as @Param() need them). You do not need emitDecoratorMetadata nor reflect-metadata.
json
{
"compilerOptions": {
"target": "ES2024",
"lib": ["ES2024", "ESNext.Disposable"],
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"experimentalDecorators": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
}3. Add the Vite plugin
The compiler runs as a Vite plugin. With its defaults it also configures Vite to build a server application: entry src/main.ts, output dist/, target node24, source maps on.
ts
import { znight } from '@znight/compiler'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [znight()],
})Compiler options go to znight({ ... }). Once you have some, move them to a znight.config.ts and pass them to the plugin (znight(options)): the Bun preload and znight generate read that file too (options).
4. Scripts and ignored files
json
{
"name": "my-service",
"private": true,
"type": "module",
"scripts": {
"dev": "znight dev",
"build": "vite build",
"start": "node dist/main.js",
"typecheck": "znight generate && tsc --noEmit"
}
}The generated container is rebuilt from your sources every time: do not commit it.
txt
node_modules
dist
.znightWrite your first provider
A provider is any class the container creates for you. Mark it with @Injectable() and declare its dependencies as constructor parameters: the compiler reads their types.
Start with a model and a repository interface:
ts
export interface User {
id: number
name: string
}
export interface UserRepository {
findById(id: number): Promise<User | undefined>
}Implement it with an in-memory class. implements UserRepository is what lets the compiler bind the interface to this class:
ts
import { Injectable } from '@znight/core'
import type { User, UserRepository } from './user.repository.js'
@Injectable()
export class InMemoryUserRepository implements UserRepository {
readonly #users = new Map<number, User>([[1, { id: 1, name: 'Ada Lovelace' }]])
async findById(id: number): Promise<User | undefined> {
return this.#users.get(id)
}
}Then a service that depends on the interface, not on the implementation:
ts
import { Injectable } from '@znight/core'
import type { User, UserRepository } from './user.repository.js'
@Injectable()
export class UserService {
constructor(private readonly users: UserRepository) {}
findById(id: number): Promise<User | undefined> {
return this.users.findById(id)
}
}Type-only imports are fine
The compiler reads types from the source, so import type works for injected interfaces. Injected classes can be imported either way.
Add a controller
Controllers are providers too. Route parameters are extracted and coerced for you: id arrives as a number (an invalid value is answered with 400 Bad Request).
ts
import { Controller, Get, HttpException, Param } from '@znight/http'
import type { User } from './user.repository.js'
import { UserService } from './user.service.js'
@Controller('/users')
export class UserController {
constructor(private readonly users: UserService) {}
@Get('/:id')
async getUser(@Param('id') id: number): Promise<User> {
const user = await this.users.findById(id)
if (user === undefined) throw new HttpException(404, `User ${id} not found`)
return user
}
}HttpException(status, message?, details?) renders { statusCode, error, message }; subclasses such as NotFoundException set the status for you.
Bootstrap the application
main.ts imports the generated metadata and creates the application with its plugins:
ts
import { Application } from '@znight/core'
import { HttpPlugin } from '@znight/http'
import { fastifyAdapter } from '@znight/http/fastify'
import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
const app = await Application.create({
metadata: __ZNIGHT_METADATA__,
plugins: [HttpPlugin.configure({ adapter: fastifyAdapter(), port: 3000 })],
})
await app.start() // Ctrl+C (SIGINT) or SIGTERM stops it gracefullyOnly entry points import the generated module
Import container.generated from main.ts and from tests, never from a provider file: that would create an ESM import cycle (ZNIGHT020). Inject the Container instead if a provider really needs it.
Build and run
sh
npm run build # vite build: compiles the container, bundles to dist/
npm start # node dist/main.jssh
curl http://localhost:3000/users/1
# {"id":1,"name":"Ada Lovelace"}
curl -i http://localhost:3000/users/abc
# HTTP/1.1 400 Bad RequestDuring development, npm run dev (znight dev) rebuilds in watch mode and restarts the process after every successful build.
Running on Bun
The build output runs on Bun as is: bun --no-env-file dist/main.js (or znight start --runtime bun). Bun can also run your sources directly with the compiler's Bun preload, which regenerates the container before the application starts and serves it from memory: bun --preload @znight/compiler/bun --no-env-file src/main.ts (also with --watch). For bun test, add it to bunfig.toml. See Bun preload.
--no-env-file (or env = false in bunfig.toml) stops Bun from loading .env files into process.env by itself, so that @znight/config reads them the same way as on Node.js (see Bun's automatic .env loading).
Why not node src/main.ts?
Node.js can run TypeScript by stripping types, but decorators are not erasable syntax. Run the Vite build output with Node.js (the plugin strips Znight decorators from the bundle), or use Bun for sources.
Look at what the compiler generated
Open src/.znight/container.generated.ts (run npx znight generate to create it without building). It is plain TypeScript:
ts
export const __ZNIGHT_METADATA__: ZnightMetadata = {
version: 1,
compiler: '0.1.0',
providers: {
['src/users/in-memory-user.repository.ts#InMemoryUserRepository']: {
scope: 'singleton',
class: InMemoryUserRepository,
deps: [],
factory: () => new InMemoryUserRepository(),
},
// UserController...
['src/users/user.service.ts#UserService']: {
scope: 'singleton',
class: UserService,
deps: ['src/users/in-memory-user.repository.ts#InMemoryUserRepository'],
factory: d => new UserService(d[0]),
},
},
resolutionOrder: [
'src/users/in-memory-user.repository.ts#InMemoryUserRepository',
'src/users/user.service.ts#UserService',
'src/users/user.controller.ts#UserController',
],
aliases: {
['src/users/user.repository.ts#UserRepository']: 'src/users/in-memory-user.repository.ts#InMemoryUserRepository',
},
extensions: {
['http']: {
// the routes of UserController
},
},
}The UserRepository parameter of UserService was bound to InMemoryUserRepository at build time.
Break it on purpose
Remove @Injectable() from InMemoryUserRepository and build again:
txt
✗ ZNIGHT001: Missing provider for 'UserRepository' required by UserService
--> src/users/user.service.ts:6:39
|
6 | constructor(private readonly users: UserRepository) {}
| ^^^^^^^^^^^^^^
= hint: InMemoryUserRepository (src/users/in-memory-user.repository.ts:5) implements UserRepository but is not a provider: add @Injectable()
Build failed with 1 errorThe mistake never reaches production. Every code is explained in the error reference.
Next steps
- Project structure: how to organise a growing application.
- Dependency injection: qualifiers, factories, optional and lazy dependencies.
- HTTP: routing, validation, guards and interceptors.
- Data access: replace the in-memory repository with a database (Drizzle).
- Security: access decisions checked at build time, authentication recipes.
- Testing: unit tests with auto-mocks and end-to-end tests without a network.
- Examples: complete applications to read and run.