npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

express-oop-framework

v0.1.2

Published

Production-grade Express + Drizzle OOP framework — type-safe routers, Better Auth, opt-in Redis/RabbitMQ broker, module generator

Readme

express-oop-framework

Production-grade, developer-first Node.js framework (ESM) wrapped around Express + Drizzle ORM (Postgres) + Zod + Pino.

  • Custom JWT auth (bcrypt) + Google/GitHub/Facebook social login — no auth vendor
  • Type-safe routers — Zod schemas validated and inferred into handlers
  • Layered by design — Router → Service (BaseService, this.db) → Drizzle, enforced by convention + scaffolding
  • Opt-in events — Redis/RabbitMQ broker, safe no-op when disabled
  • Scaffolding CLIs — init a full project, generate modules in one command

Requires Node >= 20 and a Postgres database.

Install

npm install express-oop-framework

Scaffold a new project (recommended)

npx express-framework init my-api
cd my-api
npm install
npm run db:push   # needs Postgres + DATABASE_URL in .env (a .env is created for you)
npm run dev       # http://localhost:3000

init --here scaffolds into the current directory; --force allows non-empty ones. You get a runnable API: server entry, validated config, the framework's users table, an empty modules/ directory waiting for your first module, and a .env with a fresh random JWT_SECRET. Generate a module with npx express-framework <name> — it lands in src/modules/<name> with Zod schemas, a BaseService subclass and an AppRouter, and the CLI prints the app.mountRouter(...) line for src/index.ts.

Resulting structure:

my-api/
├── package.json / tsconfig.json / drizzle.config.ts / .env(.example) / README.md
└── src/
    ├── index.ts                    # server — wires App, auth, broker, services
    ├── config/
    │   ├── env.ts                  # validated env (DATABASE_URL, JWT_SECRET, …)
    │   ├── db.ts                   # Drizzle client + Db type
    │   └── schema.ts               # tables (re-exports framework users table)
    └── modules/                    # one folder per domain — generate with the CLI
        └── <name>/
            ├── <name>.schema.ts    # Zod input schemas (inferred into handlers)
            ├── <name>.service.ts   # business logic — BaseService subclass (this.db)
            └── <name>.router.ts    # URLs + middlewares → service (AppRouter)

Where each concern lives:

| Concern | Location | |---|---| | Schema (Zod input/output) | *.schema.ts in each module | | Service (business logic + Drizzle queries) | *.service.ts in each module — extends BaseService<Db>, uses this.db | | Router (URLs + Zod validation) | *.router.ts in each module — new AppRouter<Db>('/prefix') | | Middleware / guards | framework Middleware / BaseMiddleware subclasses + auth guards, attached per route via use: [] | | Server wiring | src/index.ts (App bootstrapper: middleware order, error handler, listen) |

Use in your project (manual setup)

Minimal app (src/index.ts):

import { App, createDb } from 'express-oop-framework';
import { createAuth, createAuthGuards, createAuthRouter, createResetEmailSender } from 'express-oop-framework/auth';
import * as schema from './schema.js';

const { pool, db } = createDb(process.env.DATABASE_URL!, schema);

const auth = createAuth({
  db,
  jwtSecret: process.env.JWT_SECRET!, // openssl rand -base64 32 (min 32 chars)
  jwtExpiresIn: '7d',
  baseURL: 'http://localhost:3000',   // public API URL (OAuth callbacks)
  frontendURL: 'http://localhost:3000',
  // google: { clientId: '...', clientSecret: '...' },
  // github: { clientId: '...', clientSecret: '...' },
  // facebook: { clientId: '...', clientSecret: '...' },
  sendResetPassword: createResetEmailSender(null), // null = log reset links (dev)
});

const guards = createAuthGuards(auth);

const app = new App(db, {
  auth: { router: createAuthRouter({ auth, guards, frontendURL: 'http://localhost:3000' }) },
  services: { auth }, // exposed to every handler as ctx.services.auth
  // broker: { type: 'redis', redisUrl: 'redis://localhost:6379' }, // opt-in
});

const server = app.listen(3000, () => console.log('Listening on :3000'));
process.on('SIGTERM', () => void (async () => {
  server.close();
  await app.close(); // closes broker connections
  await pool.end();
  process.exit(0);
})());

Your schema (src/schema.ts) — re-export the auth tables plus your own:

export * from 'express-oop-framework/schema';
import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core';

export const products = pgTable('products', {
  id: uuid('id').defaultRandom().primaryKey(),
  name: varchar('name', { length: 255 }).notNull(),
  price: integer('price').notNull().default(0),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

Then npm run db:push (with drizzle-kit + a drizzle.config.ts pointing at your schema) to create tables, including the framework's users table.

Configuration

All env vars are validated with Zod at startup — bad config crashes fast with a clear message. Copy .env.example to .env and fill it in.

| Variable | Default | Description | |---|---|---| | NODE_ENV | development | development \| production \| test | | PORT | 3000 | HTTP port | | DATABASE_URL | — (required) | Postgres connection string | | JWT_SECRET | — (required, ≥ 32 chars) | HS256 signing secret. Generate: openssl rand -base64 32 | | JWT_EXPIRES_IN | 7d | Token lifetime (passed to jsonwebtoken) | | API_URL | http://localhost:{PORT} | Public base URL of the API — OAuth callback URLs are built from this | | FRONTEND_URL | http://localhost:3000 | Password-reset links + social ?format=redirect target | | BROKER_TYPE | none | none \| redis \| rabbitmq (event broker is opt-in) | | REDIS_URL / RABBITMQ_URL | — | Set the one matching BROKER_TYPE | | GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | — | Only configured providers are enabled | | GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | — | | | FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET | — | | | SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM | (FROM: [email protected]) | When SMTP_HOST is unset, reset links are logged to the console instead of emailed |

Auth

Password auth + Google/GitHub/Facebook social login, served under /auth/*. Tokens are HS256 JWTs ({ sub, email, role }) sent as Authorization: Bearer <token>. Responses never include password hashes or reset tokens.

| Method + path | Protection | Body | Returns | |---|---|---|---| | POST /auth/signup | rate-limited (10 req / 15 min) | { email, password (min 8), name? } | { user, token } | | POST /auth/signin | rate-limited | { email, password } | { user, token } | | GET /auth/me | Bearer token required | — | current user | | GET /auth/session | optional token | — | { user } or { user: null } | | POST /auth/forgot-password | rate-limited | { email } | Always { success: true } (never reveals if the email exists) | | POST /auth/reset-password | rate-limited | { token, password (min 8) } | { success: true } (tokens expire after 1 hour) | | GET /auth/providers | — | — | { providers: [...] } (enabled social providers) | | GET /auth/social/:provider | — | — | 302 redirect to the provider's login page | | GET /auth/social/:provider/callback | — | ?code=…&state=… | { user, token } — or with ?format=redirect, a 302 to {FRONTEND_URL}/auth/social/callback?token=…&provider=… |

Protecting your own routes

import { AppRouter } from 'express-oop-framework';
// guards = createAuthGuards(auth) in src/index.ts

export const adminRouter = new AppRouter('/admin');

adminRouter.get('/dashboard', {
  use: [guards.requireAuth],                       // 401 without a valid token
  handler: ({ user }) => ({ hello: user!.email }),
});

adminRouter.delete('/users/:id', {
  use: [guards.requireAuth, guards.requireRole('admin')], // 403 on role mismatch
  schema: { params: z.object({ id: z.string().uuid() }) },
  handler: ({ params }) => ({ deleted: params.id }),
});
  • requireAuth rejects missing/invalid tokens with 401. optionalAuth attaches user when a valid token is present but never rejects. requireRole(...roles) must come after requireAuth.
  • Roles come from the role column on the users table (default 'user').

Social login setup

  1. Create an OAuth app with the provider (Google / GitHub / Facebook).
  2. Set the matching {GOOGLE,GITHUB,FACEBOOK}_CLIENT_ID/SECRET env vars.
  3. Register this redirect URI in the provider console: {API_URL}/auth/social/{google|github|facebook}/callback
  4. Start login at GET /auth/social/:provider — the framework handles state (CSRF protection, 10-minute expiry), the code exchange, and account linking (by provider identity, then by email, else creates a new user).

Notes: the provider must share an email address or login fails with a clear 400 — in particular, Facebook only returns an email when the app is granted the email permission. Only providers with credentials set appear in GET /auth/providers.

Scaffold modules

npx express-framework product               # src/modules/product/{schema,service,router}.ts
npx express-framework order --with-events   # + ctx.broker event publishing example

In the starter layout (this repo) the router is auto-registered at the anchors in src/framework/app.ts; in consumer projects mount it manually:

import { orderRouter } from './modules/order/order.router.js';
app.mountRouter(orderRouter);

Framework reference

The layered pattern:

  • Router — URL prefix + Zod schemas ({ params?, query?, body? }) + use: [] middlewares → service. No business logic or SQL. Whatever the handler returns is sent as JSON (unless you already used res directly, e.g. res.redirect()). Destructure what you need: { db, services, body, query, params, user, broker, error }.
  • Service — domain rules + Drizzle queries. Extends BaseService<Db> and gets the db client through the constructor, so queries run on this.db. Plain classes — unit-test directly: new OrderService(fakeDb).list({ page: 1, limit: 20 }).
  • Middleware — class-based (subclass BaseMiddleware) or functional, attached per route via use: [].

Declare the db type once — new AppRouter<Db>('/orders') — and every route gets a fully typed ctx.db (ctx.services too, via the second generic). No as never casts anywhere:

import { AppRouter } from 'express-oop-framework';

export const orderRouter = new AppRouter<Db>('/orders');

orderRouter.get('/', {
  schema: listOrderSchema, // { query: z.object({...}) } — inferred into the handler
  handler: ({ db, query }) => new OrderService(db).list(query),
});

orderRouter.post('/', {
  use: [guards.requireAuth, guards.requireRole('admin')],
  schema: createOrderSchema,
  handler: async ({ db, body }) => ({ success: true, data: await new OrderService(db).create(body) }),
});

A service is a plain class extending BaseService:

export class OrderService extends BaseService<Db> {
  async list(query: { page: number; limit: number }) {
    const items = await this.db.select().from(orders)
      .limit(query.limit).offset((query.page - 1) * query.limit);
    return { items, page: query.page, limit: query.limit };
  }
}

Container — new App(db, { services: { auth, mailer } }) publishes the db client and services to every handler as ctx.db / ctx.services. Standalone (skipping App), attach them where you mount routers:

AppRouter.mountRoutes(expressApp, [orderRouter, authRouter], { db, services });

Middleware: class-based middleware subclasses BaseMiddleware, implements handle() and reuses this.req / this.res / this.next() plus helpers like this.unauthorized() / this.forbidden(); a single instance is safe to share (each request runs on a lightweight clone). Functional alternative: new Middleware(async ({ req, res, next }) => { … }), or Middleware.fromHandler(expressHandler) to reuse plain Express middleware.

Event broker is disabled by default (ctx.broker is a safe no-op, no connections). Enable with new App(db, { broker: { type: 'redis', redisUrl } }) (or 'rabbitmq' + rabbitmqUrl), pass your own IEventBroker instance, or use MemoryEventBroker in tests. Always await app.close() on shutdown.

Rate limiting: a global limiter (200 req / 15 min) applies to all routes; password auth endpoints get a strict 10 req / 15 min limiter. Build your own with createRateLimiter({ windowMs, max, message }) and attach via use: [].

Errors: thrown HttpErrors become { error, details } with their status code; Zod validation failures become 400; anything else becomes a logged 500.

Observability: Pino request logging (pretty in development), GET /health returns { status: 'ok', uptime }.

Database workflow

npm run db:generate   # SQL migrations from schema → drizzle/
npm run db:migrate    # apply migrations
npm run db:push       # push schema directly (dev)

drizzle.config.ts points at your schema file. Your schema should re-export express-oop-framework/schema so the users table is managed alongside yours.

Developing this package

npm install
cp .env.example .env   # set DATABASE_URL + JWT_SECRET
npm run db:push
npm run dev            # starter app in src/ (tsx watch)
npm run typecheck
npm run build          # dist/ (published library) + bin/ (CLI)
npm run build:starter && npm start  # run compiled starter
npm pack --dry-run     # inspect what gets published

See SKILL.md for the full contributor/agent spec (API reference, conventions, troubleshooting).

Troubleshooting

  • Boot crashes on env (DATABASE_URL is required, JWT_SECRET must be at least 32 chars): .env missing or not loaded — check the file exists and you're running from the project root.
  • 401 on every request: missing/expired Authorization: Bearer <token>, or the server's JWT_SECRET differs from the one that issued the token.
  • Social login 400 "did not share an email address": grant the email scope/permission (Facebook: email permission) or use a provider account with a verified email.
  • Social callback 400 (state): API_URL doesn't match the registered callback URL, or more than ~10 minutes passed during login.
  • 429 on auth routes: strict auth rate limiter (10 req / 15 min per IP).
  • Reset token rejected: tokens expire after 1 hour; request a new one via /forgot-password.

License

MIT