Routes
Route builders with middleware, guards, and handlers
Routes define individual endpoints in your controllers. JustScale uses a fluent builder API that lets you chain middleware, guards, and handlers with full type safety at every step.
Route Builders
Import route factories from the transport package: Get, Post, Put,Delete, Patch from @justscale/http, or SSE from @justscale/sse.
import { createController } from '@justscale/core';
import { Get, Post } from '@justscale/http';
import { SSE } from '@justscale/sse';
import { Ticket, HelpdeskService } from '../domain/models';
export const TicketController = createController('/tickets', {
inject: { helpdesk: HelpdeskService },
routes: ({ helpdesk }) => ({
list: Get('/'),
get: Get('/:ticket'),
create: Post('/'),
resolve: Post('/:ticket/resolve'),
close: Post('/:ticket/close'),
events: SSE('/:ticket/events'),
}),
});Path Parameters
Define path parameters with :paramName syntax. They're automatically extracted and typed in the handler context:
import { Get } from '@justscale/http';
// Single param
Get('/:ticket').handle(async ({ params }) => {
params.ticket; // string — inferred from the path
});
// Multiple params
Get('/:ticket/comments/:commentId').handle(async ({ params }) => {
params.ticket; // string
params.commentId; // string
});Typed Parameters with .types()
Use .types() to transform path parameters from strings into model references. This is the JustScale way — no raw IDs in your handlers:
import { Get } from '@justscale/http';
import { Ticket } from '../domain/models';
// Without .types() — params.ticket is a string
Get('/:ticket').handle(async ({ params }) => {
params.ticket; // string — a raw URL segment
});
// With .types() — params.ticket is Reference<Ticket>
Get('/:ticket')
.types({ Ticket })
.handle(async ({ params }) => {
const ticket = await params.ticket; // Persistent<Ticket> | undefined
ticket?.subject; // typed field access
});Info
.types({ Ticket }) matches :ticket, :Ticket, and :ticketRef. Each request gets a fresh Reference — no cross-request caching.The .handle() Method
Every route ends with .handle(). The handler receives a context object with transport-specific fields and anything added by middleware:
import { Get, Post } from '@justscale/http';
import { z } from 'zod';
import { Ticket } from '../domain/models';
// GET handler — read a ticket
Get('/:ticket')
.types({ Ticket })
.handle(async ({ params, res }) => {
const ticket = await params.ticket;
if (!ticket) return res.status(404).json({ error: 'Not found' });
res.json(ticket);
});
// POST handler — create with validated body
const CreateTicketBody = z.object({
subject: z.string().min(1),
body: z.string(),
priority: z.enum(['low', 'medium', 'high', 'critical']),
});
Post('/')
.body(CreateTicketBody)
.returns(201)
.handle(async ({ body, res }) => {
// body is typed: { subject: string, body: string, priority: ... }
const ticket = await helpdesk.createTicket(
body.subject, body.body, body.priority
);
res.status(201).json(ticket);
});The .use() Method — Middleware
Chain middleware with .use(). Each middleware adds properties to the context, which accumulate through the chain:
import { Get } from '@justscale/http';
import { auth } from '@justscale/auth';
Get('/:ticket')
.use(auth) // adds { user: User } to context
.use(loadCustomer) // adds { customer: Customer }
.handle(({ user, customer, params, res }) => {
// Both user and customer are available and typed
res.json({ user: user.name, customer: customer.name });
});The .guard() Method
Guards gate access to a route. They run after middleware and can stop execution. Use model permissions for declarative authorization:
import { Post } from '@justscale/http';
import { auth } from '@justscale/auth';
import { Ticket } from '../domain/models';
// Permission guard — only agents can resolve
Post('/:ticket/resolve')
.types({ Ticket })
.use(auth)
.guard(Ticket.can.resolve)
.handle(async ({ params, res, user }) => {
const ticket = await params.ticket;
if (!ticket) return res.status(404).json({ error: 'Not found' });
await helpdesk.resolveTicket(ticket, user.name);
res.status(204).end();
});Schema Validation
Use Zod schemas with .body() for automatic request validation and .returns() for response type declarations:
import { Post } from '@justscale/http';
import { auth } from '@justscale/auth';
import { CreateTicketBody, TicketSchema, ErrorSchema } from './schemas';
Post('/')
.use(auth)
.body(CreateTicketBody)
.returns(201, TicketSchema)
.returns(400, ErrorSchema)
.handle(async ({ body, res, user }) => {
// body is typed: { subject: string, body: string, priority: 'low' | ... }
// Invalid bodies are rejected with 400 before the handler runs
const ticket = await helpdesk.createTicket(
body.subject,
body.body,
body.priority,
Customer.ref(user),
);
res.status(201).json(ticket);
});SSE Routes
SSE routes use the same builder API but the handler is an async generator:
import { SSE } from '@justscale/sse';
import { Ticket } from '../domain/models';
SSE('/:ticket/events')
.types({ Ticket })
.handle(async function* ({ params }) {
const ticket = await params.ticket;
if (!ticket) {
yield { event: 'error', data: { message: 'Not found' } };
return;
}
yield { event: 'connected', data: { subject: ticket.subject } };
for await (const event of ticket.events) {
yield { event: event.type, data: event.data };
}
});Route Execution Order
When a request comes in, steps execute in the order they were chained:
- 1. .use() steps — middleware adds to context, in order
- 2. .guard() steps — guards check access, in order
- 3. .handle() — the handler runs with the full accumulated context
If any guard denies access, the handler never runs. If middleware throws, execution stops. Steps can be interleaved — .use(auth).guard(check).use(loadProfile).handle().
Best Practices
- Use .types() — transform URL params into references. Handlers get
Reference<Ticket>, not raw strings - Validate early — use
.body()before guards to reject invalid input fast - Chain thoughtfully — parse → validate → guard → handle
- Keep handlers thin — delegate to services. The handler orchestrates, it doesn't contain business logic