Toast
Developer

Writing a Custom Driver

Build your own driver package for email, storage, or queues.

Toast drivers are npm packages that implement a typed interface from @toast/drivers. If the built-in providers don't meet your needs, you can create your own.

Package Structure

Drivers follow a consistent layout:

drivers/email-sendgrid/
├── package.json
├── tsconfig.json
├── src/
│   └── index.ts          # Default export: the driver class
└── vitest.config.ts

Naming Convention

Driver packages are named toast-driver-{category}-{provider}:

  • toast-driver-email-sendgrid
  • toast-driver-storage-r2
  • toast-driver-queue-rabbitmq

The category must be email, storage, or queue.

package.json

{
  "name": "toast-driver-email-sendgrid",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "peerDependencies": {
    "@toast/drivers": "workspace:*"
  },
  "devDependencies": {
    "@toast/drivers": "workspace:*"
  }
}

Key requirements:

  • Default export — the driver loader imports module.default
  • @toast/drivers as a peer dependency — provides the typed interfaces
  • ESM"type": "module" with .js extensions in imports

Driver Interfaces

Email Driver

import type { EmailDriver, EmailMessage, SendResult } from '@toast/drivers';

export default class SendGridDriver implements EmailDriver {
  readonly name = 'sendgrid';

  private apiKey: string;
  private defaultFrom: string;

  constructor(config: { apiKey: string; defaultFrom?: string }) {
    if (!config.apiKey) {
      throw new Error('SendGrid API key is required');
    }
    this.apiKey = config.apiKey;
    this.defaultFrom = config.defaultFrom ?? 'noreply@example.com';
  }

  async send(message: EmailMessage): Promise<SendResult> {
    const from = message.from ?? this.defaultFrom;
    // ... send via SendGrid API
    return { success: true, messageId: 'sg-123' };
  }
}

The EmailMessage and SendResult types:

interface EmailMessage {
  to: string;
  from?: string;
  subject: string;
  html: string;
  text?: string;
}

interface SendResult {
  success: boolean;
  messageId?: string;
  error?: string;
}

Storage Driver

import type { StorageDriver, UploadInput, UploadResult } from '@toast/drivers';

export default class R2Driver implements StorageDriver {
  readonly name = 'r2';

  constructor(config: { accountId: string; accessKey: string; secretKey: string; bucket: string }) {
    // Validate required config
  }

  async upload(input: UploadInput): Promise<UploadResult> {
    // Upload to R2...
    return { key: input.key, url: `https://cdn.example.com/${input.key}`, size: 1024 };
  }

  async delete(key: string): Promise<void> {
    // Delete from R2...
  }

  async exists(key: string): Promise<boolean> {
    // Check if object exists...
    return true;
  }
}

The UploadInput and UploadResult types:

interface UploadInput {
  data: Buffer | Readable;
  key: string;
  contentType: string;
}

interface UploadResult {
  key: string;
  url: string;
  size: number;
}

Queue Driver

import type {
  QueueDriver,
  EnqueueOptions,
  EnqueueResult,
  JobSummary,
  JobProcessor,
} from '@toast/drivers';

export default class RabbitMQDriver implements QueueDriver {
  readonly name = 'rabbitmq';

  constructor(config: { connectionUrl: string }) {
    // Validate and store config
  }

  async enqueue(options: EnqueueOptions): Promise<EnqueueResult> {
    /* ... */
  }
  async cancel(queueName: string, idempotencyKey: string): Promise<boolean> {
    /* ... */
  }
  async getJob(queueName: string, idempotencyKey: string): Promise<JobSummary | null> {
    /* ... */
  }
  registerProcessor(queueName: string, handler: JobProcessor): void {
    /* ... */
  }
  async start(): Promise<void> {
    /* ... */
  }
  async stop(): Promise<void> {
    /* ... */
  }
  async healthCheck(): Promise<boolean> {
    /* ... */
  }
}

Configuration Injection

Drivers receive their configuration through the constructor — they never read process.env directly. The application maps environment variables to a config object and passes it when loading the driver.

For example, when you set EMAIL_DRIVER=toast-driver-email-sendgrid, the application:

  1. Resolves the package via require() from the app's node_modules
  2. Imports the default export (your driver class)
  3. Instantiates it with the relevant config object

This design makes drivers portable (they work in any Node.js app that provides the right config) and testable (you can instantiate them directly in tests without mocking process.env).

Fail Fast

Validate all required configuration in the constructor and throw immediately if something is missing:

constructor(config: { apiKey: string; domain: string }) {
  if (!config.apiKey) {
    throw new Error("Mailgun API key is required. Set MAILGUN_API_KEY.");
  }
  if (!config.domain) {
    throw new Error("Mailgun domain is required. Set MAILGUN_DOMAIN.");
  }
  this.apiKey = config.apiKey;
  this.domain = config.domain;
}

This ensures misconfiguration surfaces at startup rather than on the first request.

Adding a Driver to the Monorepo

If you're contributing a driver to the Toast repository:

1. Scaffold the Package

mkdir -p drivers/email-sendgrid/src

2. Create the Implementation

Write your driver class in src/index.ts as the default export.

3. Wire Up Configuration

Add the environment variable mapping in apps/api/src/config/drivers.ts:

// Add to the DriversConfig interface
sendgrid: { apiKey: string; defaultFrom?: string } | null;

// Add to the config builder
sendgrid: env.SENDGRID_API_KEY
  ? { apiKey: env.SENDGRID_API_KEY, defaultFrom: env.EMAIL_FROM }
  : null,

4. Update the Driver Loader

Update the email driver loader in packages/drivers/src/ to pass your config when loading the driver.

5. Add Tests

Write tests for your driver with 100% coverage. See the existing Mailgun driver tests for patterns.

6. Document

Add your driver to the Driver Configuration page with its environment variables and setup instructions.

Using an External Driver

Drivers don't need to live in the monorepo. You can publish your own package to npm:

# Install your driver in the API workspace
pnpm add toast-driver-email-sendgrid --filter @toast/api

# Set the environment variable
EMAIL_DRIVER=toast-driver-email-sendgrid

The driver loader resolves packages from the application's node_modules, so any installed package that exports a valid driver class will work.

External drivers need the application to map their environment variables to config. For now, this means the config mapping in apps/api/src/config/drivers.ts needs to know about your driver's config shape. This will be improved in a future version with a standardized config discovery mechanism.

On this page