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.tsNaming Convention
Driver packages are named toast-driver-{category}-{provider}:
toast-driver-email-sendgridtoast-driver-storage-r2toast-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/driversas a peer dependency — provides the typed interfaces- ESM —
"type": "module"with.jsextensions 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:
- Resolves the package via
require()from the app'snode_modules - Imports the default export (your driver class)
- 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/src2. 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-sendgridThe 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.tsneeds to know about your driver's config shape. This will be improved in a future version with a standardized config discovery mechanism.