buster/apps/server/CLAUDE.md

9.5 KiB

Hono Server Development Guidelines

This document provides specific guidelines for developing the Hono-based backend server in this monorepo.

API Development Standards

Directory Structure

apps/server/src/api/
├── v2/                    # Current API version
│   ├── chats/            # Feature folder
│   │   ├── index.ts      # Route definitions
│   │   ├── handler.ts    # Main handler (if single)
│   │   ├── create-chat.ts # Individual handlers
│   │   └── services/     # Business logic
│   └── security/         # Another feature
│       ├── index.ts
│       ├── get-workspace-settings.ts
│       └── update-workspace-settings.ts
└── healthcheck.ts        # Non-versioned endpoints

Route Definition Pattern

Always follow this pattern in index.ts:

import { RequestSchema } from '@buster/server-shared/feature';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { requireAuth } from '../../../middleware/auth';
import '../../../types/hono.types';
import { HTTPException } from 'hono/http-exception';
import { handlerFunction } from './handler-file';

const app = new Hono()
  // Apply authentication middleware
  .use('*', requireAuth)
  
  // GET endpoint (no body validation)
  .get('/endpoint', async (c) => {
    const user = c.get('busterUser');
    const response = await handlerFunction(user);
    return c.json(response);
  })
  
  // POST/PATCH/PUT endpoint (with body validation)
  .post('/endpoint', zValidator('json', RequestSchema), async (c) => {
    const request = c.req.valid('json');
    const user = c.get('busterUser');
    const response = await handlerFunction(request, user);
    return c.json(response);
  })
  
  // Error handling
  .onError((e, c) => {
    if (e instanceof HTTPException) {
      return e.getResponse();
    }
    
    throw new HTTPException(500, {
      message: 'Internal server error',
    });
  });

export default app;

Handler Function Pattern

Each handler must:

  1. Be in its own file
  2. Be a pure async function
  3. Accept typed parameters
  4. Return typed responses
  5. Handle business logic or delegate to services
import type { 
  FeatureRequest, 
  FeatureResponse 
} from '@buster/server-shared/feature';
import type { User } from '@buster/database';

export async function featureHandler(
  request: FeatureRequest,
  user: User
): Promise<FeatureResponse> {
  // TODO: Implement business logic
  
  // For complex logic, delegate to service functions
  // const result = await featureService.process(request, user);
  
  return {
    // Response data matching FeatureResponse type
  };
}

Type Safety Requirements

  1. Request/Response Types: Define all types in @buster/server-shared

    // In @buster/server-shared/feature/requests.ts
    export const FeatureRequestSchema = z.object({
      field: z.string(),
    });
    
    export type FeatureRequest = z.infer<typeof FeatureRequestSchema>;
    
  2. Validation: Always use zValidator for request body validation

    .post('/endpoint', zValidator('json', RequestSchema), async (c) => {
      const request = c.req.valid('json'); // Fully typed
    })
    
  3. User Type: Always use User from @buster/database

    import type { User } from '@buster/database';
    

Authentication Pattern

  1. Apply requireAuth middleware to all protected routes
  2. Extract user with c.get('busterUser')
  3. Pass user to handler functions
const app = new Hono()
  .use('*', requireAuth) // Protects all routes in this app
  .get('/protected', async (c) => {
    const user = c.get('busterUser'); // Type: User from @buster/database
    // Use user in handler
  });

Error Handling

  1. Custom Errors: Create domain-specific error classes

    export class FeatureError extends Error {
      constructor(
        public code: string,
        message: string,
        public statusCode: number
      ) {
        super(message);
      }
    }
    
  2. Error Responses: Handle in route error handler

    .onError((e, c) => {
      if (e instanceof FeatureError) {
        return c.json({ error: e.message, code: e.code }, e.statusCode);
      }
      // Default error handling
    })
    

Testing Guidelines

  1. Unit Tests: Test handlers in isolation

    // handler.test.ts
    describe('featureHandler', () => {
      it('should process valid request', async () => {
        const mockUser = { id: '123' } as User;
        const request = { field: 'value' };
    
        const result = await featureHandler(request, mockUser);
    
        expect(result).toMatchObject({
          // Expected response
        });
      });
    });
    
  2. Integration Tests: Test full API routes

    // index.test.ts
    import { testClient } from 'hono/testing';
    

Best Practices

  1. Separation of Concerns

    • Routes: Handle HTTP concerns only
    • Handlers: Coordinate business logic
    • Services: Implement business logic
    • Repositories: Handle data access
  2. Consistent Naming

    • Handler files: verb-resource.ts (e.g., get-user.ts, update-settings.ts)
    • Handler functions: verbResourceHandler (e.g., getUserHandler)
    • Service files: resource-service.ts
  3. Response Validation

    • Consider validating responses in development
    • Use .safeParse() for critical endpoints
  4. Logging

    • Log errors with context
    • Use appropriate log levels
    • Include user context where relevant
  5. Performance

    • Keep handlers lightweight
    • Delegate heavy computation to background jobs
    • Monitor response times

Common Patterns

Pagination

// In request schema
export const ListRequestSchema = z.object({
  page: z.number().min(1).default(1),
  limit: z.number().min(1).max(100).default(20),
});

// In handler
const offset = (request.page - 1) * request.limit;

Query Parameters

// Define query schema in server-shared
export const ListItemsQuerySchema = z.object({
  search: z.string().optional(),
  status: z.enum(['active', 'inactive', 'all']).default('all'),
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(20),
  sortBy: z.enum(['created_at', 'name', 'updated_at']).default('created_at'),
  sortOrder: z.enum(['asc', 'desc']).default('desc'),
});

export type ListItemsQuery = z.infer<typeof ListItemsQuerySchema>;

// Route definition with query validation
.get('/items', zValidator('query', ListItemsQuerySchema), async (c) => {
  const query = c.req.valid('query'); // Fully typed as ListItemsQuery
  const user = c.get('busterUser');
  
  const response = await listItemsHandler(query, user);
  return c.json(response);
})

// Handler receives typed query
export async function listItemsHandler(
  query: ListItemsQuery,
  user: User
): Promise<ListItemsResponse> {
  const offset = (query.page - 1) * query.limit;
  // Use query.search, query.status, etc. with full type safety
}

Path Parameters

// Define param schema
export const ItemParamsSchema = z.object({
  item_id: z.string().uuid(),
});

export type ItemParams = z.infer<typeof ItemParamsSchema>;

// Route with path param validation
.get('/items/:item_id', zValidator('param', ItemParamsSchema), async (c) => {
  const params = c.req.valid('param'); // Typed as ItemParams
  const user = c.get('busterUser');
  
  const response = await getItemHandler(params.item_id, user);
  return c.json(response);
})

// Handler receives typed param
export async function getItemHandler(
  itemId: string,
  user: User
): Promise<ItemResponse> {
  // itemId is guaranteed to be a valid UUID
}

Combined Parameters (Path + Query + Body)

// Define schemas
export const UpdateItemParamsSchema = z.object({
  item_id: z.string().uuid(),
});

export const UpdateItemQuerySchema = z.object({
  validate: z.coerce.boolean().default(true),
});

export const UpdateItemBodySchema = z.object({
  name: z.string().min(1).max(255),
  description: z.string().optional(),
  status: z.enum(['active', 'inactive']).optional(),
});

// Route with multiple validations
.patch(
  '/items/:item_id',
  zValidator('param', UpdateItemParamsSchema),
  zValidator('query', UpdateItemQuerySchema),
  zValidator('json', UpdateItemBodySchema),
  async (c) => {
    const params = c.req.valid('param');
    const query = c.req.valid('query');
    const body = c.req.valid('json');
    const user = c.get('busterUser');
    
    const response = await updateItemHandler(
      params.item_id,
      body,
      { validate: query.validate },
      user
    );
    return c.json(response);
  }
)

// Handler with all parameters typed
export async function updateItemHandler(
  itemId: string,
  data: UpdateItemBody,
  options: { validate: boolean },
  user: User
): Promise<ItemResponse> {
  if (options.validate) {
    // Perform validation
  }
  // Update logic
}

Important Notes on Type Coercion

  • Use z.coerce.number() for numeric query params (they come as strings)
  • Use z.coerce.boolean() for boolean query params
  • Path params are always strings, validate format with .uuid(), .regex(), etc.
  • Query arrays need special handling: ids: z.array(z.string()).or(z.string()).transform(v => Array.isArray(v) ? v : [v])

Background Jobs

// Queue job without waiting
import { tasks } from '@trigger.dev/sdk/v3';

await tasks.trigger('job-name', { data });
// Return immediately, don't await job completion