# Introduction ## Motivation Modern JavaScript applications often require complex architectures that enable the creation of scalable and maintainable systems. Existing frameworks like Express or Koa provide basic functionality but typically require additional libraries and configuration to achieve a modern application architecture. [Routing-controllers](https://github.com/typestack/routing-controllers){rel=""nofollow""} was a step in the right direction, introducing a decorator and class-based approach, but with the evolution of the JavaScript/TypeScript ecosystem and the emergence of new standards and runtimes (Bun, Deno), there was a need to create a new solution that: - Fully utilizes the capabilities of the latest versions of TypeScript and JavaScript - Offers configuration flexibility while maintaining ease of use - Provides high performance without unnecessary overhead - Is compatible with various JavaScript runtime environments - Supports both ESM and CommonJS - Ensures excellent developer experience (DX) These needs were the direct inspiration for creating Vercube-a modern, efficient, and flexible framework that solves the problems of existing solutions while offering new possibilities. ## What is Vercube? Vercube is a modern JavaScript framework based on the object-oriented programming (OOP) paradigm and TypeScript decorators, enabling the creation of server applications in an elegant and type-safe manner. The framework is built from the ground up using native Request and Response interfaces without relying on any additional HTTP frameworks, which ensures exceptional performance, low resource usage, and runtime agnosticism. Vercube seamlessly runs on different JavaScript environments (Node.js, Bun, Deno) by leveraging native interfaces common across all modern runtimes. Key features of Vercube: - **Declarative routing** - thanks to TypeScript decorators, you can declare API endpoints in a readable and concise way - **Dependency Injection** - built-in dependency injection system that facilitates code organization and testing - **Runtime agnostic** - works independently across all runtime environments (Node.js, Bun, Deno) using native interfaces - **Modularity** - flexible architecture that allows for creating modular applications - **Zero-config** - default configurations that allow for quick start without unnecessary setup - **Type safety** - full support for TypeScript that ensures type safety at all levels of the application - **Support for ESM and CommonJS** - compatibility with both module systems - **High performance** - optimized architecture using direct Request/Response handling for exceptional speed and minimal resource usage Vercube can be seen as a modern evolution of the concepts introduced by routing-controllers, but with better implementation, support for the latest standards, and much greater flexibility. ## Why Vercube? In the JavaScript ecosystem, there are many frameworks such as [Express](https://expressjs.com/){rel=""nofollow""}, [Koa](https://koajs.com/){rel=""nofollow""}, [Fastify](https://fastify.dev/){rel=""nofollow""}, [NestJS](https://nestjs.com/){rel=""nofollow""}, [Routing-Controllers](https://github.com/typestack/routing-controllers){rel=""nofollow""}, or [Ts.ED](https://tsed.dev/){rel=""nofollow""}. So why choose Vercube? Here are the main reasons: ### Performance without compromise Vercube has been designed with performance as the absolute priority from the very beginning. Built on native Request and Response interfaces with zero middleware overhead, it delivers unmatched speed and minimal latency across all runtime environments. Unlike other feature-rich frameworks (like NestJS), Vercube maintains a minimalist internal architecture that doesn't burden applications with unnecessary overhead - resulting in build times over 2× faster than NestJS. At the same time, it offers advanced features that typically require additional libraries in lighter frameworks (like Express). ::callout{color="primary" icon="i-heroicons-bolt"} Independent [benchmarks](https://github.com/vercube/benchmarks){rel=""nofollow""} comparing Vercube against NestJS and routing-controllers show that Vercube consistently leads in build time, cold start time, requests per second, and latency. Check out the latest results at [github.com/vercube/benchmarks](https://github.com/vercube/benchmarks){rel=""nofollow""}. :: ### Superior developer experience Vercube focuses on excellent developer experience (DX) through: - Intuitive decorator-based API - Extensive TypeScript hints and support - Clear and understandable error messages - Consistent and predictable application structure - Minimalist configuration while maintaining complete flexibility ### Flexibility and scalability Vercube offers various levels of abstraction that adapt to project needs: - Ability to quickly prototype with "zero-config" configuration - Support for modular architecture in larger applications - Possibility to integrate with existing libraries and middleware - Scalability from simple APIs to complex enterprise applications - Full control over the underlying request and response objects ### Compatibility with modern standards Vercube supports the latest standards and technologies: - Full support for ESM (ECMAScript Modules) - Backward compatibility with CommonJS - Support for the latest TypeScript features - Native support for all modern runtime environments (Node.js, Bun, Deno) ### Unique features unavailable in other frameworks Vercube introduces a number of unique features that distinguish it from the competition: - Advanced decorator system that simplifies typical server operations - Intelligent detection and auto-configuration system - Optimized dependency injection system tailored to TypeScript specifics - Flexible middleware system working at different application levels - Direct access to native Request/Response objects without abstractions Vercube is the ideal solution for teams and developers looking for a modern, efficient, and flexible framework that offers excellent developer experience without compromising on performance or functionality. # Installation Learn more about [Vercube's features and benefits](https://vercube.dev/docs/getting-started) before starting the installation process. ## Try it online If you want to explore Vercube without setting up a local project, you can use one of our online sandboxes: ::card-group :::card --- icon: simple-icons:stackblitz target: _blank title: Stackblitz to: https://stackblitz.com/edit/vercube-starter --- Try Vercube in a live environment on Stackblitz. ::: :::card --- icon: simple-icons:codesandbox target: _blank title: CodeSandbox to: https://codesandbox.io/p/devbox/vercube-starter-97s34j --- Try Vercube in a live environment on CodeSandbox. ::: :: ## Quick start Before you begin, make sure you have one of the following runtime environments installed: - [Node.js](https://nodejs.org/en){rel=""nofollow""} >= 22.0.0 - [Bun](https://bun.sh){rel=""nofollow""} >= 1.2.0 - [Deno](https://deno.land){rel=""nofollow""} >= 2.0.0 ::alert{icon="lucide:info" type="secondary"} We recommend using the latest stable versions of these runtimes for the best experience. :: ::steps ### Create a new project The easiest way to get started with Vercube is to use the official project generator: :::code-group ```bash [pnpm] $ pnpm create vercube@latest ``` ```bash [npm] $ npx create-vercube@latest ``` ```bash [bun] $ bun create vercube ``` ::: ### Start the development server After creating the project, navigate to the project directory and start the development server: :::code-group ```bash [pnpm] $ pnpm dev ``` ```bash [npm] $ npm run dev ``` ```bash [bun] $ bun run dev ``` ::: Open your browser and navigate to `http://localhost:3000` to see your Vercube application running! :::tip The development server will automatically reload the page when you make changes to your code. ::: :: # Examples Below you'll find a collection of example applications that demonstrate various features and use cases of Vercube. These examples showcase common patterns, best practices, and different ways to structure your applications. Each example comes with detailed explanations and source code that you can use as a reference for your own projects. | Example | Source | Try | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | | `hello-world` | [examples/base](https://github.com/vercube/vercube/tree/main/examples/base/){rel=""nofollow""} | `npx giget gh:vercube/vercube/examples/base vercube-base` | | `custom-plugin` | [examples/custom-plugin](https://github.com/vercube/vercube/tree/main/examples/custom-plugin/){rel=""nofollow""} | `npx giget gh:vercube/vercube/examples/custom-plugin vercube-custom-plugin` | | `aws-lambda` | [examples/aws-lambda](https://github.com/vercube/vercube/tree/main/examples/aws-lambda/){rel=""nofollow""} | `npx giget gh:vercube/vercube/examples/aws-lambda vercube-aws` | | `azure-functions` | [examples/azure-functions](https://github.com/vercube/vercube/tree/main/examples/azure-functions/){rel=""nofollow""} | `npx giget gh:vercube/vercube/examples/azure-functions vercube-azure` | | `websockets` | [examples/ws](https://github.com/vercube/vercube/tree/main/examples/ws/){rel=""nofollow""} | `npx giget gh:vercube/vercube/examples/ws vercube-ws` | The **`custom-plugin`** example matches the [Plugins](https://vercube.dev/docs/advanced/custom-plugin) guide: a `BasePlugin` in `vercube.config.ts` with **`configure`**, **`setup`**, and **`setupCLI`** (`GET /_health/` and a sample CLI subcommand). ## Playground patterns The `playground/` app includes reference implementations for common patterns. ### API documentation (Schema + Scalar) `playground/` registers `@vercube/schema` via `SchemaPlugin`. After `pnpm dev`, open: - `/_schema/` - OpenAPI JSON - `/_schema/docs` - Scalar API Reference See `playground/src/Controllers/PlaygroundController.ts` for `@Schema` with Zod response models. Full guide: [Schema module](https://vercube.dev/docs/modules/schema/overview). ### Typed request context One example is a typed request-context wrapper that provides compile-time safety for `RequestContext` keys and values. See: - `playground/src/Services/TypedRequestContext.ts` - `playground/src/Services/RequestContextKeys.ts` Usage looks like this: ```ts import { Container, Inject } from '@vercube/di'; import { TypedRequestContext } from '../Services/TypedRequestContext'; import { RequestIdKey } from '../Services/RequestContextKeys'; @Inject(Container) private gContainer!: Container; const ctx = this.gContainer.resolve(TypedRequestContext); ctx.set(RequestIdKey, crypto.randomUUID()); const requestId = ctx.get(RequestIdKey); ``` **Rationale** - Avoids string-typo bugs by centralizing keys. - Enforces value types at compile time (e.g., `requestStartTime` is always a `number`). - Enables incremental adoption: typed access can live alongside existing string-based calls. # Configuration Vercube provides a powerful, type-safe configuration system that lets you customize every aspect of your application. Configuration is defined in a `vercube.config.ts` file at the root of your project, and can be accessed at runtime throughout your application. ## Creating Configuration Create a `vercube.config.ts` file in your project root: ```ts [vercube.config.ts] import { defineConfig } from '@vercube/core'; export default defineConfig({ server: { port: 3000, host: 'localhost' }, logLevel: 'info' }); ``` The `defineConfig` function provides TypeScript autocomplete and type checking for all configuration options. ::tip The `defineConfig` function is a helper that provides type safety and IDE autocomple. It doesn't do anything at runtime - it just returns your configuration object with proper types. :: ## Configuration Options ### Server Configuration Controls how your HTTP server runs: ```ts [vercube.config.ts] export default defineConfig({ server: { // Network settings host: 'localhost', port: 3000, // HTTPS configuration https: false, // Or with certificates: https: { key: './certs/key.pem', cert: './certs/cert.pem' }, // Static file serving static: { dirs: ['public'], // Directories to serve maxAge: 3600, // Cache time in seconds immutable: false, // Set immutable cache header etag: true // Enable ETags } } }); ``` ### Runtime Configuration Configuration that's accessible at runtime in your application: ```ts [vercube.config.ts] export default defineConfig({ runtime: { // Built-in session configuration session: { secret: process.env.SESSION_SECRET || 'change-me-in-production', name: 'vercube_session', duration: 60 * 60 * 24 * 7 // 7 days in seconds }, // Your custom runtime config (more on this below) database: { host: 'localhost', port: 5432, name: 'myapp' }, api: { baseUrl: 'https://api.example.com', timeout: 5000 } } }); ``` ### Plugins Framework plugins extend config, runtime, CLI, and the dev parent process. **Recommended:** classes extending **`BasePlugin`**. **`defineVercubePlugin`** is optional **syntax sugar** for small inline hooks in this file (same pipeline under the hood). ```ts [vercube.config.ts] import { defineConfig, defineVercubePlugin } from '@vercube/core'; import { FeaturePlugin } from './src/plugins/FeaturePlugin'; export default defineConfig({ plugins: [ FeaturePlugin, defineVercubePlugin({ name: 'inline', config: () => ({ server: { port: 3100 } }), }), ], }); ``` See [Plugins](https://vercube.dev/docs/advanced/custom-plugin) for the canonical vs syntax-sugar distinction, **`BasePlugin`** methods, hook order, and where each hook runs (CLI vs worker vs `vercube dev`). ### Build Configuration Configuration for the Vercube CLI build process: ```ts [vercube.config.ts] export default defineConfig({ build: { // Project root directory root: process.cwd(), // Entry point(s) entry: 'src/index.ts', // Or multiple entries: entry: ['src/index.ts', 'src/worker.ts'], // Build output output: { dir: 'dist', // Output directory publicDir: 'public' // Public assets directory }, // TypeScript configuration tsconfig: './tsconfig.json', dts: true, // Generate .d.ts files // Build-time defines (injected into code) define: { 'process.env.API_URL': JSON.stringify('https://api.example.com') }, // Bundler to use bundler: 'rolldown', // Custom bundler plugins plugins: [ // Your rolldown plugins here ] } }); ``` ### Logging Configuration ```ts [vercube.config.ts] export default defineConfig({ // Log level for the application logLevel: 'debug' // 'debug' | 'info' | 'warn' | 'error' | 'silent' }); ``` ### Environment Flags ```ts [vercube.config.ts] export default defineConfig({ // Automatically set based on NODE_ENV, but you can override production: process.env.NODE_ENV === 'production', dev: process.env.NODE_ENV !== 'production' }); ``` ## Accessing Configuration at Runtime Use the `RuntimeConfig` class to access your configuration in controllers, services, and other parts of your application: ```ts [UserController.ts] import { Controller, Get, Inject } from '@vercube/core'; import { RuntimeConfig } from '@vercube/core'; @Controller('/users') export class UserController { @Inject(RuntimeConfig) private config!: RuntimeConfig; @Get('/session-info') getSessionInfo() { // Access built-in runtime config return { sessionName: this.config.runtimeConfig?.session?.name, sessionDuration: this.config.runtimeConfig?.session?.duration }; } } ``` ::warning `RuntimeConfig.runtimeConfig` is always optional (undefined until the app initializes), so always use optional chaining (`?.`) when accessing it. :: ## Custom Runtime Configuration You can add your own runtime configuration with full type safety: ### Step 1: Define Your Config Type ```ts [src/types/AppConfig.ts] export interface AppConfig { database: { host: string; port: number; name: string; }; api: { baseUrl: string; timeout: number; }; } ``` ### Step 2: Use Type Parameter in defineConfig ```ts [vercube.config.ts] import { defineConfig } from '@vercube/core'; import type { AppConfig } from './src/types/AppConfig'; export default defineConfig({ runtime: { // Built-in session config still works session: { secret: process.env.SESSION_SECRET!, duration: 60 * 60 * 24 * 7 }, // Your custom configuration with full autocomplete database: { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432'), name: process.env.DB_NAME || 'myapp' }, api: { baseUrl: process.env.API_URL || 'https://api.example.com', timeout: 5000 } } }); ``` ### Step 3: Access Your Config with Type Safety ```ts [DatabaseService.ts] import { RuntimeConfig } from '@vercube/core'; import type { AppConfig } from '../types/AppConfig'; export class DatabaseService { @Inject(RuntimeConfig) private config!: RuntimeConfig; async connect() { const dbConfig = this.config.runtimeConfig?.database; if (!dbConfig) { throw new Error('Database configuration not found'); } // Full autocomplete for your config! console.log(`Connecting to ${dbConfig.host}:${dbConfig.port}/${dbConfig.name}`); } } ``` ## Environment Variables Vercube automatically loads environment variables from `.env` files using [c12](https://github.com/unjs/c12){rel=""nofollow""}. ### Basic Usage Create a `.env` file in your project root: ```bash [.env] NODE_ENV=development SESSION_SECRET=my-secret-key DB_HOST=localhost DB_PORT=5432 API_URL=https://api.example.com ``` Access in your config: ```ts [vercube.config.ts] export default defineConfig({ runtime: { session: { secret: process.env.SESSION_SECRET || 'fallback-secret' }, database: { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432') } } }); ``` ### Customizing .env Loading ```ts [vercube.config.ts] export default defineConfig({ c12: { dotenv: { // Custom .env file path fileName: '.env.local', // Load from specific directory cwd: process.cwd() } } }); ``` ### Disabling .env Loading ```ts [vercube.config.ts] export default defineConfig({ c12: { dotenv: false // Disable automatic .env loading } }); ``` ## Default Configuration If you don't provide a `vercube.config.ts` file, Vercube uses these defaults: ```ts { logLevel: 'debug', production: process.env.NODE_ENV === 'production', dev: process.env.NODE_ENV !== 'production', build: { root: process.cwd(), entry: 'src/index.ts', output: { dir: 'dist', publicDir: 'public' }, bundler: 'rolldown' }, server: { runtime: 'node', host: 'localhost', port: 3000, https: false, static: { dirs: ['public'] } }, runtime: { session: { secret: '', name: 'vercube_session', duration: 60 * 60 * 24 * 7 // 7 days } }, experimental: {} } ``` You only need to override the values you want to change. ## Configuration by Environment ::code-group ```ts [Development] export default defineConfig({ logLevel: 'debug', dev: true, server: { port: 3000, host: 'localhost' } }); ``` ```ts [Production] export default defineConfig({ logLevel: 'warn', production: true, server: { port: parseInt(process.env.PORT || '8080'), host: '0.0.0.0' // Listen on all interfaces }, runtime: { session: { secret: process.env.SESSION_SECRET!, duration: 60 * 60 * 24 * 30 // 30 days } } }); ``` ```ts [Dynamic Based on NODE_ENV] const isDev = process.env.NODE_ENV !== 'production'; export default defineConfig({ logLevel: isDev ? 'debug' : 'warn', production: !isDev, dev: isDev, server: { port: isDev ? 3000 : parseInt(process.env.PORT || '8080'), host: isDev ? 'localhost' : '0.0.0.0' } }); ``` :: ## Complete Example Here's a real-world configuration example: ```ts [vercube.config.ts] import { defineConfig } from '@vercube/core'; import type { AppConfig } from './src/types/AppConfig'; const isDev = process.env.NODE_ENV !== 'production'; export default defineConfig({ // Environment production: !isDev, dev: isDev, logLevel: isDev ? 'debug' : 'info', // Server server: { runtime: 'node', host: isDev ? 'localhost' : '0.0.0.0', port: parseInt(process.env.PORT || '3000'), https: isDev ? false : { key: './certs/privkey.pem', cert: './certs/fullchain.pem' }, static: { dirs: ['public', 'uploads'], maxAge: isDev ? 0 : 86400, // No cache in dev, 1 day in prod immutable: !isDev, etag: true } }, // Runtime configuration runtime: { session: { secret: process.env.SESSION_SECRET || 'dev-secret', name: 'app_session', duration: 60 * 60 * 24 * (isDev ? 7 : 30) // 7 days dev, 30 days prod }, // Custom app config database: { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432'), name: process.env.DB_NAME || 'myapp', ssl: !isDev }, redis: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379') }, api: { baseUrl: process.env.API_URL || 'https://api.example.com', timeout: 5000, retries: 3 }, features: { enableWebSockets: true, enableFileUploads: true, maxUploadSize: 10 * 1024 * 1024 // 10MB } }, // Build config (for vercube CLI) build: { root: process.cwd(), entry: 'src/index.ts', output: { dir: 'dist', publicDir: 'public' }, dts: true, define: { 'process.env.BUILD_TIME': JSON.stringify(new Date().toISOString()) } } }); ``` ## Troubleshooting **Config is undefined at runtime** Make sure you're injecting `RuntimeConfig`, not trying to import the config file directly: ```ts // ❌ Wrong - config file can't be imported import config from './vercube.config'; // ✅ Correct - inject RuntimeConfig @Inject(RuntimeConfig) private config!: RuntimeConfig; ``` **TypeScript errors with custom config** Make sure you're passing your type to `defineConfig`: ```ts // ❌ Missing type parameter export default defineConfig({ runtime: { myCustomConfig: { ... } // TypeScript error! } }); // ✅ With type parameter export default defineConfig({ runtime: { myCustomConfig: { ... } // Works! } }); ``` **Environment variables not loading** Check that your `.env` file is in the project root and that dotenv loading is enabled (it is by default). # Dependency Injection Dependency Injection (DI) is a design pattern that helps you build maintainable, testable, and flexible applications. The DI Container in Vercube manages your application's dependencies automatically, making your code cleaner and easier to work with. ## What is Dependency Injection? Imagine you're building a house. Instead of each room creating its own electricity generator, water pump, and heating system, you connect them to centralized utilities. Dependency Injection works the same way - instead of each class creating its own dependencies, the container provides them. ### Without Dependency Injection ```ts class UserService { private database: Database; private logger: Logger; constructor() { // Hard to test - creates concrete instances this.database = new PostgresDatabase(); this.logger = new FileLogger(); } } ``` ### With Dependency Injection ```ts class UserService { @Inject(Database) private database: Database; @Inject(Logger) private logger: Logger; // Easy to test - dependencies injected from outside } ``` ## How the Container Works The Container acts as a centralized registry that knows how to create and manage all your application's dependencies. Here's a visual representation: ![How the container works](https://vercube.dev/images/ioc-1.svg) ### Key Benefits **Testability** - Easily replace real services with mocks in tests ```ts // In tests, swap real database with a mock container.bindMock(Database, { query: jest.fn() }); ``` **Flexibility** - Change implementations without modifying code ```ts // Switch from PostgreSQL to MongoDB without touching UserService container.bind(Database, MongoDatabase); ``` **Maintainability** - Clear dependency relationships ```ts // Just look at the constructor to see what a class needs constructor(private db: Database, private cache: Cache) {} ``` **Single Responsibility** - Classes focus on their job, not creating dependencies ```ts // UserService focuses on users, not database connection logic ``` ## Basic Usage ### Step 1: Create Your Service Create a regular TypeScript class. The container will handle creating instances. ```ts [UserService.ts] export class UserService { public getUsers(): User[] { return [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' } ]; } } ``` ### Step 2: Register in Container Tell the container about your service during application setup. ```ts [setup.ts] import { type App } from '@vercube/core'; import { UserService } from './services/UserService'; export function setup(app: App): void { app.container.bind(UserService); } ``` ### Step 3: Inject Dependencies Use the `@Inject` decorator to request dependencies in your controllers. ```ts [UserController.ts] import { Controller, Get, Inject } from '@vercube/core'; import { UserService } from './services/UserService'; @Controller('/users') export class UserController { @Inject(UserService) private userService!: UserService; @Get('/') public getUsers() { return this.userService.getUsers(); } } ``` ::note The `!` after the property name is TypeScript's "definite assignment assertion". It tells TypeScript "trust me, this will be assigned before I use it" - which is true because the container injects it. :: ## Service Lifetimes The container supports different lifetimes for your services, controlling when instances are created and how long they live. ### Singleton (Default) One instance is created and shared across your entire application. ```ts app.container.bind(Database); // or explicitly app.container.bind(Database, PostgresDatabase); ``` ![Singleton](https://vercube.dev/images/ioc-2.svg) **Use for:** Services that maintain state or are expensive to create (database connections, caches, configuration) ### Transient A new instance is created every time it's requested. ```ts app.container.bindTransient(RequestLogger); ``` ![Bind Transient](https://vercube.dev/images/ioc-3.svg) **Use for:** Stateless services or when you need isolation between uses ### Instance Bind an already-created instance to the container. ```ts const config = new AppConfig(); app.container.bindInstance(AppConfig, config); ``` **Use for:** Pre-configured objects, global singletons, or sharing instances between containers ## Working with Interfaces and Abstract Classes TypeScript interfaces don't exist at runtime, so the container can't use them directly. Instead, use symbols or abstract classes as service identifiers. ### Using Symbols ```ts [symbols.ts] import { Identity } from '@vercube/di'; export const $Database = Identity('Database'); ``` ```ts [setup.ts] import { $Database } from './symbols'; import { PostgresDatabase } from './services/PostgresDatabase'; app.container.bind($Database, PostgresDatabase); ``` ```ts [UserService.ts] import { Inject } from '@vercube/core'; import { $Database } from './symbols'; import type { Database } from './interfaces/Database'; export class UserService { @Inject($Database) private database!: Database; } ``` ### Using Abstract Classes ```ts [Database.ts] export abstract class Database { abstract query(sql: string): Promise; } ``` ```ts [PostgresDatabase.ts] export class PostgresDatabase extends Database { async query(sql: string): Promise { // Implementation } } ``` ```ts [setup.ts] app.container.bind(Database, PostgresDatabase); ``` ```ts [UserService.ts] export class UserService { @Inject(Database) private database!: Database; } ``` ## Dependency Chains The container automatically resolves nested dependencies - when Service A needs Service B, and Service B needs Service C, everything just works. ![Dependency Chains](https://vercube.dev/images/ioc-4.svg) ```ts [UserController.ts] @Controller('/users') export class UserController { @Inject(UserService) private userService!: UserService; } ``` ```ts [UserService.ts] export class UserService { @Inject(Database) private database!: Database; @Inject(Logger) private logger!: Logger; } ``` The container handles the entire chain automatically! ## Optional Dependencies Sometimes a dependency might not be available, and that's okay. Use `@InjectOptional` for dependencies that may or may not exist. ```ts [EmailService.ts] import { InjectOptional } from '@vercube/core'; export class EmailService { @InjectOptional(TemplateEngine) private templateEngine?: TemplateEngine | null; public sendEmail(to: string, message: string) { if (this.templateEngine) { // Use fancy templates message = this.templateEngine.render(message); } // Send email } } ``` ## Manual Resolution Sometimes you need to create instances manually - use `container.resolve()` for this. By using `container.resolve()`, you create a new instance of the desired service. The key difference compared to using the `new` operator directly is that `container.resolve()` will automatically resolve and inject all dependencies required by the class, following the IoC pattern. This ensures your new instance is fully constructed with everything it needs, without you having to manually create or pass any dependencies. ```ts export class ReportGenerator { @Inject(Container) private container!: Container; public generateReport(type: string) { // Dynamically create service based on report type const service = this.container.resolve(ReportService); return service.generate(); } } ``` ## Testing with Mocks The container makes testing incredibly easy. Replace real services with mocks for isolated testing. ```ts [UserService.test.ts] import { Container } from '@vercube/di'; import { UserService } from './UserService'; import type { Database } from './Database'; describe('UserService', () => { it('should get users from database', async () => { // Create test container const container = new Container(); // Mock the database container.bindMock(Database, { query: jest.fn().mockResolvedValue([ { id: 1, name: 'Test User' } ]) }); // Register service container.bind(UserService); // Get service and test const service = container.get(UserService); const users = await service.getUsers(); expect(users).toHaveLength(1); expect(users[0].name).toBe('Test User'); }); }); ``` ::note `bindMock` doesn't require a complete implementation - just provide the methods you need for your test. :: ## Container Reference Access the container itself by injecting it: ```ts import { Container } from '@vercube/di'; export class MyService { @Inject(Container) private container!: Container; public doSomething() { const otherService = this.container.get(OtherService); } } ``` ::warning Injecting the container directly is an advanced pattern. In most cases, you should inject specific services instead. This keeps your dependencies explicit and your code easier to test. :: ## Common Patterns ### Factory Pattern Create instances dynamically based on runtime conditions: ```ts export class NotificationFactory { @Inject(Container) private container!: Container; public create(type: 'email' | 'sms'): NotificationService { if (type === 'email') { return this.container.resolve(EmailNotificationService); } return this.container.resolve(SmsNotificationService); } } ``` ## Best Practices **Keep constructors clean** - Let the container inject dependencies, don't do heavy work in constructors ```ts // ✅ Good export class UserService { @Inject(Database) private db!: Database; } // ❌ Bad export class UserService { private db: Database; constructor() { this.db = new Database(); this.db.connect(); // Heavy work in constructor } } ``` **Use interfaces for flexibility** - Program to interfaces, not implementations ```ts // ✅ Good @Inject(ILogger) private logger!: Logger; // ❌ Less flexible @Inject(FileLogger) private logger!: FileLogger; ``` **One responsibility per service** - Keep services focused ```ts // ✅ Good export class UserService { /* user operations */ } export class AuthService { /* auth operations */ } // ❌ Bad export class UserAuthService { /* users AND auth */ } ``` **Avoid circular dependencies** - If Service A needs Service B and Service B needs Service A, rethink your design ```ts // ❌ Bad - circular dependency class A { @Inject(B) b!: B; } class B { @Inject(A) a!: A; } // ✅ Good - extract shared logic class SharedLogic { } class A { @Inject(SharedLogic) logic!: SharedLogic; } class B { @Inject(SharedLogic) logic!: SharedLogic; } ``` ## Troubleshooting ### "Unresolved dependency" Error This means you tried to inject a service that wasn't registered in the container. ```ts // Error: Unresolved dependency for [UserService] @Inject(UserService) private userService!: UserService; ``` **Solution:** Register the service in your setup: ```ts app.container.bind(UserService); ``` ### TypeScript Error: Property has no initializer ```ts // Error: Property 'userService' has no initializer @Inject(UserService) private userService: UserService; ``` **Solution:** Add the `!` definite assignment assertion: ```ts @Inject(UserService) private userService!: UserService; ``` ### Service is undefined when accessed Make sure you're accessing the service after the container has initialized: ```ts // ❌ Bad - accessing in constructor constructor() { console.log(this.userService); // undefined! } // ✅ Good - accessing in methods public getUsers() { console.log(this.userService); // works! } ``` # Controllers Controllers are the heart of your API in Vercube. They organize your endpoints into logical groups, handle incoming requests, and return responses. Think of controllers as the "traffic directors" of your application - they receive requests, process them, and send back responses. ## What is a Controller? A controller is a class that groups related HTTP endpoints together. Instead of scattering your API endpoints across different files, you organize them by resource or feature. For example, all user-related operations go in `UserController`, all product operations go in `ProductController`, and so on. ### The Problem Without Controllers ```ts // ❌ Scattered endpoints - hard to maintain app.get('/users', getUsersHandler); app.post('/users', createUserHandler); app.get('/users/:id', getUserHandler); app.delete('/users/:id', deleteUserHandler); app.put('/users/:id', updateUserHandler); // ... hundreds of lines later app.get('/products', getProductsHandler); // ... where does one resource end and another begin? ``` ### The Solution With Controllers ```ts // ✅ Organized, clean, maintainable @Controller('/users') export class UserController { @Get('/') getUsers() { } @Post('/') createUser() { } @Get('/:id') getUser() { } @Put('/:id') updateUser() { } @Delete('/:id') deleteUser() { } } ``` ## How Controllers Work Here's the complete flow from request to response: ![How controllers works](https://vercube.dev/images/controller-1.svg) ### Behind the Scenes When you create a controller with decorators, Vercube automatically: 1. **Registers routes** - Combines controller path with method paths 2. **Creates handlers** - Wraps your methods in proper HTTP handlers 3. **Resolves parameters** - Extracts data from URL, body, headers, etc. 4. **Handles responses** - Serializes your return value to JSON 5. **Manages errors** - Catches and formats errors appropriately ![Behind the Scenes](https://vercube.dev/images/controller-2.svg) ## Creating Your First Controller ### Step 1: Create the Controller Class ```ts [UserController.ts] import { Controller } from '@vercube/core'; @Controller('/users') export class UserController { // Your endpoints will go here } ``` The `@Controller('/users')` decorator does two things: - Marks this class as a controller - Sets `/users` as the base path for all routes ### Step 2: Add Route Handlers ```ts [UserController.ts] import { Controller, Get } from '@vercube/core'; @Controller('/users') export class UserController { @Get('/') getAllUsers() { return [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' } ]; } } ``` This creates: `GET /users/` Vercube automatically: - Converts the return value to JSON - Sets `Content-Type: application/json` - Sends status code 200 ### Step 3: Register the Controller ```ts [setup.ts] import { type App } from '@vercube/core'; import { UserController } from './controllers/UserController'; export function setup(app: App): void { app.useController(UserController); } ``` That's it! Your API is ready to handle requests. ## HTTP Methods Vercube provides decorators for all standard HTTP methods: ```ts import { Controller, Get, Post, Put, Patch, Delete } from '@vercube/core'; @Controller('/users') export class UserController { @Get('/') list() { // GET /users - List all users } @Get('/:id') get() { // GET /users/:id - Get specific user } @Post('/') create() { // POST /users - Create new user } @Put('/:id') update() { // PUT /users/:id - Replace user } @Patch('/:id') modify() { // PATCH /users/:id - Partially update user } @Delete('/:id') remove() { // DELETE /users/:id - Delete user } } ``` ## Path Parameters Extract dynamic values from URLs using the `@Param` decorator: ```ts import { Controller, Get, Param } from '@vercube/core'; @Controller('/users') export class UserController { @Get('/:id') getUserById(@Param('id') id: string) { return { id, name: 'User ' + id }; } @Get('/:userId/posts/:postId') getUserPost( @Param('userId') userId: string, @Param('postId') postId: string ) { return { userId, postId, title: 'Post title' }; } } ``` ![Path Parameters](https://vercube.dev/images/controller-3.svg) ## Request Body Access request body data with the `@Body` decorator: ```ts import { Controller, Post, Body } from '@vercube/core'; interface CreateUserDto { name: string; email: string; age: number; } @Controller('/users') export class UserController { @Post('/') createUser(@Body() userData: CreateUserDto) { // userData is automatically parsed from JSON console.log(userData.name); // "Alice" console.log(userData.email); // "alice@example.com" return { id: 1, ...userData }; } } ``` ```bash # Client sends: curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name":"Alice","email":"alice@example.com","age":25}' ``` ::tip The `@Body()` decorator automatically parses JSON request bodies. No manual `JSON.parse()` needed! :: ::tip You can validate and transform request body automatically using Zod schemas. This ensures type safety and data integrity. Learn more in the [Validation](https://vercube.dev/docs/core-features/validation) guide. ```ts import { z } from 'zod'; const CreateUserSchema = z.object({ name: z.string(), email: z.string().email(), age: z.number().min(18) }); type CreateUserDto = z.infer; @Post('/') createUser(@Body({ validationSchema: CreateUserSchema }) userData: CreateUserDto) { // userData is validated and type-safe! // If validation fails, automatic 400 Bad Request response } ``` :: ## Query Parameters Extract URL query parameters using `@QueryParam` or `@QueryParams`: ```ts import { Controller, Get, QueryParam, QueryParams } from '@vercube/core'; @Controller('/users') export class UserController { @Get('/search') searchUsers( @QueryParam('name') name: string, @QueryParam('minAge') minAge: string, // Always string! @QueryParams() allParams: Record // Always strings! ) { // Convert to number if needed const minAgeNumber = parseInt(minAge, 10); return { searching: { name, minAge: minAgeNumber }, allQueryParams: allParams }; } } ``` ```bash # Request: GET /users/search?name=Alice&minAge=21&city=NYC # Your method receives: # name = "Alice" (string) # minAge = "21" (string, not number!) # allParams = { name: "Alice", minAge: "21", city: "NYC" } (all strings) ``` ::warning **Important:** Query parameters are ALWAYS strings according to the [URL specification](https://url.spec.whatwg.org/#urlsearchparams){rel=""nofollow""}. Even if the value looks like a number (`?age=25`), you'll receive it as the string `"25"`. You must manually convert to numbers, booleans, or other types: ```ts @Get('/search') searchUsers( @QueryParam('age') age: string, @QueryParam('active') active: string ) { const ageNumber = parseInt(age, 10); // "25" → 25 const isActive = active === 'true'; // "true" → true } ``` :: ::tip **Better approach:** Use Zod schemas to automatically validate and transform query parameters to the correct types. This eliminates manual parsing and ensures type safety. Learn more in the [Validation](https://vercube.dev/docs/core-features/validation) guide. ```ts import { z } from 'zod'; const SearchUsersSchema = z.object({ age: z.coerce.number().min(0), // Automatically converts string to number! active: z.coerce.boolean(), // Automatically converts string to boolean! name: z.string().optional() }); type SearchUsersDto = z.infer; @Get('/search') searchUsers(@QueryParams({ validationSchema: SearchUsersSchema }) query: SearchUsersDto) { // query.age is now a number: 25 // query.active is now a boolean: true // No manual conversion needed! } ``` :: ## Headers Access request headers with `@Header` or `@Headers`: ```ts import { Controller, Get, Header, Headers } from '@vercube/core'; @Controller('/users') export class UserController { @Get('/') getUsers( @Header('Authorization') token: string, @Header('X-API-Key') apiKey: string, @Headers() allHeaders: Record ) { console.log('Token:', token); console.log('API Key:', apiKey); return { authenticated: !!token }; } } ``` ## Returning Responses You have multiple ways to return responses from your controllers: ### Simple Values (Auto JSON) ```ts @Get('/') getUser() { // Automatically converted to JSON with status 200 return { id: 1, name: 'Alice' }; } ``` ### Custom Status Code ```ts import { Status } from '@vercube/core'; @Post('/') @Status(201) createUser(@Body() data: any) { // Returns status 201 Created return { id: 1, ...data }; } ``` ### Custom Headers ```ts import { SetHeader } from '@vercube/core'; @Get('/') @SetHeader('X-Custom-Header', 'value') @SetHeader('X-Rate-Limit', '1000') getUsers() { return [{ id: 1, name: 'Alice' }]; } ``` ### Redirects ```ts import { Redirect } from '@vercube/core'; @Get('/old-endpoint') @Redirect('/new-endpoint', 301) oldEndpoint() { // Automatically redirects to /new-endpoint } ``` ### Manual Response Control ```ts import { Response } from '@vercube/core'; @Get('/') getUsers(@Response() res: Response) { res.headers.set('X-Custom', 'value'); return new Response( JSON.stringify({ data: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } } ); } ``` ## Working with Services Controllers should be thin - they coordinate but don't contain business logic. Use dependency injection to access services: ```ts import { Controller, Get, Inject } from '@vercube/core'; import { UserService } from '../services/UserService'; @Controller('/users') export class UserController { @Inject(UserService) private userService!: UserService; @Get('/') async getAllUsers() { // Business logic is in the service return await this.userService.findAll(); } @Get('/:id') async getUserById(@Param('id') id: string) { return await this.userService.findById(id); } @Post('/') async createUser(@Body() data: CreateUserDto) { return await this.userService.create(data); } } ``` ![Working with Services](https://vercube.dev/images/controller-4.svg) ## Async Handlers Controllers fully support async/await: ```ts @Controller('/users') export class UserController { @Inject(UserService) private userService!: UserService; @Get('/:id') async getUser(@Param('id') id: string) { // Await is handled automatically const user = await this.userService.findById(id); if (!user) { throw new NotFoundException('User not found'); } return user; } @Post('/') async createUser(@Body() data: CreateUserDto) { // Multiple awaits work perfectly await this.userService.validateEmail(data.email); const user = await this.userService.create(data); await this.userService.sendWelcomeEmail(user); return user; } } ``` ## Error Handling Vercube provides HTTP exception classes for common errors: ```ts import { NotFoundException, BadRequestException, UnauthorizedException, ForbiddenException } from '@vercube/core'; @Controller('/users') export class UserController { @Get('/:id') async getUser(@Param('id') id: string) { const user = await this.userService.findById(id); if (!user) { throw new NotFoundException('User not found'); } return user; } @Post('/') createUser(@Body() data: CreateUserDto) { if (!data.email) { throw new BadRequestException('Email is required'); } if (data.age < 18) { throw new ForbiddenException('Must be 18 or older'); } return this.userService.create(data); } } ``` These exceptions are automatically converted to proper HTTP responses: ```json // NotFoundException → 404 { "statusCode": 404, "message": "User not found", "error": "Not Found" } // BadRequestException → 400 { "statusCode": 400, "message": "Email is required", "error": "Bad Request" } ``` ## Middleware Integration Apply middleware to controllers or specific routes: ```ts import { Controller, Get, Middleware } from '@vercube/core'; import { AuthMiddleware, LoggingMiddleware } from '../middlewares'; // Applies to ALL routes in this controller @Controller('/users') @Middleware(AuthMiddleware) export class UserController { @Get('/') getUsers() { // Protected by AuthMiddleware } // Additional middleware for this route only @Get('/:id') @Middleware(LoggingMiddleware) getUserById() { // Protected by AuthMiddleware + LoggingMiddleware } } ``` ![Working with Services](https://vercube.dev/images/controller-5.svg) ## Organizing Controllers ### By Resource (Recommended) ```text src/ ├── controllers/ │ ├── UserController.ts # All user operations │ ├── ProductController.ts # All product operations │ ├── OrderController.ts # All order operations │ └── AuthController.ts # All auth operations ``` ### By Feature ```text src/ ├── features/ │ ├── users/ │ │ ├── UserController.ts │ │ ├── UserService.ts │ │ └── User.model.ts │ └── products/ │ ├── ProductController.ts │ ├── ProductService.ts │ └── Product.model.ts ``` ## Best Practices **Keep controllers thin** - Business logic belongs in services ```ts // ❌ Bad - logic in controller @Get('/:id') async getUser(@Param('id') id: string) { const user = await db.query('SELECT * FROM users WHERE id = ?', [id]); delete user.password; user.fullName = user.firstName + ' ' + user.lastName; return user; } // ✅ Good - logic in service @Get('/:id') async getUser(@Param('id') id: string) { return await this.userService.findById(id); } ``` **Use DTOs for request validation** - Type your inputs ```ts // ✅ Good interface CreateUserDto { name: string; email: string; age: number; } @Post('/') createUser(@Body() data: CreateUserDto) { // TypeScript ensures data structure } ``` **One responsibility per controller** - Don't mix concerns ```ts // ❌ Bad - mixed concerns @Controller('/api') export class ApiController { @Get('/users') getUsers() { } @Get('/products') getProducts() { } @Get('/orders') getOrders() { } } // ✅ Good - focused controllers @Controller('/users') export class UserController { } @Controller('/products') export class ProductController { } ``` **Use meaningful route names** - Be clear about what each endpoint does ```ts // ❌ Unclear @Get('/data') getData() { } // ✅ Clear @Get('/users') getAllUsers() { } ``` ## Common Patterns ### CRUD Controller Template ```ts @Controller('/users') export class UserController { @Inject(UserService) private userService!: UserService; @Get('/') async findAll() { return await this.userService.findAll(); } @Get('/:id') async findOne(@Param('id') id: string) { return await this.userService.findById(id); } @Post('/') @Status(201) async create(@Body() data: CreateUserDto) { return await this.userService.create(data); } @Put('/:id') async update( @Param('id') id: string, @Body() data: UpdateUserDto ) { return await this.userService.update(id, data); } @Delete('/:id') @Status(204) async remove(@Param('id') id: string) { await this.userService.remove(id); } } ``` ### Nested Resources ```ts @Controller('/users/:userId/posts') export class UserPostController { @Get('/') getUserPosts(@Param('userId') userId: string) { // GET /users/123/posts } @Post('/') createUserPost( @Param('userId') userId: string, @Body() data: CreatePostDto ) { // POST /users/123/posts } } ``` # Middlewares Middlewares in Vercube provides a powerful way to handle and modify requests and responses at different levels of your application. They allow you to execute code before or after specific routes, add common functionality across multiple endpoints, or implement cross-cutting concerns like authentication, logging, or error handling. The middleware system in Vercube is designed to be flexible and intuitive, while maintaining the framework's high-performance characteristics. Each middleware can access and modify the request and response objects, making it possible to implement various functionalities like request validation, response transformation, or custom header management. ## Creating Middleware In Vercube, to create a middleware, you need to create a class that extends `BaseMiddleware`. This class provides two methods: `onRequest` and `onResponse`, which are invoked at different stages of the request lifecycle. ### `onRequest` The `onRequest` method is executed before the endpoint handler is called. It allows you to modify the request, validate input data, check user permissions, or interrupt further request processing by returning a `Response` object. This is the ideal place to implement authorization logic, request logging, or preliminary data validation. ::code-group ```ts [LoggingMiddleware.ts] import { BaseMiddleware } from '@vercube/core'; import type { MiddlewareOptions } from '@vercube/core'; export class LoggingMiddleware extends BaseMiddleware { public async onRequest( request: Request, response: Response, opts: MiddlewareOptions ): Promise { console.log(`[${new Date().toISOString()}] ${request.method} ${request.url}`); } } ``` ```ts [FooController.ts] import { Controller, Get, Middleware } from '@vercube/core'; import { LoggingMiddleware } from '@/middlewares/LoggingMiddleware'; @Controller('/users') @Middleware(LoggingMiddleware, { logLevel: 'debug' }) export class UserController { // ... } ``` :: ::note If the `onRequest` method returns a `Response` object (including `FastResponse`) or throws an HTTP error - the endpoint handler will not be invoked. :: ### `onResponse` The `onResponse` method is executed after the endpoint handler has been called and returns a response. It allows you to modify the response payload, add custom headers, perform post-processing operations, or implement response logging. This is the ideal place to implement response transformation, final logging, or cleanup operations. ::code-group ```ts [ResponseLoggingMiddleware.ts] import { BaseMiddleware } from '@vercube/core'; import type { MiddlewareOptions } from '@vercube/core'; interface IMyData { name: string; age: number; } export class ResponseLoggingMiddleware extends BaseMiddleware<{}, IMyData> { public async onResponse( request: Request, response: Response, payload: IMyData, ): Promise { console.log(`[${new Date().toISOString()}] Response: ${JSON.stringify(payload)}`); } } ``` ```ts [FooController.ts] import { Controller, Get, Middleware } from '@vercube/core'; import { ResponseLoggingMiddleware } from '@/middlewares/ResponseLoggingMiddleware'; @Controller('/users') @Middleware(ResponseLoggingMiddleware, { logLevel: 'debug' }) export class UserController { // ... } ``` :: ::note The `onResponse` method receives the `payload` parameter, which is the object returned by the endpoint handler. You can modify this payload or perform operations based on its content. :: ::warning **Important:** When an endpoint handler returns a `Response` object (including `FastResponse`), the middleware will be executed but the response object, including its headers, status code, and body, cannot be modified or overridden within the `onResponse` method. :: ## Applying Middleware To apply middleware to your endpoint, use the `@Middleware` decorator. This decorator can be applied to an entire controller class or to individual methods, giving you fine-grained control over where middleware logic is executed. When applied at the class level, the middleware will be executed for all endpoints within that controller. When applied at the method level, it will only affect that specific endpoint. You can also combine both approaches - class-level middleware will execute first, followed by method-level middleware, allowing you to create layered middleware chains that handle both general and specific concerns. ```ts [FooController.ts] import { Controller, Get, Middleware } from '@vercube/core'; import { LoggingMiddleware } from '@/middlewares/LoggingMiddleware'; @Controller('/users') @Middleware(LoggingMiddleware, { logLevel: 'debug' }) export class UserController { // ... } ``` ## Middleware Prioritization Vercube provides middleware prioritization capabilities, offering flexible control over execution order and timing. This allows you to precisely manage when and in what sequence middleware components are executed. To set middleware priority, pass a second argument to the `@Middleware` decorator containing a `priority` property. Lower priority values execute earlier in the middleware chain. ```ts [FooController.ts] import { Controller, Get, Middleware } from '@vercube/core'; import { LoggingMiddleware } from '@/middlewares/LoggingMiddleware'; @Controller('/users') @Middleware(LoggingMiddleware, { priority: 1 }) export class UserController { // ... } ``` ::note The default `priority` value is `999`. :: ## Global Middlewares In addition to middleware that can be applied to specific endpoints or endpoint groups, Vercube provides the capability to create global middleware. Global middleware functions identically to regular middleware, with the key difference being in their registration process. For global middleware, you must utilize the IOC service `GlobalMiddlewareRegistry`. You can register global middleware during your application setup: ```ts [setup.ts] import { type App } from '@vercube/core'; import { LoggingMiddleware } from '@/middlewares/LoggingMiddleware'; export function setup(app: App): void { const registry = app.container.get(GlobalMiddlewareRegistry); registry.registerGlobalMiddleware(LoggingMiddleware, { priority: 1 }); } ``` Once registered, the middleware will be executed for every endpoint in your application. # Validation Validation in Vercube ensures that incoming data meets your requirements before it reaches your business logic. Instead of manually checking every field, Vercube automatically validates and transforms request data using industry-standard validation libraries. ## What is Validation? Validation is the process of checking if incoming data is correct, complete, and safe to use. Without validation, your API is vulnerable to bad data, which can cause crashes, security issues, or data corruption. ### The Problem Without Validation ```ts @Post('/users') createUser(@Body() data: any) { // ❌ What if email is missing? // ❌ What if age is negative? // ❌ What if name is 1000 characters long? // ❌ What if data is not even an object? // Manual validation is tedious and error-prone if (!data.email) throw new Error('Email required'); if (typeof data.age !== 'number') throw new Error('Age must be number'); if (data.age < 0) throw new Error('Age must be positive'); // ... and so on for every field return this.userService.create(data); } ``` ### The Solution With Validation ```ts const CreateUserSchema = z.object({ email: z.string().email(), age: z.number().min(0), name: z.string().max(100) }); @Post('/users') createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) { // ✅ Data is guaranteed to be valid // ✅ Automatic 400 response if validation fails // ✅ Type-safe and clean return this.userService.create(data); } ``` ## How Validation Works Vercube's validation system is built on [Standard Schema](https://standardschema.dev/){rel=""nofollow""}, which means it works with **any** validation library that follows this standard. You're not locked into a specific library! ![how validation works](https://vercube.dev/images/validation-1.svg) ### What Happens Behind the Scenes When you add `validationSchema` to a decorator: 1. **Vercube registers a validation middleware** that runs before your handler 2. **The middleware extracts the data** (from body, query params, etc.) 3. **Your schema validates and transforms** the data 4. **If validation fails**, Vercube automatically returns a 400 error with details 5. **If validation succeeds**, your handler receives clean, type-safe data ## Standard Schema Support Vercube supports **any** validation library that implements the [Standard Schema](https://standardschema.dev/){rel=""nofollow""} specification. This gives you the freedom to choose the library that best fits your needs. ### Supported Libraries All of these work out of the box: - **[Zod](https://zod.dev/){rel=""nofollow""}** - TypeScript-first schema validation - **[Valibot](https://valibot.dev/){rel=""nofollow""}** - Lightweight, modular validation - **[ArkType](https://arktype.io/){rel=""nofollow""}** - TypeScript-native runtime validation - **[Typebox](https://github.com/sinclairzx81/typebox){rel=""nofollow""}** - JSON Schema based validation - **And any other Standard Schema compatible library!** ::tip You can even mix different libraries in the same project! Use Zod for complex validations and Valibot for simple ones - Vercube doesn't care, it works with all of them. :: ## Validating Request Body The most common use case is validating JSON request bodies. Choose your preferred validation library - they all work the same way in Vercube: ::code-group ```ts [Zod] import { z } from 'zod'; const CreateUserSchema = z.object({ name: z.string().min(2).max(50), email: z.string().email(), age: z.number().int().min(18).max(120), role: z.enum(['user', 'admin']).default('user') }); type CreateUserDto = z.infer; @Controller('/users') export class UserController { @Post('/') @Status(201) createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) { // data is validated and typed! return this.userService.create(data); } } ``` ```ts [Valibot] import * as v from 'valibot'; const CreateUserSchema = v.object({ name: v.pipe(v.string(), v.minLength(2), v.maxLength(50)), email: v.pipe(v.string(), v.email()), age: v.pipe(v.number(), v.integer(), v.minValue(18), v.maxValue(120)), role: v.optional(v.picklist(['user', 'admin']), 'user') }); type CreateUserDto = v.InferOutput; @Controller('/users') export class UserController { @Post('/') @Status(201) createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) { // Same behavior, different library! return this.userService.create(data); } } ``` ```ts [ArkType] import { type } from 'arktype'; const CreateUserSchema = type({ name: 'string>2<50', 'email': 'string.email', age: 'number.integer>=18<=120', 'role?': '"user"|"admin" = "user"' }); type CreateUserDto = typeof CreateUserSchema.infer; @Controller('/users') export class UserController { @Post('/') @Status(201) createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) { // Works exactly the same! return this.userService.create(data); } } ``` :: ## Validating Query Parameters Query parameters are always strings in HTTP, but validation libraries can transform them to the correct types: ```ts import { z } from 'zod'; const SearchUsersSchema = z.object({ // z.coerce converts strings to numbers/booleans page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(10), active: z.coerce.boolean().optional(), role: z.enum(['user', 'admin']).optional(), search: z.string().optional() }); type SearchUsersDto = z.infer; @Controller('/users') export class UserController { @Get('/') searchUsers(@QueryParams({ validationSchema: SearchUsersSchema }) query: SearchUsersDto) { // query.page is a number (not string!) // query.limit is a number (not string!) // query.active is a boolean (not string!) return this.userService.search(query); } } ``` ```bash # Request: GET /users?page=2&limit=20&active=true&role=admin # Your handler receives: { page: 2, // number limit: 20, // number active: true, // boolean role: "admin" // string (enum validated) } ``` ::warning Remember to use `.coerce` methods (Zod) or equivalent transformations in other libraries to convert string query parameters to numbers, booleans, dates, etc. :: ## Validation Errors When validation fails, Vercube automatically returns a structured error response: ```bash # Invalid request: POST /users { "name": "A", # Too short "email": "invalid", # Not an email "age": 15 # Too young } ``` ```json # Automatic response (400 Bad Request): { "statusCode": 400, "message": "Validation failed", "errors": [ { "path": ["name"], "message": "String must contain at least 2 character(s)" }, { "path": ["email"], "message": "Invalid email" }, { "path": ["age"], "message": "Number must be greater than or equal to 18" } ] } ``` ::tip The exact error format may vary slightly between validation libraries, but they all provide clear, actionable error messages that you can return to clients. :: ## Type Safety One of the biggest benefits of using validation schemas is automatic TypeScript type inference: ```ts const UserSchema = z.object({ name: z.string(), email: z.string().email(), age: z.number(), preferences: z.object({ newsletter: z.boolean(), theme: z.enum(['light', 'dark']) }) }); // TypeScript automatically knows the type! type UserDto = z.infer; @Post('/') createUser(@Body({ validationSchema: UserSchema }) data: UserDto) { // Full autocomplete and type checking: data.name // ✅ string data.email // ✅ string data.age // ✅ number data.preferences // ✅ { newsletter: boolean, theme: 'light' | 'dark' } data.unknown // ❌ TypeScript error! } ``` ## Advanced Validation ::code-group ```ts [Nested Objects] const CreateOrderSchema = z.object({ items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1) })).min(1), shipping: z.object({ address: z.string(), city: z.string(), zipCode: z.string().regex(/^\d{5}$/) }), payment: z.object({ method: z.enum(['card', 'paypal']), token: z.string() }) }); ``` ```ts [Custom Validation] const RegisterUserSchema = z.object({ email: z.string().email(), password: z.string().min(8), confirmPassword: z.string() }).refine( (data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"] } ); ``` ```ts [Transformations] const CreateArticleSchema = z.object({ title: z.string() .trim() // Remove whitespace .min(5) .max(100), slug: z.string() .transform(s => s.toLowerCase()) // Convert to lowercase .transform(s => s.replace(/\s+/g, '-')), // Replace spaces publishedAt: z.string() .datetime() .transform(s => new Date(s)) // Convert to Date object }); ``` ```ts [Optional & Defaults] const UpdateUserSchema = z.object({ name: z.string().optional(), // Field is optional email: z.string().email().optional(), role: z.enum(['user', 'admin']).default('user'), // Default value active: z.boolean().default(true) }); ``` :: You can also validate path parameters to ensure they're in the correct format: ```ts const UserIdParamSchema = z.object({ id: z.string().uuid() }); @Get('/:id') getUser(@Param({ validationSchema: UserIdParamSchema }) params: { id: string }) { // params.id is guaranteed to be a valid UUID return this.userService.findById(params.id); } ``` ## Partial Validation For update endpoints, you often want to make all fields optional: ```ts const CreateUserSchema = z.object({ name: z.string(), email: z.string().email(), age: z.number() }); // All fields become optional const UpdateUserSchema = CreateUserSchema.partial(); @Put('/:id') updateUser( @Param('id') id: string, @Body({ validationSchema: UpdateUserSchema }) data: Partial ) { return this.userService.update(id, data); } ``` ## Reusing Schemas Define schemas in separate files and reuse them across your application: ```ts [schemas/user.schema.ts] import { z } from 'zod'; export const BaseUserSchema = z.object({ name: z.string().min(2).max(50), email: z.string().email(), age: z.number().int().min(18) }); export const CreateUserSchema = BaseUserSchema.extend({ password: z.string().min(8) }); export const UpdateUserSchema = BaseUserSchema.partial(); export type CreateUserDto = z.infer; export type UpdateUserDto = z.infer; ``` ```ts [UserController.ts] import { CreateUserSchema, UpdateUserSchema } from './schemas/user.schema'; @Controller('/users') export class UserController { @Post('/') createUser(@Body({ validationSchema: CreateUserSchema }) data: CreateUserDto) { return this.userService.create(data); } @Put('/:id') updateUser( @Param('id') id: string, @Body({ validationSchema: UpdateUserSchema }) data: UpdateUserDto ) { return this.userService.update(id, data); } } ``` ## Best Practices **Define schemas close to usage** - Keep schemas in the same file or nearby ```ts // ✅ Good - easy to find and maintain const CreateUserSchema = z.object({ ... }); @Controller('/users') export class UserController { @Post('/') createUser(@Body({ validationSchema: CreateUserSchema }) data: UserDto) { } } ``` **Use descriptive error messages** - Help clients understand what's wrong ```ts const PasswordSchema = z.string() .min(8, 'Password must be at least 8 characters') .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') .regex(/[0-9]/, 'Password must contain at least one number'); ``` **Validate early, validate often** - Don't let bad data reach your business logic ```ts // ❌ Bad - validation happens too late @Post('/') createUser(@Body() data: any) { // Business logic might fail with invalid data await this.userService.create(data); } // ✅ Good - validation happens first @Post('/') createUser(@Body({ validationSchema: CreateUserSchema }) data: UserDto) { // Guaranteed valid data await this.userService.create(data); } ``` **Don't over-validate** - Balance strictness with usability ```ts // ❌ Too strict - will frustrate users const NameSchema = z.string() .min(2) .max(20) .regex(/^[A-Za-z]+$/); // No spaces, special characters // ✅ Reasonable - accepts most valid names const NameSchema = z.string() .min(2) .max(100) .trim(); ``` **Use transformation wisely** - Clean data, but don't change meaning ```ts // ✅ Good transformations z.string().trim() // Remove whitespace z.string().toLowerCase() // Normalize case z.string().datetime().transform(Date) // Parse to Date // ❌ Questionable transformations z.string().transform(s => s.substring(0, 10)) // Silently truncating z.number().transform(n => Math.abs(n)) // Changing sign without telling user ``` ## Choosing a Validation Library All Standard Schema libraries work the same way in Vercube, but each has different strengths: ### Zod **Best for:** General use, great TypeScript support, large ecosystem - Most popular, lots of examples and plugins - Excellent error messages - Great IDE autocomplete - Slightly larger bundle size ### Valibot **Best for:** Bundle size sensitive projects, modular validation - Smallest bundle size (tree-shakeable) - Modular - only import what you need - Similar API to Zod - Growing ecosystem ### ArkType **Best for:** Runtime performance, type-first approach - Fastest runtime validation - Unique syntax using TypeScript-like strings - Excellent type inference - Smaller community (newer) ::tip Start with Zod if you're unsure - it has the best documentation and community support. You can always switch later thanks to Standard Schema! :: ## OpenAPI documentation The same Zod schemas you use for validation can drive **OpenAPI** specs and interactive docs. Install [`@vercube/schema`](https://vercube.dev/docs/modules/schema/overview), add `SchemaPlugin`, annotate routes with `@Schema`, and open `/_schema/` (JSON) or `/_schema/docs` ([Scalar](https://github.com/scalar/scalar){rel=""nofollow""} UI). See the [Schema module](https://vercube.dev/docs/modules/schema/overview) for setup and the [Scalar](https://vercube.dev/docs/modules/schema/scalar) page for UI options. # Overview The Auth module provides a powerful, flexible authentication system for Vercube applications. Built around a provider-based architecture, it allows you to implement various authentication strategies such as JWT, sessions, OAuth, or custom solutions. ## Installation ::code-group ```bash [pnpm] $ pnpm add @vercube/auth ``` ```bash [npm] $ npm install @vercube/auth ``` ```bash [bun] $ bun install @vercube/auth ``` :: ## Quick Start ::steps ### Create an Auth Provider Create a custom authentication provider by extending the `AuthProvider` class: ```ts [src/providers/JWTAuthProvider.ts] import { AuthProvider, type AuthTypes } from '@vercube/auth'; interface User { id: number; username: string; roles: string[]; } export class JWTAuthProvider extends AuthProvider { public validate(request: Request, params?: AuthTypes.MiddlewareOptions): string | null { const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) { return 'No token provided'; } try { const user = this.verifyToken(token); // Check roles if specified if (params?.roles && params.roles.length > 0) { const hasRequiredRole = params.roles.some(role => user.roles.includes(role)); if (!hasRequiredRole) { return 'Insufficient permissions'; } } return null; // Authentication successful } catch { return 'Invalid token'; } } public getCurrentUser(request: Request): User | null { const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) { return null; } try { return this.verifyToken(token); } catch { return null; } } private verifyToken(token: string): User { // Your JWT verification logic here // This is a simplified example return { id: 1, username: 'john', roles: ['user'] }; } } ``` ### Register Provider in Container Register your auth provider in the DI container during application setup: ```ts [src/setup.ts] import { type App } from '@vercube/core'; import { AuthProvider } from '@vercube/auth'; import { JWTAuthProvider } from './providers/JWTAuthProvider'; export function setup(app: App): void { app.container.bind(AuthProvider, JWTAuthProvider); } ``` ### Protect Your Endpoints Use the `@Auth` decorator to protect controller methods: ```ts [src/controllers/ProfileController.ts] import { Controller, Get } from '@vercube/core'; import { Auth, User } from '@vercube/auth'; interface User { id: number; username: string; roles: string[]; } @Controller('/profile') export class ProfileController { @Get('/') @Auth() public getProfile(@User() user: User) { return { profile: user }; } @Get('/admin') @Auth({ roles: ['admin'] }) public getAdminPanel(@User() user: User) { return { admin: true, user }; } } ``` :: ## Core Concepts ### AuthProvider The `AuthProvider` is an abstract class that defines the interface for authentication implementations. All authentication providers must extend this class and implement two methods: - **`validate()`** - Validates incoming requests and returns `null` on success or an error message string on failure - **`getCurrentUser()`** - Returns the authenticated user object or `null` if not authenticated ### Decorators The Auth module provides two decorators for easy integration with controllers: | Decorator | Description | | --------- | ------------------------------------------------------------ | | `@Auth()` | Protects a method, requiring authentication before execution | | `@User()` | Injects the current authenticated user as a method parameter | ### Role-Based Access Control You can restrict access based on user roles by passing options to the `@Auth` decorator: ```ts @Auth({ roles: ['admin', 'moderator'] }) public adminOnly(@User() user: User) { // Only accessible by admins and moderators } ``` The `validate()` method in your provider receives these options and should check if the user has the required roles. ## Authentication Flow When a request hits a protected endpoint: 1. The `@Auth` decorator triggers the authentication middleware 2. Your `AuthProvider.validate()` method is called with the request 3. If `validate()` returns `null`, authentication succeeds 4. If `validate()` returns a string, authentication fails with that error message 5. The `@User` decorator calls `getCurrentUser()` to inject the user object ![Authentication Flow](https://vercube.dev/images/auth-1.svg) ## Common Patterns ::code-group ```ts [JWT] import { AuthProvider, type AuthTypes } from '@vercube/auth'; import jwt from 'jsonwebtoken'; export class JWTAuthProvider extends AuthProvider { private secret = process.env.JWT_SECRET!; public validate(request: Request, params?: AuthTypes.MiddlewareOptions): string | null { const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) { return 'Authorization header required'; } try { const decoded = jwt.verify(token, this.secret) as User; if (params?.roles?.length) { if (!params.roles.some(role => decoded.roles.includes(role))) { return 'Insufficient permissions'; } } return null; } catch (error) { if (error instanceof jwt.TokenExpiredError) { return 'Token expired'; } return 'Invalid token'; } } public getCurrentUser(request: Request): User | null { const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) return null; try { return jwt.verify(token, this.secret) as User; } catch { return null; } } } ``` ```ts [API Key] import { AuthProvider, type AuthTypes } from '@vercube/auth'; interface ApiKeyUser { apiKeyId: string; permissions: string[]; } export class ApiKeyAuthProvider extends AuthProvider { private validKeys = new Map(); public validate(request: Request): string | null { const apiKey = request.headers.get('X-API-Key'); if (!apiKey) { return 'API key required'; } if (!this.validKeys.has(apiKey)) { return 'Invalid API key'; } return null; } public getCurrentUser(request: Request): ApiKeyUser | null { const apiKey = request.headers.get('X-API-Key'); return apiKey ? this.validKeys.get(apiKey) || null : null; } } ``` ```ts [Session] import { AuthProvider } from '@vercube/auth'; import { Inject } from '@vercube/di'; export class SessionAuthProvider extends AuthProvider { @Inject(SessionStore) private sessions!: SessionStore; public async validate(request: Request): Promise { const sessionId = this.getSessionCookie(request); if (!sessionId) { return 'Session required'; } const session = await this.sessions.get(sessionId); if (!session) { return 'Invalid or expired session'; } return null; } public async getCurrentUser(request: Request): Promise { const sessionId = this.getSessionCookie(request); if (!sessionId) return null; const session = await this.sessions.get(sessionId); return session?.user || null; } private getSessionCookie(request: Request): string | null { const cookies = request.headers.get('Cookie'); // Parse session cookie... return null; } } ``` :: # Decorators The Auth module provides decorators that integrate seamlessly with Vercube controllers, making it easy to protect endpoints and access authenticated user data. ## `@Auth` The `@Auth` decorator protects controller methods by requiring authentication before execution. It invokes the registered `AuthProvider.validate()` method to verify the request. ### Basic Usage ```ts import { Controller, Get, Post } from '@vercube/core'; import { Auth } from '@vercube/auth'; @Controller('/api') export class ApiController { @Get('/public') public publicEndpoint() { return { message: 'Anyone can access this' }; } @Get('/protected') @Auth() public protectedEndpoint() { return { message: 'Only authenticated users can access this' }; } } ``` ### Role-Based Protection Pass options to restrict access based on user roles: ```ts import { Controller, Get, Delete } from '@vercube/core'; import { Auth, User } from '@vercube/auth'; @Controller('/admin') export class AdminController { @Get('/dashboard') @Auth({ roles: ['admin'] }) public getDashboard() { return { dashboard: 'admin data' }; } @Delete('/users/:id') @Auth({ roles: ['admin', 'superadmin'] }) public deleteUser() { // Only admins and superadmins can delete users } } ``` ### Options | Option | Type | Description | | ------- | ---------- | --------------------------------------------- | | `roles` | `string[]` | Array of roles allowed to access the endpoint | The options are passed to your `AuthProvider.validate()` method as the second parameter. ## `@User` The `@User` decorator injects the currently authenticated user into a controller method parameter. It calls the registered `AuthProvider.getCurrentUser()` method to retrieve the user. ### Basic Usage ```ts import { Controller, Get } from '@vercube/core'; import { Auth, User } from '@vercube/auth'; interface User { id: number; username: string; email: string; } @Controller('/profile') export class ProfileController { @Get('/') @Auth() public getProfile(@User() user: User) { return { id: user.id, username: user.username, email: user.email }; } } ``` ### Custom Auth Provider You can specify a custom auth provider for the `@User` decorator: ```ts import { Controller, Get } from '@vercube/core'; import { User } from '@vercube/auth'; import { CustomAuthProvider } from '../providers/CustomAuthProvider'; @Controller('/api') export class ApiController { @Get('/me') public getMe(@User({ provider: CustomAuthProvider }) user: CustomUser) { return user; } } ``` ### Options | Option | Type | Description | | ---------- | --------------------- | --------------------------------- | | `provider` | `typeof AuthProvider` | Custom auth provider class to use | ## Combining Decorators Use `@Auth` and `@User` together to protect endpoints and access user data: ```ts import { Controller, Get, Post, Put } from '@vercube/core'; import { Auth, User } from '@vercube/auth'; interface User { id: number; username: string; roles: string[]; } @Controller('/users') export class UserController { @Get('/me') @Auth() public getCurrentUser(@User() user: User) { return user; } @Put('/me') @Auth() public updateProfile(@User() user: User) { // Update user profile return { updated: true, userId: user.id }; } @Get('/all') @Auth({ roles: ['admin'] }) public getAllUsers(@User() admin: User) { // Admin can view all users return { requestedBy: admin.username, users: [] }; } @Post('/ban/:id') @Auth({ roles: ['admin', 'moderator'] }) public banUser(@User() moderator: User) { // Admins and moderators can ban users return { bannedBy: moderator.username }; } } ``` # API Reference Complete API documentation for the Auth module. ## AuthProvider Abstract base class for implementing authentication providers. ### Class Definition ```ts abstract class AuthProvider { public abstract validate( request: Request, params?: AuthTypes.MiddlewareOptions ): Promise | string | null; public abstract getCurrentUser( request: Request ): Promise | U | null; } ``` ### Type Parameters | Parameter | Description | | --------- | ---------------------------------------------------------- | | `U` | The type of the user object returned by `getCurrentUser()` | ### Methods #### `validate()` Validates an incoming request for authentication. ```ts public abstract validate( request: Request, params?: AuthTypes.MiddlewareOptions ): Promise | string | null; ``` **Parameters:** | Parameter | Type | Description | | --------- | ----------------------------- | -------------------------------------------------- | | `request` | `Request` | The incoming HTTP request object | | `params` | `AuthTypes.MiddlewareOptions` | Optional middleware options (e.g., required roles) | **Returns:** `Promise | string | null` - `null` if authentication succeeds - Error message string if authentication fails **Example:** ```ts public validate(request: Request, params?: AuthTypes.MiddlewareOptions): string | null { const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) { return 'No token provided'; } try { const user = jwt.verify(token, this.secret); if (params?.roles?.length) { const hasRole = params.roles.some(role => user.roles.includes(role)); if (!hasRole) { return 'Insufficient permissions'; } } return null; } catch { return 'Invalid token'; } } ``` --- #### `getCurrentUser()` Retrieves the currently authenticated user from the request. ```ts public abstract getCurrentUser( request: Request ): Promise | U | null; ``` **Parameters:** | Parameter | Type | Description | | --------- | --------- | -------------------------------- | | `request` | `Request` | The incoming HTTP request object | **Returns:** `Promise | U | null` - User object if authenticated - `null` if not authenticated **Example:** ```ts public getCurrentUser(request: Request): User | null { const token = request.headers.get('Authorization')?.replace('Bearer ', ''); if (!token) { return null; } try { return jwt.verify(token, this.secret) as User; } catch { return null; } } ``` --- ## Decorators ### @Auth() Protects a controller method by requiring authentication. ```ts function Auth(options?: AuthTypes.MiddlewareOptions): MethodDecorator ``` **Parameters:** | Parameter | Type | Description | | --------- | ----------------------------- | ------------------------------- | | `options` | `AuthTypes.MiddlewareOptions` | Optional authentication options | **Example:** ```ts @Get('/protected') @Auth() public protectedRoute() {} @Get('/admin') @Auth({ roles: ['admin'] }) public adminRoute() {} ``` --- ### @User() Injects the current authenticated user into a method parameter. ```ts function User(options?: UserDecoratorOptions): ParameterDecorator ``` **Parameters:** | Parameter | Type | Description | | --------- | ---------------------- | -------------------------- | | `options` | `UserDecoratorOptions` | Optional decorator options | **UserDecoratorOptions:** | Property | Type | Description | | ---------- | --------------------- | --------------------------- | | `provider` | `typeof AuthProvider` | Custom auth provider to use | **Example:** ```ts @Get('/me') public getMe(@User() user: User) { return user; } @Get('/custom') public getCustom(@User({ provider: CustomAuthProvider }) user: CustomUser) { return user; } ``` --- ## Types ### AuthTypes.MiddlewareOptions Options for the authentication middleware. ```ts interface MiddlewareOptions { roles?: string[]; } ``` | Property | Type | Description | | -------- | ---------- | --------------------------------------------- | | `roles` | `string[]` | Array of roles allowed to access the endpoint | --- ## Usage Examples ### Complete JWT Provider ```ts import { AuthProvider, type AuthTypes } from '@vercube/auth'; import jwt from 'jsonwebtoken'; interface JWTPayload { id: number; username: string; email: string; roles: string[]; iat: number; exp: number; } export class JWTAuthProvider extends AuthProvider { private readonly secret = process.env.JWT_SECRET!; public validate(request: Request, params?: AuthTypes.MiddlewareOptions): string | null { const authHeader = request.headers.get('Authorization'); if (!authHeader) { return 'Authorization header required'; } if (!authHeader.startsWith('Bearer ')) { return 'Invalid authorization format. Use: Bearer '; } const token = authHeader.slice(7); try { const payload = jwt.verify(token, this.secret) as JWTPayload; // Check roles if specified if (params?.roles && params.roles.length > 0) { const hasRequiredRole = params.roles.some(role => payload.roles.includes(role) ); if (!hasRequiredRole) { return 'Insufficient permissions'; } } return null; } catch (error) { if (error instanceof jwt.TokenExpiredError) { return 'Token expired'; } if (error instanceof jwt.JsonWebTokenError) { return 'Invalid token'; } return 'Authentication failed'; } } public getCurrentUser(request: Request): JWTPayload | null { const authHeader = request.headers.get('Authorization'); if (!authHeader?.startsWith('Bearer ')) { return null; } const token = authHeader.slice(7); try { return jwt.verify(token, this.secret) as JWTPayload; } catch { return null; } } } ``` ### Registration in Container ```ts import { type App } from '@vercube/core'; import { AuthProvider } from '@vercube/auth'; import { JWTAuthProvider } from './providers/JWTAuthProvider'; export function setup(app: App): void { // Bind JWTAuthProvider as the implementation for AuthProvider app.container.bind(AuthProvider, JWTAuthProvider); } ``` ### Controller with Authentication ```ts import { Controller, Get, Post, Put, Delete } from '@vercube/core'; import { Auth, User } from '@vercube/auth'; interface User { id: number; username: string; roles: string[]; } @Controller('/api/resources') export class ResourceController { // Public endpoint - no authentication required @Get('/public') public getPublic() { return { message: 'Public data' }; } // Authenticated endpoint - any logged in user @Get('/') @Auth() public getAll(@User() user: User) { return { requestedBy: user.username, resources: [] }; } // Role-protected endpoint - only admins @Post('/') @Auth({ roles: ['admin'] }) public create(@User() admin: User) { return { createdBy: admin.username, success: true }; } // Multiple roles - admins OR moderators @Delete('/:id') @Auth({ roles: ['admin', 'moderator'] }) public delete(@User() user: User) { return { deletedBy: user.username, success: true }; } } ``` # Overview The Logger module is Vercube's logging system. As of v1 it is a thin, dependency-injected wrapper around [evlog](https://evlog.dev){rel=""nofollow""} - a structured, wide-event logger with pretty development output, JSON production output, PII redaction, sampling and pluggable drains/adapters. You keep the familiar `Logger` DI token and the `debug` / `info` / `warn` / `error` methods; underneath, every call becomes an evlog event. ## Installation ::code-group ```bash [pnpm] $ pnpm add @vercube/logger ``` ```bash [npm] $ npm install @vercube/logger ``` ```bash [bun] $ bun install @vercube/logger ``` :: `@vercube/core` already depends on `@vercube/logger` and binds it automatically - you only install it directly when using it standalone. ## Quick Start ::steps ### The logger is bound for you `createApp` binds the evlog-backed `Logger` and configures it from your app config. You normally don't bind anything yourself: ```ts [vercube.config.ts] import { defineConfig } from '@vercube/core'; export default defineConfig({ // forwarded to evlog's `minLevel` logLevel: 'debug', }); ``` If you bootstrap a container manually, bind it explicitly: ```ts [src/container.ts] import { Container } from '@vercube/di'; import { Logger, BaseLogger } from '@vercube/logger'; export function setupContainer(container: Container): void { container.bind(Logger, BaseLogger); container.get(Logger).configure({ logLevel: 'info', pretty: process.env.NODE_ENV !== 'production', }); } ``` ### Inject Logger into Services ```ts [src/services/UserService.ts] import { Inject } from '@vercube/di'; import { Logger } from '@vercube/logger'; export class UserService { @Inject(Logger) private logger!: Logger; async createUser(data: CreateUserDto) { this.logger.debug('UserService::createUser', 'creating user'); try { const user = await this.database.createUser(data); this.logger.info('UserService::createUser', { userId: user.id }); return user; } catch (error) { this.logger.error('UserService::createUser', error as Error); throw error; } } } ``` ### Use Logger in Controllers ```ts [src/controllers/UserController.ts] import { Controller, Get, Post } from '@vercube/core'; import { Inject } from '@vercube/di'; import { Logger } from '@vercube/logger'; @Controller('/users') export class UserController { @Inject(Logger) private logger!: Logger; @Post('/') async create(req: Request) { this.logger.info('POST /users', 'creating new user'); // ... return Response.json({ ok: true }); } } ``` :: ## Core Concepts ### Logger `Logger` is the DI token injected throughout your application. It exposes the four classic level methods plus a small wide-event API: - **debug() / info() / warn() / error()** - fire-and-forget logs, one evlog event per call. - **set(context)** - merge structured fields into every subsequent event. - **getContext()** - read the accumulated context. - **child(context)** - derive a logger that inherits context plus extra fields. - **emit(overrides?)** - flush the accumulated context as a single wide event, then reset. ### How arguments map to events The variadic arguments are translated into an evlog event: | Call | Resulting event | | ---------------------------- | ------------------------------------- | | `info('tag', 'message')` | tagged log `tag` → `message` | | `warn('something happened')` | `{ message: 'something happened' }` | | `error(err)` | `{ error: { name, message, stack } }` | | `error('tag', err)` | `{ message: 'tag', error: {...} }` | | `info('tag', { userId: 1 })` | `{ message: 'tag', userId: 1 }` | Any object argument is merged into the event; any `Error` is captured under `error`. ### Log Levels Levels are hierarchical and identical to evlog's: ```text debug → info → warn → error ``` `logLevel` maps to evlog's `minLevel` (a hard threshold for the simple log API). Order: `debug < info < warn < error`. ### Wide events A "wide event" is a single, richly-structured log line that accumulates context over an operation, then is emitted once. Use `set()` to add context and `emit()` to flush it: ```ts logger.set({ jobId: 'sync-001', queue: 'emails' }); logger.set({ processed: 120 }); logger.emit({ outcome: 'success' }); // → one event: { jobId, queue, processed, outcome } ``` For per-request wide events (method, path, status, duration), Vercube ships the `EvlogMiddleware` in `@vercube/core`, enabled by default. Disable it with `requestLogging: false` in your app config. ## Configuration `configure()` accepts evlog's full [`LoggerConfig`](https://evlog.dev/reference/configuration){rel=""nofollow""} plus the `logLevel` alias: ```ts container.get(Logger).configure({ logLevel: 'info', // alias for evlog `minLevel` pretty: true, // human-readable output (auto: true in dev) silent: false, // suppress console output (drains still run) stringify: true, // emit JSON strings when pretty is off redact: true, // PII redaction (auto: true in production) env: { service: 'api' }, // environment context sampling: { rates: { info: 0.5 } }, drain: (ctx) => sendToBackend(ctx.event), }); ``` ### Sending logs elsewhere (drains & adapters) evlog's drains and ready-made adapters (Axiom, OTLP, Sentry, Datadog, …) are available directly from the `evlog` package and re-exported from `@vercube/logger`. See the [Drains & Adapters](https://vercube.dev/modules/logger/drivers) page. # Drains & Adapters The Vercube logger is backed by [evlog](https://evlog.dev){rel=""nofollow""}, so output formatting and shipping logs to external systems is handled by evlog's **output modes**, **drains** and **adapters** rather than by custom provider classes. ## Output modes evlog has two built-in output modes, selected automatically by environment and controllable via `configure()`. ### Pretty (development) Human-readable, colorized output. Enabled automatically outside production. ```ts container.get(Logger).configure({ logLevel: 'debug', pretty: true }); ``` ```text 12:34:56.789 [UserService::createUser] creating user 12:34:56.801 INFO [app] ├─ userId: abc-123 └─ message: UserService::createUser ``` Disable colors with the standard `NO_COLOR` environment variable. ### JSON (production) Structured JSON, ideal for log aggregation (ELK, Splunk, CloudWatch, …). Used automatically in production, or force it by disabling pretty: ```ts container.get(Logger).configure({ logLevel: 'info', pretty: false }); ``` ```json {"level":"info","userId":"abc-123","message":"UserService::createUser","timestamp":"2026-01-01T12:34:56.801Z"} ``` When `pretty` is `false`, `stringify: true` (the default) emits JSON strings; set `stringify: false` for raw objects (e.g. Cloudflare Workers). ## Drains A **drain** is a callback invoked with every emitted event - use it to forward logs anywhere. Configure it globally via `configure()`: ```ts container.get(Logger).configure({ logLevel: 'info', drain: async (ctx) => { await fetch('https://logs.example.com/ingest', { method: 'POST', body: JSON.stringify(ctx.event), }); }, }); ``` For request-scoped drains/enrichment, pass options to the `EvlogMiddleware` (see the [core middleware](https://vercube.dev/#per-request-wide-events)). ## Adapters evlog ships first-class adapters for popular backends. They are plain drains, so they plug straight into `configure({ drain })`: ```ts import { createAxiomDrain } from 'evlog/axiom'; container.get(Logger).configure({ drain: createAxiomDrain({ dataset: 'logs', token: process.env.AXIOM_TOKEN! }), }); ``` Available adapters include Axiom, OTLP, Sentry, Datadog, PostHog, Better Stack, HyperDX, filesystem and memory - see the [evlog adapters overview](https://evlog.dev/integrate/adapters/overview){rel=""nofollow""}. ### Batching & retries Wrap any drain with evlog's pipeline for batching and retry: ```ts import { createDrainPipeline } from 'evlog/pipeline'; import { createAxiomDrain } from 'evlog/axiom'; const pipeline = createDrainPipeline({ batch: { size: 25 } }); const drain = pipeline(createAxiomDrain({ dataset: 'logs', token: '...' })); container.get(Logger).configure({ drain }); ``` ## Sampling & redaction Reduce volume and protect PII with evlog's built-ins: ```ts container.get(Logger).configure({ // probabilistic sampling on top of `minLevel` sampling: { rates: { info: 0.25, debug: 0.05 } }, // redaction: `true` enables built-in patterns (email, JWT, card, …) redact: { paths: ['user.password', 'headers.authorization'] }, }); ``` ## Per-request wide events `@vercube/core` registers `EvlogMiddleware` by default, emitting one wide event per request (method, path, status, duration). Configure or disable it: ```ts [vercube.config.ts] import { defineConfig } from '@vercube/core'; export default defineConfig({ // set to false to turn off per-request wide events requestLogging: true, }); ``` For advanced integrations (custom request loggers, route filtering, enrich/keep callbacks) the evlog toolkit is re-exported from `@vercube/logger/toolkit`: ```ts import { createMiddlewareLogger, extractSafeHeaders } from '@vercube/logger/toolkit'; ``` # API This page documents the public API of the `@vercube/logger` package. Under the hood it delegates to [evlog](https://evlog.dev){rel=""nofollow""}; evlog's own primitives are re-exported for advanced use. ## Logger (Abstract Class) The DI token and contract implemented by every logger. ```ts abstract class Logger { public abstract configure(options: LoggerTypes.Options): void; public abstract debug(...args: LoggerTypes.Arg[]): void; public abstract info(...args: LoggerTypes.Arg[]): void; public abstract warn(...args: LoggerTypes.Arg[]): void; public abstract error(...args: LoggerTypes.Arg[]): void; public abstract set(context: LoggerTypes.Context): void; public abstract getContext(): LoggerTypes.Context; public abstract child(context: LoggerTypes.Context): Logger; public abstract emit(overrides?: LoggerTypes.Context): void; } ``` ### `configure(options)` Initializes the underlying evlog logger. Maps to evlog's `initLogger`. The `logLevel` option is an alias for evlog's `minLevel`. ```ts container.get(Logger).configure({ logLevel: 'info', pretty: true }); ``` ### `debug()` / `info()` / `warn()` / `error()` Emit a single event at the given level. Accept any mix of strings, objects and errors: ```ts logger.info('AuthService', 'user logged in'); // tagged log logger.info('request completed', { status: 200 }); // message + fields logger.error('DB::connect', error); // tag + Error logger.warn('rate limit approaching'); // message only ``` See [How arguments map to events](https://vercube.dev/modules/logger/overview#how-arguments-map-to-events). ### `set(context)` Merge structured fields into every subsequent event from this logger. ```ts logger.set({ requestId: 'abc', userId: 42 }); logger.info('processing'); // event includes requestId + userId ``` ### `getContext()` Returns a shallow copy of the accumulated context. ### `child(context)` Creates a derived logger that inherits the parent's context plus extra fields. The child's context is isolated from the parent. ```ts const reqLogger = logger.child({ requestId: 'abc' }); ``` ### `emit(overrides?)` Flush the accumulated context as one wide event, then reset it. ```ts logger.set({ jobId: 'sync-001' }); logger.emit({ outcome: 'success' }); ``` --- ## BaseLogger (Class) The default evlog-backed implementation of `Logger`, bound automatically by `@vercube/core`. ```ts import { Logger, BaseLogger } from '@vercube/logger'; container.bind(Logger, BaseLogger); ``` All methods are described under [Logger](https://vercube.dev/#logger-abstract-class). --- ## Types ### `LoggerTypes.Level` ```ts type Level = 'debug' | 'info' | 'warn' | 'error'; ``` Hierarchical, aligned with evlog: `debug < info < warn < error`. ### `LoggerTypes.Arg` ```ts type Arg = unknown; ``` A single argument passed to a log method. ### `LoggerTypes.Context` ```ts type Context = Record; ``` Structured fields attached to wide events. ### `LoggerTypes.Options` Configuration accepted by `configure()`. Extends evlog's `LoggerConfig`: ```ts interface Options extends LoggerConfig { /** Alias for evlog `minLevel`; wins when both are set. */ logLevel?: Level; } ``` Notable `LoggerConfig` fields: `enabled`, `env`, `pretty`, `silent`, `stringify`, `minLevel`, `sampling`, `redact`, `drain`. See the [evlog configuration reference](https://evlog.dev/reference/configuration){rel=""nofollow""}. --- ## Re-exported evlog primitives For advanced wide-event and structured-error usage, `@vercube/logger` re-exports evlog directly: ```ts import { log, // evlog's simple singleton log API initLogger, // low-level init (configure() wraps this) createLogger, // create an isolated wide-event logger createRequestLogger, createError, // structured errors parseError, EvlogError, defineError, defineErrorCatalog, } from '@vercube/logger'; import type { Log, RequestLogger, RequestLoggerOptions, LoggerConfig, LogLevel, WideEvent, EnvironmentContext, SamplingConfig, RedactConfig, DrainContext, DrainFn, ErrorOptions, } from '@vercube/logger'; ``` ## Toolkit The evlog toolkit (framework-integration building blocks) is available as a subpath export: ```ts import { createMiddlewareLogger, createLoggerStorage, extractSafeHeaders, extractSafeNodeHeaders, } from '@vercube/logger/toolkit'; ``` ## EvlogMiddleware (from `@vercube/core`) Global middleware that emits one wide event per request. Registered by default; disable via `requestLogging: false` in the app config. ```ts import { EvlogMiddleware, EVLOG_REQUEST_LOGGER_KEY } from '@vercube/core'; ``` The request-scoped logger is stored in `RequestContext` under `EVLOG_REQUEST_LOGGER_KEY`, so handlers can enrich the wide event. # Overview The **Serverless** module adds adapters so you can run the same Vercube app on **AWS Lambda** or **Azure Functions**. Platform-specific HTTP events are converted to the standard Web [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request){rel=""nofollow""} / [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response){rel=""nofollow""} APIs your controllers already use. **Vercel** does not use this package: deploy with a `fetch` handler instead - see [Deploy to Vercel](https://vercube.dev/docs/deployment/vercel). ## Installation ::code-group ```bash [pnpm] $ pnpm add @vercube/serverless ``` ```bash [npm] $ npm install @vercube/serverless ``` ```bash [bun] $ bun install @vercube/serverless ``` :: ## Entry points | Import | Use case | | ------------------------------------- | ------------------------------ | | `@vercube/serverless/aws-lambda` | API Gateway (v1 / v2) + Lambda | | `@vercube/serverless/azure-functions` | Azure Functions HTTP triggers | Deploying to each cloud is covered step by step in **Deployment**: - [AWS Lambda](https://vercube.dev/docs/deployment/aws-lambda) - [Azure Functions](https://vercube.dev/docs/deployment/azure-functions) ## Minimal handler shape You build a Vercube `App` once, then wrap it with `toServerlessHandler`: ::code-group ```ts [AWS Lambda] import { createApp } from '@vercube/core'; import { toServerlessHandler } from '@vercube/serverless/aws-lambda'; const app = createApp(); export const handler = toServerlessHandler(app); ``` ```ts [Azure Functions] import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; import { toServerlessHandler } from '@vercube/serverless/azure-functions'; import { app as vercubeApp } from '../index'; const handler = toServerlessHandler(vercubeApp); export async function httpTrigger( request: HttpRequest, context: InvocationContext ): Promise { return await handler(request); } app.http('httpTrigger', { methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'], authLevel: 'anonymous', handler: httpTrigger, }); ``` :: ## What is serverless hosting? Providers run your code on demand: no servers to manage, billing roughly tied to execution time, and automatic scaling. This module translates each platform’s event shape into a `Request`, runs your app, then maps the `Response` back. ## How it works The adapter sits between the platform and Vercube: ![Serverless adapter flow](https://vercube.dev/images/serverless-1.svg) 1. **Platform event** - Lambda or Azure receives HTTP 2. **Adapter** - builds a standard `Request` 3. **Vercube** - controllers and middleware run as usual 4. **Adapter** - turns the `Response` into the platform format 5. **Platform** - returns it to the client ### What gets converted **Request:** method, path, query, headers (including cookies), body (JSON, forms, binary). **Response:** status, headers, `Set-Cookie`, body (text, JSON, binary). ## Performance notes Initialize **once** at module scope (app, pools, clients). Creating a new `createApp()` per invocation wastes cold-start budget. Platform-specific tuning (memory, timeout, concurrency, pooling) is described in the deployment guides: - [AWS Lambda](https://vercube.dev/docs/deployment/aws-lambda) - provisioned concurrency, RDS Proxy, package size - [Azure Functions](https://vercube.dev/docs/deployment/azure-functions) - Premium plan, Always On, `host.json` limits ## Accessing configuration ```ts import { Controller, Get } from '@vercube/core'; @Controller('/config') export class ConfigController { @Get('/info') getInfo() { return { environment: process.env.NODE_ENV, database: process.env.DATABASE_URL ? 'configured' : 'not configured', }; } } ``` Set secrets in each provider’s dashboard or IaC (Serverless Framework, Bicep, Terraform, etc.) - examples live in the [AWS](https://vercube.dev/docs/deployment/aws-lambda) and [Azure](https://vercube.dev/docs/deployment/azure-functions) deployment pages. # Overview The Storage module provides a powerful, flexible storage system for Vercube applications. Built around a provider-based architecture, it allows you to store and retrieve data across multiple storage backends through a unified interface. Whether you need in-memory caching, file system storage, or cloud-based solutions, the Storage module handles it seamlessly. ## Installation ::code-group ```bash [pnpm] $ pnpm add @vercube/storage ``` ```bash [npm] $ npm install @vercube/storage ``` ```bash [bun] $ bun install @vercube/storage ``` :: ## Quick Start ::steps ### Register StorageManager in Container Set up the StorageManager in your DI container. This is typically done once during application bootstrap. ```ts [src/container.ts] import { Container } from '@vercube/di'; import { StorageManager } from '@vercube/storage'; import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage'; export function setupContainer(container: Container): void { // Bind StorageManager to the container container.bind(StorageManager); // Get the storage manager instance const storageManager = container.get(StorageManager); // Mount a memory storage instance storageManager.mount({ name: 'cache', storage: MemoryStorage }); } ``` ### Use Storage in Services Use dependency injection to access storage in your services. Store and retrieve data for caching, session management, or any other purpose. ```ts [src/services/UserService.ts] import { Inject } from '@vercube/di'; import { StorageManager } from '@vercube/storage'; export class UserService { @Inject(StorageManager) private storageManager!: StorageManager; async getUserProfile(userId: string) { const cacheKey = `user:${userId}`; // Try to get from cache first const cached = await this.storageManager.getItem({ storage: 'cache', key: cacheKey }); if (cached) { return cached; } // Fetch from database const user = await this.database.findUser(userId); // Cache for future requests await this.storageManager.setItem({ storage: 'cache', key: cacheKey, value: user }); return user; } async updateUserProfile(userId: string, data: UpdateUserDto) { // Update in database const user = await this.database.updateUser(userId, data); // Invalidate cache await this.storageManager.deleteItem({ storage: 'cache', key: `user:${userId}` }); return user; } } ``` ### Use Storage in Controllers Inject the StorageManager into controllers for request-level caching or rate limiting. ```ts [src/controllers/ProductController.ts] import { Controller, Get, Post } from '@vercube/core'; import { Inject } from '@vercube/di'; import { StorageManager } from '@vercube/storage'; @Controller('/products') export class ProductController { @Inject(StorageManager) private storageManager!: StorageManager; @Inject(ProductService) private productService!: ProductService; @Get('/') async getProducts() { const cacheKey = 'products:all'; // Check cache const cached = await this.storageManager.getItem({ storage: 'cache', key: cacheKey }); if (cached) { return Response.json(cached); } // Fetch and cache const products = await this.productService.findAll(); await this.storageManager.setItem({ storage: 'cache', key: cacheKey, value: products }); return Response.json(products); } @Get('/:id') async getProduct(req: Request, params: { id: string }) { const cacheKey = `products:${params.id}`; const cached = await this.storageManager.getItem({ storage: 'cache', key: cacheKey }); if (cached) { return Response.json(cached); } const product = await this.productService.findById(params.id); if (product) { await this.storageManager.setItem({ storage: 'cache', key: cacheKey, value: product }); } return Response.json(product); } } ``` :: ## Core Concepts ### StorageManager The StorageManager is the central service for managing storage instances. It handles mounting and accessing storage backends. Think of it as a registry for all your storage providers. ```ts import { StorageManager } from '@vercube/storage'; import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage'; const storageManager = container.get(StorageManager); // Mount storage backends storageManager.mount({ name: 'cache', storage: MemoryStorage }); storageManager.mount({ name: 'sessions', storage: MemoryStorage }); // Use storage operations directly through StorageManager await storageManager.setItem({ storage: 'cache', key: 'foo', value: 'bar' }); const value = await storageManager.getItem({ storage: 'cache', key: 'foo' }); // Or get a storage instance directly const cache = storageManager.getStorage('cache'); ``` ### Storage Interface The Storage abstract class defines the interface for all storage implementations. Each storage backend must implement these core methods: - **`getItem()`** - Retrieve a value by key - **`setItem()`** - Store a value with a key - **`hasItem()`** - Check if a key exists - **`deleteItem()`** - Delete a value by key - **`getKeys()`** - List all keys - **`clear()`** - Remove all items - **`size()`** - Get the number of stored items ### Type Safety Vercube Storage is fully type-safe. You can specify the expected type when retrieving data: ```ts interface UserProfile { id: string; name: string; email: string; } // Type-safe retrieval const user = await storage.getItem('user:123'); // user is UserProfile | null // Works with arrays too const users = await storage.getItem('users:all'); ``` ### IOC Container Integration The Storage module integrates seamlessly with Vercube's dependency injection system. You register the StorageManager in the IOC container and inject it wherever needed using the `@Inject` decorator. ## Configuration ### Basic Configuration Configure storage when setting up your application: ```ts import { Container } from '@vercube/di'; import { StorageManager } from '@vercube/storage'; import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage'; export function setupContainer(container: Container): void { container.bind(StorageManager); const storageManager = container.get(StorageManager); // Mount with basic configuration storageManager.mount({ name: 'cache', storage: MemoryStorage }); } ``` ### Multiple Storage Backends You can mount multiple storage backends for different use cases: ```ts import { StorageManager } from '@vercube/storage'; import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage'; const storageManager = container.get(StorageManager); // Fast in-memory cache for frequently accessed data storageManager.mount({ name: 'cache', storage: MemoryStorage }); // Separate storage for user sessions storageManager.mount({ name: 'sessions', storage: MemoryStorage }); // Storage for temporary data storageManager.mount({ name: 'temp', storage: MemoryStorage }); ``` ### Environment-Specific Configuration Configure different storage backends for different environments: ```ts import { StorageManager } from '@vercube/storage'; import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage'; const isDevelopment = process.env.NODE_ENV === 'development'; const isProduction = process.env.NODE_ENV === 'production'; const storageManager = container.get(StorageManager); if (isDevelopment) { // Use memory storage in development storageManager.mount({ name: 'cache', storage: MemoryStorage }); } else { // Use persistent storage in production storageManager.mount({ name: 'cache', storage: MemoryStorage, // Replace with your production storage initOptions: { // Production-specific options } }); } ``` ## Storage Operations ### Basic CRUD Operations You can use StorageManager methods directly with object parameters: ```ts // Create/Update - setItem await storageManager.setItem({ storage: 'cache', key: 'user:123', value: { id: '123', name: 'John Doe', email: 'john@example.com' } }); // Read - getItem const user = await storageManager.getItem({ storage: 'cache', key: 'user:123' }); // Check existence - hasItem const exists = await storageManager.hasItem({ storage: 'cache', key: 'user:123' }); // Delete - deleteItem await storageManager.deleteItem({ storage: 'cache', key: 'user:123' }); ``` Or get a storage instance and use it directly: ```ts const storage = storageManager.getStorage('cache'); await storage.setItem('user:123', { name: 'John' }); const user = await storage.getItem('user:123'); await storage.deleteItem('user:123'); ``` ### Working with Keys ```ts // Get all keys from storage const allKeys = await storageManager.getKeys({ storage: 'cache' }); console.log(allKeys); // ['user:1', 'user:2', 'product:1', ...] // Filter keys by prefix manually const userKeys = allKeys.filter(key => key.startsWith('user:')); console.log(userKeys); // ['user:1', 'user:2', ...] // Get storage size const count = await storageManager.size({ storage: 'cache' }); console.log(count); // 5 // Clear all items await storageManager.clear({ storage: 'cache' }); ``` ## Advanced Patterns ### Cache-Aside Pattern Implement cache-aside (lazy-loading) pattern for database queries: ```ts export class ProductRepository { @Inject(StorageManager) private storageManager!: StorageManager; @Inject(Database) private database!: Database; async findById(id: string): Promise { const cacheKey = `product:${id}`; // 1. Check cache first const cached = await this.storageManager.getItem({ storage: 'cache', key: cacheKey }); if (cached) { return cached; } // 2. Cache miss - fetch from database const product = await this.database.products.findById(id); // 3. Store in cache for next time if (product) { await this.storageManager.setItem({ storage: 'cache', key: cacheKey, value: product }); } return product; } async update(id: string, data: UpdateProductDto): Promise { // Update database const product = await this.database.products.update(id, data); // Invalidate cache await this.storageManager.deleteItem({ storage: 'cache', key: `product:${id}` }); return product; } } ``` ### Cache Warming Pre-populate cache with frequently accessed data: ```ts export class CacheWarmingService { @Inject(StorageManager) private storageManager!: StorageManager; @Inject(ProductService) private productService!: ProductService; async warmProductCache(): Promise { // Fetch popular products const popularProducts = await this.productService.findPopular(100); // Cache each product for (const product of popularProducts) { await this.storageManager.setItem({ storage: 'cache', key: `product:${product.id}`, value: product }); } console.log(`Warmed cache with ${popularProducts.length} products`); } } ``` ### Cache Invalidation Patterns ```ts export class CacheInvalidationService { @Inject(StorageManager) private storageManager!: StorageManager; // Invalidate single item async invalidateProduct(id: string): Promise { await this.storageManager.deleteItem({ storage: 'cache', key: `product:${id}` }); } // Invalidate by pattern (all products) async invalidateAllProducts(): Promise { const allKeys = await this.storageManager.getKeys({ storage: 'cache' }); const productKeys = allKeys.filter(key => key.startsWith('product:')); for (const key of productKeys) { await this.storageManager.deleteItem({ storage: 'cache', key }); } } // Invalidate related items async invalidateCategory(categoryId: string): Promise { // Remove category await this.storageManager.deleteItem({ storage: 'cache', key: `category:${categoryId}` }); // Remove all products in category const allKeys = await this.storageManager.getKeys({ storage: 'cache' }); const productKeys = allKeys.filter(key => key.startsWith(`category:${categoryId}:products`) ); for (const key of productKeys) { await this.storageManager.deleteItem({ storage: 'cache', key }); } } } ``` ### Rate Limiting with Storage ```ts export class RateLimiter { @Inject(StorageManager) private storageManager!: StorageManager; private readonly maxRequests = 100; async isRateLimited(clientId: string): Promise { const key = `ratelimit:${clientId}`; const current = await this.storageManager.getItem({ storage: 'cache', key }) || 0; if (current >= this.maxRequests) { return true; } await this.storageManager.setItem({ storage: 'cache', key, value: current + 1 }); return false; } } ``` ### Session Management ```ts interface Session { userId: string; createdAt: number; data: Record; } export class SessionService { @Inject(StorageManager) private storageManager!: StorageManager; async createSession(userId: string): Promise { const sessionId = crypto.randomUUID(); const session: Session = { userId, createdAt: Date.now(), data: {} }; await this.storageManager.setItem({ storage: 'sessions', key: `session:${sessionId}`, value: session }); return sessionId; } async getSession(sessionId: string): Promise { return this.storageManager.getItem({ storage: 'sessions', key: `session:${sessionId}` }); } async destroySession(sessionId: string): Promise { await this.storageManager.deleteItem({ storage: 'sessions', key: `session:${sessionId}` }); } async updateSessionData( sessionId: string, data: Record ): Promise { const session = await this.getSession(sessionId); if (session) { session.data = { ...session.data, ...data }; await this.storageManager.setItem({ storage: 'sessions', key: `session:${sessionId}`, value: session }); } } } ``` # Drivers Storage providers (also called drivers) are responsible for storing and retrieving data from various backends. Vercube includes built-in providers and makes it easy to create custom ones for any storage backend. ## Built-in Providers ### MemoryStorage The MemoryStorage provider stores data in memory. It's fast and ideal for caching and temporary data storage. **Usage:** ```ts import { StorageManager } from '@vercube/storage'; import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage'; container.bind(StorageManager); const storageManager = container.get(StorageManager); storageManager.mount({ name: 'cache', storage: MemoryStorage }); // Store data await storageManager.setItem({ storage: 'cache', key: 'mykey', value: { foo: 'bar' } }); // Retrieve data const value = await storageManager.getItem({ storage: 'cache', key: 'mykey' }); console.log(value); // { foo: 'bar' } ``` **Characteristics:** | Feature | Description | | ------------ | ------------------------------------ | | Persistence | None - data is lost on restart | | Speed | Extremely fast | | Memory Usage | Grows with stored data | | Use Cases | Caching, temporary data, development | **Best Practices:** - Use for caching frequently accessed data - Ideal for development and testing - Clear periodically to prevent memory leaks - Consider size limits for production use --- ### S3Storage The S3Storage provider stores data in AWS S3 (or S3-compatible services like MinIO, DigitalOcean Spaces, etc.). It's ideal for distributed applications, serverless environments, and persistent cloud storage. **Installation:** You need to install the AWS SDK for S3: ::code-group ```bash [pnpm] $ pnpm add @aws-sdk/client-s3 ``` ```bash [npm] $ npm install @aws-sdk/client-s3 ``` ```bash [bun] $ bun install @aws-sdk/client-s3 ``` :: **Basic Usage:** ```ts import { StorageManager } from '@vercube/storage'; import { S3Storage } from '@vercube/storage/drivers/S3Storage'; container.bind(StorageManager); const storageManager = container.get(StorageManager); // Recommended: Use IAM roles (no credentials needed) storageManager.mount({ name: 's3', storage: S3Storage, initOptions: { bucket: 'my-app-bucket', region: 'us-east-1' // Credentials are optional - IAM role is used if available } }); // Alternative: Use explicit credentials for local development // storageManager.mount({ // name: 's3', // storage: S3Storage, // initOptions: { // bucket: 'my-app-bucket', // region: 'us-east-1', // credentials: { // accessKeyId: process.env.AWS_ACCESS_KEY_ID!, // secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY! // } // } // }); // Store data await storageManager.setItem({ storage: 's3', key: 'user:123', value: { id: '123', name: 'John' } }); // Retrieve data const user = await storageManager.getItem({ storage: 's3', key: 'user:123' }); ``` **Configuration Options:** The `initOptions` extends AWS S3ClientConfig with an additional `bucket` property: | Option | Type | Required | Description | | ---------------- | --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bucket` | `string` | Yes | S3 bucket name | | `region` | `string` | Yes | AWS region (e.g., 'us-east-1') | | `credentials` | `object` | No | AWS credentials (accessKeyId, secretAccessKey, sessionToken). If omitted, AWS SDK uses the default credential provider chain (IAM roles, environment variables, config files) | | `endpoint` | `string` | No | Custom endpoint for S3-compatible services | | `forcePathStyle` | `boolean` | No | Use path-style URLs (required for some S3-compatible services like MinIO) | **Note:** When `credentials` is omitted, the AWS SDK automatically attempts to load credentials from: 1. IAM roles (Lambda execution role, EC2 instance profile, ECS task role) 2. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) 3. Shared credentials file (`~/.aws/credentials`) 4. ECS container credentials 5. EC2 instance metadata service **Using with S3-Compatible Services:** ```ts // MinIO storageManager.mount({ name: 'minio', storage: S3Storage, initOptions: { bucket: 'my-bucket', region: 'us-east-1', endpoint: 'http://localhost:9000', credentials: { accessKeyId: 'minioadmin', secretAccessKey: 'minioadmin' }, forcePathStyle: true // Required for MinIO } }); // DigitalOcean Spaces storageManager.mount({ name: 'spaces', storage: S3Storage, initOptions: { bucket: 'my-space', region: 'nyc3', endpoint: 'https://nyc3.digitaloceanspaces.com', credentials: { accessKeyId: process.env.DO_SPACES_KEY, secretAccessKey: process.env.DO_SPACES_SECRET } } }); // Cloudflare R2 storageManager.mount({ name: 'r2', storage: S3Storage, initOptions: { bucket: 'my-bucket', region: 'auto', endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY } } }); ``` **Characteristics:** | Feature | Description | | ----------- | -------------------------------------------------- | | Persistence | Yes - data persists across restarts | | Speed | Network-dependent (slower than memory) | | Scalability | Highly scalable, distributed | | Use Cases | Production storage, file storage, distributed apps | **Authentication Methods:** The S3Storage driver supports multiple authentication methods, with different security characteristics suitable for various environments. ##### 1. IAM Roles (Recommended for AWS Environments) When running in AWS environments (Lambda, EC2, ECS, etc.), IAM roles provide the most secure authentication method. Credentials are automatically managed by AWS without manual handling. ```ts // AWS Lambda - No credentials needed! storageManager.mount({ name: 's3', storage: S3Storage, initOptions: { bucket: 'my-app-bucket', region: 'us-east-1' // No credentials field - IAM role is used automatically } }); ``` **How it works:** - AWS SDK automatically uses the IAM role attached to your Lambda function, EC2 instance, or ECS task - Credentials are temporary and automatically rotated by AWS - No secrets to manage or risk exposing in code **Setup:** 1. Attach an IAM role to your AWS resource (Lambda function, EC2 instance, etc.) 2. Grant the role S3 permissions (e.g., `s3:GetObject`, `s3:PutObject`) 3. Omit the `credentials` field in `initOptions` ##### 2. AWS Secrets Manager (Recommended for Secure Credential Storage) For applications that require explicit credentials, use AWS Secrets Manager to store and retrieve them securely. ```ts import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; async function getS3Credentials() { const client = new SecretsManagerClient({ region: 'us-east-1' }); const response = await client.send( new GetSecretValueCommand({ SecretId: 'my-app/s3-credentials' }) ); if (!response.SecretString) { throw new Error('Secret not found or uses SecretBinary'); } return JSON.parse(response.SecretString); } // In your container setup const credentials = await getS3Credentials(); storageManager.mount({ name: 's3', storage: S3Storage, initOptions: { bucket: 'my-app-bucket', region: 'us-east-1', credentials: { accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey } } }); ``` **Benefits:** - Credentials are encrypted at rest and in transit - Centralized secret management - Automatic rotation capabilities - Audit logging of secret access ##### 3. STS Temporary Credentials (For Cross-Account or Federated Access) Use AWS Security Token Service (STS) to assume roles and obtain temporary credentials. ```ts import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; async function assumeRole() { const stsClient = new STSClient({ region: 'us-east-1' }); const response = await stsClient.send( new AssumeRoleCommand({ RoleArn: 'arn:aws:iam::123456789012:role/S3AccessRole', RoleSessionName: 'vercube-s3-session', DurationSeconds: 3600 // 1 hour }) ); const { Credentials } = response; if (!Credentials?.AccessKeyId || !Credentials?.SecretAccessKey || !Credentials?.SessionToken) { throw new Error('Failed to assume role - incomplete credentials returned'); } return { accessKeyId: Credentials.AccessKeyId, secretAccessKey: Credentials.SecretAccessKey, sessionToken: Credentials.SessionToken }; } // Get temporary credentials const credentials = await assumeRole(); storageManager.mount({ name: 's3', storage: S3Storage, initOptions: { bucket: 'my-app-bucket', region: 'us-east-1', credentials } }); ``` **Use cases:** - Cross-account S3 access - Federated user access - Time-limited access requirements - Enhanced security through temporary credentials ##### 4. Environment Variables (For Development) For local development, you can use environment variables. However, this method should be avoided in production. ```ts // .env file // AWS_ACCESS_KEY_ID=your-access-key // AWS_SECRET_ACCESS_KEY=your-secret-key storageManager.mount({ name: 's3', storage: S3Storage, initOptions: { bucket: 'my-app-bucket', region: 'us-east-1', credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY! } } }); ``` **Important:** Never commit credentials to source control. Use `.gitignore` to exclude `.env` files. **Environment-Specific Configuration:** Configure different authentication methods based on your environment: ```ts import { StorageManager } from '@vercube/storage'; import { S3Storage } from '@vercube/storage/drivers/S3Storage'; const isProduction = process.env.NODE_ENV === 'production'; const isLambda = !!process.env.AWS_LAMBDA_FUNCTION_NAME; const storageManager = container.get(StorageManager); if (isLambda || isProduction) { // Production: Use IAM roles (no credentials needed) storageManager.mount({ name: 's3', storage: S3Storage, initOptions: { bucket: process.env.S3_BUCKET!, region: process.env.AWS_REGION || 'us-east-1' // IAM role provides credentials automatically } }); } else { // Development: Use local credentials storageManager.mount({ name: 's3', storage: S3Storage, initOptions: { bucket: process.env.S3_BUCKET || 'dev-bucket', region: process.env.AWS_REGION || 'us-east-1', credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY! } } }); } ``` **Best Practices:** - **Use IAM roles in production** - Most secure method for AWS environments - **Never hardcode credentials** - Always use environment variables, Secrets Manager, or IAM roles - **Use Secrets Manager** - For storing credentials that can't use IAM roles - **Rotate credentials regularly** - If using static credentials, rotate them periodically - **Apply least privilege** - Grant only the S3 permissions your application needs - **Enable S3 bucket encryption** - Encrypt data at rest using S3-managed or KMS keys - **Use VPC endpoints** - For enhanced security when accessing S3 from within AWS VPC - **Implement caching layer** - Use MemoryStorage for frequently accessed data to reduce S3 calls - **Use appropriate S3 bucket policies** - Restrict access to authorized principals only - **Consider S3 storage classes** - Optimize costs based on access patterns - **Monitor access logs** - Enable S3 access logging for security auditing --- ## Creating Custom Providers Creating a custom provider allows you to store data in any backend: databases, cloud storage, external services, etc. ### Basic Provider Template ```ts import { Storage } from '@vercube/storage'; import type { StorageTypes } from '@vercube/storage'; export class CustomStorage extends Storage { private client: any; // Your storage client /** * Initialize the storage with options */ public async initialize(options?: StorageTypes.Options): Promise { // Setup code: connect to service, initialize client, etc. this.client = await connectToService(options); } /** * Retrieve a value by key */ public async getItem(key: string): Promise { const data = await this.client.get(key); return data ? JSON.parse(data) : null; } /** * Store a value with a key */ public async setItem(key: string, value: T): Promise { await this.client.set(key, JSON.stringify(value)); } /** * Check if a key exists */ public async hasItem(key: string): Promise { return this.client.has(key); } /** * Remove a value by key */ public async deleteItem(key: string): Promise { await this.client.delete(key); } /** * Get all keys */ public async getKeys(): Promise { return this.client.keys(); } /** * Remove all items */ public async clear(): Promise { await this.client.clear(); } /** * Get the number of stored items */ public async size(): Promise { return this.client.size(); } } ``` ### Required Methods Every custom storage provider must implement these methods: | Method | Description | | ------------------------------------- | ------------------------------------------------- | | `initialize(options)` | Initialize the storage with configuration options | | `getItem(key)` | Retrieve a value by key | | `setItem(key, value, options?)` | Store a value with a key and optional options | | `hasItem(key)` | Check if a key exists | | `deleteItem(key)` | Remove a value by key | | `getKeys()` | Get all keys | | `clear()` | Remove all items | | `size()` | Get the number of stored items | --- ## Example: Redis Storage A complete example of a Redis storage provider: ```ts import { Storage } from '@vercube/storage'; import type { StorageTypes } from '@vercube/storage'; import Redis from 'ioredis'; interface RedisStorageOptions { host: string; port: number; password?: string; db?: number; keyPrefix?: string; } export class RedisStorage extends Storage { private redis!: Redis; private prefix: string = ''; public async initialize(options: RedisStorageOptions): Promise { this.redis = new Redis({ host: options.host, port: options.port, password: options.password, db: options.db || 0 }); this.prefix = options.keyPrefix || ''; } private getKey(key: string): string { return this.prefix + key; } public async getItem(key: string): Promise { const data = await this.redis.get(this.getKey(key)); return data ? JSON.parse(data) : null; } public async setItem(key: string, value: T): Promise { await this.redis.set(this.getKey(key), JSON.stringify(value)); } public async hasItem(key: string): Promise { const exists = await this.redis.exists(this.getKey(key)); return exists === 1; } public async deleteItem(key: string): Promise { await this.redis.del(this.getKey(key)); } public async getKeys(): Promise { const keys = await this.redis.keys(this.prefix + '*'); return keys.map(k => k.replace(this.prefix, '')); } public async clear(): Promise { const keys = await this.redis.keys(this.prefix + '*'); if (keys.length > 0) { await this.redis.del(...keys); } } public async size(): Promise { const keys = await this.redis.keys(this.prefix + '*'); return keys.length; } } ``` **Usage:** ```ts import { StorageManager } from '@vercube/storage'; import { RedisStorage } from './storages/RedisStorage'; // Register custom storage in container container.bind(RedisStorage); const storageManager = container.get(StorageManager); // Mount custom storage storageManager.mount({ name: 'redis', storage: RedisStorage, initOptions: { host: 'localhost', port: 6379, keyPrefix: 'myapp:' } }); // Use it like any other storage await storageManager.setItem({ storage: 'redis', key: 'user:123', value: { name: 'John' } }); ``` --- ## Example: File System Storage A storage provider that persists data to the file system: ```ts import { Storage } from '@vercube/storage'; import type { StorageTypes } from '@vercube/storage'; import * as fs from 'fs/promises'; import * as path from 'path'; interface FileStorageOptions { directory: string; } export class FileStorage extends Storage { private directory!: string; public async initialize(options: FileStorageOptions): Promise { this.directory = options.directory; // Ensure directory exists await fs.mkdir(this.directory, { recursive: true }); } private getFilePath(key: string): string { // Sanitize key for use as filename const safeKey = key.replace(/[^a-zA-Z0-9-_:]/g, '_'); return path.join(this.directory, `${safeKey}.json`); } public async getItem(key: string): Promise { try { const filePath = this.getFilePath(key); const data = await fs.readFile(filePath, 'utf-8'); return JSON.parse(data); } catch (error) { return null; } } public async setItem(key: string, value: T): Promise { const filePath = this.getFilePath(key); await fs.writeFile(filePath, JSON.stringify(value, null, 2)); } public async hasItem(key: string): Promise { try { await fs.access(this.getFilePath(key)); return true; } catch { return false; } } public async deleteItem(key: string): Promise { try { await fs.unlink(this.getFilePath(key)); } catch { // Ignore if file doesn't exist } } public async getKeys(): Promise { const files = await fs.readdir(this.directory); return files .filter(f => f.endsWith('.json')) .map(f => f.replace('.json', '')); } public async clear(): Promise { const files = await fs.readdir(this.directory); for (const file of files) { if (file.endsWith('.json')) { await fs.unlink(path.join(this.directory, file)); } } } public async size(): Promise { const keys = await this.getKeys(); return keys.length; } } ``` **Usage:** ```ts storageManager.mount({ name: 'files', storage: FileStorage, initOptions: { directory: './data/storage' } }); await storageManager.setItem({ storage: 'files', key: 'config', value: { theme: 'dark' } }); ``` --- ## Provider Configuration ### Register Provider in Container If your provider has dependencies, register it in the container: ```ts import { Container } from '@vercube/di'; import { CustomStorage } from './storages/CustomStorage'; export function setupContainer(container: Container): void { // Register provider container.bind(CustomStorage); // Configure storage manager to use it const storageManager = container.get(StorageManager); storageManager.mount({ name: 'custom', storage: CustomStorage, initOptions: { ... } }); } ``` ### Provider with Dependencies If your provider needs other services: ```ts import { Inject } from '@vercube/di'; import { Storage } from '@vercube/storage'; import { Logger } from '@vercube/logger'; export class LoggingStorage extends Storage { @Inject(Logger) private logger!: Logger; private innerStorage!: Storage; public async initialize(options: { storage: Storage }): Promise { this.innerStorage = options.storage; } public async getItem(key: string): Promise { this.logger.debug(`Getting item: ${key}`); const value = await this.innerStorage.getItem(key); this.logger.debug(`Got item: ${key}`, { found: value !== null }); return value; } public async setItem(key: string, value: T): Promise { this.logger.debug(`Setting item: ${key}`); await this.innerStorage.setItem(key, value); this.logger.info(`Item set: ${key}`); } // ... implement other methods with logging } ``` ### Async Initialization If your provider needs async initialization: ```ts export class AsyncStorage extends Storage { private connection: Connection; public async initialize(options: ConnectionOptions): Promise { // Async initialization is fully supported this.connection = await connectToService(options); // Wait for connection to be ready await this.connection.ready(); } // ... implement other methods } ``` --- ## Using Multiple Providers Configure multiple providers for different use cases: ```ts import { StorageManager } from '@vercube/storage'; import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage'; import { S3Storage } from '@vercube/storage/drivers/S3Storage'; import { RedisStorage } from './storages/RedisStorage'; const storageManager = container.get(StorageManager); // Memory for fast local cache storageManager.mount({ name: 'cache', storage: MemoryStorage }); // S3 for persistent cloud storage storageManager.mount({ name: 's3', storage: S3Storage, initOptions: { bucket: process.env.S3_BUCKET, region: process.env.AWS_REGION, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY } } }); // Redis for distributed cache storageManager.mount({ name: 'distributed', storage: RedisStorage, initOptions: { host: process.env.REDIS_HOST, port: 6379, keyPrefix: 'app:' } }); ``` --- ## Troubleshooting ### Provider Not Storing Data **Problem:** `setItem()` works but `getItem()` returns null **Solutions:** 1. Check serialization/deserialization: ```ts // Make sure data can be serialized await storage.setItem('key', { date: new Date().toISOString(), // ✅ Serialize dates as strings // date: new Date(), // ❌ May not deserialize correctly }); ``` 2. Verify key format is consistent: ```ts // Use consistent key formatting await storage.setItem('user:123', data); const value = await storage.getItem('user:123'); // ✅ Same key // Not this: await storage.setItem('user:123', data); const value = await storage.getItem('user-123'); // ❌ Different key ``` ### Provider Initialization Error **Problem:** "Failed to initialize storage provider" **Solutions:** 1. Check `initialize()` method doesn't throw: ```ts public async initialize(options: any): Promise { try { // initialization code } catch (error) { console.error('Storage init failed:', error); // Don't throw - handle gracefully } } ``` 2. Verify provider is bound in container: ```ts container.bind(CustomStorage); ``` 3. Check provider initOptions: ```ts storageManager.mount({ name: 'custom', storage: CustomStorage, initOptions: { // Make sure all required options are provided requiredOption: 'value' } }); ``` ### Provider Missing Dependency **Problem:** Injected dependency is undefined **Solution:** Make sure dependency is registered before storage configuration: ```ts // Register dependencies first container.bind(Logger); container.bind(CustomStorage); // Then configure storage container.bind(StorageManager); const storageManager = container.get(StorageManager); storageManager.mount({ name: 'custom', storage: CustomStorage }); ``` ### Async Provider Not Working **Problem:** Async `initialize()` doesn't complete before use **Solution:** StorageManager handles async initialization automatically. Make sure you're using `await` when needed: ```ts // Initialize is called automatically when mounting storageManager.mount({ name: 'async', storage: AsyncStorage, initOptions: { ... } }); // Storage is ready to use after mount await storageManager.setItem({ storage: 'async', key: 'mykey', value: 'myvalue' }); ``` # API This page provides a complete API reference for all classes, interfaces, and utilities in the `@vercube/storage` package. ## StorageManager (Class) The StorageManager is the central service for managing storage instances. It handles mounting and provides a unified interface for storage operations across multiple storage backends. ### Signature ```ts class StorageManager { public mount(params: StorageTypes.Mount): Promise; public getStorage(name?: string): Storage | undefined; public getItem(params: StorageTypes.GetItem): Promise; public setItem(params: StorageTypes.SetItem): Promise; public deleteItem(params: StorageTypes.DeleteItem): Promise; public hasItem(params: StorageTypes.HasItem): Promise; public getKeys(params: StorageTypes.GetKeys): Promise; public clear(params: StorageTypes.Clear): Promise; public size(params: StorageTypes.Size): Promise; } ``` ### Methods #### `mount()` Mounts a storage instance with the given configuration. ```ts public async mount({ name, storage, initOptions }: StorageTypes.Mount): Promise ``` **Parameters:** | Parameter | Type | Required | Description | | ------------- | ---------------- | -------- | ----------------------------------------------------- | | `name` | `string` | No | Name for the storage instance (defaults to 'default') | | `storage` | `typeof Storage` | Yes | Storage class to instantiate | | `initOptions` | `object` | No | Options passed to storage's `initialize()` method | **Example:** ```ts import { StorageManager } from '@vercube/storage'; import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage'; const storageManager = container.get(StorageManager); // Basic mount storageManager.mount({ name: 'cache', storage: MemoryStorage }); // Mount with initOptions storageManager.mount({ name: 'redis', storage: RedisStorage, initOptions: { host: 'localhost', port: 6379 } }); ``` --- #### `getStorage()` Gets a mounted storage instance by name. ```ts public getStorage(name: string = 'default'): Storage | undefined ``` **Parameters:** | Parameter | Type | Description | | --------- | -------- | --------------------------------------------------- | | `name` | `string` | Name of the mounted storage (defaults to 'default') | **Returns:** `Storage | undefined` - The storage instance or undefined if not found **Example:** ```ts const cache = storageManager.getStorage('cache'); if (cache) { await cache.setItem('key', 'value'); } ``` --- #### `getItem()` Retrieves an item from the specified storage. ```ts public async getItem({ storage, key }: StorageTypes.GetItem): Promise ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `storage` | `string` | No | Name of the storage (defaults to 'default') | | `key` | `string` | Yes | Key of the item to retrieve | **Returns:** `Promise` - The stored value or null if not found **Example:** ```ts const user = await storageManager.getItem({ storage: 'cache', key: 'user:123' }); ``` --- #### `setItem()` Stores an item in the specified storage. ```ts public async setItem({ storage, key, value, options }: StorageTypes.SetItem): Promise ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `storage` | `string` | No | Name of the storage (defaults to 'default') | | `key` | `string` | Yes | Key under which to store the value | | `value` | `T` | Yes | Value to store | | `options` | `U` | No | Additional options (driver-specific) | **Example:** ```ts await storageManager.setItem({ storage: 'cache', key: 'user:123', value: { id: '123', name: 'John' } }); ``` --- #### `deleteItem()` Deletes an item from the specified storage. ```ts public async deleteItem({ storage, key }: StorageTypes.DeleteItem): Promise ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `storage` | `string` | No | Name of the storage (defaults to 'default') | | `key` | `string` | Yes | Key of the item to delete | **Example:** ```ts await storageManager.deleteItem({ storage: 'cache', key: 'user:123' }); ``` --- #### `hasItem()` Checks if an item exists in the specified storage. ```ts public async hasItem({ storage, key }: StorageTypes.HasItem): Promise ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `storage` | `string` | No | Name of the storage (defaults to 'default') | | `key` | `string` | Yes | Key to check for | **Returns:** `Promise` - True if the item exists **Example:** ```ts const exists = await storageManager.hasItem({ storage: 'cache', key: 'user:123' }); ``` --- #### `getKeys()` Retrieves all keys from the specified storage. ```ts public async getKeys({ storage }: StorageTypes.GetKeys): Promise ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `storage` | `string` | No | Name of the storage (defaults to 'default') | **Returns:** `Promise` - Array of all keys **Example:** ```ts const keys = await storageManager.getKeys({ storage: 'cache' }); // Filter keys by prefix manually const userKeys = keys.filter(key => key.startsWith('user:')); ``` --- #### `clear()` Clears all items from the specified storage. ```ts public async clear({ storage }: StorageTypes.Clear): Promise ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `storage` | `string` | No | Name of the storage (defaults to 'default') | **Example:** ```ts await storageManager.clear({ storage: 'cache' }); ``` --- #### `size()` Gets the number of items in the specified storage. ```ts public async size({ storage }: StorageTypes.Size): Promise ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------- | | `storage` | `string` | No | Name of the storage (defaults to 'default') | **Returns:** `Promise` - Number of items in storage **Example:** ```ts const count = await storageManager.size({ storage: 'cache' }); console.log(`Cache has ${count} items`); ``` --- ## Storage (Abstract Class) Abstract base class for implementing storage providers. All storage implementations must extend this class. ### Signature ```ts abstract class Storage { public abstract initialize(options: T): void | Promise; public abstract getItem(key: string): T | Promise; public abstract setItem(key: string, value: T, options?: U): void | Promise; public abstract deleteItem(key: string): void | Promise; public abstract hasItem(key: string): boolean | Promise; public abstract getKeys(): string[] | Promise; public abstract clear(): void | Promise; public abstract size(): number | Promise; } ``` ### Methods #### `initialize()` Initializes the storage with configuration options. Called automatically when the storage is mounted. ```ts public abstract initialize(options: T): void | Promise ``` **Parameters:** | Parameter | Type | Description | | --------- | ---- | --------------------------------------- | | `options` | `T` | Provider-specific configuration options | **Example:** ```ts export class CustomStorage extends Storage { public async initialize(options: CustomOptions): Promise { // Connect to database, initialize client, etc. this.client = await connectToService(options); } } ``` --- #### `getItem()` Retrieves a value by key. ```ts public abstract getItem(key: string): T | Promise ``` **Type Parameters:** | Parameter | Description | | --------- | ------------------------ | | `T` | Type of the stored value | **Parameters:** | Parameter | Type | Description | | --------- | -------- | ------------------- | | `key` | `string` | The key to retrieve | **Returns:** The value (implementation may return null if not found) **Example:** ```ts const user = await storage.getItem('user:123'); ``` --- #### `setItem()` Stores a value with a key. ```ts public abstract setItem(key: string, value: T, options?: U): void | Promise ``` **Type Parameters:** | Parameter | Description | | --------- | ----------------------------------- | | `T` | Type of the value to store | | `U` | Type of the optional options object | **Parameters:** | Parameter | Type | Description | | --------- | -------- | -------------------------------- | | `key` | `string` | The key to store under | | `value` | `T` | The value to store | | `options` | `U` | Optional driver-specific options | **Example:** ```ts await storage.setItem('user:123', { id: '123', name: 'John' }); // With options (if supported by driver) await storage.setItem('session:abc', data, { ttl: 3600 }); ``` --- #### `deleteItem()` Removes a value by key. ```ts public abstract deleteItem(key: string): void | Promise ``` **Parameters:** | Parameter | Type | Description | | --------- | -------- | ----------------- | | `key` | `string` | The key to remove | **Example:** ```ts await storage.deleteItem('user:123'); ``` --- #### `hasItem()` Checks if a key exists in storage. ```ts public abstract hasItem(key: string): boolean | Promise ``` **Parameters:** | Parameter | Type | Description | | --------- | -------- | ---------------- | | `key` | `string` | The key to check | **Returns:** `boolean` - True if the key exists **Example:** ```ts const exists = await storage.hasItem('user:123'); ``` --- #### `getKeys()` Gets all keys in storage. ```ts public abstract getKeys(): string[] | Promise ``` **Returns:** Array of all keys **Example:** ```ts const allKeys = await storage.getKeys(); // Filter by prefix manually const userKeys = allKeys.filter(key => key.startsWith('user:')); ``` --- #### `clear()` Removes all items from storage. ```ts public abstract clear(): void | Promise ``` **Example:** ```ts await storage.clear(); ``` --- #### `size()` Gets the number of items in storage. ```ts public abstract size(): number | Promise ``` **Returns:** Number of stored items **Example:** ```ts const count = await storage.size(); console.log(`Storage has ${count} items`); ``` --- ## Types ### `StorageTypes.Mount` Configuration for mounting a storage instance. ```ts interface Mount> { name?: string; storage: IOC.Newable; initOptions?: Parameters[0]; } ``` **Properties:** | Property | Type | Required | Description | | ------------- | ---------------- | -------- | ----------------------------------------------------- | | `name` | `string` | No | Name for the storage instance (defaults to 'default') | | `storage` | `IOC.Newable` | Yes | Storage class to instantiate | | `initOptions` | `object` | No | Options passed to storage's `initialize()` method | --- ### `StorageTypes.GetItem` Parameters for retrieving an item. ```ts interface GetItem { storage?: string; key: string; } ``` --- ### `StorageTypes.SetItem` Parameters for storing an item. ```ts interface SetItem { storage?: string; key: string; value: T; options?: U; } ``` --- ### `StorageTypes.DeleteItem` Parameters for deleting an item. ```ts interface DeleteItem { storage?: string; key: string; } ``` --- ### `StorageTypes.HasItem` Parameters for checking item existence. ```ts interface HasItem { storage?: string; key: string; } ``` --- ### `StorageTypes.GetKeys` Parameters for retrieving keys. ```ts interface GetKeys { storage?: string; } ``` --- ### `StorageTypes.Clear` Parameters for clearing storage. ```ts interface Clear { storage?: string; } ``` --- ### `StorageTypes.Size` Parameters for getting storage size. ```ts interface Size { storage?: string; } ``` --- ## Type Declarations Complete TypeScript type declarations for the storage module: ```ts export abstract class Storage { public abstract initialize(options: T): void | Promise; public abstract getItem(key: string): T | Promise; public abstract setItem(key: string, value: T, options?: U): void | Promise; public abstract deleteItem(key: string): void | Promise; public abstract hasItem(key: string): boolean | Promise; public abstract getKeys(): string[] | Promise; public abstract clear(): void | Promise; public abstract size(): number | Promise; } export class StorageManager { public mount(params: StorageTypes.Mount): Promise; public getStorage(name?: string): Storage | undefined; public getItem(params: StorageTypes.GetItem): Promise; public setItem(params: StorageTypes.SetItem): Promise; public deleteItem(params: StorageTypes.DeleteItem): Promise; public hasItem(params: StorageTypes.HasItem): Promise; public getKeys(params: StorageTypes.GetKeys): Promise; public clear(params: StorageTypes.Clear): Promise; public size(params: StorageTypes.Size): Promise; } export class MemoryStorage extends Storage { public initialize(): void; public getItem(key: string): T; public setItem(key: string, value: T): void; public deleteItem(key: string): void; public hasItem(key: string): boolean; public getKeys(): string[]; public clear(): void; public size(): number; } export interface S3BaseOptions extends S3ClientConfig { bucket: string; } export class S3Storage extends Storage { public initialize(options: S3BaseOptions): Promise; public getItem(key: string): Promise; public setItem(key: string, value: T, options?: U): Promise; public deleteItem(key: string): Promise; public hasItem(key: string): Promise; public getKeys(): Promise; public clear(): Promise; public size(): Promise; } ``` --- ## Troubleshooting ### Storage Not Found **Problem:** `getStorage()` returns undefined **Solution:** Make sure you've mounted the storage before accessing it: ```ts // Mount first storageManager.mount({ name: 'cache', storage: MemoryStorage }); // Then access - check if storage exists const cache = storageManager.getStorage('cache'); if (cache) { await cache.setItem('key', 'value'); } // Or use StorageManager methods directly (recommended) await storageManager.setItem({ storage: 'cache', key: 'mykey', value: 'myvalue' }); ``` ### Type Mismatch **Problem:** Retrieved data doesn't match expected type **Solution:** Use proper type parameters and validate data: ```ts // Specify expected type const user = await storageManager.getItem({ storage: 'cache', key: 'user:123' }); // Validate before using if (user && isValidUser(user)) { // Safe to use } ``` ### Memory Leaks with MemoryStorage **Problem:** Application memory grows over time **Solutions:** 1. Clear old data periodically: ```ts // Clear all data await storageManager.clear({ storage: 'cache' }); // Or remove specific keys const keys = await storageManager.getKeys({ storage: 'cache' }); const tempKeys = keys.filter(key => key.startsWith('temp:')); for (const key of tempKeys) { await storageManager.deleteItem({ storage: 'cache', key }); } ``` 2. Implement TTL (time-to-live) for cached items: ```ts interface CachedItem { value: T; expiresAt: number; } async function getWithTTL(key: string): Promise { const cached = await storageManager.getItem>({ storage: 'cache', key }); if (!cached) return null; if (Date.now() > cached.expiresAt) { await storageManager.deleteItem({ storage: 'cache', key }); return null; } return cached.value; } async function setWithTTL(key: string, value: T, ttlMs: number): Promise { await storageManager.setItem({ storage: 'cache', key, value: { value, expiresAt: Date.now() + ttlMs } }); } ``` ### Custom Storage Initialization Errors **Problem:** "Failed to initialize storage" **Solutions:** 1. Check `initialize()` method doesn't throw: ```ts public async initialize(options: any): Promise { try { // initialization code } catch (error) { console.error('Storage init failed:', error); // Handle gracefully or re-throw } } ``` 2. Verify storage is bound in container: ```ts container.bind(CustomStorage); ``` 3. Check all required initOptions are provided: ```ts storageManager.mount({ name: 'custom', storage: CustomStorage, initOptions: { requiredOption: 'value' // Don't forget required options } }); ``` # Overview The WebSocket module enables real-time bidirectional communication between clients and your Vercube server using WebSocket connections. Built on top of [crossws](https://crossws.unjs.io/){rel=""nofollow""}, it provides a decorator-based API that makes WebSocket development intuitive and type-safe. ## Installation ::code-group ```bash [pnpm] $ pnpm add @vercube/ws ``` ```bash [npm] $ npm install @vercube/ws ``` ```bash [bun] $ bun install @vercube/ws ``` :: ## Quick Start ::steps ### Enable the WebSocket Plugin ```ts [src/index.ts] import { createApp } from '@vercube/core'; import { WebsocketPlugin } from '@vercube/ws'; const app = createApp({ setup: async (app) => { app.addPlugin(WebsocketPlugin); } }); ``` ### Create a WebSocket Gateway ```ts [src/gateways/ChatGateway.ts] import { Controller } from '@vercube/core'; import { Namespace, Message, Emit, OnConnectionAttempt } from '@vercube/ws'; import type { Peer } from 'crossws'; @Namespace('/chat') @Controller() export class ChatGateway { // Optional: Control connection access @OnConnectionAttempt() async handleConnection( params: Record, request: Request ): Promise { // Validate authentication token from query params const token = params.token as string; if (!token || !this.isValidToken(token)) { return false; // Reject connection } return true; // Accept connection } // Listen for 'message' events from clients @Message({ event: 'message' }) @Emit('message-received') async onMessage( data: { text: string; user: string }, peer: Peer ) { console.log(`Message from ${peer.id}:`, data); // Return value is automatically emitted to the sender return { status: 'received', timestamp: new Date().toISOString() }; } private isValidToken(token: string): boolean { // Your token validation logic return true; } } ``` ### Connect from the Client ```ts [client.ts] // Connect to the chat namespace const ws = new WebSocket('ws://localhost:3000/chat?token=your-auth-token'); // Handle connection ws.onopen = () => { console.log('Connected to chat'); // Send a message ws.send(JSON.stringify({ event: 'message', data: { text: 'Hello, server!', user: 'Alice' } })); }; // Receive messages ws.onmessage = (event) => { const message = JSON.parse(event.data); console.log('Received:', message); }; ws.onerror = (error) => { console.error('WebSocket error:', error); }; ws.onclose = () => { console.log('Disconnected from chat'); }; ``` :: ## Core Concepts ### Namespaces Namespaces are logical channels that group WebSocket connections. They allow you to organize different types of real-time functionality within your application. ```ts @Namespace('/chat') // Chat namespace at ws://yourserver.com/chat @Namespace('/notifications') // Notifications namespace ``` Each namespace: - Has its own connection URL path - Maintains separate client connections - Can have different connection handlers - Isolates message events ### Events Events are named message types that clients and servers use to communicate. Think of them as typed message channels within a namespace. ```ts // Server listens for 'message' event from clients @Message({ event: 'message' }) handleMessage(data: any, peer: Peer) { ... } // Server emits 'update' event to client(s) @Emit('update') sendUpdate() { ... } ``` ### Peers A peer represents a connected WebSocket client. Each peer has: - **Unique ID**: Automatically assigned identifier - **Namespace**: The namespace they're connected to - **IP Address**: Client's IP address - **Send method**: To send messages directly to that peer ```ts interface Peer { id: string; namespace?: string; send(message: unknown): void; } ``` ## Message Flow Understanding how messages flow through the WebSocket system is crucial: ![Message flow](https://vercube.dev/images/wss-1.svg) **Connection Flow:** 1. Client initiates WebSocket connection to a namespace 2. Server calls `@OnConnectionAttempt()` handler (if defined) 3. Handler validates connection (query params, headers) 4. Connection accepted/rejected based on handler return value **Message Flow:** 1. Client sends message with `{ event: '...', data: {...} }` 2. Server matches event to `@Message({ event: '...' })` handler 3. Handler processes message and returns response 4. Response decorators (`@Emit`, `@Broadcast`) send data back ## Message Handlers ### Listening to Events Use `@Message()` to listen for specific events from clients: ```ts @Namespace('/chat') @Controller() export class ChatGateway { @Message({ event: 'send-message' }) handleMessage(data: any, peer: Peer) { console.log(`Received from ${peer.id}:`, data); // Process message } @Message({ event: 'typing' }) handleTyping(data: { isTyping: boolean }, peer: Peer) { // Handle typing indicator } } ``` ### Validation Add schema validation to ensure message data integrity: ```ts import { z } from 'zod'; const MessageSchema = z.object({ text: z.string().min(1).max(500), user: z.string(), room: z.string().optional() }); @Message({ event: 'send-message', validationSchema: MessageSchema }) handleMessage(data: z.infer, peer: Peer) { // data is typed and validated console.log(data.text); } ``` Invalid messages are automatically rejected with validation errors. ## Sending Messages ### Emit to Sender Send message back to the client that triggered the handler: ```ts @Message({ event: 'ping' }) @Emit('pong') handlePing() { return { timestamp: Date.now() }; } // Client receives: { event: 'pong', data: { timestamp: ... } } ``` ### Broadcast to All Send message to all clients in the namespace (including sender): ```ts @Message({ event: 'user-joined' }) @Broadcast('user-status') handleUserJoined(data: { username: string }) { return { type: 'joined', username: data.username, timestamp: Date.now() }; } // All clients receive the message ``` ### Broadcast to Others Send message to all clients except the sender: ```ts @Message({ event: 'typing' }) @BroadcastOthers('user-typing') handleTyping(data: { username: string }) { return { username: data.username, isTyping: true }; } // All clients except the sender receive the message ``` ## Connection Management ### Accepting Connections Control who can connect to your namespace: ```ts @Namespace('/private-chat') @Controller() export class PrivateChatGateway { @Inject(AuthService) private authService!: AuthService; @OnConnectionAttempt() async validateConnection( params: Record, request: Request ): Promise { const token = params.token as string; try { // Validate JWT token const user = await this.authService.verifyToken(token); // Check permissions if (!user.hasPermission('access-private-chat')) { return false; } return true; } catch (error) { return false; } } } ``` ### Rejecting Connections Return `false` or throw an error to reject: ```ts @OnConnectionAttempt() async validateConnection(params: Record) { if (!params.token) { throw new Error('Token required'); } const isValid = await this.validateToken(params.token as string); if (!isValid) { return false; // Reject with 403 } return true; } ``` ## Working with Multiple Namespaces You can create multiple namespaces for different purposes: ```ts // Chat namespace @Namespace('/chat') @Controller() export class ChatGateway { @Message({ event: 'message' }) handleMessage(data: any) { ... } } // Notifications namespace @Namespace('/notifications') @Controller() export class NotificationsGateway { @Message({ event: 'subscribe' }) handleSubscribe(data: any) { ... } } // Admin namespace @Namespace('/admin') @Controller() export class AdminGateway { @OnConnectionAttempt() async validateAdmin(params: Record) { // Only allow admins return this.authService.isAdmin(params.token); } @Message({ event: 'broadcast' }) @Broadcast('admin-message') handleBroadcast(data: any) { ... } } ``` Clients connect to different URLs: - `ws://server.com/chat` - Chat namespace - `ws://server.com/notifications` - Notifications - `ws://server.com/admin` - Admin only ## Advanced Patterns ### Room-Based Messaging Implement room functionality using namespaces and filtering: ```ts @Namespace('/chat') @Controller() export class ChatGateway { private rooms = new Map>(); // room -> peer IDs @Inject($WebsocketService) private ws!: WebsocketService; @Message({ event: 'join-room' }) joinRoom(data: { room: string }, peer: Peer) { if (!this.rooms.has(data.room)) { this.rooms.set(data.room, new Set()); } this.rooms.get(data.room)!.add(peer.id); // Notify room members this.broadcastToRoom(data.room, 'user-joined', { userId: peer.id, room: data.room }, peer); } @Message({ event: 'room-message' }) sendToRoom(data: { room: string; text: string }, peer: Peer) { this.broadcastToRoom(data.room, 'message', { from: peer.id, text: data.text }, peer); } private broadcastToRoom( room: string, event: string, data: any, sender: Peer ) { const roomPeers = this.rooms.get(room); if (!roomPeers) return; // Send to all peers in room except sender for (const peerId of roomPeers) { if (peerId !== sender.id) { // You'd need to track peers separately // This is a simplified example } } } } ``` ### Private Messaging Send messages between specific peers: ```ts @Namespace('/chat') @Controller() export class ChatGateway { private peers = new Map(); // userId -> Peer @Message({ event: 'register' }) registerUser(data: { userId: string }, peer: Peer) { this.peers.set(data.userId, peer); } @Message({ event: 'private-message' }) sendPrivate(data: { to: string; text: string }, sender: Peer) { const recipient = this.peers.get(data.to); if (recipient) { recipient.send({ event: 'private-message', data: { from: sender.id, text: data.text } }); return { status: 'delivered' }; } return { status: 'user-offline' }; } } ``` ### Heartbeat / Ping-Pong Keep connections alive and detect disconnects: ```ts @Namespace('/realtime') @Controller() export class RealtimeGateway { @Message({ event: 'ping' }) @Emit('pong') handlePing() { return { timestamp: Date.now() }; } } // Client-side const ws = new WebSocket('ws://server.com/realtime'); let pingInterval: NodeJS.Timeout; ws.onopen = () => { // Send ping every 30 seconds pingInterval = setInterval(() => { ws.send(JSON.stringify({ event: 'ping', data: {} })); }, 30000); }; ws.onclose = () => { clearInterval(pingInterval); }; ``` ### Presence Tracking Track online users in a namespace: ```ts @Namespace('/presence') @Controller() export class PresenceGateway { private onlineUsers = new Set(); @OnConnectionAttempt() async handleConnection(params: Record) { const userId = params.userId as string; this.onlineUsers.add(userId); // Broadcast updated presence this.broadcastPresence(); return true; } @Message({ event: 'disconnect' }) handleDisconnect(data: { userId: string }) { this.onlineUsers.delete(data.userId); this.broadcastPresence(); } private broadcastPresence() { // Broadcast to all connected clients // Implementation depends on how you track peers } } ``` ## Error Handling ### Handler Errors Errors in message handlers are caught and logged: ```ts @Message({ event: 'risky-operation' }) async handleRiskyOp(data: any, peer: Peer) { try { const result = await this.performOperation(data); return { success: true, result }; } catch (error) { console.error('Operation failed:', error); return { success: false, error: error.message }; } } ``` ### Validation Errors Schema validation errors are automatically handled: ```ts const StrictSchema = z.object({ name: z.string().min(3), age: z.number().positive() }); @Message({ event: 'submit', validationSchema: StrictSchema }) handleSubmit(data: z.infer) { // If validation fails, this handler never runs // Client receives validation error details } ``` ## Troubleshooting **"Namespace not registered"** ```bash # Error: Namespace "/chat" is not registered. Connection rejected. ``` **Solution:** Make sure your gateway class has the `@Namespace()` decorator **"No message handler for event"** ```bash # Warning: No message handler for event "foo" in namespace "/chat" ``` **Solution:** Add a `@Message({ event: 'foo' })` handler or check event name spelling **"WebsocketService is not registered"** ```bash # Warning: MessageDecorator::WebsocketService is not registered ``` **Solution:** Add `WebsocketPlugin` to your app setup **Validation errors** ```bash # Message validation error: [{ path: 'text', message: 'Required' }] ``` **Solution:** Check your message data matches the validation schema # API This page provides a complete reference for all decorators available in the `@vercube/ws` module. Each decorator is explained with its purpose, parameters, behavior, and practical examples. ## `@Namespace` The `@Namespace` decorator defines a WebSocket namespace path and must be applied to a controller class. ### Signature ```ts function Namespace(path: string): ClassDecorator ``` ### Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------------------- | | `path` | `string` | Yes | The namespace path (URL path) for WebSocket connections | ### Behavior - Registers the namespace path in the WebSocket service - Clients connect to `ws://yourserver.com{path}` - All message handlers in the controller are scoped to this namespace - Must be used on the same class as `@Controller()` ### Examples ::code-group ```ts [Basic] @Namespace('/chat') @Controller() export class ChatGateway { // All handlers are under /chat namespace } // Clients connect to: ws://yourserver.com/chat ``` ```ts [Multiple namespaces] @Namespace('/public-chat') @Controller() export class PublicChatGateway { @Message({ event: 'message' }) handleMessage(data: any) { ... } } @Namespace('/private-chat') @Controller() export class PrivateChatGateway { @Message({ event: 'message' }) handleMessage(data: any) { ... } } // Clients connect to: // - ws://yourserver.com/public-chat // - ws://yourserver.com/private-chat ``` ```ts [Nested path] @Namespace('/api/v1/realtime') @Controller() export class RealtimeGateway { // ... } // Clients connect to: ws://yourserver.com/api/v1/realtime ``` :: ### Rules - Path is case-insensitive (internally normalized to lowercase) - Leading slash is recommended but not required - Each namespace is isolated - messages don't cross namespace boundaries - You cannot have duplicate namespace paths ### Common Mistakes ```ts // ❌ Missing @Namespace @Controller() export class BadGateway { @Message({ event: 'test' }) handleTest() { ... } } // Warning: Unable to find namespace. Did you use @Namespace()? // ❌ Empty namespace @Namespace('') @Controller() export class BadGateway { ... } // ✅ Correct @Namespace('/my-namespace') @Controller() export class GoodGateway { ... } ``` --- ## `@OnConnectionAttempt` The `@OnConnectionAttempt` decorator handles WebSocket connection attempts, allowing you to accept or reject connections based on authentication, authorization, or other criteria. ### Signature ```ts function OnConnectionAttempt(): MethodDecorator ``` ### Parameters This decorator takes no parameters. ### Handler Method Signature ```ts async ( params: Record, request: Request ): Promise ``` | Parameter | Type | Description | | --------- | ------------------------- | ---------------------------------------- | | `params` | `Record` | Query parameters from the connection URL | | `request` | `Request` | The original HTTP upgrade request | ### Behavior - Called before a WebSocket connection is established - Return `true` or `undefined` to accept the connection - Return `false` to reject with 403 Forbidden - Throw an error to reject with 403 and error message - Optional - if not defined, all connections are accepted - Only one `@OnConnectionAttempt` handler per namespace ### Examples ::code-group ```ts [Basic] @Namespace('/secure') @Controller() export class SecureGateway { @OnConnectionAttempt() async validateConnection( params: Record, request: Request ): Promise { const token = params.token as string; if (!token) { return false; // Reject } return true; // Accept } } // Client: ws://server.com/secure?token=abc123 ``` ```ts [JWT Token validation] @Namespace('/authenticated') @Controller() export class AuthenticatedGateway { @Inject(AuthService) private authService!: AuthService; @OnConnectionAttempt() async validateToken(params: Record): Promise { const token = params.token as string; try { const user = await this.authService.verifyJWT(token); // Check if user is active if (!user.isActive) { throw new Error('Account is inactive'); } return true; } catch (error) { console.error('Auth failed:', error); return false; } } } ``` ```ts [Role-Based access] @Namespace('/admin') @Controller() export class AdminGateway { @Inject(UserService) private userService!: UserService; @OnConnectionAttempt() async checkAdminAccess(params: Record): Promise { const userId = params.userId as string; if (!userId) { throw new Error('User ID required'); } const user = await this.userService.findById(userId); if (!user || user.role !== 'admin') { throw new Error('Admin access required'); } return true; } } ``` ```ts [IP Whitelist] @Namespace('/internal') @Controller() export class InternalGateway { private allowedIPs = new Set(['127.0.0.1', '192.168.1.1']); @OnConnectionAttempt() async checkIP(params: Record, request: Request): Promise { const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; if (!this.allowedIPs.has(ip)) { throw new Error(`IP ${ip} not allowed`); } return true; } } ``` ```ts [Custom headers validation] @OnConnectionAttempt() async validateHeaders( params: Record, request: Request ): Promise { const apiKey = request.headers.get('x-api-key'); const version = request.headers.get('x-client-version'); if (apiKey !== process.env.WS_API_KEY) { throw new Error('Invalid API key'); } // Check minimum client version if (version && this.isVersionTooOld(version)) { throw new Error('Please update your client'); } return true; } ``` :: ### Return Values | Return Value | Behavior | | ------------------ | --------------------------------- | | `true` | Accept connection | | `false` | Reject with 403 Forbidden | | `undefined` (void) | Accept connection | | Thrown error | Reject with 403 and error message | ### Common Patterns ::code-group ```ts [Async Validation] @OnConnectionAttempt() async validateConnection(params: Record): Promise { // All async operations work const isValid = await this.database.checkUser(params.userId); const hasPermission = await this.checkPermissions(params.userId); return isValid && hasPermission; } ``` ```ts [Multiple Checks] @OnConnectionAttempt() async validateConnection(params: Record): Promise { // Check 1: Token exists if (!params.token) { throw new Error('Token required'); } // Check 2: Valid token const user = await this.authService.verify(params.token as string); if (!user) { throw new Error('Invalid token'); } // Check 3: User has access if (!user.permissions.includes('websocket:connect')) { throw new Error('Insufficient permissions'); } return true; } ``` :: --- ## `@Message` The `@Message` decorator listens for incoming WebSocket messages with a specific event name. ### Signature ```ts function Message(options: MessageDecoratorOptions): MethodDecorator interface MessageDecoratorOptions { event: string; validationSchema?: ValidationTypes.Schema; } ``` ### Parameters | Parameter | Type | Required | Description | | ------------------ | -------- | -------- | ----------------------------------------------- | | `event` | `string` | Yes | The event name to listen for | | `validationSchema` | `Schema` | No | Zod schema for validating incoming message data | ### Handler Method Signature ```ts async ( data: unknown, peer: Peer ): Promise ``` | Parameter | Type | Description | | --------- | -------------------------------- | ---------------------------------------- | | `data` | `unknown` (or typed with schema) | The message data sent by the client | | `peer` | `Peer` | The connected peer that sent the message | ### Peer Object ```ts interface Peer { id: string; // Unique peer identifier namespace?: string; // Namespace the peer is connected to ip?: string; // Client IP address send(message: unknown): void; // Send message to this peer } ``` ### Behavior - Listens for messages with `{ event: '...', data: {...} }` structure - Can have multiple message handlers with different events - Supports schema validation with Zod or other validators - Handler return value can be used with `@Emit`, `@Broadcast`, etc. - Invalid messages (validation errors) are logged and ignored ### Examples ::code-group ```ts [Basic Message Handler] @Namespace('/chat') @Controller() export class ChatGateway { @Message({ event: 'send-message' }) handleMessage(data: any, peer: Peer) { console.log(`Message from ${peer.id}:`, data); } } // Client sends: // ws.send(JSON.stringify({ // event: 'send-message', // data: { text: 'Hello!' } // })); ``` ```ts [With Validation Schema] import { z } from 'zod'; const ChatMessageSchema = z.object({ text: z.string().min(1).max(500), username: z.string().min(3).max(20), room: z.string().optional() }); type ChatMessage = z.infer; @Message({ event: 'send-message', validationSchema: ChatMessageSchema }) handleMessage(data: ChatMessage, peer: Peer) { // data is validated and typed console.log(`${data.username}: ${data.text}`); if (data.room) { this.broadcastToRoom(data.room, data); } } ``` ```ts [Multiple Event Handlers] @Namespace('/game') @Controller() export class GameGateway { @Message({ event: 'player-move' }) handleMove(data: { x: number; y: number }, peer: Peer) { // Handle player movement } @Message({ event: 'player-attack' }) handleAttack(data: { target: string }, peer: Peer) { // Handle player attack } @Message({ event: 'player-chat' }) handleChat(data: { text: string }, peer: Peer) { // Handle chat message } } ``` ```ts [Accessing Peer Information] @Message({ event: 'action' }) handleAction(data: any, peer: Peer) { console.log('Peer ID:', peer.id); console.log('Namespace:', peer.namespace); console.log('IP:', peer.ip); // Send message directly to this peer peer.send({ event: 'action-response', data: { status: 'received' } }); } ``` ```ts [With Dependency Injection] @Namespace('/chat') @Controller() export class ChatGateway { @Inject(ChatService) private chatService!: ChatService; @Inject(UserService) private userService!: UserService; @Message({ event: 'send-message' }) async handleMessage(data: { text: string; userId: string }, peer: Peer) { // Use injected services const user = await this.userService.findById(data.userId); await this.chatService.saveMessage({ text: data.text, user: user.name, peerId: peer.id }); } } ``` ```ts [Complex Validation] const UpdateProfileSchema = z.object({ userId: z.string().uuid(), profile: z.object({ name: z.string().min(2).max(50), email: z.string().email(), age: z.number().int().positive().max(120).optional(), preferences: z.object({ theme: z.enum(['light', 'dark']), notifications: z.boolean() }).optional() }) }); @Message({ event: 'update-profile', validationSchema: UpdateProfileSchema }) async handleUpdateProfile( data: z.infer, peer: Peer ) { await this.userService.updateProfile(data.userId, data.profile); return { success: true }; } ``` :: ### Validation Error Handling When validation fails, the message is rejected and error details are logged: ```ts // Client sends invalid data: ws.send(JSON.stringify({ event: 'send-message', data: { text: '' } // Too short! })); // Server logs: // "Websocket message validation error" // [{ path: 'text', message: 'String must contain at least 1 character(s)' }] ``` ### Common Patterns ::code-group ```ts [Stateful Handler] @Namespace('/game') @Controller() export class GameGateway { private playerStates = new Map(); @Message({ event: 'player-move' }) handleMove(data: { x: number; y: number }, peer: Peer) { // Update player state const state = this.playerStates.get(peer.id) || this.createInitialState(); state.position = { x: data.x, y: data.y }; this.playerStates.set(peer.id, state); } } ``` ```ts [Error Handling] @Message({ event: 'risky-operation' }) async handleRiskyOp(data: any, peer: Peer) { try { const result = await this.performOperation(data); return { success: true, result }; } catch (error) { console.error('Operation failed:', error); return { success: false, error: error.message }; } } ``` :: --- ## `@Emit` The `@Emit` decorator sends the handler's return value back to the client that sent the message. ### Signature ```ts function Emit(event: string): MethodDecorator ``` ### Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | -------------------------------------- | | `event` | `string` | Yes | The event name for the emitted message | ### Behavior - Sends message only to the peer that triggered the handler - Uses the handler's return value as message data - Must be used with `@Message()` - Executes after the handler completes - Automatically wraps data in `{ event: '...', data: {...} }` format ### Examples ::code-group ```ts [Basic Emit] @Namespace('/chat') @Controller() export class ChatGateway { @Message({ event: 'ping' }) @Emit('pong') handlePing() { return { timestamp: Date.now() }; } } // Client sends: { event: 'ping', data: {} } // Client receives: { event: 'pong', data: { timestamp: 1234567890 } } ``` ```ts [With Async Handler] @Message({ event: 'get-user' }) @Emit('user-data') async getUserData(data: { userId: string }) { const user = await this.userService.findById(data.userId); return { id: user.id, name: user.name, status: user.status }; } // Client receives: { event: 'user-data', data: { id, name, status } } ``` ```ts [Multiple Emits (Not Supported)] // ❌ You cannot have multiple @Emit decorators @Message({ event: 'test' }) @Emit('response-1') @Emit('response-2') handleTest() { ... } // ✅ Instead, emit once and let client handle @Message({ event: 'test' }) @Emit('response') handleTest() { return { type: 'multiple', data: [...] }; } ``` ```ts [Acknowledgment Pattern] @Message({ event: 'create-order' }) @Emit('order-created') async createOrder(data: CreateOrderDto, peer: Peer) { const order = await this.orderService.create(data); // Return confirmation return { orderId: order.id, status: 'created', timestamp: new Date().toISOString() }; } // Client can await response ``` ```ts [Dynamic Response] @Message({ event: 'query' }) @Emit('query-result') async handleQuery(data: { query: string }) { if (data.query === 'users') { return await this.getUsers(); } else if (data.query === 'orders') { return await this.getOrders(); } return { error: 'Unknown query' }; } ``` :: ### Return Value Requirements The handler must return a value (or Promise that resolves to a value): ```ts // ✅ Good - Returns value @Message({ event: 'test' }) @Emit('result') handleTest() { return { foo: 'bar' }; } // ✅ Good - Async return @Message({ event: 'test' }) @Emit('result') async handleTest() { const data = await this.fetchData(); return data; } // ⚠️ Warning - Returns void @Message({ event: 'test' }) @Emit('result') handleTest() { // No return - client receives empty data } ``` --- ## `@Broadcast` The `@Broadcast` decorator sends the handler's return value to all connected peers in the namespace, **including** the sender. ### Signature ```ts function Broadcast(event: string): MethodDecorator ``` ### Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------ | | `event` | `string` | Yes | The event name for the broadcasted message | ### Behavior - Sends message to ALL peers in the namespace - Includes the peer that triggered the handler - Uses the handler's return value as message data - Must be used with `@Message()` - All peers receive the same message simultaneously ### Examples ::code-group ```ts [Basic Broadcast] @Namespace('/chat') @Controller() export class ChatGateway { @Message({ event: 'user-message' }) @Broadcast('new-message') handleMessage(data: { text: string; user: string }) { return { text: data.text, user: data.user, timestamp: Date.now() }; } } // When user A sends a message: // - User A receives it // - User B receives it // - User C receives it // ... all users receive the same message ``` ```ts [User Status Updates] @Message({ event: 'user-joined' }) @Broadcast('user-status') handleUserJoined(data: { username: string; userId: string }) { return { type: 'joined', username: data.username, userId: data.userId, timestamp: new Date().toISOString() }; } // All users see when someone joins ``` ```ts [Game State Updates] @Namespace('/game') @Controller() export class GameGateway { @Message({ event: 'player-action' }) @Broadcast('game-update') handleAction(data: { action: string; playerId: string }) { // Update game state const newState = this.gameEngine.processAction(data); // Broadcast new state to all players return { state: newState, lastAction: data.action, timestamp: Date.now() }; } } ``` ```ts [Live Notifications] @Namespace('/notifications') @Controller() export class NotificationGateway { @Message({ event: 'system-alert' }) @Broadcast('alert') handleSystemAlert(data: { message: string; severity: string }) { return { type: 'system', message: data.message, severity: data.severity, timestamp: Date.now() }; } } // All connected clients receive the alert ``` ```ts [Collaborative Editing] @Message({ event: 'document-change' }) @Broadcast('document-updated') handleDocumentChange(data: { documentId: string; changes: any[]; userId: string; }) { return { documentId: data.documentId, changes: data.changes, userId: data.userId, version: this.incrementVersion(data.documentId) }; } // All editors see changes in real-time ``` :: ### Broadcast vs Emit ```ts // @Emit - Only sender receives response @Message({ event: 'ping' }) @Emit('pong') handlePing() { ... } // @Broadcast - Everyone receives (including sender) @Message({ event: 'announce' }) @Broadcast('announcement') handleAnnounce() { ... } ``` ### Common Patterns ::code-group ```ts [Broadcast with Validation] @Message({ event: 'post-message', validationSchema: MessageSchema }) @Broadcast('new-post') async handlePost(data: MessageDto) { // Save to database await this.messageService.save(data); // Broadcast to all return { id: generateId(), ...data, createdAt: new Date() }; } ``` ```ts [Conditional Broadcast] @Message({ event: 'update' }) async handleUpdate(data: any, peer: Peer) { // Process update const result = await this.process(data); // Manually broadcast if needed if (result.shouldNotify) { this.wsService.broadcast(peer, { event: 'update-notification', data: result }); } return result; } ``` :: --- ## `@BroadcastOthers` The `@BroadcastOthers` decorator sends the handler's return value to all connected peers in the namespace, **excluding** the sender. ### Signature ```ts function BroadcastOthers(event: string): MethodDecorator ``` ### Parameters | Parameter | Type | Required | Description | | --------- | -------- | -------- | ------------------------------------------ | | `event` | `string` | Yes | The event name for the broadcasted message | ### Behavior - Sends message to ALL peers in the namespace EXCEPT the sender - Uses the handler's return value as message data - Must be used with `@Message()` - Sender doesn't receive the broadcast (use `@Emit` if needed) ### Examples ::code-group ```ts [Typing Indicator] @Namespace('/chat') @Controller() export class ChatGateway { @Message({ event: 'typing' }) @BroadcastOthers('user-typing') handleTyping(data: { username: string; isTyping: boolean }) { return { username: data.username, isTyping: data.isTyping }; } } // User A types: // - User A: doesn't see their own typing indicator // - User B: sees "User A is typing..." // - User C: sees "User A is typing..." ``` ```ts [Player Movement (Multiplayer Games)] @Namespace('/game') @Controller() export class GameGateway { @Message({ event: 'move' }) @BroadcastOthers('player-moved') handleMove(data: { x: number; y: number }, peer: Peer) { return { playerId: peer.id, position: { x: data.x, y: data.y }, timestamp: Date.now() }; } } // Player moves: // - Moving player: sees their own movement locally // - Other players: see the movement via broadcast ``` ```ts [Cursor Position (Collaborative Tools)] @Message({ event: 'cursor-move' }) @BroadcastOthers('cursor-update') handleCursorMove(data: { x: number; y: number; userId: string }) { return { userId: data.userId, position: { x: data.x, y: data.y } }; } // User moves cursor: // - Moving user: sees own cursor locally // - Other users: see cursor position update ``` ```ts [User Status Without Self-Notification] @Message({ event: 'status-change' }) @BroadcastOthers('user-status-changed') handleStatusChange(data: { status: 'online' | 'away' | 'busy' }, peer: Peer) { return { userId: peer.id, status: data.status, timestamp: new Date().toISOString() }; } // User changes status: // - Status changing user: already knows their status // - Other users: notified of the change ``` ```ts [Selection Updates] @Message({ event: 'select-item' }) @BroadcastOthers('item-selected') handleSelection(data: { itemId: string; userId: string }) { return { itemId: data.itemId, userId: data.userId, action: 'selected' }; } // User selects item: // - Selecting user: handles selection locally // - Other users: see what was selected ``` :: ### Combining with @Emit You can use both decorators to send different messages: ```ts @Message({ event: 'action' }) @Emit('action-confirmed') // Sender gets confirmation @BroadcastOthers('user-action') // Others get notification handleAction(data: any, peer: Peer) { return { action: data.action, userId: peer.id }; } // Sender receives: { event: 'action-confirmed', data: {...} } // Others receive: { event: 'user-action', data: {...} } ``` ### Common Patterns ::code-group ```ts [Presence Awareness] @Message({ event: 'join-room' }) @BroadcastOthers('user-joined') handleJoinRoom(data: { roomId: string; username: string }) { // Add user to room this.addToRoom(data.roomId, data.username); // Notify others return { roomId: data.roomId, username: data.username, message: `${data.username} joined the room` }; } ``` ```ts [Real-Time Collaboration] @Message({ event: 'document-edit' }) @BroadcastOthers('edit-applied') handleEdit(data: { documentId: string; edit: any; userId: string; }) { // Apply edit this.applyEdit(data.documentId, data.edit); // Notify others (editor already has it locally) return { documentId: data.documentId, edit: data.edit, userId: data.userId }; } ``` :: --- ## Decorator Combinations You can combine decorators to create powerful patterns: ### `@Message` + `@Emit` Send response back to sender only: ```ts @Message({ event: 'request' }) @Emit('response') handleRequest(data: any) { return { result: 'processed' }; } ``` ### `@Message` + `@Broadcast` Notify everyone including sender: ```ts @Message({ event: 'update' }) @Broadcast('updated') handleUpdate(data: any) { return { updated: true }; } ``` ### `@Message` + `@BroadcastOthers` Notify everyone except sender: ```ts @Message({ event: 'action' }) @BroadcastOthers('notification') handleAction(data: any) { return { action: 'completed' }; } ``` ### `@Message` + `@Emit` + `@BroadcastOthers` Send different messages to sender and others: ```ts @Message({ event: 'post' }) @Emit('post-confirmed') @BroadcastOthers('new-post') handlePost(data: PostDto) { return data; } // Sender: { event: 'post-confirmed', data: {...} } // Others: { event: 'new-post', data: {...} } ``` ### Multiple Handlers Same Namespace ```ts @Namespace('/app') @Controller() export class AppGateway { @Message({ event: 'ping' }) @Emit('pong') handlePing() { return { time: Date.now() }; } @Message({ event: 'broadcast' }) @Broadcast('message') handleBroadcast(data: any) { return data; } @Message({ event: 'notify-others' }) @BroadcastOthers('notification') handleNotify(data: any) { return data; } } ``` **Event Naming** ```ts // ✅ Good - Descriptive, namespaced @Message({ event: 'chat:send-message' }) @Message({ event: 'game:player-move' }) @Message({ event: 'document:edit' }) // ❌ Bad - Generic, unclear @Message({ event: 'message' }) @Message({ event: 'update' }) @Message({ event: 'data' }) ``` **Type Safety** ```ts // ✅ Good - Typed with schema const Schema = z.object({ ... }); type Data = z.infer; @Message({ event: 'action', validationSchema: Schema }) handleAction(data: Data, peer: Peer) { ... } // ❌ Bad - Untyped @Message({ event: 'action' }) handleAction(data: any, peer: Peer) { ... } ``` **Error Handling** ```ts // ✅ Good - Graceful error handling @Message({ event: 'risky' }) async handleRisky(data: any) { try { return await this.process(data); } catch (error) { return { error: error.message }; } } // ❌ Bad - Unhandled errors @Message({ event: 'risky' }) async handleRisky(data: any) { return await this.process(data); // May throw } ``` **Single Responsibility** ```ts // ✅ Good - One concern per handler @Message({ event: 'create-order' }) async createOrder(data: OrderDto) { ... } @Message({ event: 'cancel-order' }) async cancelOrder(data: { orderId: string }) { ... } // ❌ Bad - Multiple concerns @Message({ event: 'order-action' }) async handleOrder(data: any) { if (data.action === 'create') { ... } else if (data.action === 'cancel') { ... } } ``` # Overview The Schema module (`@vercube/schema`) turns your route metadata and Zod validation schemas into an **OpenAPI 3** document at runtime. It also serves a **[Scalar](https://github.com/scalar/scalar){rel=""nofollow""}** API Reference UI so you can browse and try your API without extra tooling. Define validation once with `@Body` / `@QueryParams`, annotate routes with `@Schema`, and get: - **OpenAPI JSON** at `GET /_schema/` - **Scalar docs** at `GET /_schema/docs` (enabled by default) ## Installation ::code-group ```bash [pnpm] $ pnpm add @vercube/schema ``` ```bash [npm] $ npm install @vercube/schema ``` ```bash [bun] $ bun install @vercube/schema ``` :: The package re-exports **`z`** with OpenAPI helpers from [`@asteasolutions/zod-to-openapi`](https://github.com/asteasolutions/zod-to-openapi){rel=""nofollow""}. Import `z` from `@vercube/schema` (not only from `zod`) when you want `.openapi()` metadata on your schemas. ## Quick Start ::steps ### Register the plugin ```ts [src/Boot/Setup.ts] import { createApp } from '@vercube/core'; import { SchemaPlugin } from '@vercube/schema'; export async function setup(app: App) { app.addPlugin(SchemaPlugin); } ``` Or from `vercube.config.ts` with a class plugin: ```ts [vercube.config.ts] import { defineConfig, withPluginOptions } from '@vercube/core'; import { SchemaPlugin } from '@vercube/schema'; export default defineConfig({ plugins: [SchemaPlugin], }); ``` ### Annotate a route Use `@Schema` on controller methods. The decorator registers the route in the OpenAPI registry. Request body and query schemas are picked up automatically when you use `validationSchema` on `@Body` and `@QueryParams` (see [Decorators](https://vercube.dev/docs/modules/schema/decorators)). ```ts [src/controllers/UserController.ts] import { Body, Controller, Post, QueryParams } from '@vercube/core'; import { Schema, z } from '@vercube/schema'; const CreateUserSchema = z.object({ name: z.string(), email: z.string().email(), }); const ListUsersQuerySchema = z.object({ page: z.coerce.number().optional(), }); @Controller('/users') export class UserController { @Post('/') @Schema({ summary: 'Create user', responses: { 201: { description: 'Created user', content: { 'application/json': { schema: z.object({ id: z.string(), name: z.string() }), }, }, }, }, }) create( @Body({ validationSchema: CreateUserSchema }) body: z.infer, @QueryParams({ validationSchema: ListUsersQuerySchema }) _query: z.infer, ) { return { id: '1', ...body }; } } ``` ### Open the docs With the dev server running: | URL | Content | | --------------- | --------------------------- | | `/_schema/` | OpenAPI 3.0 JSON | | `/_schema/docs` | Scalar API Reference (HTML) | :::tip The playground app already registers `SchemaPlugin` - try `http://localhost:3000/_schema/docs` after `pnpm dev` in `playground/`. ::: :: ## Zod and OpenAPI metadata Use `.openapi()` on Zod types for richer docs (examples, component names): ```ts const UserSchema = z .object({ id: z.string().openapi({ example: 'usr_123' }), name: z.string().openapi({ example: 'Jane Doe' }), }) .openapi('User'); ``` Reference named components in `@Schema` responses: ```ts @Schema({ responses: { 200: { description: 'User', content: { 'application/json': { schema: UserSchema }, }, }, }, }) ``` ## Plugin options `SchemaPlugin` accepts optional `SchemaPluginOptions`: ```ts import { SchemaPlugin } from '@vercube/schema'; app.addPlugin(SchemaPlugin, { scalar: { pageTitle: 'My API', openApiUrl: '/_schema/', config: { theme: 'purple' }, }, }); // Disable Scalar UI (OpenAPI JSON remains at /_schema/): app.addPlugin(SchemaPlugin, { scalar: false }); ``` See [Scalar integration](https://vercube.dev/docs/modules/schema/scalar) for all Scalar-related options. ## Relationship to validation Schema generation is **additive** to [Validation](https://vercube.dev/docs/core-features/validation). Validation runs on every request via middleware; `@Schema` only affects documentation and the generated OpenAPI spec. Use the same Zod schemas for both when possible so docs stay in sync with runtime behavior. # Decorators The Schema module exposes a single route decorator, **`@Schema`**, which registers OpenAPI path metadata for a controller method. It is built on [`@asteasolutions/zod-to-openapi`](https://github.com/asteasolutions/zod-to-openapi){rel=""nofollow""} `RouteConfig` (without `method` and `path`, which are inferred from the route). ## `@Schema` Apply `@Schema` to a method that already has an HTTP verb decorator (`@Get`, `@Post`, etc.) and `@Controller` base path. ```ts import { Controller, Get } from '@vercube/core'; import { Schema, z } from '@vercube/schema'; @Controller('/items') export class ItemsController { @Get('/:id') @Schema({ summary: 'Get item by ID', tags: ['Items'], responses: { 200: { description: 'Item found', content: { 'application/json': { schema: z.object({ id: z.string(), title: z.string() }), }, }, }, 404: { description: 'Not found', }, }, }) getOne() { return { id: '1', title: 'Example' }; } } ``` ### Inferred fields | Field | Source | | -------- | ---------------------------------------------------- | | `method` | HTTP decorator on the same method (`get`, `post`, …) | | `path` | `@Controller` path + route path (e.g. `/items/:id`) | You do not pass `method` or `path` to `@Schema`. ### Options `@Schema` accepts any other [`RouteConfig`](https://github.com/asteasolutions/zod-to-openapi){rel=""nofollow""} field, for example: | Field | Purpose | | ------------------------- | ------------------------------------------------------------ | | `summary` / `description` | Human-readable route text | | `tags` | Grouping in Scalar / OpenAPI UIs | | `request` | Request body, query, headers (often auto-filled - see below) | | `responses` | Status codes and response schemas | | `security` | Auth requirements in the spec | | `deprecated` | Mark route as deprecated | ```ts @Schema({ summary: 'Upload file', description: 'Accepts multipart uploads up to 10MB', tags: ['Files'], security: [{ bearerAuth: [] }], responses: { 200: { description: 'Upload accepted' }, }, }) ``` ## Automatic request schemas When a route uses validation on parameters, the Schema module merges those schemas into the OpenAPI `request` object. You usually do not duplicate them in `@Schema`. ### Request body (`@Body`) If the method has `@Body({ validationSchema: SomeSchema })`, the body is documented as `application/json` with that schema: ```ts const CreateItemSchema = z.object({ title: z.string(), quantity: z.number().int().positive(), }); @Post('/') @Schema({ responses: { 201: { description: 'Created' }, }, }) create(@Body({ validationSchema: CreateItemSchema }) body: z.infer) { return body; } ``` Generated OpenAPI includes `request.body.content['application/json'].schema` from `CreateItemSchema`. ### Query parameters (`@QueryParams`) If the method has `@QueryParams({ validationSchema: QuerySchema })`, the query object is merged into `request.query`: ```ts const SearchSchema = z.object({ q: z.string().optional(), limit: z.coerce.number().optional(), }); @Get('/search') @Schema({ responses: { 200: { description: 'Search results' }, }, }) search(@QueryParams({ validationSchema: SearchSchema }) query: z.infer) { return { query }; } ``` ::tip `@QueryParam` (single param) is not auto-mapped today - use `@QueryParams` with a Zod object for documented query strings, or describe query manually in `@Schema({ request: { query: … } })`. :: ### Combining manual and automatic request metadata Manual `request` fields in `@Schema` are deep-merged with auto-resolved body/query via `defu`. Prefer validation schemas as the source of truth when both exist. ## Response schemas Document responses explicitly in `@Schema`. Use Zod schemas (with optional `.openapi()` metadata): ```ts const ErrorSchema = z.object({ message: z.string() }).openapi('Error'); @Get('/risky') @Schema({ responses: { 200: { description: 'OK', content: { 'application/json': { schema: z.object({ ok: z.boolean() }), }, }, }, 500: { description: 'Server error', content: { 'application/json': { schema: ErrorSchema }, }, }, }, }) risky() { return { ok: true }; } ``` ## Importing `z` Always import from `@vercube/schema` when using OpenAPI helpers: ```ts import { Schema, z } from '@vercube/schema'; ``` The package calls `extendZodWithOpenApi(z)` on load so `.openapi()` is available on Zod types. ## Registration timing `@Schema` registers paths asynchronously after the controller metadata is ready (next event-loop tick). Ensure **`SchemaPlugin`** is added before controllers are bound and the app is initialized, same as other Vercube plugins. # Scalar By default, `SchemaPlugin` serves **[Scalar API Reference](https://github.com/scalar/scalar){rel=""nofollow""}** at **`GET /_schema/docs`**. Scalar reads your live OpenAPI document from **`/_schema/`** and renders a modern, interactive UI (try requests, browse models, multiple themes). No separate Express/Fastify adapter is required - Vercube uses [`@scalar/client-side-rendering`](https://www.npmjs.com/package/@scalar/client-side-rendering){rel=""nofollow""} to return a self-contained HTML page that loads Scalar from a CDN. ## Default behavior After `app.addPlugin(SchemaPlugin)`: | Endpoint | Description | | --------------- | ---------------------------------------------------- | | `/_schema/` | OpenAPI 3.0 JSON (`SchemaRegistry.generateSchema()`) | | `/_schema/docs` | Scalar HTML UI pointing at `/_schema/` | Open `http://localhost:/_schema/docs` while your app is running. ## Configuration Pass options as the second argument to `addPlugin` or via `withPluginOptions`: ```ts import { createApp, withPluginOptions } from '@vercube/core'; import { SchemaPlugin } from '@vercube/schema'; const app = await createApp({ setup: async (app) => { app.addPlugin( SchemaPlugin, { scalar: { pageTitle: 'Acme API', openApiUrl: '/_schema/', config: { theme: 'purple', }, }, }, ); }, }); ``` ```ts [vercube.config.ts] import { defineConfig, withPluginOptions } from '@vercube/core'; import { SchemaPlugin } from '@vercube/schema'; export default defineConfig({ plugins: [ withPluginOptions(SchemaPlugin, { scalar: { pageTitle: 'Acme API' }, }), ], }); ``` ### `SchemaPluginOptions` | Option | Type | Default | Description | | -------- | ----------------------------- | ------- | ---------------------------------------- | | `scalar` | `false | SchemaScalarOptions` | enabled | Set to `false` to disable the docs route | ### `SchemaScalarOptions` | Option | Type | Default | Description | | ------------ | ------------------------------------- | ------------------------- | ---------------------------------------------- | | `openApiUrl` | `string` | `'/_schema/'` | URL Scalar fetches for the OpenAPI document | | `pageTitle` | `string` | `'API Reference'` | HTML `` | | `cdn` | `string` | Scalar default (jsDelivr) | CDN URL for the `@scalar/api-reference` bundle | | `config` | `Partial<HtmlRenderingConfiguration>` | - | Extra Scalar settings (theme, layout, etc.) | `config` accepts the same options as Scalar’s [API Reference configuration](https://scalar.com/products/api-references/configuration){rel=""nofollow""} (for example `theme`, custom CSS, multiple sources). ### Disable Scalar Keep OpenAPI JSON, hide the UI: ```ts app.addPlugin(SchemaPlugin, { scalar: false }); ``` `GET /_schema/docs` then returns **404**. ## Custom OpenAPI URL If you proxy the spec or mount the app under a prefix, point Scalar at the correct spec URL: ```ts app.addPlugin(SchemaPlugin, { scalar: { openApiUrl: '/api/v1/_schema/', }, }); ``` The path must match where `SchemaController` is actually served (controller base path is `/_schema` unless you change it in the framework). ## Behind reverse proxies Use an absolute or root-relative `openApiUrl` that the **browser** can reach, not an internal hostname. For example: ```ts scalar: { openApiUrl: 'https://api.example.com/_schema/', } ``` ## Types Exported from `@vercube/schema`: ```ts import type { SchemaPluginOptions, SchemaScalarOptions } from '@vercube/schema'; ``` # Overview ::callout{icon="i-lucide-triangle-alert" type="warning"} **Experimental.** The Nitro integration is in an early experimental stage. APIs may change without notice between releases. Use in production at your own risk. :: The `@vercube/nitro` package lets you run Vercube controllers inside a [Nitro](https://nitro.build){rel=""nofollow""} application. You get Vercube's decorator-based routing and full dependency injection while keeping everything that makes Nitro powerful: multi-platform deployment, built-in storage, and file-based routing. ## Installation ::code-group ```bash [pnpm] $ pnpm add @vercube/nitro ``` ```bash [npm] $ npm install @vercube/nitro ``` ```bash [bun] $ bun add @vercube/nitro ``` :: ### Requirements - **Nitro 3** (currently in beta) is required - `experimentalDecorators: true` must be set in your `tsconfig.json` - The `rolldown` bundler must be used (Nitro validates this at build time) ::callout{icon="i-lucide-triangle-alert" type="warning"} **Nitro 3 beta.** This integration targets Nitro 3, which is itself currently in beta. Both the Nitro API and this package may introduce breaking changes before their stable releases. :: ```json [tsconfig.json] { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } } ``` ## Quick Start ::steps ### Configure Nitro Add the `vercubeNitro` module to your `nitro.config.ts`: ```ts [nitro.config.ts] import { vercubeNitro } from '@vercube/nitro'; import { defineConfig } from 'nitro'; export default defineConfig({ modules: [ vercubeNitro({ scanDirs: ['routes', 'services'], }), ], serverDir: './src', }); ``` ### Create a Controller Place a `@Controller` class anywhere inside your scanned directories: ```ts [src/routes/UserController.ts] import { Controller, Get, Post, Param } from '@vercube/core'; import { Inject } from '@vercube/di'; import { UserService } from '../services/UserService'; @Controller('/api/users') export class UserController { @Inject(UserService) private userService: UserService; @Get('/') async list() { return this.userService.findAll(); } @Get('/:id') async get(@Param('id') id: string) { return this.userService.findById(id); } @Post('/') async create() { return this.userService.create(); } } ``` ### Create a Service :::callout{icon="i-lucide-triangle-alert" type="warning"} **Nitro only.** In a standard Vercube application you do **not** need `@Injectable()` - the DI container discovers and binds classes automatically. In the Nitro integration, `@Injectable()` is required on every service you want auto-registered. The plugin uses it as an explicit opt-in signal so that only the classes you actually need get bound to the container, saving memory and avoiding unnecessary bindings at startup. ::: ```ts [src/services/UserService.ts] import { Injectable } from '@vercube/di'; @Injectable() export class UserService { findAll() { return [{ id: '1', name: 'Alice' }]; } findById(id: string) { return { id, name: 'Alice' }; } create() { return { id: '2', name: 'Bob' }; } } ``` ### Run ```bash npx nitro dev ``` :: ## How It Works The module scans your source directories at build time using AST parsing and generates a Nitro virtual plugin module that: 1. Creates a Vercube application via `createNitroApp()` 2. Binds all discovered `@Controller` and `@Injectable` classes to the DI container 3. Excludes `BaseMiddleware` subclasses from Nitro's native middleware handling 4. Registers a global route handler that delegates every request to the Vercube app Nitro's own file-based routes continue to work normally alongside Vercube controllers. ## Module Options | Option | Type | Default | Description | | ----------- | ---------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scanDirs` | `string[]` | `['api', 'routes', 'services', 'repositories']` | Directories to scan for `@Injectable` services. Merged with any value you provide via `defu`. | | `setupFile` | `string` | `undefined` | Path to a file that exports a default function `(app: App) => void | Promise<void>`. Called after all auto-discovered bindings are registered but **before** `flushQueue()`. Use it to bind tokens that auto-discovery cannot handle. | **Controllers** (`@Controller`) are always scanned from `apiDir` (default: `api/`) and `routesDir` (default: `routes/`) - these come from Nitro's own config and are not affected by `scanDirs`. **Services** (`@Injectable`) are scanned exclusively from `scanDirs`. The default covers the most common layout. Add extra directories if your project places services elsewhere: ```ts [nitro.config.ts] // Default - scans api/, routes/, services/, repositories/ vercubeNitro() // Extended - add a custom directory on top of the defaults vercubeNitro({ scanDirs: ['api', 'routes', 'services', 'repositories', 'handlers'], }) ``` ## Customizing the Container There are two ways to add custom bindings or perform setup that auto-discovery cannot handle. ### `setupFile` (recommended) Pass a `setupFile` path pointing to a module that exports a default function. The module is bundled by Rolldown alongside the rest of your app and the function is called after all auto-discovered controllers and services are registered but **before** `flushQueue()` - so you can override or extend bindings freely. ```ts [nitro.config.ts] import { vercubeNitro } from '@vercube/nitro'; import { defineConfig } from 'nitro'; export default defineConfig({ modules: [ vercubeNitro({ setupFile: './src/container.ts', }), ], }); ``` ```ts [src/container.ts] import type { App } from '@vercube/core'; import { DatabaseToken } from './tokens'; import { PostgresDatabase } from './infra/PostgresDatabase'; export default async (app: App) => { app.container.bind(DatabaseToken, PostgresDatabase); }; ``` ### `useVercubeApp()` (runtime access) If you need access to the app instance after startup - for example inside a Nitro plugin or a request handler - use the `useVercubeApp()` helper: ```ts import { useVercubeApp } from '@vercube/nitro'; const { container } = useVercubeApp(); container.bind($MyService, MyService); ``` ::callout{icon="i-lucide-triangle-alert" type="warning"} Only call `useVercubeApp()` after the Nitro app has fully initialized (e.g. inside a Nitro plugin or a request handler). Calling it too early will return `undefined`. For container setup, prefer `setupFile` instead. :: # Storage The Nitro integration replaces the default `StorageManager` with `NitroStorageManager`, which delegates all operations to Nitro's built-in `useStorage()` API. Your controllers use the same `StorageManager` interface - no code changes needed. ## Configuration Storage drivers are configured in `nitro.config.ts`, not at runtime. The standard `StorageManager.mount()` method is not supported and will log a warning if called. ```ts [nitro.config.ts] import { vercubeNitro } from '@vercube/nitro'; import { defineConfig } from 'nitro'; export default defineConfig({ modules: [vercubeNitro()], storage: { cache: { driver: 'redis', url: process.env.REDIS_URL, }, }, }); ``` ## Using Storage in a Controller Inject `StorageManager` exactly as you would in a standalone Vercube app: ```ts [src/routes/CacheController.ts] import { Controller, Get, Param } from '@vercube/core'; import { Inject } from '@vercube/di'; import { StorageManager } from '@vercube/storage'; @Controller('/api/cache') export class CacheController { @Inject(StorageManager) private storage: StorageManager; @Get('/:key') async get(@Param('key') key: string) { const value = await this.storage.getItem<string>({ key }); return { key, value }; } @Get('/set/:key/:value') async set(@Param('key') key: string, @Param('value') value: string) { await this.storage.setItem({ key, value }); return { key, value }; } } ``` ## Named Storage Pass the `storage` option to target a named storage defined in `nitro.config.ts`: ```ts // Read from the 'cache' storage const value = await this.storage.getItem<string>({ storage: 'cache', key: 'foo' }); // Write to the 'cache' storage await this.storage.setItem({ storage: 'cache', key: 'foo', value: 'bar' }); ``` If the named storage doesn't exist, Nitro's default storage is used as a fallback. ## Supported Operations | Method | Support | | ------------ | --------------------------------------- | | `getItem` | ✅ | | `getItems` | ✅ | | `setItem` | ✅ | | `deleteItem` | ✅ | | `hasItem` | ✅ | | `getKeys` | ✅ | | `clear` | ✅ | | `size` | ⚠️ Always returns `0` | | `mount` | ❌ Not supported - use `nitro.config.ts` | # File-Based Routes Vercube controllers and Nitro file-based routes coexist without any extra configuration. You can use both patterns in the same project and they will work independently. ## Nitro File-Based Routes Standard Nitro route files work exactly as documented in the [Nitro docs](https://nitro.build/docs/routing){rel=""nofollow""}. They are handled by Nitro's own routing layer and are completely unaware of Vercube: ::code-group ```ts [src/routes/health.get.ts] import { defineEventHandler } from 'nitro/h3'; export default defineEventHandler(() => { return { status: 'ok' }; }); ``` ```ts [src/routes/items/[id\\].post.ts] import { defineEventHandler, getRouterParam, readBody } from 'nitro/h3'; export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id'); const body = await readBody(event); return { id, ...body }; }); ``` :: ## Vercube Controllers Controllers registered via the `vercubeNitro` plugin take a different path - they are handled by the Vercube app and go through Vercube's request pipeline: ```ts [src/routes/ItemController.ts] import { Controller, Get, Post, Param } from '@vercube/core'; @Controller('/api/items') export class ItemController { @Get('/:id') get(@Param('id') id: string) { return { id }; } @Post('/') create() { return { created: true }; } } ``` ## Route Priority When both a Nitro file route and a Vercube controller are registered for the same path, **Nitro file routes take precedence**. To avoid conflicts, use distinct path prefixes - for example, serve Vercube controllers under `/api/` and keep Nitro file routes elsewhere. ## Recommended Project Layout ```text src/ ├── middleware/ │ └── AuthMiddleware.ts # Vercube middleware (excluded from Nitro) ├── routes/ │ ├── healthz.get.ts # Nitro file route │ ├── items/ │ │ └── [id].post.ts # Nitro file route │ └── ItemController.ts # Vercube controller └── services/ └── ItemService.ts # Vercube injectable service ``` # Middleware Vercube middleware classes placed in the `middleware/` directory are automatically detected and excluded from Nitro's native middleware handling. This prevents Nitro from trying to auto-register them as H3 middleware, which would cause errors since they are not standard Nitro event handlers. ## How It Works At build time the module scans the `middleware/` directory (hardcoded in Nitro) and looks for classes that extend `BaseMiddleware` from `@vercube/core`. Any matching file is added to Nitro's ignore list so Nitro's own file scanner skips it. The middleware classes are **not** bound to the DI container automatically - Vercube handles middleware registration differently and does not require it. ## Creating a Middleware Extend `BaseMiddleware` and place the file inside your `middleware/` directory: ```ts [src/middleware/AuthMiddleware.ts] import { BaseMiddleware, UnauthorizedError } from '@vercube/core'; export class AuthMiddleware extends BaseMiddleware { public async onRequest(request: Request): Promise<void> { const token = request.headers.get('authorization'); if (!token) { throw new UnauthorizedError('Missing authorization header'); } } } ``` ```ts [src/middleware/LogMiddleware.ts] import { BaseMiddleware } from '@vercube/core'; export class LogMiddleware extends BaseMiddleware { public async onRequest(request: Request): Promise<void> { console.log(`[${new Date().toISOString()}] ${request.method} ${request.url}`); } public async onResponse(request: Request, response: Response): Promise<void> { console.log(`[${new Date().toISOString()}] ${response.status}`); } } ``` ## Applying Middleware to a Controller Apply middleware via the `@Middleware` decorator on a controller class or individual method: ```ts [src/routes/UserController.ts] import { Controller, Get, Middleware } from '@vercube/core'; import { AuthMiddleware } from '../middleware/AuthMiddleware'; import { LogMiddleware } from '../middleware/LogMiddleware'; @Controller('/api/users') @Middleware(LogMiddleware) export class UserController { @Get('/') @Middleware(AuthMiddleware) list() { return [{ id: '1', name: 'Alice' }]; } @Get('/public') public() { return [{ id: '2', name: 'Bob' }]; } } ``` ## Global Middleware Middleware classes in the `middleware/` directory are **not** registered as global middleware automatically. They are only applied where you explicitly use the `@Middleware` decorator. If you want a middleware to run on every request, register it in `GlobalMiddlewareRegistry` via a [`setupFile`](https://vercube.dev/overview#customizing-the-container). See [Global Middlewares](https://vercube.dev/docs/core/middlewares#global-middlewares) in the core docs for the full API. ```ts [src/container.ts] import type { App } from '@vercube/core'; import { GlobalMiddlewareRegistry } from '@vercube/core'; import { LogMiddleware } from './middleware/LogMiddleware'; export default async (app: App) => { const registry = app.container.get(GlobalMiddlewareRegistry); registry.registerGlobalMiddleware(LogMiddleware, { priority: 1 }); }; ``` ```ts [nitro.config.ts] import { vercubeNitro } from '@vercube/nitro'; import { defineConfig } from 'nitro'; export default defineConfig({ modules: [ vercubeNitro({ setupFile: './src/container.ts', }), ], }); ``` ## BaseMiddleware API | Method | Arguments | Description | | ------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------- | | `onRequest` | `request: Request, response: Response, args: MiddlewareOptions` | Called before the controller method. Throw an error to abort the request. | | `onResponse` | `request: Request, response: Response, payload: T` | Called after the controller method returns. Can modify the response. | Both methods are optional - implement only the ones you need. ::callout{icon="i-lucide-info" type="info"} Middleware can abort a request by throwing an `HttpError` (e.g. `BadRequestError`, `ForbiddenError`). Returning a value from `onRequest` or `onResponse` is not supported - use the `response` argument to modify the response object instead. :: # Overview ::callout{icon="i-lucide-triangle-alert" type="warning"} **Experimental.** The Vite integration is in an early stage. APIs may change between releases. :: `@vercube/vite` runs a Vercube server as a [Vite](https://vite.dev){rel=""nofollow""} plugin, built on Vite's [Environment API](https://vite.dev/guide/api-environment.html){rel=""nofollow""}. You write decorator based controllers as usual and get Vite's dev server, fast server side HMR, and a single build command. Discovery is zero config: controllers and services are found by scanning your source. It is a standalone alternative to the [Nitro integration](https://vercube.dev/modules/nitro/overview) and does not touch the `vercube dev` / `vercube build` CLI. You drive it with `vite` and `vite build`. A common setup is a frontend (Vue, React, plain JS) served by Vite next to a Vercube API on the same server. Vercube handles only the routes you define, everything else stays with Vite. See [Using with a frontend](https://vercube.dev/#using-with-a-frontend). ## Installation ::code-group ```bash [pnpm] $ pnpm add -D @vercube/vite vite ``` ```bash [npm] $ npm install -D @vercube/vite vite ``` ```bash [bun] $ bun add -D @vercube/vite vite ``` :: Requirements: **Vite 8**, and `experimentalDecorators` enabled in `tsconfig.json`. ```json [tsconfig.json] { "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": false } } ``` ::callout{icon="i-lucide-info" type="info"} Vercube's DI does not rely on `emitDecoratorMetadata` (it uses explicit `@Inject(Token)`), so you can leave it off. :: ## Quick start ::steps ### Add the plugin ```ts [vite.config.ts] import { vercube } from '@vercube/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [vercube()], server: { port: 3000 }, }); ``` ### Write a controller Place a `@Controller` anywhere under `src/`. It is discovered automatically, with no manual registration. ```ts [src/Controllers/UserController.ts] import { Controller, Get, Param } from '@vercube/core'; import { Inject } from '@vercube/di'; import { UserService } from '../Services/UserService'; @Controller('/api/users') export default class UserController { @Inject(UserService) private userService: UserService; @Get('/') list() { return this.userService.findAll(); } @Get('/:id') get(@Param('id') id: string) { return this.userService.findById(id); } } ``` ### Write a service :::callout{icon="i-lucide-info" type="info"} Auto-discovery uses `@Injectable()` as an opt-in signal. Decorate every service you want registered automatically. ::: ```ts [src/Services/UserService.ts] import { Injectable } from '@vercube/di'; @Injectable() export class UserService { findAll() { return [{ id: '1', name: 'Alice' }]; } findById(id: string) { return { id, name: 'Alice' }; } } ``` ### Run ```bash npx vite ``` Add or edit a controller while the server runs and the change is picked up live, no restart needed. :: ## How it works The plugin defines a dedicated Vite environment named `vercube`. At startup it scans your source ([`@vercube/scan`](https://github.com/vercube/vercube/tree/main/packages/scan){rel=""nofollow""}) for `@Controller` and `@Injectable` classes and generates a server entry that creates the app, binds the discovered classes, flushes the container (which registers routes through decorator initialization), and exports a `fetch` handler. In dev that entry runs inside an isolated [`env-runner`](https://www.npmjs.com/package/env-runner){rel=""nofollow""} worker through Vite's [`ModuleRunner`](https://vite.dev/guide/api-environment-runtimes.html#modulerunner){rel=""nofollow""}. Vercube claims only the routes you define: a request matching a discovered route is forwarded into the worker, everything else falls through to Vite. Editing or adding a controller reloads the worker. The generated entry lives at `node_modules/.vercube/server-entry.mjs`, so it stays out of source control. ## Using with a frontend Because Vercube claims only its own routes, the plugin sits next to a frontend that Vite serves on the same server. Add your framework's plugin alongside `vercube()`. ```ts [vite.config.ts] import { vercube } from '@vercube/vite'; import vue from '@vitejs/plugin-vue'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [ vue(), vercube({ scanDirs: ['src/server'], setupFile: './src/server/Boot/Setup.ts' }), ], }); ``` Vite serves `index.html`, your components and HMR. Requests to your `@Controller` routes (for example `/api/*`) go to Vercube. A full Vite + Vue example lives in [`examples/vite`](https://github.com/vercube/vercube/tree/main/examples/vite){rel=""nofollow""}. ## Build and run ```bash npx vite build node dist/index.mjs ``` `vite build` builds the frontend (when there is one) into `dist/public`, then bundles the server into `dist/index.mjs`. Dependencies are kept external, so the bundle stays small and imports them from `node_modules` at runtime. ```text dist/ index.mjs # the Vercube server public/ # the frontend, only if the project has one index.html assets/… ``` `node dist/index.mjs` serves your API routes and the static frontend from `dist/public` (with `index.html` for `/`) from a single process, the same way the dev server does. The entry also exports `fetch`, so you can mount it in any Web `fetch` environment (edge runtimes, serverless adapters, tests) instead of starting the listener. ## Options | Option | Type | Default | Description | | ----------- | ---------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `rootDir` | `string` | Vite `root` | Project root used to resolve `scanDirs` and `setupFile`. | | `scanDirs` | `string[]` | `['src']` | Directories scanned recursively for `@Controller` and `@Injectable` classes. | | `setupFile` | `string` | `undefined` | Module whose default export `(app: App) => void | Promise<void>` runs before the app initializes. See [Setup file](https://vercube.dev/#setup-file). | | `runner` | `string` | `'node-worker'` | The `env-runner` runner used to execute server code in dev. | ## Setup file Auto-discovery binds `@Controller` and `@Injectable` classes. Anything it cannot infer, such as registering plugins, mounting storage, or binding an interface token to an implementation, goes in a `setupFile`. It runs as `createApp`'s setup hook, before the app initializes, which is early enough to register plugins. ```ts [vite.config.ts] vercube({ setupFile: './src/Boot/Setup.ts' }) ``` ```ts [src/Boot/Setup.ts] import type { App } from '@vercube/core'; import { AuthProvider } from '@vercube/auth'; import { StorageManager } from '@vercube/storage'; import { MemoryStorage } from '@vercube/storage/drivers/MemoryStorage'; import { BasicAuthenticationProvider } from '../Services/BasicAuthenticationProvider'; export default async function setup(app: App) { app.container.bind(AuthProvider, BasicAuthenticationProvider); app.container.bind(StorageManager); app.container.get(StorageManager).mount({ storage: MemoryStorage }); } ``` ## WebSockets The [`@vercube/ws`](https://vercube.dev/docs/modules/web-sockets/overview) plugin works in both dev and production. Register it from your `setupFile` and define WebSocket controllers (`@Controller` + `@Namespace`) as usual. They are auto-discovered alongside HTTP controllers. ```ts [src/Boot/Setup.ts] import { WebsocketPlugin } from '@vercube/ws'; import type { App } from '@vercube/core'; export default async function setup(app: App) { app.addPlugin(WebsocketPlugin); } ``` The dev server forwards upgrade handshakes into the worker. The production server handles them natively through `srvx`. # Overview The same Vercube codebase can be **shipped to different hosts** (Vercel, AWS Lambda, Azure Functions, and so on). Each guide in this section shows the **small amount of wiring** that host needs-extra files, env vars, or the [`@vercube/serverless`](https://www.npmjs.com/package/@vercube/serverless){rel=""nofollow""} adapters-so you are not maintaining separate applications per provider. How routing and configuration work in your app is covered in [Core features](https://vercube.dev/docs/core-features/configuration). Adapter internals for Lambda and Azure are in [Serverless](https://vercube.dev/docs/modules/serverless/overview). ## Default output The default **production** output is a **Node.js** bundle: run **`vercube build`** and you get ESM under **`dist/index.mjs`** by default (see [Configuration](https://vercube.dev/docs/core-features/configuration) for `build.entry` and `build.output`). When you run **`vercube dev`**, the CLI uses a **development** pipeline that stays as close as practical to that production bundle-same decorators, same request path through your app-so behavior in development and after `build` does not drift unnecessarily. ## Choose a platform | Platform | How Vercube runs there | Guide | | ------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | **Vercel** | Node.js function with a Web **`fetch`** handler (no `@vercube/serverless`) | [Deploy to Vercel](https://vercube.dev/docs/deployment/vercel) | | **AWS Lambda** | API Gateway event → `Request` via `@vercube/serverless/aws-lambda` | [AWS Lambda](https://vercube.dev/docs/deployment/aws-lambda) | | **Azure Functions** | HTTP trigger → `Request` via `@vercube/serverless/azure-functions` | [Azure Functions](https://vercube.dev/docs/deployment/azure-functions) | ::tip{icon="i-lucide-book-open"} **AWS** and **Azure** guides assume `@vercube/serverless`. For how adapters map events to `Request` / `Response`, see the [Serverless module overview](https://vercube.dev/docs/modules/serverless/overview). :: # Vercel ::tip{icon="i-lucide-layers"} **AWS Lambda** and **Azure Functions** use the [`@vercube/serverless`](https://www.npmjs.com/package/@vercube/serverless){rel=""nofollow""} adapters instead of this flow. See [AWS Lambda](https://vercube.dev/docs/deployment/aws-lambda), [Azure Functions](https://vercube.dev/docs/deployment/azure-functions), and the [Serverless module](https://vercube.dev/docs/modules/serverless/overview) overview. :: Vercube targets the standard Web [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request){rel=""nofollow""} / [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response){rel=""nofollow""} APIs. That lines up with [Vercel Functions that use a `fetch` handler](https://vercel.com/docs/functions/runtimes/node-js#web-handler){rel=""nofollow""} on the Node.js runtime: you ship one serverless function and route all HTTP traffic to it. This guide assumes a typical Vercube entry file that: - boots the app with `createApp()` (and your container setup), - calls `app.listen()` only when `import.meta.main` is true (local dev / `node dist/index.mjs`), - **exports** `default: { fetch: app.fetch.bind(app) }` so a platform can drive the app without `listen()`. If your entry file does not export that object yet, add it before deploying. ## 1. Production build Vercel should run the same build you use locally. The CLI writes a Node ESM bundle to `dist/index.mjs` by default (see [Configuration](https://vercube.dev/docs/core-features/configuration) for `build.output` / `build.entry`). In `package.json`: ```json { "scripts": { "build": "vercube build" }, "engines": { "node": ">=22" } } ``` In the Vercel project settings, set **Build Command** to `pnpm build`, `npm run build`, or `bun run build`-whatever matches your package manager. You do **not** need a separate "Output Directory" for static assets unless you also deploy a frontend; the API bundle lives under `dist/` and is loaded by the function below. ## 2. Vercel function wrapper Vercel discovers handlers under `/api`. Add a small file that re-exports your built app (so the platform invokes `fetch` on every request): ::code-group ```ts [api/index.ts] import handler from '../dist/index.mjs'; export default handler; ``` ```js [api/index.js] import handler from '../dist/index.mjs'; export default handler; ``` :: Ensure the **Install** + **Build** steps run before the function is bundled so `dist/index.mjs` exists. ## 3. Route everything to the function Add a `vercel.json` at the repository root so paths like `/api/foo` and `/` both reach the same handler (adjust if you expose static files or multiple functions): ```json [vercel.json] { "rewrites": [{ "source": "/(.*)", "destination": "/api" }] } ``` ## 4. Environment variables Configure secrets and config in the Vercel dashboard (**Settings → Environment Variables**). They are exposed to the Node runtime as `process.env` like anywhere else. Use **Production** / **Preview** / **Development** scopes as appropriate so local `.env` and Vercel stay in sync conceptually. ## 5. Node.js version on Vercel Vercube expects **Node.js 22+** (see the monorepo `engines` field). On Vercel, set the major version explicitly: - **Project → Settings → General → Node.js Version**, or - `engines.node` in `package.json` as shown above. Mismatch here is a common source of "works locally, fails on Vercel". ## Pitfalls and limitations - **Cold starts** - The first request after idle time pays serverless startup cost. Keep module scope lean; avoid heavy work at import time except what you truly need for every invocation. - **Execution time** - Default function duration limits apply. Long-running requests or background work belong in queues, cron, or other services-not in a single HTTP invocation. - **WebSockets** - Vercel’s serverless model is request/response oriented. Real-time patterns usually need a dedicated host or a Vercel-supported alternative; plan accordingly. - **Monorepos** - If the app lives in a subdirectory, set **Root Directory** in the Vercel project to that package so `vercube build` and `api/` resolve correctly. ## Optional checks - Run `vercube build` locally, then `node dist/index.mjs` and hit your routes. - After deploy, hit a known route (for example from the base example, `GET /api/foo`) and confirm status codes and logs in the Vercel dashboard. # AWS Lambda ::tip{icon="i-lucide-puzzle"} This guide focuses on **deploying** to AWS. For what `toServerlessHandler` does and how events map to `Request` / `Response`, read the [Serverless module overview](https://vercube.dev/docs/modules/serverless/overview). :: Deploy your Vercube application to AWS Lambda with full support for API Gateway v1 and v2, automatic binary content handling, and seamless cookie management. ## Basic Setup ```ts [lambda.ts] import { createApp } from '@vercube/core'; import { toServerlessHandler } from '@vercube/serverless/aws-lambda'; const app = createApp(); // Your controllers are automatically registered export const handler = toServerlessHandler(app); ``` ## Supported API Gateway Versions The adapter automatically detects and supports both API Gateway versions: ### API Gateway v1 (REST API) Works with `APIGatewayProxyEvent`: ```ts // API Gateway v1 Event Format { httpMethod: 'GET', path: '/api/users', headers: { 'content-type': 'application/json', 'authorization': 'Bearer token...' }, body: '{"name":"John"}', queryStringParameters: { page: '1', limit: '10' }, pathParameters: { id: '123' } } ``` **Key Features:** - Traditional REST API structure - Simple header handling - Cookie handling via `Set-Cookie` header - Binary content with `isBase64Encoded` flag ### API Gateway v2 (HTTP API) Works with `APIGatewayProxyEventV2`: ```ts // API Gateway v2 Event Format { requestContext: { http: { method: 'GET', path: '/api/users' } }, headers: { 'content-type': 'application/json', 'authorization': 'Bearer token...' }, body: '{"name":"John"}', queryStringParameters: { page: '1', limit: '10' }, pathParameters: { id: '123' } } ``` **Key Features:** - Improved performance and lower cost - Enhanced cookie handling with cookies array - Streamlined event structure - Better WebSocket support ## Binary Content Handling Binary responses are automatically encoded as base64: ```ts @Controller('/files') export class FileController { @Inject(FileService) private fileService!: FileService; @Get('/download/:id') async downloadFile(@Param('id') id: string) { const fileBuffer = await this.fileService.getFile(id); return new Response(fileBuffer, { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="file-${id}.pdf"` } }); } @Get('/image/:id') async getImage(@Param('id') id: string) { const imageBuffer = await this.fileService.getImage(id); return new Response(imageBuffer, { headers: { 'Content-Type': 'image/png' } }); } } ``` The adapter automatically: 1. Detects binary content types 2. Encodes the body as base64 3. Sets `isBase64Encoded: true` in the response 4. API Gateway decodes it for the client ### Configure Binary Media Types Tell API Gateway which content types should be treated as binary: ```yaml [serverless.yml] functions: api: handler: lambda.handler events: - http: path: /{proxy+} method: ANY binaryMediaTypes: - 'image/*' - 'application/pdf' - 'application/zip' - 'application/octet-stream' ``` ## Cookie Handling Cookies work seamlessly across both API Gateway versions: ```ts @Controller('/auth') export class AuthController { @Inject(AuthService) private authService!: AuthService; @Post('/login') async login(@Body({ validationSchema: LoginSchema }) credentials: LoginDto) { const token = await this.authService.generateToken(credentials); // Set authentication cookie return FastResponse.ok({ success: true }) .cookie('auth_token', token, { httpOnly: true, secure: true, maxAge: 3600, sameSite: 'strict' }); } @Post('/logout') async logout() { // Clear authentication cookie return FastResponse.ok({ success: true }) .cookie('auth_token', '', { httpOnly: true, secure: true, maxAge: 0 }); } @Get('/session') async getSession(@Cookie('auth_token') token: string) { if (!token) { throw new UnauthorizedException('Not authenticated'); } const session = await this.authService.validateToken(token); return { session }; } } ``` **How it works:** - **API Gateway v1**: Cookies set via `Set-Cookie` header - **API Gateway v2**: Cookies set via `cookies` array for better handling ## Environment Variables Access Lambda-specific environment variables: ```ts import { RuntimeConfig } from '@vercube/core'; @Controller('/config') export class ConfigController { @Get('/lambda-info') getLambdaInfo() { return { // AWS Lambda environment variables region: process.env.AWS_REGION, functionName: process.env.AWS_LAMBDA_FUNCTION_NAME, functionVersion: process.env.AWS_LAMBDA_FUNCTION_VERSION, memoryLimit: process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE, logGroup: process.env.AWS_LAMBDA_LOG_GROUP_NAME, logStream: process.env.AWS_LAMBDA_LOG_STREAM_NAME }; } @Get('/runtime-info') getRuntimeInfo() { return { // Runtime execution details requestId: process.env.AWS_REQUEST_ID, executionEnv: process.env.AWS_EXECUTION_ENV, runtime: process.env.AWS_LAMBDA_RUNTIME_API }; } } ``` ## Serverless Framework Configuration ### Basic Configuration ```yaml [serverless.yml] service: vercube-api provider: name: aws runtime: nodejs22.x region: us-east-1 memorySize: 512 timeout: 30 # Environment variables environment: NODE_ENV: production DATABASE_URL: ${env:DATABASE_URL} JWT_SECRET: ${env:JWT_SECRET} functions: api: handler: lambda.handler events: - http: path: /{proxy+} method: ANY cors: true ``` ### Advanced Configuration ```yaml [serverless.yml] service: vercube-api provider: name: aws runtime: nodejs22.x region: us-east-1 # Performance settings memorySize: 1024 timeout: 60 # VPC configuration (for database access) vpc: securityGroupIds: - sg-xxxxxxxxx subnetIds: - subnet-xxxxxxxxx - subnet-yyyyyyyyy # IAM permissions iam: role: statements: - Effect: Allow Action: - s3:GetObject - s3:PutObject Resource: 'arn:aws:s3:::my-bucket/*' - Effect: Allow Action: - dynamodb:Query - dynamodb:Scan - dynamodb:GetItem - dynamodb:PutItem Resource: 'arn:aws:dynamodb:${aws:region}:*:table/my-table' # Environment variables environment: NODE_ENV: ${opt:stage, 'dev'} DATABASE_URL: ${env:DATABASE_URL} REDIS_URL: ${env:REDIS_URL} JWT_SECRET: ${env:JWT_SECRET} S3_BUCKET: ${env:S3_BUCKET} functions: api: handler: lambda.handler # Provisioned concurrency for predictable performance provisionedConcurrency: 2 # Reserved concurrent executions reservedConcurrency: 100 events: - http: path: /{proxy+} method: ANY cors: origin: '*' headers: - Content-Type - Authorization - X-Api-Key allowCredentials: true # Binary media types binaryMediaTypes: - 'image/*' - 'application/pdf' - 'application/zip' # Layer for dependencies layers: - arn:aws:lambda:us-east-1:xxxxx:layer:my-dependencies:1 # Tags tags: Environment: ${opt:stage, 'dev'} Service: vercube-api # Plugins plugins: - serverless-offline - serverless-plugin-typescript # Custom configuration custom: serverless-offline: httpPort: 3000 ``` ## Cold Start Optimization Minimize cold start times for better performance: ### Module-Level Initialization ```ts [lambda.ts] import { createApp } from '@vercube/core'; import { toServerlessHandler } from '@vercube/serverless/aws-lambda'; import { DatabaseService } from './services/Database'; // Initialize app at module level (happens once per container) const app = createApp({ setup: async (app) => { // Bind services app.container.bind(DatabaseService); // Initialize database connection (reused across invocations) const db = app.container.get(DatabaseService); await db.connect(); } }); // Export handler (execution is fast) export const handler = toServerlessHandler(app); ``` ### Lazy Loading Heavy Dependencies ```ts export class HeavyServiceFactory { private static instance: HeavyService | null = null; static async getInstance() { if (!this.instance) { // Only import when needed const { HeavyService } = await import('./HeavyService'); this.instance = new HeavyService(); } return this.instance; } } @Controller('/heavy') export class HeavyController { @Get('/process') async process() { // Load heavy service only when this endpoint is called const service = await HeavyServiceFactory.getInstance(); return await service.process(); } } ``` ### Provisioned Concurrency Keep functions warm to eliminate cold starts: ```yaml [serverless.yml] functions: api: handler: lambda.handler provisionedConcurrency: 5 # Keep 5 instances always warm # Or use auto-scaling provisionedConcurrency: minCapacity: 2 maxCapacity: 10 targetUtilizationPercent: 0.75 ``` ## Database Connections Handle database connections efficiently in Lambda: ### Connection Pooling ```ts export class DatabaseService { private static pool: Pool | null = null; async getPool() { // Reuse connection pool across invocations if (DatabaseService.pool) { return DatabaseService.pool; } DatabaseService.pool = new Pool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, // Lambda-optimized settings max: 1, // Single connection per Lambda instance idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000 }); return DatabaseService.pool; } async query(sql: string, params?: any[]) { const pool = await this.getPool(); return await pool.query(sql, params); } } ``` ### RDS Proxy Use RDS Proxy to manage database connections: ```yaml [serverless.yml] provider: environment: DB_HOST: my-rds-proxy.proxy-xxxxxxxxx.us-east-1.rds.amazonaws.com DB_PORT: 5432 iam: role: statements: - Effect: Allow Action: - rds-db:connect Resource: 'arn:aws:rds-db:us-east-1:xxxxx:dbuser:prx-xxxxx/*' ``` ## Advanced Response Handling ### Custom Headers ```ts @Controller('/api') export class ApiController { @Get('/cached-data') getCachedData() { return FastResponse.ok({ data: 'cached' }) .header('Cache-Control', 'public, max-age=3600') .header('X-Custom-Header', 'custom-value'); } @Get('/streaming-data') getStreamingData() { const stream = this.createDataStream(); return new Response(stream, { headers: { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' } }); } } ``` ### Error Handling ```ts import { NotFoundException, BadRequestException, InternalServerErrorException } from '@vercube/core'; @Controller('/users') export class UserController { @Get('/:id') async getUser(@Param('id') id: string) { try { const user = await this.userService.findById(id); if (!user) { throw new NotFoundException(`User with ID ${id} not found`); } return user; } catch (error) { if (error instanceof NotFoundException) { throw error; } // Log unexpected errors console.error('Error fetching user:', error); throw new InternalServerErrorException('Failed to fetch user'); } } } // Automatically returns proper AWS response: // { // statusCode: 404, // headers: { 'Content-Type': 'application/json' }, // body: '{"statusCode":404,"message":"User with ID ... not found"}' // } ``` ## Deployment ### Deploy to AWS ```bash # Deploy to default stage (dev) serverless deploy # Deploy to production serverless deploy --stage prod # Deploy specific function serverless deploy function -f api # Deploy with verbose output serverless deploy --verbose ``` ### Environment-Specific Deployments ```yaml [serverless.yml] service: vercube-api provider: name: aws runtime: nodejs22.x region: ${opt:region, 'us-east-1'} stage: ${opt:stage, 'dev'} environment: NODE_ENV: ${self:provider.stage} DATABASE_URL: ${env:DATABASE_URL_${self:provider.stage}} functions: api: handler: lambda.handler ``` ```bash # Deploy to development serverless deploy --stage dev # Deploy to production serverless deploy --stage prod --region us-west-2 ``` ## Monitoring and Debugging ### CloudWatch Metrics Monitor your Lambda function performance: ```yaml [serverless.yml] functions: api: handler: lambda.handler # Enable detailed CloudWatch metrics tracing: lambda: true apiGateway: true ``` **Key Metrics to Monitor:** - **Invocations** - Number of times function is invoked - **Duration** - Execution time per invocation - **Errors** - Number of failed invocations - **Throttles** - Number of throttled invocations - **ConcurrentExecutions** - Number of concurrent invocations - **IteratorAge** - For stream-based invocations ### X-Ray Tracing Enable AWS X-Ray for detailed tracing: ```yaml [serverless.yml] provider: tracing: lambda: true apiGateway: true functions: api: handler: lambda.handler ``` ```ts import AWSXRay from 'aws-xray-sdk-core'; import AWS from 'aws-sdk'; // Wrap AWS SDK const XAWS = AWSXRay.captureAWS(AWS); @Controller('/traced') export class TracedController { @Get('/data') async getData() { // This will appear in X-Ray traces const segment = AWSXRay.getSegment(); const subsegment = segment.addNewSubsegment('custom-operation'); try { const data = await this.processData(); subsegment.close(); return data; } catch (error) { subsegment.addError(error); subsegment.close(); throw error; } } } ``` ## Troubleshooting ### Common Issues **Handler not found** ```bash Error: Cannot find module 'lambda' ``` **Solution:** Ensure handler path matches your file structure: ```yaml functions: api: handler: lambda.handler # <filename>.<export name> ``` **Request timeout** ```bash Task timed out after 30.00 seconds ``` **Solution:** Increase timeout: ```yaml functions: api: timeout: 60 ``` **Binary content corrupted** ```bash Response body appears corrupted or truncated ``` **Solution:** Configure binary media types: ```yaml functions: api: events: - http: binaryMediaTypes: - 'image/*' - 'application/pdf' ``` **Cold start too slow** ```bash Duration: 3000ms (Cold Start: 2500ms) ``` **Solutions:** - Reduce deployment package size - Use Lambda layers for dependencies - Enable provisioned concurrency - Lazy load heavy modules **Memory limit exceeded** ```bash Process exited before completing request ``` **Solution:** Increase memory: ```yaml functions: api: memorySize: 1024 # or higher ``` # Azure Functions ::tip{icon="i-lucide-puzzle"} This guide focuses on **deploying** to Azure. For adapter behavior and package setup, see the [Serverless module overview](https://vercube.dev/docs/modules/serverless/overview). :: Deploy your Vercube application to Azure Functions with complete support for HTTP triggers, efficient streaming, and seamless cookie management. ## Basic Setup ```ts [src/functions/httpTrigger.ts] import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; import { toServerlessHandler } from '@vercube/serverless/azure-functions'; import { app as vercubeApp } from '../index'; const handler = toServerlessHandler(vercubeApp); export async function httpTrigger( request: HttpRequest, context: InvocationContext ): Promise<HttpResponseInit> { return await handler(request); } app.http('httpTrigger', { methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'], authLevel: 'anonymous', handler: httpTrigger, route: '{*route}' // Catch all routes }); ``` ## Request Conversion The adapter converts Azure Functions `HttpRequest` to standard web `Request`: ### Input Format ```ts // Azure HttpRequest { method: 'GET', url: 'https://myapp.azurewebsites.net/api/users?page=1&limit=10', headers: { 'content-type': 'application/json', 'authorization': 'Bearer token...', 'cookie': 'session_id=abc123; theme=dark' }, query: { page: '1', limit: '10' }, params: { id: '123' }, body: ReadableStream { ... } } ``` ### Output Format ```ts // Standard Request { method: 'GET', url: 'https://myapp.azurewebsites.net/api/users?page=1&limit=10', headers: Headers { 'content-type': 'application/json', 'authorization': 'Bearer token...', 'cookie': 'session_id=abc123; theme=dark' }, body: ReadableStream { ... } } ``` ## Response Conversion with Streaming Azure Functions responses use `AsyncIterableIterator` for efficient data streaming: ```ts @Controller('/data') export class DataController { @Inject(DataService) private dataService!: DataService; @Get('/export') async exportData() { const data = await this.dataService.getLargeDataset(); return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json', 'Content-Disposition': 'attachment; filename="export.json"' } }); } @Get('/stream') async streamData() { const stream = this.dataService.createDataStream(); return new Response(stream, { headers: { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' } }); } } ``` The adapter automatically: 1. Reads the Response body 2. Converts it to `AsyncIterableIterator<Uint8Array>` 3. Sets proper headers 4. Streams data efficiently to the client ## Cookie Handling Cookies are properly handled through `Set-Cookie` headers: ```ts @Controller('/auth') export class AuthController { @Inject(AuthService) private authService!: AuthService; @Post('/login') async login(@Body({ validationSchema: LoginSchema }) credentials: LoginDto) { const token = await this.authService.generateToken(credentials); // Set authentication cookie return FastResponse.ok({ success: true }) .cookie('session_id', token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 3600 }) .cookie('user_preferences', 'theme=dark', { maxAge: 86400 }); } @Post('/logout') async logout() { // Clear authentication cookie return FastResponse.ok({ success: true }) .cookie('session_id', '', { httpOnly: true, secure: true, maxAge: 0 }); } @Get('/session') async getSession(@Cookie('session_id') sessionId: string) { if (!sessionId) { throw new UnauthorizedException('No active session'); } const session = await this.authService.validateSession(sessionId); return { session }; } } ``` **How it works:** - Multiple cookies via multiple `Set-Cookie` headers - Proper cookie attribute handling (HttpOnly, Secure, SameSite) - Cookie parsing from request headers ## Azure-Specific Features ### Invocation Context Access Azure Functions execution context: ```ts import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; import { toServerlessHandler } from '@vercube/serverless/azure-functions'; import { app as vercubeApp } from '../index'; const handler = toServerlessHandler(vercubeApp); export async function httpTrigger( request: HttpRequest, context: InvocationContext ): Promise<HttpResponseInit> { // Log invocation details context.log('HTTP trigger function processed request'); context.log('Request URL:', request.url); context.log('Request method:', request.method); context.log('Invocation ID:', context.invocationId); // Set trace context context.traceContext.traceparent = request.headers.get('traceparent'); try { const result = await handler(request); context.log('Request completed successfully'); return result; } catch (error) { context.error('Request failed:', error); throw error; } } app.http('httpTrigger', { methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'], authLevel: 'anonymous', handler: httpTrigger, route: '{*route}' }); ``` ### Environment Variables Access Azure Functions environment variables: ```ts import { RuntimeConfig } from '@vercube/core'; @Controller('/config') export class ConfigController { @Get('/azure-info') getAzureInfo() { return { // Azure Functions environment variables siteName: process.env.WEBSITE_SITE_NAME, resourceGroup: process.env.WEBSITE_RESOURCE_GROUP, region: process.env.REGION_NAME, instanceId: process.env.WEBSITE_INSTANCE_ID, hostname: process.env.WEBSITE_HOSTNAME, // Function execution details functionsVersion: process.env.FUNCTIONS_EXTENSION_VERSION, workerRuntime: process.env.FUNCTIONS_WORKER_RUNTIME }; } @Get('/app-settings') getAppSettings() { return { nodeEnv: process.env.NODE_ENV, databaseUrl: process.env.DATABASE_URL ? 'configured' : 'not configured', customSetting: process.env.CUSTOM_SETTING }; } } ``` ## Function Configuration ### Basic host.json ```json [host.json] { "version": "2.0", "logging": { "applicationInsights": { "samplingSettings": { "isEnabled": true, "maxTelemetryItemsPerSecond": 20, "excludedTypes": "Request" } }, "logLevel": { "default": "Information", "Function": "Information" } }, "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[4.*, 5.0.0)" } } ``` ### Advanced host.json ```json [host.json] { "version": "2.0", "logging": { "applicationInsights": { "samplingSettings": { "isEnabled": true, "maxTelemetryItemsPerSecond": 20 }, "enableDependencyTracking": true }, "logLevel": { "default": "Information", "Host.Results": "Error", "Function.httpTrigger": "Debug" } }, "http": { "routePrefix": "api", "maxOutstandingRequests": 200, "maxConcurrentRequests": 100, "dynamicThrottlesEnabled": true }, "functionTimeout": "00:05:00", "healthMonitor": { "enabled": true, "healthCheckInterval": "00:00:10", "healthCheckWindow": "00:02:00", "healthCheckThreshold": 6, "counterThreshold": 0.80 }, "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[4.*, 5.0.0)" } } ``` ### Function-Specific Configuration ```ts [src/functions/httpTrigger.ts] import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions'; import { toServerlessHandler } from '@vercube/serverless/azure-functions'; import { app as vercubeApp } from '../index'; const handler = toServerlessHandler(vercubeApp); export async function httpTrigger( request: HttpRequest, context: InvocationContext ): Promise<HttpResponseInit> { return await handler(request); } app.http('httpTrigger', { // HTTP methods methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'], // Authentication level authLevel: 'anonymous', // or 'function', 'admin' // Route pattern route: '{*route}', // Handler function handler: httpTrigger }); // Add more function endpoints app.http('adminTrigger', { methods: ['GET', 'POST'], authLevel: 'admin', route: 'admin/{*route}', handler: async (request: HttpRequest) => { // Admin-only endpoint return await handler(request); } }); ``` ## Database Connections Handle database connections efficiently in Azure Functions: ### Connection Pooling ```ts export class DatabaseService { private static pool: Pool | null = null; async getPool() { // Reuse connection pool across invocations if (DatabaseService.pool) { return DatabaseService.pool; } DatabaseService.pool = new Pool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, // Azure Functions optimized settings max: 10, // Higher than Lambda due to concurrent request handling idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000 }); return DatabaseService.pool; } async query(sql: string, params?: any[]) { const pool = await this.getPool(); return await pool.query(sql, params); } } ``` ### Azure SQL Database ```ts import sql from 'mssql'; export class AzureSqlService { private static pool: sql.ConnectionPool | null = null; async getPool() { if (AzureSqlService.pool) { return AzureSqlService.pool; } const config = { server: process.env.AZURE_SQL_SERVER!, database: process.env.AZURE_SQL_DATABASE!, authentication: { type: 'azure-active-directory-default' as const }, options: { encrypt: true, enableArithAbort: true }, pool: { max: 10, min: 0, idleTimeoutMillis: 30000 } }; AzureSqlService.pool = await sql.connect(config); return AzureSqlService.pool; } async query(queryText: string) { const pool = await this.getPool(); const result = await pool.request().query(queryText); return result.recordset; } } ``` ## Application Insights Integration ### Enable Application Insights ```json [local.settings.json] { "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "node", "AzureWebJobsStorage": "", "APPINSIGHTS_INSTRUMENTATIONKEY": "your-instrumentation-key", "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=your-key;..." } } ``` ### Custom Telemetry ```ts import { TelemetryClient } from 'applicationinsights'; const telemetry = new TelemetryClient( process.env.APPLICATIONINSIGHTS_CONNECTION_STRING ); @Controller('/tracked') export class TrackedController { @Post('/order') async createOrder(@Body() order: OrderDto) { const startTime = Date.now(); try { // Track custom event telemetry.trackEvent({ name: 'OrderCreated', properties: { userId: order.userId, items: order.items.length } }); const result = await this.orderService.create(order); // Track custom metric telemetry.trackMetric({ name: 'OrderProcessingTime', value: Date.now() - startTime }); return result; } catch (error) { // Track exception telemetry.trackException({ exception: error, properties: { userId: order.userId } }); throw error; } } } ``` ## Advanced Response Handling ### Custom Headers ```ts @Controller('/api') export class ApiController { @Get('/cached-data') getCachedData() { return FastResponse.ok({ data: 'cached' }) .header('Cache-Control', 'public, max-age=3600') .header('X-Custom-Header', 'custom-value') .header('Access-Control-Allow-Origin', '*'); } @Get('/download') downloadFile() { const fileContent = Buffer.from('file content'); return new Response(fileContent, { headers: { 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="file.txt"', 'Content-Length': fileContent.length.toString() } }); } } ``` ### Error Handling ```ts import { NotFoundException, BadRequestException, InternalServerErrorException } from '@vercube/core'; import { InvocationContext } from '@azure/functions'; export async function httpTrigger( request: HttpRequest, context: InvocationContext ): Promise<HttpResponseInit> { try { return await handler(request); } catch (error) { // Log to Azure context.error('Request failed:', error); // Return appropriate error response if (error instanceof NotFoundException) { return { status: 404, jsonBody: { statusCode: 404, message: error.message } }; } return { status: 500, jsonBody: { statusCode: 500, message: 'Internal server error' } }; } } ``` ## Deployment ### Deploy Using Azure CLI ```bash # Login to Azure az login # Create resource group az group create --name MyResourceGroup --location eastus # Create storage account az storage account create \ --name mystorageaccount \ --resource-group MyResourceGroup \ --location eastus \ --sku Standard_LRS # Create function app az functionapp create \ --name MyFunctionApp \ --resource-group MyResourceGroup \ --storage-account mystorageaccount \ --consumption-plan-location eastus \ --runtime node \ --runtime-version 18 \ --functions-version 4 # Deploy func azure functionapp publish MyFunctionApp ``` ### Deploy Using Azure Functions Core Tools ```bash # Install Azure Functions Core Tools npm install -g azure-functions-core-tools@4 # Initialize function app func init --worker-runtime node --language typescript # Start local development func start # Deploy to Azure func azure functionapp publish <APP_NAME> ``` ### Deploy with GitHub Actions ```yaml [.github/workflows/deploy.yml] name: Deploy to Azure Functions on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup Node.js uses: actions/setup-node@v2 with: node-version: '18' - name: Install dependencies run: npm ci - name: Build run: npm run build - name: Deploy to Azure Functions uses: Azure/functions-action@v1 with: app-name: ${{ secrets.AZURE_FUNCTIONAPP_NAME }} package: . publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }} ``` ## Environment Configuration ### Local Development ```json [local.settings.json] { "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "node", "AzureWebJobsStorage": "UseDevelopmentStorage=true", // Application settings "NODE_ENV": "development", "DATABASE_URL": "postgresql://localhost:5432/myapp", "REDIS_URL": "redis://localhost:6379", "JWT_SECRET": "dev-secret", // Azure services "AZURE_STORAGE_CONNECTION_STRING": "...", "COSMOS_DB_ENDPOINT": "...", "COSMOS_DB_KEY": "...", // Application Insights "APPINSIGHTS_INSTRUMENTATIONKEY": "...", "APPLICATIONINSIGHTS_CONNECTION_STRING": "..." }, "Host": { "CORS": "*", "CORSCredentials": false } } ``` ### Production Configuration Configure application settings in Azure Portal: ```bash # Using Azure CLI az functionapp config appsettings set \ --name MyFunctionApp \ --resource-group MyResourceGroup \ --settings \ NODE_ENV=production \ DATABASE_URL="postgresql://..." \ JWT_SECRET="production-secret" ``` ## Monitoring and Debugging ### Application Insights Queries ```kusto // Query failed requests requests | where success == false | where timestamp > ago(1h) | project timestamp, name, resultCode, duration | order by timestamp desc // Query slow requests requests | where duration > 1000 | where timestamp > ago(1h) | project timestamp, name, duration, resultCode | order by duration desc // Query exceptions exceptions | where timestamp > ago(1h) | project timestamp, type, outerMessage, innerMessage | order by timestamp desc // Custom events customEvents | where name == "OrderCreated" | where timestamp > ago(24h) | summarize count() by bin(timestamp, 1h) ``` ### Live Metrics Monitor your function in real-time: ```bash # View live metrics in portal # Navigate to: Function App > Monitoring > Live Metrics ``` ### Log Streaming ```bash # Stream logs using Azure CLI az webapp log tail \ --name MyFunctionApp \ --resource-group MyResourceGroup # Or using Core Tools func azure functionapp logstream MyFunctionApp ``` ## Performance Optimization ### Cold Start Reduction ```ts // Initialize at module level import { createApp } from '@vercube/core'; import { toServerlessHandler } from '@vercube/serverless/azure-functions'; const app = createApp({ setup: async (app) => { // Initialize heavy services once app.container.bind(DatabaseService); app.container.bind(CacheService); const db = app.container.get(DatabaseService); await db.connect(); } }); const handler = toServerlessHandler(app); export async function httpTrigger( request: HttpRequest, context: InvocationContext ): Promise<HttpResponseInit> { // Fast execution - just handle the request return await handler(request); } ``` ### Always-On Setting Enable Always On to prevent cold starts: ```bash # Using Azure CLI az functionapp config set \ --name MyFunctionApp \ --resource-group MyResourceGroup \ --always-on true ``` ### Premium Plan Use Azure Functions Premium Plan for better performance: ```bash # Create Premium plan az functionapp plan create \ --name MyPremiumPlan \ --resource-group MyResourceGroup \ --location eastus \ --sku EP1 \ --is-linux false # Create function app with Premium plan az functionapp create \ --name MyFunctionApp \ --resource-group MyResourceGroup \ --plan MyPremiumPlan \ --runtime node \ --runtime-version 18 ``` ## Troubleshooting ### Common Issues **Function not triggering** ```bash Error: No HTTP triggers found ``` **Solution:** Ensure route configuration is correct: ```ts app.http('httpTrigger', { route: '{*route}', // Catch all routes handler: httpTrigger }); ``` **CORS errors** ```bash Access to fetch has been blocked by CORS policy ``` **Solution:** Configure CORS in host.json: ```json { "version": "2.0", "extensions": { "http": { "routePrefix": "api", "cors": { "allowedOrigins": ["*"], "allowedMethods": ["GET", "POST", "PUT", "DELETE"], "allowedHeaders": ["*"] } } } } ``` **Request timeout** ```bash Function execution timed out ``` **Solution:** Increase timeout in host.json: ```json { "functionTimeout": "00:10:00" } ``` **Memory issues** ```bash JavaScript heap out of memory ``` **Solution:** Increase memory by upgrading plan or optimizing code **Connection string not found** ```bash AzureWebJobsStorage connection string not found ``` **Solution:** Set storage connection string: ```json { "Values": { "AzureWebJobsStorage": "DefaultEndpointsProtocol=https;..." } } ``` ## Best Practices **Use Application Insights for monitoring** ```ts // Always enable Application Insights const telemetry = new TelemetryClient(); telemetry.trackEvent({ name: 'CustomEvent' }); ``` **Handle concurrent requests efficiently** ```ts // Use proper connection pooling const pool = new Pool({ max: 10, // Azure Functions can handle multiple concurrent requests idleTimeoutMillis: 30000 }); ``` **Implement proper error handling** ```ts export async function httpTrigger( request: HttpRequest, context: InvocationContext ): Promise<HttpResponseInit> { try { return await handler(request); } catch (error) { context.error('Error:', error); return { status: 500, jsonBody: { error: 'Internal server error' } }; } } ``` **Use managed identity for Azure services** ```ts // Instead of connection strings, use managed identity import { DefaultAzureCredential } from '@azure/identity'; const credential = new DefaultAzureCredential(); const client = new BlobServiceClient( `https://${accountName}.blob.core.windows.net`, credential ); ``` # Custom Decorator Vercube provides a powerful decorator system that allows you to create custom decorators for your application. Custom decorators extend the `BaseDecorator` class and can be used to add metadata, modify behavior, or inject dependencies into your classes and methods. ## How Decorators Work Decorators in Vercube are created using the `createDecorator()` factory function. Each decorator is a class that extends `BaseDecorator` and is instantiated by the IOC container, which means you can use dependency injection inside your decorators. ```ts import { BaseDecorator, createDecorator, Inject } from '@vercube/di'; class MyDecorator extends BaseDecorator<MyOptions> { @Inject(SomeService) private someService!: SomeService; public override created(): void { // Called when decorator is initialized } public override destroyed(): void { // Called when decorator is cleaned up } } export function MyDecorator(options?: MyOptions): Function { return createDecorator(MyDecorator, options); } ``` ## BaseDecorator Class The `BaseDecorator` class is the foundation for all custom decorators. It provides access to the decorated element and lifecycle hooks. ### Properties | Property | Type | Description | | --------------- | -------------------- | -------------------------------------------- | | `options` | `T` | Options object passed to the decorator | | `instance` | `any` | The class instance that is decorated | | `prototype` | `P` | The class prototype that is decorated | | `propertyName` | `string` | Name of the decorated property or method | | `descriptor` | `PropertyDescriptor` | Property descriptor of the decorated element | | `propertyIndex` | `number` | Parameter index (for parameter decorators) | ### Lifecycle Methods #### `created()` Called when the decorator is created and ready to be used. Use this to set up event listeners, register hooks, or perform initialization logic. ```ts public override created(): void { // Initialize your decorator console.log(`Decorator applied to ${this.propertyName}`); } ``` #### `destroyed()` Called when the decorator is destroyed. Use this for cleanup tasks like unregistering listeners or clearing timers. This is called at the end of SSR requests or when a component is destroyed. ```ts public override destroyed(): void { // Cleanup resources this.unsubscribe(); } ``` ## Creating a Custom Decorator ### Basic Example Here's a simple decorator that logs method calls: ```ts import { BaseDecorator, createDecorator } from '@vercube/di'; interface LogDecoratorOptions { level?: 'debug' | 'info' | 'warn'; prefix?: string; } class LogMethodDecorator extends BaseDecorator<LogDecoratorOptions> { private originalMethod!: Function; public override created(): void { // Store the original method this.originalMethod = this.descriptor.value; // Replace with wrapped version const options = this.options; const methodName = this.propertyName; this.descriptor.value = function(...args: any[]) { const prefix = options?.prefix || ''; console.log(`${prefix}[${methodName}] called with:`, args); const result = this.originalMethod.apply(this, args); console.log(`${prefix}[${methodName}] returned:`, result); return result; }; } } /** * Logs method calls with arguments and return values */ export function LogMethod(options?: LogDecoratorOptions): Function { return createDecorator(LogMethodDecorator, options); } ``` **Usage:** ```ts import { Controller, Get } from '@vercube/core'; import { LogMethod } from './decorators/LogMethod'; @Controller('/users') export class UserController { @LogMethod({ prefix: '[UserController] ' }) @Get('/:id') async getUser(req: Request, params: { id: string }) { return { id: params.id, name: 'John' }; } } ``` ## API Reference ### `createDecorator()` Factory function to create a decorator from a `BaseDecorator` class. ```ts function createDecorator<T>( DecoratorClass: typeof BaseDecorator<T>, options?: T ): Function ``` **Parameters:** | Parameter | Type | Description | | ---------------- | ------------------------- | ---------------------------------- | | `DecoratorClass` | `typeof BaseDecorator<T>` | The decorator class to instantiate | | `options` | `T` | Options to pass to the decorator | **Returns:** A decorator function that can be applied to classes, methods, or parameters. ### `BaseDecorator<T, P>` Abstract base class for all decorators. ```ts abstract class BaseDecorator<T = any, P = any> { public options: T; public instance: any; public prototype: P; public propertyName: string; public descriptor: PropertyDescriptor; public propertyIndex: number; public created(): void; public destroyed(): void; } ``` **Type Parameters:** | Parameter | Description | | --------- | ---------------------------------------------- | | `T` | Type of the options object | | `P` | Type of the prototype (for advanced use cases) | # Plugins Plugins let you change the merged configuration, bind services in the runtime worker, register CLI commands, and subscribe to dev-only events in the parent process (`vercube dev`). Register them in `**defineConfig({ plugins: [...] })**`, or keep using `**app.addPlugin()**` in `createApp`’s `setup` (without listing the class in config). ## One pipeline, two ways to write it There is **a single plugin model**: an object with optional lifecycle fields (`config`, `setup`, `cli`, `**hooks`\*\*, …). Nothing "different" runs in the engine depending on how you authored it. - **Canonical (recommended)** - extend `**BasePlugin`\*\*. Fits the rest of Vercube (OOP, DI, packages you publish, anything that grows beyond a few lines). - **Syntax sugar** - `**defineVercubePlugin({ ... })`\*\* (or a plain object / factory that returns the same shape). Same fields, less ceremony for **small, local** snippets right inside `vercube.config.ts` - similar in spirit to a quick inline plugin in Vite. Prefer classes for shared or long-lived plugins; use `**defineVercubePlugin`\*\* when a class would be noisy. Both can appear in the same `plugins` array. ## Declaring plugins in `vercube.config.ts` ```ts [vercube.config.ts] import { defineConfig, withPluginOptions } from '@vercube/core'; import { HealthPlugin } from './src/Plugins/HealthPlugin'; export default defineConfig({ plugins: [ HealthPlugin, withPluginOptions(HealthPlugin, { externals: ['some-native-module'] }), defineVercubePlugin({ name: 'inline', config: () => ({ server: { port: 3000 } }), setup: (app) => { /* bind services when the worker starts */ }, }), ], }); ``` The list accepts: - Classes extending `**BasePlugin**` (recommended default) - `**[Class, options]**` tuples - **Factories** - `() => ({ ... })` (often returns the same object as below) - **Objects** - including those returned from `**defineVercubePlugin`\*\* (syntax sugar for typing / readability) ### Options for a class plugin **Why not "just infer" inside `plugins: [...]`?**:br`plugins` is typed as a **mixed** list (classes, tuples, objects, factories). TypeScript does not keep a strong link between the first and second element of `**[Class, options]`\*\* in that position, so `**options`\*\* is often widened to `**unknown`**. There is no good way to get **automatic** strict typing there without either a **small function** or an explicit `**satisfies`** on the tuple. **Recommended - `withPluginOptions(class, options)`**:br Options are checked against `**BasePlugin<TOptions>**` (inference from the class; `**NoInfer**` avoids bad widening). Runtime it is still a normal `**[Class, options]**` tuple. ```ts [vercube.config.ts] import { defineConfig, withPluginOptions } from '@vercube/core'; import { HealthPlugin } from './src/Plugins/HealthPlugin'; export default defineConfig({ plugins: [withPluginOptions(HealthPlugin, { externals: ['my-native-addon'] })], }); ``` **Zero runtime (types only)** - if you prefer not to call a function: ```ts [HealthPlugin, { externals: ['my-native-addon'] }] satisfies PluginWithOptions<typeof HealthPlugin> ``` Define options on the class as `**BasePlugin<MyOptions>**` (see `**HealthPlugin**` / `**HealthPluginOptions**` in `**examples/custom-plugin**`). Do **not** list the same class twice with different options unless you want two independent plugin instances. ### Hook order (`enforce`) Like Vite, a plugin can run its `config` hook earlier or later. Works the same on `**BasePlugin`\*\* (via `configure`) and on inline / `**defineVercubePlugin`\*\* objects (`config` hook): ```ts defineVercubePlugin({ name: 'runs-first', enforce: 'pre', config: (cfg) => ({ /* ... */ }), }) ``` Order is: all `enforce: 'pre'`, then default order, then `enforce: 'post'`. ## Class-based plugins (`BasePlugin`) `BasePlugin` supports optional methods that map to the unified pipeline: | Method | When it runs | Purpose | | --------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `**configure(config, options?)**` | While resolving config (CLI, `vercube dev` parent, worker) | Return a partial config; merged so your values override previous keys | | `**setup(app, options?)**` | Worker runtime, after `PluginsRegistry` runs `addPlugin` plugins | Bind services, controllers, start background work | | `**use(app, options?)**` | Legacy: used if `**setup**` is not defined (for plugins registered only via `configure` pipeline) | Same as runtime attach | | `**setupCLI(ctx)**` | Config resolution when the CLI loads the config | Call `**ctx.register(MyCommand)**` | | `**hooks(ctx)**` | Only the `**vercube dev**` parent process, after config load | `ctx.hooks` is the dev `**Hookable**` (`bundler-watch:*`, `dev:reload`, …). Narrow with `import type { DevKitTypes } from '@vercube/devkit'` for typed event names | The package `**@vercube/devkit**` implements that parent process (bundler watch + worker fork); the plugin **method** is named `**hooks`\*\* because it wires into that `Hookable`. ```ts import { BasePlugin } from '@vercube/core'; import type { App, ConfigTypes, VercubePluginCliContext, VercubePluginHooksContext } from '@vercube/core'; import type { DevKitTypes } from '@vercube/devkit'; export class MyPlugin extends BasePlugin { public override name = 'MyPlugin'; public override configure(_cfg: ConfigTypes.Config) { return { logLevel: 'info' }; } public override setup(app: App) { // app.container.bind(...) } public override setupCLI(ctx: VercubePluginCliContext) { ctx.register(MyCommand); } public override hooks(ctx: VercubePluginHooksContext) { const hookable = ctx.hooks as DevKitTypes.App['hooks']; hookable.hook('dev:reload', () => { /* optional side effects in parent dev process */ }); } } ``` Avoid registering the same plugin both in `**plugins: []**` and `**app.addPlugin()**` - hooks would run twice. ## Syntax sugar: `defineVercubePlugin` `**defineVercubePlugin(obj)**` is a typed helper that **returns the same plugin object** you could write literally. It does not add a second runtime or ruleset - it only helps TypeScript and keeps small one-off plugins readable next to `**defineConfig`\*\*. ```ts import { defineVercubePlugin } from '@vercube/core'; import type { DevKitTypes } from '@vercube/devkit'; export const portPlugin = defineVercubePlugin({ name: 'port', config: () => ({ server: { port: 4000 } }), setup: async (app) => { /* ... */ }, cli: ({ register }) => { // register(MyCmd); }, hooks: (ctx) => { (ctx.hooks as DevKitTypes.App['hooks']).hook('bundler-watch:end', () => {}); }, }); ``` Use `**vercubePluginFromClass(SchemaPlugin, options)**` when you already have a `**BasePlugin**` class (e.g. from `**@vercube/schema**`) but want it next to inline objects in the same `plugins` array without wrapping it in a new class. ## Where hooks run | Field | `vercube dev` parent | `vercube` CLI | Bundled worker (`createApp`) | | ---------------------- | -------------------- | ------------- | ---------------------------- | | `config` / `configure` | ✅ | ✅ | ✅ | | `cli` / `setupCLI` | ✅ | ✅ | skipped | | `hooks` | ✅ | skipped | skipped | | `setup` / `use` | skipped | skipped | ✅ | `loadVercubeConfig` always runs the `config` and `cli` phases so the worker and tooling share one merged config. The dev parent (via `**@vercube/devkit**`) calls `**invokeVercubePluginDevHooks**` after each load (and again when `vercube.config.ts` changes during watch). ## API reference - `**defineVercubePlugin(plugin)**` - **syntax sugar**: typed helper; returns the plugin object unchanged - `**withPluginOptions(PluginClass, options)`\*\* - **recommended** for class + options: infers `**TOptions`\*\* from the class; emits a `**[Class, options]`\*\* tuple - `**PluginWithOptions<typeof MyPlugin>`\*\* / `**InferPluginOptions<typeof MyPlugin>**` - type-only: use with `**satisfies**` if you want zero runtime calls - `**vercubePluginFromClass(Class, options?, name?)**` - adapter for `BasePlugin` subclasses - `**loadVercubeConfig(overrides?, opts?)**` - `opts.cwd`, `opts.import` (CLI jiti), `opts.command` - `**applyVercubePluginHooks(config, env)**` - advanced: run the config + CLI pipeline on an in-memory config - `**invokeVercubePluginDevHooks(plugins, ctx)**` - advanced: run each plugin’s `**hooks()**` in the dev parent; used by `**@vercube/devkit**` ### Types All plugin-related public types and the `**PluginTypes**` namespace live in **`@vercube/core`** source as a single module: `**Types/Plugin.ts**` (interfaces `**VercubePlugin**`, `**VercubePluginEnv**`, `**VercubePluginCliContext**`, `**VercubePluginHooksContext**`, unions `**VercubePluginInput**`, `**PluginWithOptions**`, `**InferPluginOptions**`, etc.). ### `app.addPlugin(class, options?)` Still supported: registers a `BasePlugin` subclass on the DI `**PluginsRegistry**` before config-file plugin `setup` hooks run. ## Example in the repo The `**[examples/custom-plugin](https://github.com/vercube/vercube/tree/main/examples/custom-plugin)**` app is the hands-on sample: - `**HealthPlugin**` is listed only under `**plugins**` in `vercube.config.ts` (no `addPlugin` in `createApp`). - `**configure**` - optional `**externals**` for Rolldown (via `**withPluginOptions(HealthPlugin, { … })**` or an options tuple). - `**setup**` - binds `**HealthController**` → `**GET /_health/**`. - `**setupCLI**` - registers `**vercube plugin-info**`. Clone or copy it with giget (see that folder’s **README**). It is also listed on the **[Examples](https://vercube.dev/docs/getting-started/examples)** docs page next to other starter projects. **Config loading:** `vercube.config.ts` is loaded with **jiti**, which does not resolve `tsconfig` `**paths`\*\* (e.g. `@/`). Any module pulled in from that file should use **relative imports**, or aliases will fail at runtime when you run `vercube dev` / the CLI. # Custom CLI Command Vercube CLI is built on top of [citty](https://github.com/unjs/citty){rel=""nofollow""} but exposes a class-based decorator layer that lets you define commands as TypeScript classes. Custom commands are loaded at runtime via [jiti](https://github.com/unjs/jiti){rel=""nofollow""} - **no build step required**. ## How Custom Commands Work 1. Define a class extending `BaseCommand` and decorate it with `@Command`. 2. Annotate properties with `@Arg` (positional) or `@Flag` (named option). 3. Register the class in `vercube.config.ts` under `cli.commands`. 4. Run `vercube <name>` - the CLI loads your config, resolves the class, injects dependencies, injects parsed args, and calls `run()`. ```ts // src/Commands/Deploy.ts import { BaseCommand, Command, Flag } from '@vercube/cli/toolkit'; @Command({ name: 'deploy', description: 'Deploy to production' }) export class DeployCommand extends BaseCommand { @Flag({ name: 'env', description: 'Target environment', default: 'staging' }) public env: string; public override async run(): Promise<void> { console.log(`Deploying to ${this.env}...`); } } ``` ```ts // vercube.config.ts import { defineConfig } from '@vercube/core'; import { DeployCommand } from './src/Commands/Deploy'; export default defineConfig({ cli: { commands: [DeployCommand], }, }); ``` ```sh vercube deploy --env production ``` ## BaseCommand Every command must extend `BaseCommand` and implement `run()`. ```ts import { BaseCommand } from '@vercube/cli/toolkit'; export abstract class BaseCommand { protected container: Container; // CLI DI container - prefer @Inject instead public abstract run(): Promise<void>; } ``` ### Dependency Injection Commands are resolved through the CLI DI container, so any property decorated with `@Inject` is automatically wired before `run()` is called. ```ts import { Inject } from '@vercube/di'; import { Logger } from '@vercube/logger'; import { BaseCommand, Command } from '@vercube/cli/toolkit'; @Command({ name: 'status', description: 'Show application status' }) export class StatusCommand extends BaseCommand { @Inject(Logger) private readonly gLogger: Logger; public override async run(): Promise<void> { this.gLogger.info('Application is running.'); } } ``` ## @Command Class decorator that registers command metadata. ```ts @Command(meta: CommandMeta) ``` | Field | Type | Required | Description | | ------------- | --------------------------- | -------- | --------------------------------------------------------------------------- | | `name` | `string` | ✅ | Command name used on the CLI (`vercube <name>`) | | `description` | `string` | ✅ | Short description shown in `--help` | | `subCommands` | `(new () => BaseCommand)[]` | - | Child command classes (see [Subcommands](https://vercube.dev/#subcommands)) | ## @Arg Property decorator for **positional** arguments (order-sensitive, no `--` prefix). ```ts @Arg(options: ArgOptions) ``` | Field | Type | Required | Description | | ------------- | --------- | -------- | --------------------------------------------------- | | `name` | `string` | ✅ | Argument name shown in usage (`vercube cmd <name>`) | | `description` | `string` | - | Short description shown in `--help` | | `required` | `boolean` | - | Whether the argument is required (default: `false`) | ```ts @Command({ name: 'greet', description: 'Print a greeting' }) export class GreetCommand extends BaseCommand { @Arg({ name: 'name', description: 'Name to greet', required: true }) public name: string; public override async run(): Promise<void> { console.log(`Hello, ${this.name}!`); } } ``` ## @Flag Property decorator for **named options** (`--flag-name value`). ```ts @Flag(options: FlagOptions) ``` | Field | Type | Required | Description | | ------------- | --------------------------------- | -------- | ---------------------------------------------------------------- | | `name` | `string` | ✅ | Flag name used on the CLI (`--name`) | | `description` | `string` | - | Short description shown in `--help` | | `default` | `unknown` | - | Default value - also used to infer `type` | | `required` | `boolean` | - | Whether the flag is required (default: `false`) | | `type` | `'string' | 'boolean' | 'number'` | - | Explicit citty value type (inferred from `default` when omitted) | The value type is automatically inferred from `default`: ```ts @Flag({ name: 'limit', default: 10 }) // → type: 'number' @Flag({ name: 'json', default: false }) // → type: 'boolean' @Flag({ name: 'env', default: 'dev' }) // → type: 'string' ``` Full example with multiple flags: ```ts @Command({ name: 'export', description: 'Export data' }) export class ExportCommand extends BaseCommand { @Flag({ name: 'format', description: 'Output format', default: 'json' }) public format: string; @Flag({ name: 'limit', description: 'Max records', default: 100 }) public limit: number; @Flag({ name: 'pretty', description: 'Pretty-print output', default: false }) public pretty: boolean; public override async run(): Promise<void> { console.log(`Exporting ${this.limit} records as ${this.format} (pretty: ${this.pretty})`); } } ``` ## Subcommands Commands can be nested by passing `subCommands` to `@Command`. Each subcommand is an independent `BaseCommand` class. ```ts // src/Commands/DbMigrate.ts @Command({ name: 'migrate', description: 'Run pending migrations' }) export class DbMigrateCommand extends BaseCommand { @Flag({ name: 'dry-run', description: 'Preview without applying', default: false }) public dryRun: boolean; public override async run(): Promise<void> { console.log(this.dryRun ? 'Dry run - no changes applied.' : 'Migrations applied.'); } } // src/Commands/Db.ts @Command({ name: 'db', description: 'Database utilities', subCommands: [DbMigrateCommand, DbSeedCommand], }) export class DbCommand extends BaseCommand { public override async run(): Promise<void> {} } ``` Register only the **top-level** parent in `cli.commands` - subcommands are discovered automatically: ```ts // vercube.config.ts export default defineConfig({ cli: { commands: [DbCommand], // DbMigrateCommand and DbSeedCommand are picked up automatically }, }); ``` Usage: ```sh vercube db --help vercube db migrate vercube db migrate --dry-run vercube db seed --env test ``` ## Registration via plugins Plugins can call **`ctx.register(MyCommand)`** from **`setupCLI`** (on a **`BasePlugin`** subclass) or from the **`cli`** hook on an object / **`defineVercubePlugin`** (syntax sugar for the same shape). The CLI uses the same `loadVercubeConfig` pipeline as the runtime, so commands registered from **`plugins`** appear next to those listed under **`cli.commands`**. See [Plugins](https://vercube.dev/docs/advanced/custom-plugin). ## Registration Register commands in `vercube.config.ts` under **`cli.commands`**, or from a plugin’s **`setupCLI`** / **`cli`** hook. The CLI loads this file using [jiti](https://github.com/unjs/jiti){rel=""nofollow""} at startup - TypeScript is transpiled on-the-fly, so no separate build step is needed for your command files. ```ts // vercube.config.ts import { defineConfig } from '@vercube/core'; import { DeployCommand } from './src/Commands/Deploy'; import { DbCommand } from './src/Commands/Db'; export default defineConfig({ cli: { commands: [ DeployCommand, DbCommand, // registers DbCommand + all its subCommands ], }, }); ``` ::callout{icon="i-lucide-info"} Only register **top-level** commands. Subcommands declared in `subCommands: [...]` are discovered and registered automatically by the CLI. :: ## Full Example A working example is available in the repository under [`examples/cli-commands`](https://github.com/vercube/vercube/tree/main/examples/cli-commands){rel=""nofollow""}. It demonstrates: - A simple `greet` command with `@Arg`, `@Flag`, and `@Inject` - A `db` parent command with `migrate` and `seed` subcommands - Registering everything in `vercube.config.ts` # Announcing Vercube 1.0 Today I can finally say it out loud: **Vercube 1.0 is here**. For me, that is a huge weight off my shoulders - and the start of a new chapter for a project that has been with me almost every day for the past year and change. ## More than a year in open source Vercube has been open source for over a year. Throughout that time, I tried to hold it to a few clear principles: a coherent direction, sensible APIs, and code that is actually maintainable. The project has been kept up to date, but it has also steadily grown with **new packages** - on purpose, not by accident. I will not pretend otherwise: in the beginning, **I worked on it mostly alone**. I wanted to prove to myself that I could build it from scratch and that it had a place in the Node.js and TypeScript ecosystem - alongside names like [NestJS](https://nestjs.com){rel=""nofollow""}, [routing-controllers](https://github.com/typestack/routing-controllers){rel=""nofollow""}, and [Ts.ED](https://tsed.dev/){rel=""nofollow""}. Mature frameworks were always the natural benchmark: they set the bar for quality and expectations. ## Days full of doubt The hardest part was always the same: **I had no idea whether anyone was actually using it**. The code lived on GitHub, the docs kept growing, and I was still left asking, “Does this even reach anyone?” I started posting short updates on [X](https://x.com/OskarLebuda){rel=""nofollow""} - reach was modest, as it often is at the start. Month after month, more **GitHub stars** showed up. These are not headline-grabbing numbers, and I know that - but **each new star was real motivation to keep going** and to keep polishing what I ship. ## Benchmarks that speak for themselves Along the way, a separate, fully open project appeared: **[vercube/benchmarks](https://github.com/vercube/benchmarks){rel=""nofollow""}**. The goal was comparisons that are repeatable, fair, and easy to verify - the same endpoints, comparable configuration, and metrics you can inspect. Results from the latest published run (January 2026; see the repository README for the exact environment): ::tabs :::tabs-item{icon="i-lucide-activity" label="Load test"} | Framework | Requests/s | p95 latency | vs best RPS | vs best p95 | | --------------------------------------------------------------------------------------------------- | ---------- | ----------- | ----------- | ----------- | | [**Vercube**](https://vercube.dev){rel=""nofollow""} | 95,588 | 19 ms | - | - | | [NestJS](https://nestjs.com){rel=""nofollow""} | 82,705 | 19 ms | −16% | +0% | | [Rikta](https://rikta.dev/){rel=""nofollow""} | 81,156 | 23 ms | −18% | +21% | | [Routing Controllers](https://github.com/typestack/routing-controllers){rel=""nofollow""} | 78,195 | 20 ms | −22% | +5% | | [Ts.ED](https://tsed.dev/){rel=""nofollow""} | 32,156 | 56 ms | −197% | +195% | ::: :::tabs-item{icon="i-lucide-power" label="Cold start"} | Framework | Mean | vs best | | --------------------------------------------------------------------------------------------------- | ------ | ------- | | [**Vercube**](https://vercube.dev){rel=""nofollow""} | 280 ms | - | | [Rikta](https://rikta.dev/){rel=""nofollow""} | 326 ms | +16% | | [Routing Controllers](https://github.com/typestack/routing-controllers){rel=""nofollow""} | 329 ms | +18% | | [NestJS](https://nestjs.com){rel=""nofollow""} | 377 ms | +35% | | [Ts.ED](https://tsed.dev/){rel=""nofollow""} | 946 ms | +238% | ::: :::tabs-item{icon="i-lucide-package" label="Build time"} | Framework | Mean | vs best | | --------------------------------------------------------------------------------------------------- | ------ | ------- | | [**Vercube**](https://vercube.dev){rel=""nofollow""} | 0.28 s | - | | [Routing Controllers](https://github.com/typestack/routing-controllers){rel=""nofollow""} | 0.42 s | +49% | | [Ts.ED](https://tsed.dev/){rel=""nofollow""} | 0.46 s | +62% | | [Rikta](https://rikta.dev/){rel=""nofollow""} | 0.97 s | +244% | | [NestJS](https://nestjs.com){rel=""nofollow""} | 1.30 s | +358% | ::: :: This is not a promise that your app will always be faster - real numbers depend on your code, your infrastructure, and what you measure. In **this** benchmark suite, though, **the gaps are clear**, and they illustrate the cost of the framework layer. Clone the repo, run the scenarios on your machine, and read the methodology with a critical eye. In the load-test table, **vs best RPS** is relative to the fastest framework (a negative value means lower throughput than Vercube). ## What 1.0 means **1.0** is the first stable release: the APIs and packages shipped here are what I intend to support going forward, and breaking changes will follow [semver](https://semver.org/){rel=""nofollow""}. Thank you to everyone who ran the betas and sent feedback - it shaped what shipped today. I still want to hear from you: in issues, in discussions, and about what you run in production. ## What is new There is a lot in this release. Below is a focused slice: **performance first**, then **Nitro**, the new **CLI** model, the new **plugin** system, **OpenAPI docs with Scalar**, logging rebuilt on **evlog**, and a **leaner set of published packages**. ### Performance Request handling in **1.0** is **about 15% faster** end-to-end than the last pre-1.0 line, measured on **24 internal fetch micro-benchmarks** (ops/s, identical harness), with **no regressions** in that set. - **\~15.5%** higher throughput in aggregate (total 1.0 ops/s vs the previous line - hot paths weigh more). - Most scenarios land in the **low teens**; a few **query/param-heavy** cases peak near **+25%**; a handful are **flat** (within noise). Lab numbers are not your app - but the picture here is simple: **faster across the board** in this suite. ### Nitro [`@vercube/nitro`](https://www.npmjs.com/package/@vercube/nitro){rel=""nofollow""} connects Vercube’s controllers, DI, and middleware to a [Nitro](https://nitro.build/){rel=""nofollow""} app with almost no glue. Register the module, keep writing `@Controller` / `@Get` / `@Post` as usual, and the plugin discovers handlers at build time. (You can still pass a `setupFile` if you need explicit bootstrap - it is optional.) Deeper topics (file-based routes, middleware, storage) live in the docs. ::code-group ```ts [nitro.config.ts] import { vercubeNitro } from '@vercube/nitro'; export default defineNitroConfig({ modules: [vercubeNitro()], }); ``` ```ts [UserController.ts] import { Controller, Get, Post } from '@vercube/core'; @Controller('/users') export class UserController { @Get('/') list() { return [{ id: 1, name: 'Alice' }]; } @Post('/') create() { return { id: 2, name: 'Bob' }; } } ``` :: Read more: **[Nitro module overview](https://vercube.dev/docs/modules/nitro/overview)**. ### Extensible CLI The CLI is now built to be **extended**: you define commands as classes (`BaseCommand`, `@Command`, `@Arg` / `@Flag`), register them in `vercube.config.ts` under `cli.commands`, and run `vercube <name>` - arguments are parsed for you, dependencies can be injected from the CLI container, and **no extra build step** is required for your command files. ::code-group ```ts [src/Commands/GreetCommand.ts] import { BaseCommand, Command, Flag } from '@vercube/cli/toolkit'; @Command({ name: 'greet', description: 'Print a greeting' }) export class GreetCommand extends BaseCommand { @Flag({ name: 'name', description: 'Who to greet', default: 'Vercube' }) public name: string; public override async run(): Promise<void> { console.log(`Hello, ${this.name}!`); } } ``` ```ts [vercube.config.ts] import { defineConfig } from '@vercube/core'; import { GreetCommand } from './src/Commands/GreetCommand'; export default defineConfig({ cli: { commands: [GreetCommand], }, }); ``` ```bash [terminal] vercube greet --name world ``` :: Read more: **[Custom CLI command](https://vercube.dev/docs/advanced/custom-cli-command)**. ### Plugins Plugins are **one pipeline** for modifying the merged config, binding services when the worker starts, registering CLI commands, and hooking into **dev-only** behavior in the parent process during `vercube dev`. Use a class extending `BasePlugin`, or `defineVercubePlugin` for small inline snippets - same hooks, Vite-style `enforce` ordering when you need it. ```ts [vercube.config.ts] import { defineConfig, defineVercubePlugin } from '@vercube/core'; export default defineConfig({ plugins: [ defineVercubePlugin({ name: 'example', config: () => ({ server: { port: 3000 }, }), setup: (app) => { // Worker runtime: bind services, start background work, etc. }, }), ], }); ``` Read more: **[Plugins](https://vercube.dev/docs/advanced/custom-plugin)**. ### OpenAPI and Scalar (`@vercube/schema`) API documentation should not be a second codebase. **[`@vercube/schema`](https://www.npmjs.com/package/@vercube/schema){rel=""nofollow""}** generates **OpenAPI 3** from the same Zod schemas you already use for validation on `@Body` and `@QueryParams`. Register **`SchemaPlugin`** in **`vercube.config.ts`** under **`plugins`**, annotate routes with **`@Schema`**, and you get a live spec plus an interactive UI - no Swagger setup, no duplicate DTO layers. Out of the box: | Endpoint | What you get | | ------------------- | ----------------------------------------------------------------------------------------------- | | `GET /_schema/` | OpenAPI JSON | | `GET /_schema/docs` | **[Scalar](https://github.com/scalar/scalar){rel=""nofollow""}** API Reference (HTML) | Scalar is enabled by default once the plugin is registered. It loads your spec from `/_schema/` and gives you try-it-out requests, models, and themes - the same experience teams reach for via `@scalar/express-api-reference`, but wired natively for Vercube’s router. ::code-group ```ts [vercube.config.ts] import { defineConfig } from '@vercube/core'; import { SchemaPlugin } from '@vercube/schema'; export default defineConfig({ plugins: [SchemaPlugin], }); ``` ```ts [UserController.ts] import { Body, Controller, Post } from '@vercube/core'; import { Schema, z } from '@vercube/schema'; const CreateUserSchema = z.object({ name: z.string(), email: z.string().email(), }); @Controller('/users') export class UserController { @Post('/') @Schema({ summary: 'Create user', responses: { 201: { description: 'Created', content: { 'application/json': { schema: z.object({ id: z.string(), name: z.string() }), }, }, }, }, }) create(@Body({ validationSchema: CreateUserSchema }) body: z.infer<typeof CreateUserSchema>) { return { id: '1', ...body }; } } ``` ```bash [browser] # With the dev server running: open http://localhost:3000/_schema/docs ``` :: The package re-exports **`z`** with [`zod-to-openapi`](https://github.com/asteasolutions/zod-to-openapi){rel=""nofollow""} helpers (`.openapi()` for examples and named components). Request body and query shapes from `validationSchema` are merged into the spec for you. Disable the UI with `withPluginOptions(SchemaPlugin, { scalar: false })` in **`plugins`** if you only want the JSON endpoint. Read more: **[Schema module overview](https://vercube.dev/docs/modules/schema/overview)** · **[Scalar integration](https://vercube.dev/docs/modules/schema/scalar)**. ### Logging on evlog **[evlog](https://www.evlog.dev){rel=""nofollow""}** is now Vercube’s **built-in, default logger** - not an optional add-on you wire up later. [`@vercube/logger`](https://www.npmjs.com/package/@vercube/logger){rel=""nofollow""} stays the package you import in app code; under the hood it delegates to evlog, and **`@Inject(Logger)`** works the same way it did before. For a long time the framework maintained its own logging layer: pluggable providers, custom drivers, formatting rules living entirely inside the monorepo. That was enough to ship, but every improvement - another observability backend, smarter request tracing, production-grade sampling - meant more surface area for Vercube to own while you waited on framework releases. **[evlog](https://www.evlog.dev){rel=""nofollow""}** is a different bet: a focused TypeScript logger that has already grown deep roots. It gives you simple structured logs (a drop-in for `console.log`, pino, or consola), **wide events** that accumulate context and emit once per operation, **structured errors** that explain *why* something failed and how to fix it, and a single **drain pipeline** (pretty output in dev, JSON in production, redaction, tail sampling, adapters for the backends people actually use). It ships integrations for Nitro, Hono, Nest, and the other stacks many of you run next to Vercube. The switch to evlog keeps Vercube lean on observability while you get tooling that keeps maturing on its own schedule. Request logging is on by default: each HTTP call produces **one wide event** with method, path, status, and duration (disable with `requestLogging: false` in config). When you need more, evlog’s primitives are re-exported from `@vercube/logger` - including `createError` for actionable errors and `@vercube/logger/toolkit` for advanced request-scoped logging. ::code-group ```ts [UserService.ts] import { Inject } from '@vercube/di'; import { Logger } from '@vercube/logger'; export class UserService { @Inject(Logger) private logger!: Logger; async createUser(data: CreateUserDto) { this.logger.info('user.create', 'Creating user', { email: data.email }); // ... } } ``` ```ts [vercube.config.ts] import { defineConfig } from '@vercube/core'; export default defineConfig({ logLevel: 'info', // one structured wide event per request (EvlogMiddleware); default: true requestLogging: true, }); ``` :: Read more: **[Logger module overview](https://vercube.dev/docs/modules/logger/overview)** · **[evlog](https://www.evlog.dev){rel=""nofollow""}**. ### Fewer packages to maintain **1.0** also **drops packages that were not part of the real stack**. [`@vercube/h3`](https://www.npmjs.com/package/@vercube/h3){rel=""nofollow""} and [`@vercube/mcp`](https://www.npmjs.com/package/@vercube/mcp){rel=""nofollow""} were published early on, but nothing in the framework or the examples depended on them anymore - only docs and release plumbing kept them alive. Keeping unused packages around means version bumps, changelogs, and confusion for anyone browsing npm. They are **removed from the monorepo** in this release. If you never imported them, nothing changes. If you did, migrate to the core HTTP layer (Vercube’s own router and middleware) or wire MCP through a solution you already run in production - the framework no longer ships a dedicated wrapper for either. ## Try it ```bash pnpm create vercube@beta ``` For a fuller picture of changes, migrations, and docs, see the [project site](https://vercube.dev){rel=""nofollow""} and [repository](https://github.com/vercube/vercube){rel=""nofollow""} - and the [changelog](https://vercube.dev/changelog){rel=""nofollow""} for what ships after 1.0. --- Thank you to everyone who starred the repo, left a comment, filed a bug, or simply **tried Vercube in their own code**. You are why this moment matters at all. Questions or ideas? Open an issue on GitHub, join the Discord, or reach out on X. **I am glad I get to announce this today.**