buster/apps/server
Wells Bunker d2ff191f17
send slack message if user is unauthorized
2025-09-25 17:13:13 -06:00
..
.cursor Update documentation for future background agents 2025-07-17 12:34:48 -06:00
scripts remove slack integration feature flag 2025-08-25 01:11:10 -06:00
src send slack message if user is unauthorized 2025-09-25 17:13:13 -06:00
.dockerignore Mastra braintrust (#391) 2025-07-02 14:33:40 -07:00
.env.example remove slack integration feature flag 2025-08-25 01:11:10 -06:00
.gitignore Mastra braintrust (#391) 2025-07-02 14:33:40 -07:00
CLAUDE.md CLAUDE.md and README.md updates... 2025-09-15 15:06:41 -06:00
Dockerfile upgrade bun version 2025-09-02 15:01:37 -06:00
Dockerfile.prebuilt moved 2025-09-09 12:00:55 -06:00
README.md CLAUDE.md and README.md updates... 2025-09-15 15:06:41 -06:00
biome.json Mastra braintrust (#391) 2025-07-02 14:33:40 -07:00
env.d.ts remove slack integration feature flag 2025-08-25 01:11:10 -06:00
package.json Merge remote-tracking branch 'origin/staging' into dallin-bus-1816-dataset-sample-endpoint-not-working 2025-09-19 10:16:14 -06:00
test-docker.sh upgrade bun version 2025-09-02 15:01:37 -06:00
tsconfig.json Update tests 2025-07-23 23:06:45 -06:00
tsup.config.ts Update tsup.config.ts 2025-07-10 12:53:00 -06:00
turbo.json move supabase start to its own commands 2025-09-24 16:38:15 -06:00
vitest.config.ts Mastra braintrust (#391) 2025-07-02 14:33:40 -07:00

README.md

Server Application

This is the main TypeScript/Node.js API server using Hono framework. It assembles packages to create the REST API.

Installation

pnpm add @buster-app/server

Overview

@buster-app/server is responsible for:

  • HTTP API endpoints
  • Request/response handling
  • Authentication middleware
  • Routing and middleware
  • Assembling package functionality

Technology Stack

  • Runtime: Bun (optimized for performance)
  • Framework: Hono (lightweight, fast)
  • Validation: Zod with @hono/zod-validator
  • Architecture: File-path based routing

Architecture

Packages → @buster-app/server → HTTP API
               ↓
         File-based routes
        (Matches URL paths)

File-Path Based Routing

CRITICAL: Routes are organized using file-path based structure that matches the actual API path.

Routing Structure

server/
├── src/
│   ├── api/
│   │   ├── v2/
│   │   │   ├── users/
│   │   │   │   ├── index.ts              # /v2/users
│   │   │   │   ├── GET.ts                # GET /v2/users
│   │   │   │   ├── POST.ts               # POST /v2/users
│   │   │   │   └── [id]/
│   │   │   │       ├── index.ts          # /v2/users/:id
│   │   │   │       ├── GET.ts            # GET /v2/users/:id
│   │   │   │       ├── PUT.ts            # PUT /v2/users/:id
│   │   │   │       ├── DELETE.ts         # DELETE /v2/users/:id
│   │   │   │       └── suggested-questions/
│   │   │   │           ├── index.ts      # /v2/users/:id/suggested-questions
│   │   │   │           └── GET.ts        # GET /v2/users/:id/suggested-questions
│   │   │   ├── organizations/
│   │   │   │   ├── index.ts
│   │   │   │   ├── GET.ts
│   │   │   │   ├── POST.ts
│   │   │   │   └── [orgId]/
│   │   │   │       ├── members/
│   │   │   │       │   ├── GET.ts
│   │   │   │       │   └── POST.ts
│   │   │   │       └── settings/
│   │   │   │           └── GET.ts
│   │   │   └── index.ts                  # Main v2 router
│   │   └── index.ts                       # Main API router
│   └── index.ts                           # App entry point

Route File Pattern

Each HTTP method gets its own file:

// src/api/v2/users/[id]/GET.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import type { GetUserResponse } from '@buster/server-shared';
import { getUserHandler } from './handlers/get-user';

const ParamsSchema = z.object({
  id: z.string().uuid()
});

export const GET = new Hono()
  .get(
    '/',
    zValidator('param', ParamsSchema),
    async (c) => {
      const user = c.get('busterUser');
      const params = c.req.valid('param');
      
      const response = await getUserHandler({
        userId: params.id,
        requestingUser: user
      });
      
      return c.json<GetUserResponse>(response);
    }
  );

Index Router Pattern

Each directory has an index.ts that combines routes:

// src/api/v2/users/[id]/index.ts
import { Hono } from 'hono';
import { GET } from './GET';
import { PUT } from './PUT';
import { DELETE } from './DELETE';
import { suggestedQuestions } from './suggested-questions';

export const userById = new Hono()
  .route('/', GET)
  .route('/', PUT)
  .route('/', DELETE)
  .route('/suggested-questions', suggestedQuestions);

Parent Router Pattern

// src/api/v2/users/index.ts
import { Hono } from 'hono';
import { GET } from './GET';
import { POST } from './POST';
import { userById } from './[id]';

export const users = new Hono()
  .route('/', GET)
  .route('/', POST)
  .route('/:id', userById);

Handler Pattern

Handlers are pure functions in separate files:

// src/api/v2/users/[id]/handlers/get-user.ts
import { z } from 'zod';
import type { User } from '@buster/database';
import { getUser } from '@buster/database';
import { checkPermission } from '@buster/access-controls';

const GetUserHandlerParamsSchema = z.object({
  userId: z.string().uuid(),
  requestingUser: z.custom<User>()
});

type GetUserHandlerParams = z.infer<typeof GetUserHandlerParamsSchema>;

export async function getUserHandler(params: GetUserHandlerParams) {
  const validated = GetUserHandlerParamsSchema.parse(params);
  
  // Check permissions
  const canAccess = await checkPermission({
    user: validated.requestingUser,
    action: 'read',
    resource: {
      type: 'user',
      id: validated.userId
    }
  });
  
  if (!canAccess) {
    throw new ForbiddenError();
  }
  
  // Get user from database
  const user = await getUser({ userId: validated.userId });
  
  if (!user) {
    throw new NotFoundError('User not found');
  }
  
  return {
    user,
    permissions: await getUserPermissions(user.id)
  };
}

Middleware

Authentication Middleware

// src/middleware/auth.ts
import type { Context, Next } from 'hono';
import { validateSession } from '@buster/access-controls';

export async function requireAuth(c: Context, next: Next) {
  const token = c.req.header('Authorization')?.replace('Bearer ', '');
  
  if (!token) {
    return c.json({ error: 'Unauthorized' }, 401);
  }
  
  const user = await validateSession(token);
  if (!user) {
    return c.json({ error: 'Invalid session' }, 401);
  }
  
  c.set('busterUser', user);
  await next();
}

Error Handling Middleware

// src/middleware/error-handler.ts
export async function errorHandler(c: Context, next: Next) {
  try {
    await next();
  } catch (error) {
    if (error instanceof ValidationError) {
      return c.json({ 
        error: 'Validation failed',
        details: error.errors 
      }, 400);
    }
    
    if (error instanceof ForbiddenError) {
      return c.json({ error: 'Forbidden' }, 403);
    }
    
    if (error instanceof NotFoundError) {
      return c.json({ error: error.message }, 404);
    }
    
    // Log internal errors
    console.error('Unhandled error:', error);
    
    // Don't expose internal errors
    return c.json({ error: 'Internal server error' }, 500);
  }
}

Type Safety

Request/Response Types

All types come from server-shared:

import type { 
  CreateUserRequest,
  CreateUserResponse,
  GetUsersRequest,
  GetUsersResponse 
} from '@buster/server-shared';

// Never define types locally
// ❌ Wrong
interface LocalUserType {
  id: string;
  email: string;
}

// ✅ Correct - import from server-shared
import type { User } from '@buster/server-shared';

Validation Pattern

import { zValidator } from '@hono/zod-validator';
import { CreateUserRequestSchema } from '@buster/server-shared';

// Validate request body
.post(
  '/',
  zValidator('json', CreateUserRequestSchema),
  async (c) => {
    const data = c.req.valid('json');
    // data is fully typed
  }
)

// Validate query params
.get(
  '/',
  zValidator('query', GetUsersQuerySchema),
  async (c) => {
    const query = c.req.valid('query');
    // query is fully typed
  }
)

App Structure

Main App Setup

// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { api } from './api';
import { errorHandler } from './middleware/error-handler';

const app = new Hono();

// Global middleware
app.use('*', cors());
app.use('*', logger());
app.use('*', errorHandler);

// Mount API routes
app.route('/api', api);

// Health check
app.get('/health', (c) => c.json({ status: 'ok' }));

export default app;

Environment Configuration

// src/config/env.ts
import { z } from 'zod';

const EnvSchema = z.object({
  PORT: z.string().default('8080'),
  DATABASE_URL: z.string(),
  REDIS_URL: z.string(),
  JWT_SECRET: z.string(),
  NODE_ENV: z.enum(['development', 'production', 'test'])
});

export const env = EnvSchema.parse(process.env);

Testing Patterns

Route Testing

describe('GET /v2/users/:id', () => {
  it('should return user when authorized', async () => {
    const app = createTestApp();
    
    const response = await app.request('/api/v2/users/123', {
      headers: {
        Authorization: 'Bearer valid-token'
      }
    });
    
    expect(response.status).toBe(200);
    const data = await response.json();
    expect(data.user).toBeDefined();
  });
  
  it('should return 401 when not authenticated', async () => {
    const app = createTestApp();
    
    const response = await app.request('/api/v2/users/123');
    
    expect(response.status).toBe(401);
  });
});

Handler Testing

describe('getUserHandler', () => {
  it('should return user data', async () => {
    const mockUser = { id: '123', email: 'test@example.com' };
    jest.spyOn(database, 'getUser').mockResolvedValue(mockUser);
    
    const result = await getUserHandler({
      userId: '123',
      requestingUser: mockUser
    });
    
    expect(result.user).toEqual(mockUser);
  });
});

Best Practices

DO:

  • Use file-path based routing
  • Separate handlers from routes
  • Import types from server-shared
  • Validate all inputs with Zod
  • Use functional handlers
  • Apply auth middleware globally
  • Handle errors gracefully
  • Test routes and handlers separately

DON'T:

  • Define types locally
  • Mix business logic with routes
  • Use classes for handlers
  • Skip validation
  • Expose internal errors
  • Access database directly
  • Hardcode configuration

WebSocket Support

WebSocket Handler

// src/api/ws/chat.ts
import { createBunWebSocket } from 'hono/bun';
import type { ServerWebSocket } from 'bun';

const { upgradeWebSocket, websocket } = createBunWebSocket<{ user: User }>();

export const chatWebSocket = new Hono()
  .get(
    '/chat',
    upgradeWebSocket((c) => {
      return {
        onOpen(evt, ws) {
          console.info('WebSocket opened');
        },
        onMessage(evt, ws) {
          const message = JSON.parse(evt.data);
          // Handle message
          ws.send(JSON.stringify({ type: 'ack' }));
        },
        onClose(evt, ws) {
          console.info('WebSocket closed');
        }
      };
    })
  );

Performance Optimization

Response Caching

import { cache } from 'hono/cache';

// Cache GET requests
.get(
  '/',
  cache({
    cacheName: 'users',
    cacheControl: 'max-age=3600'
  }),
  getUsersHandler
)

Rate Limiting

import { rateLimiter } from './middleware/rate-limiter';

// Apply rate limiting
.use('/api/*', rateLimiter({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100 // limit each IP to 100 requests per windowMs
}))

Development

# Development
turbo dev --filter=@buster-app/server

# Build
turbo build --filter=@buster-app/server

# Test
turbo test:unit --filter=@buster-app/server
turbo test:integration --filter=@buster-app/server

# Lint
turbo lint --filter=@buster-app/server

Deployment

The server runs on Bun runtime for optimal performance:

# Production
turbo build --filter=@buster-app/server
bun run dist/index.js

This app should ONLY assemble packages and handle HTTP concerns. All business logic belongs in packages.