playbook/outfitter-agents/plugins/outfitter/skills/hono-dev/references/factory-pattern.md

15 KiB

Factory Pattern — Context Typing

createFactory<Env>() provides type-safe context variables across middleware and routes.

Environment Definition

import { createFactory } from 'hono/factory';
import type { Database } from 'bun:sqlite';

type Env = {
  Variables: {
    user: {
      id: string;
      email: string;
      role: 'admin' | 'user' | 'guest';
    };
    requestId: string;
    db: Database;
    session: {
      id: string;
      expiresAt: Date;
    };
  };
  Bindings: {
    // Cloudflare Workers bindings (if deploying to CF)
    DB: D1Database;
    BUCKET: R2Bucket;
    API_KEY: string;
  };
};

export const factory = createFactory<Env>();

Typed Middleware

Basic Middleware

// Request ID middleware
export const requestIdMiddleware = factory.createMiddleware(async (c, next) => {
  const requestId = c.req.header('x-request-id') || crypto.randomUUID();
  c.set('requestId', requestId);

  await next();

  // Add to response
  c.res.headers.set('x-request-id', requestId);
});

// Database middleware
export const dbMiddleware = factory.createMiddleware(async (c, next) => {
  const db = new Database('app.db');
  c.set('db', db);

  try {
    await next();
  } finally {
    db.close(); // Cleanup
  }
});

Authentication Middleware

import { HTTPException } from 'hono/http-exception';

export const authMiddleware = factory.createMiddleware(async (c, next) => {
  const token = c.req.header('authorization')?.replace('Bearer ', '');

  if (!token) {
    throw new HTTPException(401, { message: 'Missing authorization token' });
  }

  // Verify token (simplified)
  const payload = await verifyJWT(token);

  if (!payload) {
    throw new HTTPException(401, { message: 'Invalid token' });
  }

  const db = c.get('db');
  const user = db.query('SELECT * FROM users WHERE id = ?').get(payload.userId);

  if (!user) {
    throw new HTTPException(401, { message: 'User not found' });
  }

  c.set('user', {
    id: user.id,
    email: user.email,
    role: user.role,
  });

  await next();
});

// Optional auth — doesn't throw if no token
export const optionalAuthMiddleware = factory.createMiddleware(async (c, next) => {
  const token = c.req.header('authorization')?.replace('Bearer ', '');

  if (token) {
    try {
      const payload = await verifyJWT(token);
      const db = c.get('db');
      const user = db.query('SELECT * FROM users WHERE id = ?').get(payload.userId);

      if (user) {
        c.set('user', {
          id: user.id,
          email: user.email,
          role: user.role,
        });
      }
    } catch {
      // Ignore invalid tokens for optional auth
    }
  }

  await next();
});

Authorization Middleware

type Role = 'admin' | 'user' | 'guest';

export const requireRole = (requiredRole: Role) => {
  return factory.createMiddleware(async (c, next) => {
    const user = c.get('user');

    if (!user) {
      throw new HTTPException(401, { message: 'Unauthorized' });
    }

    // Admin has access to everything
    if (user.role === 'admin') {
      await next();
      return;
    }

    // Check role hierarchy
    const roleHierarchy: Record<Role, number> = {
      guest: 0,
      user: 1,
      admin: 2,
    };

    if (roleHierarchy[user.role] < roleHierarchy[requiredRole]) {
      throw new HTTPException(403, {
        message: `${requiredRole} access required`,
      });
    }

    await next();
  });
};

// Resource ownership check
export const requireOwnership = (resourceKey: 'userId' | 'authorId' = 'userId') => {
  return factory.createMiddleware(async (c, next) => {
    const user = c.get('user');

    if (!user) {
      throw new HTTPException(401, { message: 'Unauthorized' });
    }

    // Admin bypasses ownership check
    if (user.role === 'admin') {
      await next();
      return;
    }

    // Get resource ID from path params
    const resourceUserId = c.req.param(resourceKey);

    if (user.id !== resourceUserId) {
      throw new HTTPException(403, { message: 'Access denied' });
    }

    await next();
  });
};

Session Middleware

export const sessionMiddleware = factory.createMiddleware(async (c, next) => {
  const sessionId = c.req.header('x-session-id');

  if (!sessionId) {
    throw new HTTPException(401, { message: 'Missing session' });
  }

  const db = c.get('db');
  const session = db.query(
    'SELECT * FROM sessions WHERE id = ? AND expires_at > CURRENT_TIMESTAMP'
  ).get(sessionId);

  if (!session) {
    throw new HTTPException(401, { message: 'Invalid or expired session' });
  }

  c.set('session', {
    id: session.id,
    expiresAt: new Date(session.expires_at),
  });

  // Extend session on activity
  db.run(
    'UPDATE sessions SET expires_at = datetime(CURRENT_TIMESTAMP, "+1 hour") WHERE id = ?',
    [sessionId]
  );

  await next();
});

Typed Handlers

Basic Handlers

// Single handler
const getProfile = factory.createHandlers((c) => {
  const user = c.get('user'); // Fully typed!
  const requestId = c.get('requestId');

  return c.json({
    user,
    requestId,
  });
});

// Multiple handlers (middleware + handler)
const getUsers = factory.createHandlers(
  // Middleware
  async (c, next) => {
    console.log('Fetching users...');
    await next();
  },
  // Handler
  async (c) => {
    const db = c.get('db');
    const users = db.query('SELECT id, email, role FROM users').all();

    return c.json({ users });
  }
);

Handlers with Validation

import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

const UpdateProfileSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  bio: z.string().max(500).optional(),
});

const updateProfile = factory.createHandlers(
  zValidator('json', UpdateProfileSchema),
  async (c) => {
    const user = c.get('user');
    const data = c.req.valid('json');
    const db = c.get('db');

    const updated = db.query(`
      UPDATE users
      SET name = COALESCE(?, name),
          bio = COALESCE(?, bio)
      WHERE id = ?
      RETURNING *
    `).get(data.name || null, data.bio || null, user.id);

    return c.json({ user: updated });
  }
);

App Assembly

Simple App

const app = factory.createApp()
  // Global middleware
  .use('*', requestIdMiddleware)
  .use('*', dbMiddleware)

  // Public routes
  .get('/health', (c) => c.json({ status: 'ok' }))
  .post('/auth/login', loginHandler)

  // Protected routes
  .use('/api/*', authMiddleware)
  .get('/api/profile', ...getProfile)
  .put('/api/profile', ...updateProfile)

  // Admin routes
  .use('/api/admin/*', requireRole('admin'))
  .get('/api/admin/users', ...getUsers);

export type AppType = typeof app;
export default app;

Multi-Module App

// routes/users.ts
import { factory } from '../factory';
import { requireRole } from '../middleware/auth';

export const usersRoute = factory.createApp()
  .get('/', async (c) => {
    const db = c.get('db');
    const users = db.query('SELECT id, email, role FROM users').all();
    return c.json({ users });
  })
  .get('/:id', async (c) => {
    const db = c.get('db');
    const user = db.query('SELECT id, email, role FROM users WHERE id = ?')
      .get(c.req.param('id'));

    if (!user) {
      throw new HTTPException(404, { message: 'User not found' });
    }

    return c.json({ user });
  })
  .use(requireRole('admin')) // Admin-only routes below
  .delete('/:id', async (c) => {
    const db = c.get('db');
    const user = db.query('DELETE FROM users WHERE id = ? RETURNING *')
      .get(c.req.param('id'));

    if (!user) {
      throw new HTTPException(404, { message: 'User not found' });
    }

    return c.json({ deleted: true, user });
  });

// routes/posts.ts
import { factory } from '../factory';

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
});

export const postsRoute = factory.createApp()
  .get('/', async (c) => {
    const db = c.get('db');
    const posts = db.query('SELECT * FROM posts ORDER BY created_at DESC').all();
    return c.json({ posts });
  })
  .post('/', zValidator('json', CreatePostSchema), async (c) => {
    const user = c.get('user');
    const data = c.req.valid('json');
    const db = c.get('db');

    const post = db.query(`
      INSERT INTO posts (id, user_id, title, content)
      VALUES (?, ?, ?, ?)
      RETURNING *
    `).get(crypto.randomUUID(), user.id, data.title, data.content);

    return c.json({ post }, 201);
  })
  .get('/:id', async (c) => {
    const db = c.get('db');
    const post = db.query('SELECT * FROM posts WHERE id = ?')
      .get(c.req.param('id'));

    if (!post) {
      throw new HTTPException(404, { message: 'Post not found' });
    }

    return c.json({ post });
  });

// index.ts
import { factory } from './factory';
import { usersRoute } from './routes/users';
import { postsRoute } from './routes/posts';

const app = factory.createApp()
  .use('*', requestIdMiddleware)
  .use('*', dbMiddleware)

  // Mount routes
  .route('/users', usersRoute)
  .route('/posts', postsRoute);

export type AppType = typeof app;
export default app;

Type Propagation

Extending Environment

// base-env.ts
export type BaseEnv = {
  Variables: {
    requestId: string;
    db: Database;
  };
};

// auth-env.ts
import type { BaseEnv } from './base-env';

export type AuthEnv = BaseEnv & {
  Variables: BaseEnv['Variables'] & {
    user: {
      id: string;
      role: 'admin' | 'user';
    };
  };
};

// Usage
const authFactory = createFactory<AuthEnv>();

export const authRoute = authFactory.createApp()
  .get('/profile', (c) => {
    const user = c.get('user'); // Typed!
    const requestId = c.get('requestId'); // Also typed!
    const db = c.get('db'); // Also typed!

    return c.json({ user, requestId });
  });

Merging Environments

type Env1 = {
  Variables: {
    foo: string;
  };
};

type Env2 = {
  Variables: {
    bar: number;
  };
};

type MergedEnv = {
  Variables: Env1['Variables'] & Env2['Variables'];
};

const factory = createFactory<MergedEnv>();

const app = factory.createApp()
  .get('/test', (c) => {
    const foo = c.get('foo'); // string
    const bar = c.get('bar'); // number

    return c.json({ foo, bar });
  });

Advanced Patterns

Conditional Middleware

export const conditionalAuth = (condition: (c: Context) => boolean) => {
  return factory.createMiddleware(async (c, next) => {
    if (condition(c)) {
      // Apply auth
      await authMiddleware(c, next);
    } else {
      // Skip auth
      await next();
    }
  });
};

// Usage
const app = factory.createApp()
  .use('/api/*', conditionalAuth((c) => {
    // Skip auth for health checks
    return c.req.path !== '/api/health';
  }))
  .get('/api/health', (c) => c.json({ status: 'ok' }))
  .get('/api/profile', (c) => {
    const user = c.get('user'); // May be undefined
    return c.json({ user });
  });

Middleware Composition

const composeMiddleware = (...middlewares: MiddlewareHandler[]) => {
  return factory.createMiddleware(async (c, next) => {
    const execute = async (index: number) => {
      if (index >= middlewares.length) {
        await next();
        return;
      }

      await middlewares[index](c, async () => {
        await execute(index + 1);
      });
    };

    await execute(0);
  });
};

// Usage
const app = factory.createApp()
  .use('/api/*', composeMiddleware(
    requestIdMiddleware,
    dbMiddleware,
    authMiddleware
  ))
  .get('/api/profile', (c) => {
    // All middleware ran
    const requestId = c.get('requestId');
    const db = c.get('db');
    const user = c.get('user');

    return c.json({ user, requestId });
  });

Scoped Factories

// Public routes — no auth
const publicFactory = createFactory<{
  Variables: {
    requestId: string;
    db: Database;
  };
}>();

export const publicRoute = publicFactory.createApp()
  .get('/status', (c) => {
    // No user available here
    return c.json({ status: 'ok' });
  });

// Protected routes — auth required
const protectedFactory = createFactory<{
  Variables: {
    requestId: string;
    db: Database;
    user: { id: string; role: string };
  };
}>();

export const protectedRoute = protectedFactory.createApp()
  .get('/profile', (c) => {
    const user = c.get('user'); // Always available!
    return c.json({ user });
  });

// Combine
const app = factory.createApp()
  .use('*', requestIdMiddleware)
  .use('*', dbMiddleware)
  .route('/public', publicRoute)
  .use('/protected/*', authMiddleware)
  .route('/protected', protectedRoute);

Dependency Injection

interface IDatabase {
  query(sql: string): any;
}

interface ICache {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
}

type Env = {
  Variables: {
    db: IDatabase;
    cache: ICache;
    user: { id: string };
  };
};

const factory = createFactory<Env>();

// Inject dependencies
const createApp = (db: IDatabase, cache: ICache) => {
  return factory.createApp()
    .use('*', async (c, next) => {
      c.set('db', db);
      c.set('cache', cache);
      await next();
    })
    .get('/users/:id', async (c) => {
      const cache = c.get('cache');
      const db = c.get('db');
      const id = c.req.param('id');

      // Try cache first
      const cached = await cache.get(`user:${id}`);
      if (cached) {
        return c.json(JSON.parse(cached));
      }

      // Fetch from DB
      const user = db.query('SELECT * FROM users WHERE id = ?').get(id);

      // Cache result
      await cache.set(`user:${id}`, JSON.stringify(user));

      return c.json({ user });
    });
};

// Usage
const db = new Database('app.db');
const cache = new RedisClient();

const app = createApp(db, cache);

Common Pitfalls

// ❌ Wrong: Variables set but not in type
type Env = {
  Variables: {
    user: { id: string };
  };
};

const factory = createFactory<Env>();

const app = factory.createApp()
  .use('*', async (c, next) => {
    c.set('requestId', crypto.randomUUID()); // Type error!
    await next();
  });


// ✅ Correct: Include all variables in type
type Env = {
  Variables: {
    user: { id: string };
    requestId: string; // Added!
  };
};


// ❌ Wrong: Using base Hono with factory
import { Hono } from 'hono';

const app = new Hono() // Lost types!
  .use(authMiddleware) // Middleware expects typed context
  .get('/profile', (c) => {
    const user = c.get('user'); // Type error!
  });


// ✅ Correct: Use factory.createApp()
const app = factory.createApp()
  .use(authMiddleware)
  .get('/profile', (c) => {
    const user = c.get('user'); // Fully typed!
  });


// ❌ Wrong: Middleware doesn't use factory
const authMiddleware = async (c: Context, next: Next) => {
  c.set('user', { id: '123' }); // Lost types!
  await next();
};


// ✅ Correct: Use factory.createMiddleware
const authMiddleware = factory.createMiddleware(async (c, next) => {
  c.set('user', { id: '123' }); // Typed!
  await next();
});