JSPM

  • Created
  • Published
  • Downloads 379
  • Score
    100M100P100Q95232F
  • License MIT

SPFN Framework Core - File-based routing, transactions, repository pattern

Package Exports

  • @spfn/core
  • @spfn/core/client
  • @spfn/core/codegen
  • @spfn/core/db
  • @spfn/core/env
  • @spfn/core/route
  • @spfn/core/server

Readme

@spfn/core

Core framework for building type-safe backend APIs with Next.js and Hono

npm version License: MIT TypeScript

⚠️ Alpha Release: SPFN is currently in alpha. APIs may change. Use @alpha tag for installation.

Installation

Recommended: Create New Project

npx spfn@alpha create my-app

Add to Existing Next.js Project

cd your-nextjs-project
npx spfn@alpha init

Manual Installation

npm install @spfn/core hono drizzle-orm postgres @sinclair/typebox

Quick Start

1. Define a Contract

// src/server/routes/users/contract.ts
import { Type } from '@sinclair/typebox';

export const getUsersContract = {
  method: 'GET' as const,
  path: '/',
  query: Type.Object({
    page: Type.Optional(Type.Number()),
    limit: Type.Optional(Type.Number()),
  }),
  response: Type.Object({
    users: Type.Array(Type.Object({
      id: Type.Number(),
      name: Type.String(),
      email: Type.String(),
    })),
    total: Type.Number(),
  }),
};

2. Create a Route

// src/server/routes/users/index.ts
import { createApp } from '@spfn/core/route';
import { getUsersContract } from './contract.js';
import { Repository } from '@spfn/core/db';
import { users } from '../../entities/users.js';

const app = createApp();

app.bind(getUsersContract, async (c) => {
  const { page = 1, limit = 10 } = c.query;

  // Create repository instance
  const repo = new Repository(users);

  const offset = (page - 1) * limit;
  const result = await repo.select()
    .limit(limit)
    .offset(offset);

  return c.json({ users: result, total: result.length });
});

export default app;

3. Start Server

npm run spfn:dev
# Server starts on http://localhost:8790

Architecture Pattern

SPFN follows a layered architecture that separates concerns and keeps code maintainable:

┌─────────────────────────────────────────┐
│            Routes Layer                  │  HTTP handlers, contracts
│  - Define API contracts (TypeBox)       │
│  - Handle requests/responses            │
│  - Thin handlers                        │
└──────────────┬──────────────────────────┘
               │
┌──────────────▼──────────────────────────┐
│           Service Layer                  │  Business logic
│  - Orchestrate operations               │
│  - Implement business rules             │
│  - Use repositories                     │
└──────────────┬──────────────────────────┘
               │
┌──────────────▼──────────────────────────┐
│         Repository Layer                 │  Data access
│  - CRUD operations                      │
│  - Custom queries                       │
│  - Extend base Repository               │
└──────────────┬──────────────────────────┘
               │
┌──────────────▼──────────────────────────┐
│           Entity Layer                   │  Database schema
│  - Table definitions (Drizzle)          │
│  - Type inference                       │
│  - Schema helpers                       │
└─────────────────────────────────────────┘

Complete Example: Blog Post System

1. Entity Layer - Define database schema

// src/server/entities/posts.ts
import { pgTable, text } from 'drizzle-orm/pg-core';
import { id, timestamps } from '@spfn/core/db';

export const posts = pgTable('posts', {
  id: id(),
  title: text('title').notNull(),
  slug: text('slug').notNull().unique(),
  content: text('content').notNull(),
  status: text('status', {
    enum: ['draft', 'published']
  }).notNull().default('draft'),
  ...timestamps(),
});

export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

2. Repository Layer - Data access with custom methods

// src/server/entities/posts.ts (continued)
import { eq } from 'drizzle-orm';
import { Repository } from '@spfn/core/db';

export class PostRepository extends Repository<typeof posts> {
  async findBySlug(slug: string) {
    const results = await this.select()
      .where(eq(this.table.slug, slug))
      .limit(1);
    return results[0] ?? null;
  }

  async findPublished() {
    return this.select()
      .where(eq(this.table.status, 'published'))
      .orderBy(this.table.createdAt);
  }

  async create(data: NewPost) {
    const [post] = await this.insert()
      .values(data)
      .returning();
    return post;
  }
}

// Export repository instance for reuse
export const postRepository = new PostRepository(posts);

3. Routes Layer - HTTP API

// src/server/routes/posts/contracts.ts
import { Type } from '@sinclair/typebox';

export const createPostContract = {
  method: 'POST' as const,
  path: '/',
  body: Type.Object({
    title: Type.String(),
    content: Type.String(),
  }),
  response: Type.Object({
    id: Type.String(),
    title: Type.String(),
    slug: Type.String(),
  }),
};

export const listPostsContract = {
  method: 'GET' as const,
  path: '/',
  response: Type.Array(Type.Object({
    id: Type.String(),
    title: Type.String(),
    slug: Type.String(),
  })),
};
// src/server/routes/posts/index.ts
import { createApp } from '@spfn/core/route';
import { Transactional } from '@spfn/core/db';
import { postRepository } from '../../entities/posts';
import { createPostContract, listPostsContract } from './contracts';

const app = createApp();

// POST /posts - Create new post (with transaction)
app.bind(createPostContract, Transactional(), async (c) => {
  const body = await c.data();

  // Generate slug from title
  const slug = body.title.toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '');

  // Check if slug exists
  const existing = await postRepository.findBySlug(slug);
  if (existing) {
    return c.json({ error: 'Post with this title already exists' }, 409);
  }

  // Create post
  const post = await postRepository.create({
    ...body,
    slug,
    status: 'draft'
  });

  // ✅ Auto-commit on success, auto-rollback on error
  return c.json(post, 201);
});

// GET /posts - List published posts (no transaction needed)
app.bind(listPostsContract, async (c) => {
  const posts = await postRepository.findPublished();
  return c.json(posts);
});

export default app;

Why This Architecture?

✅ Separation of Concerns

  • Each layer has a single responsibility
  • Easy to locate and modify code

✅ Testability

  • Test each layer independently
  • Mock dependencies easily

✅ Reusability

  • Services can be used by multiple routes
  • Repositories can be shared across services

✅ Type Safety

  • Types flow from Entity → Repository → Service → Route
  • Full IDE autocomplete and error checking

✅ Maintainability

  • Add features without breaking existing code
  • Clear boundaries prevent coupling

Layer Responsibilities

Layer Responsibility Examples
Entity Define data structure Schema, types, constraints
Repository Data access CRUD, custom queries, joins
Service Business logic Validation, orchestration, rules
Routes HTTP interface Contracts, request handling

Best Practices

Entity Layer:

  • ✅ Use schema helpers: id(), timestamps()
  • ✅ Export inferred types: Post, NewPost
  • ✅ Use TEXT with enum for status fields

Repository Layer:

  • ✅ Extend Repository<typeof table> for custom methods
  • ✅ Export repository instance from entity file: export const repo = new MyRepository(table)
  • ✅ Add domain-specific query methods
  • ✅ Return typed results

Routes Layer:

  • ✅ Keep handlers thin (delegate to services)
  • ✅ Define contracts with TypeBox
  • ✅ Use Transactional() middleware for write operations
  • ✅ Use c.data() for validated input
  • ✅ Return c.json() responses

Core Modules

📁 Routing

File-based routing with contract validation and type safety.

→ Read Routing Documentation

Key Features:

  • Automatic route discovery (index.ts, [id].ts, [...slug].ts)
  • Contract-based validation with TypeBox
  • Type-safe request/response handling
  • Method-level middleware control (skip auth per HTTP method)

🗄️ Database & Repository

Drizzle ORM integration with repository pattern and pagination.

→ Read Database Documentation

Key Features:

  • Repository pattern with automatic transaction handling
  • Read/Write database separation
  • Schema helpers: id(), timestamps(), foreignKey()

🔄 Transactions

Automatic transaction management with async context propagation.

→ Read Transaction Documentation

Key Features:

  • Auto-commit on success, auto-rollback on error
  • AsyncLocalStorage-based context
  • Transaction logging

💾 Cache

Redis integration with master-replica support.

→ Read Cache Documentation

⚠️ Error Handling

Custom error classes with unified HTTP responses.

→ Read Error Documentation

🔐 Middleware

Request logging, CORS, and error handling middleware.

→ Read Middleware Documentation

🖥️ Server

Server configuration and lifecycle management.

→ Read Server Documentation

📝 Logger

High-performance logging with multiple transports, sensitive data masking, and automatic validation.

→ Read Logger Documentation

Key Features:

  • Adapter pattern (Pino for production, custom for full control)
  • Sensitive data masking (passwords, tokens, API keys)
  • File rotation (date and size-based) with automatic cleanup
  • Configuration validation with clear error messages
  • Multiple transports (Console, File, Slack, Email)

⚙️ Code Generation

Automatic code generation with pluggable generators and centralized file watching.

→ Read Codegen Documentation

Key Features:

  • Orchestrator pattern for managing multiple generators
  • Built-in contract generator for type-safe API clients
  • Configuration-based setup (.spfnrc.json or package.json)
  • Watch mode integrated into spfn dev
  • Extensible with custom generators

Module Exports

Main Export

import { startServer, createServer } from '@spfn/core';

Routing

import { createApp, bind, loadRoutes } from '@spfn/core/route';
import type { RouteContext, RouteContract } from '@spfn/core/route';

Database

import {
  getDatabase,
  Repository
} from '@spfn/core/db';

Transactions

import {
  Transactional,
  getTransaction,
  runWithTransaction
} from '@spfn/core/db';

Cache

import { initRedis, getRedis, getRedisRead } from '@spfn/core';

Logger

import { logger } from '@spfn/core';

Client (for frontend)

import { ContractClient, createClient } from '@spfn/core/client';

Environment Variables

# Database (required)
DATABASE_URL=postgresql://user:pass@localhost:5432/db

# Database Read Replica (optional)
DATABASE_READ_URL=postgresql://user:pass@replica:5432/db

# Redis (optional)
REDIS_URL=redis://localhost:6379
REDIS_WRITE_URL=redis://master:6379  # Master-replica setup
REDIS_READ_URL=redis://replica:6379

# Server
PORT=8790
HOST=localhost
NODE_ENV=development

# Server Timeouts (optional, in milliseconds)
SERVER_TIMEOUT=120000              # Request timeout (default: 120000)
SERVER_KEEPALIVE_TIMEOUT=65000     # Keep-alive timeout (default: 65000)
SERVER_HEADERS_TIMEOUT=60000       # Headers timeout (default: 60000)
SHUTDOWN_TIMEOUT=30000             # Graceful shutdown timeout (default: 30000)

# Logger (optional)
LOGGER_ADAPTER=pino               # pino | custom (default: pino)
LOGGER_FILE_ENABLED=true          # Enable file logging (production only)
LOG_DIR=/var/log/myapp           # Log directory (required when file logging enabled)

Requirements

  • Node.js >= 18
  • Next.js 15+ with App Router (when using with CLI)
  • PostgreSQL
  • Redis (optional)

Testing

npm test                    # Run all tests
npm test -- route           # Run route tests only
npm test -- --coverage      # With coverage

Test Coverage: 120+ tests across all modules

Documentation

Guides

API Reference

  • See module-specific README files linked above

License

MIT


Part of the SPFN Framework