Putnami
DocsGitHub

Licensed under FSL-1.1-MIT

Getting Started
Concepts
How To
Build A Web App
Build An Api Service
Share Code Between Projects
Configure Your App
Add Persistence
Add Authentication
Add Background Jobs
Principles
Tooling & Workspace
Workspace Overview
Cli
Jobs & Commands
SDK
Error Handling
Extensions
Typescript
Go
Python
Docker
Ci
Frameworks
Typescript
OverviewWebReact RoutingForms And ActionsStatic FilesApiErrors And ResponsesConfigurationLoggingHttp And MiddlewareDependency InjectionPlugins And LifecycleSessionsAuthPersistenceEventsStorageCachingWebsocketsTestingHealth ChecksTelemetryProto GrpcSmart Client
Go
OverviewHttpDependency InjectionPlugins And LifecycleConfigurationSecurityPersistenceErrorsEventsStorageCachingLoggingTelemetryGrpcService ClientsValidationOpenapiTesting
Platform
  1. DocsSeparator
  2. FrameworksSeparator
  3. TypescriptSeparator
  4. Plugins And Lifecycle

Plugins & Lifecycle

Putnami applications are composed from plugins. Each plugin can hook into a lifecycle that spans build time and runtime, making it easy to extend the framework with custom functionality.

Plugin interface

A plugin is an object that implements one or more lifecycle hooks:

import type { Module, Plugin, GenerateResult } from '@putnami/application';

interface Plugin {
  generate?(owner: Module): Promise<GenerateResult> | GenerateResult;
  warmup?(owner: Module): Promise<void> | void;
  start?(owner: Module): Promise<void> | void;
  stop?(owner: Module): Promise<void> | void;
}

Plugins receive their owning Module (or Application, which extends Module) as the first argument. This lets plugins resolve other plugins or access module-level configuration.

Lifecycle hooks

generate()

Runs at build time during putnami build. Use this for:

  • Code generation
  • Asset compilation
  • Route discovery
  • Type generation
import type { Module, Plugin, GenerateResult } from '@putnami/application';
import { writeFileSync } from 'fs';

export function routeTypesPlugin(): Plugin {
  return {
    async generate(owner: Module): Promise<GenerateResult> {
      // Generate TypeScript types for routes
      const routes = discoverRoutes();
      const types = generateRouteTypes(routes);

      writeFileSync('.gen/routes.d.ts', types);

      return {
        files: ['.gen/routes.d.ts'],
      };
    },
  };
}

warmup()

Runs before the application starts, after all plugins are loaded. Use this for:

  • Route registration
  • Cache preloading
  • Connection pooling setup
  • Dependency initialization
export function cachePlugin(): Plugin {
  const cache = new Map<string, unknown>();

  return {
    async warmup(owner: Module) {
      // Preload frequently accessed data
      const data = await fetchInitialData();
      for (const [key, value] of Object.entries(data)) {
        cache.set(key, value);
      }
      console.log(`Cache warmed with ${cache.size} entries`);
    },
  };
}

start()

Runs when the application starts. Use this for:

  • Starting HTTP servers
  • Opening database connections
  • Starting background workers
  • Initializing external services
export function metricsPlugin(): Plugin {
  let metricsServer: Server;

  return {
    async start(owner: Module) {
      // Start a separate metrics server
      metricsServer = Bun.serve({
        port: 9090,
        fetch(req) {
          return new Response(getMetrics(), {
            headers: { 'Content-Type': 'text/plain' },
          });
        },
      });
      console.log('Metrics server started on port 9090');
    },

    async stop() {
      metricsServer?.stop();
    },
  };
}

stop()

Runs during graceful shutdown (in reverse order of plugin registration). Use this for:

  • Closing connections
  • Flushing buffers
  • Cleanup operations
  • Releasing resources
export function databasePlugin(): Plugin {
  let connection: DatabaseConnection;

  return {
    async start() {
      connection = await createConnection();
    },

    async stop() {
      // Gracefully close the database connection
      await connection?.close();
      console.log('Database connection closed');
    },
  };
}

Creating custom plugins

Basic plugin

import type { Plugin } from '@putnami/application';

export function myPlugin(): Plugin {
  return {
    async warmup() {
      console.log('My plugin is warming up');
    },

    async start() {
      console.log('My plugin started');
    },

    async stop() {
      console.log('My plugin stopped');
    },
  };
}

// Usage
const app = application()
  .use(myPlugin());

Plugin with configuration

import type { Plugin } from '@putnami/application';

interface LoggerPluginOptions {
  level: 'debug' | 'info' | 'warn' | 'error';
  prefix?: string;
}

export function loggerPlugin(options: LoggerPluginOptions): Plugin {
  const { level, prefix = '[APP]' } = options;

  return {
    async warmup() {
      console.log(`${prefix} Logger initialized at ${level} level`);
    },
  };
}

// Usage
const app = application()
  .use(loggerPlugin({ level: 'info', prefix: '[MyApp]' }));

Plugin with state

import type { Plugin } from '@putnami/application';
import { json } from '@putnami/application';

export function rateLimiterPlugin(maxRequests: number, windowMs: number): Plugin {
  const requests = new Map<string, number[]>();

  const isRateLimited = (ip: string): boolean => {
    const now = Date.now();
    const windowStart = now - windowMs;

    const timestamps = requests.get(ip) || [];
    const recent = timestamps.filter(t => t > windowStart);

    if (recent.length >= maxRequests) {
      return true;
    }

    recent.push(now);
    requests.set(ip, recent);
    return false;
  };

  return {
    async warmup(owner: Module) {
      // Access other plugins if needed
      const httpPlugin = owner.getPlugin(HttpPlugin);

      httpPlugin.prepend(async (ctx, next) => {
        const ip = ctx.headers.get('x-forwarded-for') || 'unknown';

        if (isRateLimited(ip)) {
          return json(
            { error: 'Too many requests' },
            { status: 429 }
          );
        }

        return next();
      });
    },
  };
}

Plugin class

For complex plugins, use a class:

import type { Module, Plugin } from '@putnami/application';
import { json } from '@putnami/application';

export class HealthCheckPlugin implements Plugin {
  private checks: Map<string, () => Promise<boolean>> = new Map();

  addCheck(name: string, check: () => Promise<boolean>) {
    this.checks.set(name, check);
  }

  async warmup(owner: Module) {
    const httpPlugin = owner.getPlugin(HttpPlugin);

    httpPlugin.get('/health', async () => {
      const results: Record<string, boolean> = {};

      for (const [name, check] of this.checks) {
        try {
          results[name] = await check();
        } catch {
          results[name] = false;
        }
      }

      const healthy = Object.values(results).every(Boolean);

      return json(
        { healthy, checks: results },
        { status: healthy ? 200 : 503 }
      );
    });
  }
}

// Usage
const healthCheck = new HealthCheckPlugin();
healthCheck.addCheck('database', async () => {
  return await database().query('SELECT 1');
});

const app = application()
  .use(healthCheck);

Accessing other plugins

Get a registered plugin

import { HttpPlugin } from '@putnami/application';

export function myPlugin(): Plugin {
  return {
    async warmup(owner: Module) {
      // Get an existing plugin (throws if not found)
      const httpPlugin = owner.getPlugin(HttpPlugin);

      // Register routes
      httpPlugin.get('/my-route', () => ({ ok: true }));
    },
  };
}

Ensure a plugin exists

export function myPlugin(): Plugin {
  return {
    async warmup(owner: Module) {
      // Get or wait for a plugin to be ready
      const httpPlugin = await owner.ensurePlugin(HttpPlugin);

      httpPlugin.get('/my-route', () => ({ ok: true }));
    },
  };
}

List all plugins

export function debugPlugin(): Plugin {
  return {
    async warmup(owner: Module) {
      const plugins = owner.getPlugins();
      console.log(`Loaded ${plugins.length} plugins`);
    },
  };
}

Module composition

Modules let you group related plugins into reusable, composable units. The hierarchy is Application > Module > Plugin: applications and modules can contain plugins and sub-modules, but only an Application has a startable lifecycle.

Creating a module

import { module } from '@putnami/application';

const authModule = module('auth')
  .path('/auth')
  .use(oAuth2())
  .use(api());

Module path

Use .path() to set a base path for the module. Child plugins (api(), react(), statics()) automatically inherit this path as their route prefix:

import { module, api } from '@putnami/application';
import { react } from '@putnami/react';

const tasksModule = module('tasks')
  .path('/tasks')         // All child routes prefixed with /tasks
  .use(api())             // API routes: /tasks, /tasks/[id], etc.
  .use(react());          // React pages: /tasks, /tasks/new, etc.

If a plugin specifies an explicit prefix, it takes priority over the module path.

Module security

Use .secure() to protect all routes registered by plugins in the module:

const dashboard = module('dashboard')
  .path('/dashboard')
  .secure()                      // Require authentication
  .use(api());

const admin = module('admin')
  .path('/admin')
  .secure({ roles: ['admin'] })  // Require admin role
  .use(api());

Composing modules

import { application, http } from '@putnami/application';

const app = application()
  .use(http({ port: 3000 }))
  .use(tasksModule)
  .use(authModule);

await app.start();

When the application starts, plugins from all modules are collected depth-first and executed in registration order. During shutdown, plugins are stopped in reverse order.

Nested modules

Modules can contain other modules:

const inner = module('inner').use(somePlugin());
const outer = module('outer').use(inner).use(anotherPlugin());

const app = application().use(outer);

Plugin resolution across modules

Plugins can find other plugins by walking up the module hierarchy. This means a plugin registered on a parent module is visible to all child modules:

export function myPlugin(): Plugin {
  return {
    async warmup(owner) {
      // Searches owner's plugins first, then walks up to parent modules
      const http = owner.getPlugin(HttpPlugin);
      http.get('/my-route', () => ({ ok: true }));
    },
  };
}

Modules with DI providers

Modules can declare DI providers and requirements:

const dbModule = module('db')
  .provide(DatabaseService)
  .use(sql());

const authModule = module('auth')
  .require(DatabaseService)
  .provide(AuthService, { deps: [DatabaseService] })
  .use(oAuth2());

Application lifecycle

Creating an application

Use the application() factory for fluent composition, or instantiate new Application() directly when needed:

import { application } from '@putnami/application';

const app = application()
  .use(http({ port: 3000 }))
  .use(myPlugin());

Running an application

const app = application()
  .use(http({ port: 3000 }))
  .use(myPlugin());

// Start the application
await app.start();

// Check if running
console.log(app.isRunning()); // true

// Stop the application
await app.stop();

One-shot jobs

For scripts that run once and exit:

const job = application()
  .use(sql())
  .run(async () => {
    // This runs after warmup, before start
    await migrateDatabase();
    console.log('Migration complete');
  });

await job.start();
// Application exits after the run callback completes

Shutdown hooks

const app = application()
  .use(http())
  .onStop(async () => {
    // Custom shutdown logic
    await flushLogs();
    await closeConnections();
  });

Plugin ordering

Plugins are executed in registration order for generate, warmup, and start. For stop, they execute in reverse order:

const app = application()
  .use(pluginA())  // warmup: 1st, stop: 3rd
  .use(pluginB())  // warmup: 2nd, stop: 2nd
  .use(pluginC()); // warmup: 3rd, stop: 1st

Build vs runtime

Build-only plugins

Some plugins only run at build time:

export function typeGenPlugin(): Plugin {
  return {
    async generate(app) {
      // Only runs during `putnami build`
      await generateTypes();
      return { files: ['.gen/types.ts'] };
    },
    // No runtime hooks
  };
}

Runtime-only plugins

Some plugins only run at runtime:

export function requestLoggerPlugin(): Plugin {
  return {
    async warmup(owner) {
      const http = owner.getPlugin(HttpPlugin);
      http.use(async (ctx, next) => {
        const start = Date.now();
        const response = await next();
        console.log(`${ctx.method} ${ctx.path()} - ${Date.now() - start}ms`);
        return response;
      });
    },
  };
}

Related guides

  • Overview
  • HTTP & Middleware

On this page

  • Plugins & Lifecycle
  • Plugin interface
  • Lifecycle hooks
  • generate()
  • warmup()
  • start()
  • stop()
  • Creating custom plugins
  • Basic plugin
  • Plugin with configuration
  • Plugin with state
  • Plugin class
  • Accessing other plugins
  • Get a registered plugin
  • Ensure a plugin exists
  • List all plugins
  • Module composition
  • Creating a module
  • Module path
  • Module security
  • Composing modules
  • Nested modules
  • Plugin resolution across modules
  • Modules with DI providers
  • Application lifecycle
  • Creating an application
  • Running an application
  • One-shot jobs
  • Shutdown hooks
  • Plugin ordering
  • Build vs runtime
  • Build-only plugins
  • Runtime-only plugins
  • Related guides