Your First Server
This walkthrough builds a complete working API from scratch, explaining every decision along the way. By the end you will have a server with dependency injection, routing, validation, error handling, and a database connection.
Step 1: Entry point
Every street application starts with src/main.ts. The entry point is responsible for three things: loading config, wiring dependencies, and starting the server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src/main.ts
import 'reflect-metadata'; // Must be first — loads the metadata polyfill
import { container } from './core/container.js';
import { AppConfig } from './config/index.js';
import { PgPool } from './database/pool.js';
import { streetApp } from './http/server.js';
import { HelloController } from './controllers/hello.controller.js';
async function bootstrap(): Promise<void> {
// 1. Load and validate environment config
const config = new AppConfig();
config.load(); // Reads from process.env
container.register(AppConfig, config); // Make it injectable
// 2. Connect to the database
const pool = new PgPool({
host: config.pgHost,
port: config.pgPortNumber,
user: config.pgUser,
password: config.pgPassword,
database: config.pgDatabase,
minConnections: 2,
maxConnections: 10,
});
await pool.initialize(); // Warm up min connections
container.register(PgPool, pool);
// 3. Create and configure the HTTP app
const app = streetApp({
port: config.httpPort,
host: config.host,
uploadsDir: './uploads',
});
// 4. Register controllers
app.registerController(HelloController);
// 5. Start listening
await app.listen();
// 6. Graceful shutdown
process.once('SIGTERM', async () => {
await app.close();
await pool.close();
process.exit(0);
});
}
bootstrap().catch((err) => {
console.error('[street] Fatal error:', err);
process.exit(1);
});
Why
import 'reflect-metadata'first? The metadata polyfill patches the globalReflectobject before any decorator runs. If it loads after a decorated class is imported,Reflect.getMetadatawill returnundefinedfor that class and dependency injection will silently fail.
Step 2: A simple controller
Controllers group related route handlers. The @Controller decorator registers a URL prefix and optional middleware. Each method decorator (@Get, @Post, etc.) registers a route.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/controllers/hello.controller.ts
import { Injectable } from '../core/container.js';
import { Controller, Get, Post } from '../core/decorators.js';
import type { StreetContext } from '../core/context.js';
@Injectable()
@Controller('/api')
export class HelloController {
@Get('/hello')
async hello(ctx: StreetContext): Promise<void> {
ctx.json({ message: 'Hello, world!' });
}
@Get('/hello/:name')
async helloName(ctx: StreetContext): Promise<void> {
const name = ctx.params['name'] ?? 'stranger';
ctx.json({ message: `Hello, ${name}!` });
}
@Post('/echo')
async echo(ctx: StreetContext): Promise<void> {
ctx.json({ received: ctx.body }, 201);
}
}
Test it:
1
2
3
4
5
6
7
8
9
10
curl http://localhost:3000/api/hello
# {"message":"Hello, world!"}
curl http://localhost:3000/api/hello/Alice
# {"message":"Hello, Alice!"}
curl -X POST http://localhost:3000/api/echo \
-H 'Content-Type: application/json' \
-d '{"key":"value"}'
# {"received":{"key":"value"}}
Step 3: Injecting a service
Services contain business logic. They are marked @Injectable() and injected via constructor parameters.
1
2
3
4
5
6
7
8
9
// src/services/greeter.service.ts
import { Injectable } from '../core/container.js';
@Injectable()
export class GreeterService {
greet(name: string): string {
return `Hello, ${name}! The time is ${new Date().toISOString()}.`;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/controllers/hello.controller.ts
import { Injectable } from '../core/container.js';
import { Controller, Get } from '../core/decorators.js';
import type { StreetContext } from '../core/context.js';
import { GreeterService } from '../services/greeter.service.js';
@Injectable()
@Controller('/api')
export class HelloController {
// TypeScript emits constructor parameter types as metadata.
// The IoC container reads that metadata to resolve GreeterService automatically.
constructor(private readonly greeter: GreeterService) {}
@Get('/hello/:name')
async helloName(ctx: StreetContext): Promise<void> {
const name = ctx.params['name'] ?? 'stranger';
ctx.json({ message: this.greeter.greet(name) });
}
}
Register in main.ts — no change needed. The container resolves GreeterService automatically when it resolves HelloController:
1
2
3
4
5
6
app.registerController(HelloController);
// Internally: container.resolve(HelloController)
// → sees constructor needs GreeterService
// → container.resolve(GreeterService)
// → new GreeterService() (no deps)
// → new HelloController(greetService)
Step 4: Reading the request
StreetContext exposes everything about the request and provides typed methods to write the response.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Get('/search')
async search(ctx: StreetContext): Promise<void> {
// Query string: /search?q=street&page=2
const query = ctx.query['q'] ?? '';
const page = parseInt(ctx.query['page'] ?? '1', 10);
// Route params: /users/:id
const id = ctx.params['id'];
// Headers (lowercased keys)
const auth = ctx.headers['authorization'];
// Parsed body (for POST/PUT with Content-Type: application/json)
const body = ctx.body as { name?: string };
// Request timing (BigInt nanoseconds since epoch)
const elapsed = process.hrtime.bigint() - ctx.startTime;
ctx.json({ query, page, elapsed: elapsed.toString() });
}
Writing responses
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// JSON (most common)
ctx.json({ data: result });
ctx.json({ error: 'not found' }, 404);
// Plain text
ctx.text('pong', 200);
// HTML
ctx.html('<h1>Hello</h1>');
// Empty response (204 No Content)
ctx.send(204);
// Custom headers
ctx.setHeader('X-Request-Id', requestId);
// Cookies
ctx.setCookie('session', encryptedBlob, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: 86400,
});
Step 5: Validation
Add schema validation to any route using @Validate. It runs before the handler and throws BadRequestException automatically if validation fails.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Controller, Post, Validate } from '../core/decorators.js';
import type { ValidationSchema } from '../core/types.js';
const createItemSchema: ValidationSchema = {
body: {
name: { type: 'string', required: true, min: 1, max: 100 },
price: { type: 'number', required: true },
email: { type: 'email', required: false },
},
};
@Injectable()
@Controller('/api/items')
export class ItemController {
@Post('/')
@Validate(createItemSchema)
async create(ctx: StreetContext): Promise<void> {
// Reaches here only if validation passed
const body = ctx.body as { name: string; price: string };
ctx.json({ created: body }, 201);
}
}
Invalid request:
1
2
3
4
5
6
7
8
9
10
11
curl -X POST http://localhost:3000/api/items \
-H 'Content-Type: application/json' \
-d '{"price": "not-a-number"}'
# HTTP 400:
# {
# "error": "BadRequestException",
# "message": "Validation failed",
# "status": 400,
# "details": ["body.name is required", "body.price must be a number"]
# }
Step 6: Error handling
Throw any StreetException subclass from a handler or middleware. The global error handler catches it and formats the response automatically.
1
2
3
4
5
6
7
8
9
10
11
12
import { NotFoundException, BadRequestException } from '../http/exceptions.js';
@Get('/:id')
async getOne(ctx: StreetContext): Promise<void> {
const id = ctx.params['id'];
if (!id) throw new BadRequestException('Missing id parameter');
const item = await this.service.findById(id);
if (!item) throw new NotFoundException(`Item ${id} not found`);
ctx.json(item);
}
Available exceptions:
| Class | Status | Default message |
|---|---|---|
BadRequestException |
400 | Bad Request |
UnauthorizedException |
401 | Unauthorized |
ForbiddenException |
403 | Forbidden |
NotFoundException |
404 | Not Found |
ConflictException |
409 | Conflict |
UnprocessableException |
422 | Unprocessable Entity |
InternalException |
500 | Internal Server Error |
ServiceUnavailableException |
503 | Service Unavailable |
Unhandled errors (non-StreetException) are caught by the global handler, logged to stderr, and returned as HTTP 500.
Step 7: Compile and run
1
2
npx tsc
node dist/main.js
Full sequence output:
1
[street] Listening on http://0.0.0.0:3000
Test your endpoints:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Health check
curl http://localhost:3000/api/health | jq
# Hello world
curl http://localhost:3000/api/hello/Bob
# {"message":"Hello, Bob! The time is 2024-01-15T10:23:45.123Z."}
# Validation error
curl -X POST http://localhost:3000/api/echo \
-H 'Content-Type: application/json' \
-d '{"test":true}'
# {"received":{"test":true}}
# 404
curl http://localhost:3000/does/not/exist
# {"error":"NotFoundException","message":"Route GET /does/not/exist not found","status":404}
Complete first server (all together)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
my-api/
├── package.json
├── tsconfig.json
├── src/
│ ├── main.ts
│ ├── reflect-shim.d.ts
│ ├── config/index.ts
│ ├── core/
│ │ ├── container.ts
│ │ ├── context.ts
│ │ ├── decorators.ts
│ │ └── types.ts
│ ├── http/
│ │ ├── server.ts
│ │ └── exceptions.ts
│ ├── router/router.ts
│ ├── controllers/hello.controller.ts
│ └── services/greeter.service.ts
└── migrations/
Compile and start:
1
npx tsc && node dist/main.js
You now have a type-safe, injection-driven, validated HTTP API server — built entirely on Node.js core modules.