Procedures
Create reusable middleware with Igniter.js procedures to handle authentication, validation, logging, and cross-cutting concerns with full type safety.
Overview
Procedures are reusable middleware functions that run before your action handlers. They allow you to:
- ✅ Authenticate and authorize requests
- ✅ Validate and transform inputs
- ✅ Add request logging and tracing
- ✅ Inject dependencies into context
- ✅ Handle rate limiting
- ✅ Enrich context with computed data
import { igniter } from '@/igniter';
const authProcedure = igniter.procedure({
handler: async ({ context, request }) => {
const token = request.headers.get('authorization');
const user = await verifyToken(token);
// ✅ Extend context with user
return {
currentUser: user
};
}
});
// Use in actions
const protectedAction = igniter.query({
path: '/protected',
use: [authProcedure],
handler: async ({ context, response }) => {
// ✅ context.currentUser is now available!
return response.success({
message: `Hello, ${context.currentUser.name}`
});
}
});What is a Procedure?
A procedure is middleware that runs before your action handler and can extend the context with new properties. It's perfect for cross-cutting concerns.
Creating Procedures
Basic Procedure
const timestampProcedure = igniter.procedure({
handler: async ({ context }) => {
return {
requestTime: new Date(),
timestamp: Date.now()
};
}
});
const userController = igniter.controller({
path: '/users',
actions: {
list: igniter.query({
use: [timestampProcedure],
handler: async ({ context, response }) => {
// ✅ context.requestTime and context.timestamp are available
console.log('Request received at:', context.requestTime);
const users = await context.db.users.findMany();
return response.success({ users, timestamp: context.timestamp });
}
})
}
});Authentication Procedure
const authProcedure = igniter.procedure({
handler: async ({ context, request, response }) => {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
// ✅ Return early with error response
return response.unauthorized({ message: 'No authorization token' });
}
try {
const user = await verifyJWT(token);
if (!user) {
return response.unauthorized({ message: 'Invalid token' });
}
// ✅ Extend context with authenticated user
return {
currentUser: user,
isAuthenticated: true
};
} catch (error) {
return response.unauthorized({ message: 'Token verification failed' });
}
}
});Logging Procedure
const requestLoggerProcedure = igniter.procedure({
handler: async ({ request, context }) => {
const requestId = crypto.randomUUID();
const startTime = performance.now();
context.logger?.info('Request started', {
requestId,
method: request.method,
path: request.path,
ip: request.headers.get('x-forwarded-for') || 'unknown',
userAgent: request.headers.get('user-agent')
});
return {
requestId,
timing: {
start: startTime,
end: () => {
const duration = performance.now() - startTime;
context.logger?.info('Request completed', { requestId, duration });
return duration;
}
}
};
}
});Procedure Handler
The procedure handler receives the same context as actions:
handler: async ({ request, context, response, plugins }) => {
// Access request data
const token = request.headers.get('authorization');
const userId = request.params.id;
const query = request.query;
// Access app context
const user = await context.db.users.findUnique({ where: { id: userId } });
// Return early with response
if (!user) {
return response.notFound({ message: 'User not found' });
}
// Or extend context
return {
currentUser: user,
permissions: await getPermissions(user.id)
};
}Prop
Type
Context Extension
Procedures can extend the context by returning an object. The returned properties are merged into the context and available in subsequent procedures and the action handler.
Single Extension
const authProcedure = igniter.procedure({
handler: async ({ request }) => {
const user = await getUserFromToken(request);
// ✅ Add user to context
return {
currentUser: user
};
}
});
// Usage
const action = igniter.query({
use: [authProcedure],
handler: async ({ context }) => {
// ✅ context.currentUser is available and typed!
console.log(context.currentUser.email);
}
});Multiple Extensions
Procedures are executed in order, and each can extend the context:
const authProcedure = igniter.procedure({
handler: async ({ request }) => {
return {
currentUser: await getUserFromToken(request)
};
}
});
const permissionsProcedure = igniter.procedure({
handler: async ({ context }) => {
// ✅ Can access currentUser from previous procedure
const permissions = await getPermissions(context.currentUser.id);
return {
permissions,
can: (action: string) => permissions.includes(action)
};
}
});
// Usage
const action = igniter.query({
use: [authProcedure, permissionsProcedure],
handler: async ({ context }) => {
// ✅ Both currentUser and permissions are available!
if (!context.can('read:users')) {
return response.forbidden({ message: 'Insufficient permissions' });
}
}
});Common Patterns
Role-Based Authorization
const requireRoleProcedure = igniter.procedure({
handler: async ({ context, response }) => {
if (!context.currentUser) {
return response.unauthorized({ message: 'Authentication required' });
}
const userRoles = await context.db.userRoles.findMany({
where: { userId: context.currentUser.id }
});
return {
roles: userRoles.map(r => r.name),
hasRole: (role: string) => userRoles.some(r => r.name === role),
requireRole: (role: string) => {
if (!userRoles.some(r => r.name === role)) {
throw new Error(`Role '${role}' required`);
}
}
};
}
});
// Usage
const adminAction = igniter.query({
use: [authProcedure, requireRoleProcedure],
handler: async ({ context, response }) => {
// ✅ Type-safe role check
context.requireRole('admin');
const users = await context.db.users.findMany();
return response.success({ users });
}
});Request Validation
import { z } from 'zod';
const validateApiKeyProcedure = igniter.procedure({
handler: async ({ request, response }) => {
const apiKey = request.headers.get('x-api-key');
if (!apiKey) {
return response.unauthorized({ message: 'API key required' });
}
const keyRecord = await db.apiKeys.findUnique({
where: { key: apiKey },
include: { user: true }
});
if (!keyRecord || !keyRecord.isActive) {
return response.unauthorized({ message: 'Invalid API key' });
}
return {
apiKeyUser: keyRecord.user,
apiKeyPermissions: keyRecord.permissions
};
}
});Rate Limiting
const rateLimitProcedure = igniter.procedure({
handler: async ({ request, context, response }) => {
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const key = `rate_limit:${ip}`;
// Increment request count
const count = await context.store?.increment(key, { ttl: 60 }); // 60 seconds
const limit = 100;
const remaining = Math.max(0, limit - (count || 0));
if ((count || 0) > limit) {
return response.json(
{ message: 'Rate limit exceeded' },
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': (Date.now() + 60000).toString()
}
}
);
}
return {
rateLimit: {
limit,
remaining,
current: count || 0
}
};
}
});Request Tracing
const tracingProcedure = igniter.procedure({
handler: async ({ request, context }) => {
const traceId = request.headers.get('x-trace-id') || crypto.randomUUID();
const spanId = crypto.randomUUID();
const parentSpanId = request.headers.get('x-parent-span-id') || null;
const span = context.telemetry?.startSpan('http-request', {
attributes: {
'http.method': request.method,
'http.path': request.path,
'http.user_agent': request.headers.get('user-agent'),
'trace.id': traceId,
'span.id': spanId,
'span.parent_id': parentSpanId
}
});
return {
trace: {
traceId,
spanId,
parentSpanId,
span,
endSpan: () => span?.end()
}
};
}
});Input Transformation
const sanitizeInputProcedure = igniter.procedure({
handler: async ({ request }) => {
// Sanitize query parameters
const sanitizedQuery = Object.entries(request.query || {}).reduce(
(acc, [key, value]) => ({
...acc,
[key]: typeof value === 'string' ? value.trim() : value
}),
{}
);
// Sanitize body
const sanitizedBody = request.body ?
JSON.parse(JSON.stringify(request.body).replace(/<[^>]*>/g, '')) :
undefined;
return {
sanitized: {
query: sanitizedQuery,
body: sanitizedBody
}
};
}
});Procedure Composition
Chaining Procedures
Procedures run in order and can build on each other:
const procedure1 = igniter.procedure({
handler: async () => ({ step1: 'done' })
});
const procedure2 = igniter.procedure({
handler: async ({ context }) => {
// ✅ Access step1 from previous procedure
console.log(context.step1);
return { step2: 'done' };
}
});
const procedure3 = igniter.procedure({
handler: async ({ context }) => {
// ✅ Access both step1 and step2
console.log(context.step1, context.step2);
return { step3: 'done' };
}
});
// Use all three
const action = igniter.query({
use: [procedure1, procedure2, procedure3],
handler: async ({ context }) => {
// ✅ All three steps are available
console.log(context.step1, context.step2, context.step3);
}
});Conditional Procedures
Create procedures that conditionally extend context:
const optionalAuthProcedure = igniter.procedure({
handler: async ({ request }) => {
const token = request.headers.get('authorization');
if (!token) {
// ✅ Return minimal context if no token
return {
currentUser: null,
isAuthenticated: false
};
}
try {
const user = await verifyToken(token);
return {
currentUser: user,
isAuthenticated: true
};
} catch {
return {
currentUser: null,
isAuthenticated: false
};
}
}
});Error Handling
Early Return with Response
Procedures can return a Response to short-circuit execution:
const authProcedure = igniter.procedure({
handler: async ({ request, response }) => {
const token = request.headers.get('authorization');
if (!token) {
// ✅ Return response - action handler won't run
return response.unauthorized({ message: 'Token required' });
}
const user = await verifyToken(token);
return { currentUser: user };
}
});Throwing Errors
You can also throw errors that will be caught automatically:
const authProcedure = igniter.procedure({
handler: async ({ request }) => {
const token = request.headers.get('authorization');
if (!token) {
// ✅ Throw error - automatically returns 500
throw new Error('No authorization token');
}
const user = await verifyToken(token);
if (!user) {
throw new Error('Invalid token');
}
return { currentUser: user };
}
});Custom Error Classes
class UnauthorizedError extends Error {
statusCode = 401;
constructor(message: string) {
super(message);
this.name = 'UnauthorizedError';
}
}
const authProcedure = igniter.procedure({
handler: async ({ request }) => {
const token = request.headers.get('authorization');
if (!token) {
throw new UnauthorizedError('Token required');
}
const user = await verifyToken(token);
return { currentUser: user };
}
});Reusable Procedure Factories
Create factory functions for configurable procedures:
function createAuthProcedure(options: { required: boolean }) {
return igniter.procedure({
handler: async ({ request, response }) => {
const token = request.headers.get('authorization');
if (!token) {
if (options.required) {
return response.unauthorized({ message: 'Token required' });
}
return { currentUser: null, isAuthenticated: false };
}
const user = await verifyToken(token);
return { currentUser: user, isAuthenticated: true };
}
});
}
// Usage
const optionalAuth = createAuthProcedure({ required: false });
const requiredAuth = createAuthProcedure({ required: true });
const publicAction = igniter.query({
use: [optionalAuth],
handler: async ({ context }) => {
// context.currentUser might be null
}
});
const protectedAction = igniter.query({
use: [requiredAuth],
handler: async ({ context }) => {
// context.currentUser is guaranteed
}
});Best Practices
1. Keep Procedures Focused
Each procedure should have a single responsibility:
// ✅ Good - Single responsibility
const authProcedure = igniter.procedure({
handler: async ({ request }) => {
const user = await authenticateUser(request);
return { currentUser: user };
}
});
const permissionsProcedure = igniter.procedure({
handler: async ({ context }) => {
const permissions = await getPermissions(context.currentUser.id);
return { permissions };
}
});
// ❌ Bad - Multiple responsibilities
const authAndPermissionsProcedure = igniter.procedure({
handler: async ({ request, context }) => {
const user = await authenticateUser(request);
const permissions = await getPermissions(user.id);
const settings = await getSettings(user.id);
const preferences = await getPreferences(user.id);
return { currentUser: user, permissions, settings, preferences };
}
});2. Use Type-Safe Returns
Always return typed objects:
// ✅ Good - Typed return
interface AuthContext {
currentUser: User;
isAuthenticated: boolean;
}
const authProcedure = igniter.procedure({
handler: async ({ request }): Promise<AuthContext> => {
const user = await authenticateUser(request);
return {
currentUser: user,
isAuthenticated: true
};
}
});3. Order Matters
Place procedures in the correct order:
// ✅ Correct order
const action = igniter.query({
use: [
requestLoggerProcedure, // 1. Log request
authProcedure, // 2. Authenticate
permissionsProcedure, // 3. Check permissions (needs auth)
rateLimitProcedure // 4. Rate limit
],
handler: async ({ context }) => { /* ... */ }
});
// ❌ Wrong order
const action = igniter.query({
use: [
permissionsProcedure, // ❌ Runs before auth!
authProcedure,
requestLoggerProcedure
],
handler: async ({ context }) => { /* ... */ }
});4. Early Returns for Errors
Return error responses early to avoid unnecessary processing:
const authProcedure = igniter.procedure({
handler: async ({ request, response }) => {
// ✅ Early return
if (!request.headers.get('authorization')) {
return response.unauthorized({ message: 'Token required' });
}
const user = await verifyToken(request.headers.get('authorization'));
// ✅ Another early return
if (!user.isActive) {
return response.forbidden({ message: 'Account deactivated' });
}
return { currentUser: user };
}
});Testing Procedures
Unit Tests
Test procedures in isolation:
import { describe, it, expect } from 'vitest';
describe('authProcedure', () => {
it('should authenticate valid token', async () => {
const mockRequest = {
headers: new Headers({ 'authorization': 'Bearer valid-token' })
};
const result = await authProcedure.handler({
request: mockRequest,
context: {},
response: mockResponseProcessor,
plugins: {}
});
expect(result.currentUser).toBeDefined();
expect(result.isAuthenticated).toBe(true);
});
it('should reject missing token', async () => {
const mockRequest = {
headers: new Headers()
};
const result = await authProcedure.handler({
request: mockRequest,
context: {},
response: mockResponseProcessor,
plugins: {}
});
expect(result.status).toBe(401);
});
});Next Steps
Actions
Master queries and mutations in Igniter.js to create type-safe API endpoints with automatic validation, full IntelliSense, and end-to-end type inference.
Validation
Master input validation in Igniter.js using Zod, Valibot, Yup, or any StandardSchemaV1-compliant library for type-safe request handling.