Skip to content

Custom Connectors (BYOC)

Unireq's Bring Your Own Connector (BYOC) pattern lets you swap the underlying protocol implementation while preserving the unified client API. This guide covers when and how to implement custom connectors for HTTP, FTP, and IMAP transports.

Why BYOC?

Use CaseBenefit
TestingMock connectors for unit tests without real servers
EnterpriseIntegrate with internal libraries that handle auth/metrics/logging
Edge casesSupport non-standard server behaviors or legacy protocols
Tree-shakingAvoid bundling undici, basic-ftp, or imapflow when unused
Alternative librariesPrefer node-fetch, got, or any HTTP library of your choice

Core Connector Interface

All connectors implement the same base interface from @unireq/core:

typescript
interface Connector<TSession> {
  /** Establish connection and return session */
  connect(uri: string): Promise<TSession>;

  /** Execute request using the session */
  request(session: TSession, context: RequestContext): Promise<Response>;

  /** Clean up resources */
  disconnect(session: TSession): Promise<void> | void;

  /** Capabilities metadata */
  readonly capabilities: TransportCapabilities;
}

Each protocol extends this interface with protocol-specific session types and capabilities.

HTTP Connector

Interface

typescript
import type { Connector, RequestContext, Response } from '@unireq/core';

interface HTTPSession {
  baseUrl: string;
  connected: boolean;
}

interface HTTPCapabilities {
  readonly http: boolean;
  readonly https: boolean;
  readonly http2: boolean;
  readonly streaming: boolean;
}

interface HTTPConnector extends Connector<HTTPSession> {
  connect(uri: string): Promise<HTTPSession>;
  request(session: HTTPSession, context: RequestContext): Promise<Response>;
  disconnect(session: HTTPSession): Promise<void> | void;
  readonly capabilities: HTTPCapabilities;
}

Implementation Example

typescript
import type { HTTPConnector, HTTPSession, HTTPCapabilities } from '@unireq/http';
import type { RequestContext, Response } from '@unireq/core';

class NodeFetchConnector implements HTTPConnector {
  readonly capabilities: HTTPCapabilities = {
    http: true,
    https: true,
    http2: false,
    streaming: true,
  };

  async connect(uri: string): Promise<HTTPSession> {
    // Validate and normalize the base URL
    const url = new URL(uri);
    return {
      baseUrl: `${url.protocol}//${url.host}`,
      connected: true,
    };
  }

  async request(session: HTTPSession, ctx: RequestContext): Promise<Response> {
    const url = ctx.url.startsWith('http')
      ? ctx.url
      : `${session.baseUrl}${ctx.url}`;

    const response = await fetch(url, {
      method: ctx.method,
      headers: ctx.headers as HeadersInit,
      body: ctx.body ? JSON.stringify(ctx.body) : undefined,
    });

    const data = await response.json().catch(() => response.text());

    return {
      status: response.status,
      statusText: response.statusText,
      headers: Object.fromEntries(response.headers),
      data,
      ok: response.ok,
    };
  }

  disconnect(): void {
    // No cleanup needed for fetch
  }
}

// Usage
import { http } from '@unireq/http';
import { client } from '@unireq/core';

const { transport } = http('https://api.example.com', new NodeFetchConnector());
const api = client(transport);

Testing with Mock Connector

typescript
class MockHTTPConnector implements HTTPConnector {
  readonly capabilities = { http: true, https: true, http2: false, streaming: false };

  private responses = new Map<string, Response>();

  // Configure mock responses
  onGet(path: string, response: Response) {
    this.responses.set(`GET:${path}`, response);
    return this;
  }

  onPost(path: string, response: Response) {
    this.responses.set(`POST:${path}`, response);
    return this;
  }

  async connect(uri: string) {
    return { baseUrl: uri, connected: true };
  }

  async request(_session: HTTPSession, ctx: RequestContext): Promise<Response> {
    const key = `${ctx.method}:${ctx.url}`;
    const response = this.responses.get(key);

    if (!response) {
      return { status: 404, statusText: 'Not Found', headers: {}, data: null, ok: false };
    }

    return response;
  }

  disconnect() {}
}

// In tests
const mock = new MockHTTPConnector()
  .onGet('/users', { status: 200, statusText: 'OK', headers: {}, data: [{ id: 1 }], ok: true })
  .onPost('/users', { status: 201, statusText: 'Created', headers: {}, data: { id: 2 }, ok: true });

const { transport } = http('https://api.test', mock);
const api = client(transport);

const users = await api.get('/users'); // Returns mock data

FTP Connector

Interface

typescript
interface FTPSession {
  connected: boolean;
  host: string;
  user: string;
  secure: boolean;
}

interface FTPCapabilities {
  readonly ftp: boolean;
  readonly ftps: boolean;
  readonly delete: boolean;
  readonly rename: boolean;
  readonly mkdir: boolean;
  readonly rmdir: boolean;
}

interface FTPConnector extends Connector<FTPSession> {
  connect(uri: string): Promise<FTPSession>;
  request(session: FTPSession, context: RequestContext): Promise<Response>;
  disconnect(session: FTPSession): Promise<void> | void;
  readonly capabilities: FTPCapabilities;
}

Implementation Example

typescript
import type { FTPConnector, FTPSession, FTPCapabilities } from '@unireq/ftp';
import type { RequestContext, Response } from '@unireq/core';
import { Client } from 'ssh2-sftp-client'; // Alternative to basic-ftp

class SFTPConnector implements FTPConnector {
  private client = new Client();

  readonly capabilities: FTPCapabilities = {
    ftp: false, // SFTP only
    ftps: true,
    delete: true,
    rename: true,
    mkdir: true,
    rmdir: true,
  };

  async connect(uri: string): Promise<FTPSession> {
    const url = new URL(uri);

    await this.client.connect({
      host: url.hostname,
      port: Number(url.port) || 22,
      username: decodeURIComponent(url.username),
      password: decodeURIComponent(url.password),
    });

    return {
      connected: true,
      host: url.hostname,
      user: url.username,
      secure: true,
    };
  }

  async request(_session: FTPSession, ctx: RequestContext): Promise<Response> {
    const operation = ctx['operation'] as string;
    const path = ctx.url;

    try {
      switch (operation) {
        case 'list':
          const files = await this.client.list(path);
          return this.success(files.map(f => ({
            name: f.name,
            type: f.type === 'd' ? 1 : 0,
            size: f.size,
          })));

        case 'get':
          const content = await this.client.get(path);
          return this.success(content);

        case 'put':
          await this.client.put(Buffer.from(ctx.body as string), path);
          return this.success({ uploaded: true });

        case 'delete':
          await this.client.delete(path);
          return this.success({ deleted: true, path });

        case 'mkdir':
          await this.client.mkdir(path, true);
          return this.success({ created: true, path });

        case 'rmdir':
          await this.client.rmdir(path);
          return this.success({ removed: true, path });

        case 'rename':
          const destination = ctx['destination'] as string;
          await this.client.rename(path, destination);
          return this.success({ renamed: true, from: path, to: destination });

        default:
          return this.error(`Unsupported operation: ${operation}`);
      }
    } catch (err) {
      return this.error((err as Error).message);
    }
  }

  disconnect(): void {
    this.client.end();
  }

  private success<T>(data: T): Response {
    return { status: 200, statusText: 'OK', headers: {}, data, ok: true };
  }

  private error(message: string): Response {
    return { status: 500, statusText: 'Error', headers: {}, data: { error: message }, ok: false };
  }
}

// Usage
import { ftp, ftpOperation } from '@unireq/ftp';
import { client } from '@unireq/core';

const { transport } = ftp('sftp://user:pass@server.com', new SFTPConnector());
const api = client(transport);

const files = await api.get('/uploads', ftpOperation('list'));

IMAP Connector

Interface

typescript
interface IMAPSession {
  connected: boolean;
  host: string;
  user: string;
  usable: boolean;
  secure: boolean;
}

interface IMAPCapabilities {
  readonly imap: boolean;
  readonly xoauth2: boolean;
  readonly idle: boolean;
  readonly append: boolean;
  readonly search: boolean;
  readonly move: boolean;
  readonly flags: boolean;
  readonly expunge: boolean;
}

interface IMAPConnector extends Connector<IMAPSession> {
  connect(uri: string): Promise<IMAPSession>;
  request(session: IMAPSession, context: RequestContext): Promise<Response>;
  disconnect(session: IMAPSession): Promise<void> | void;
  readonly capabilities: IMAPCapabilities;
}

Implementation Example

typescript
import type { IMAPConnector, IMAPSession, IMAPCapabilities } from '@unireq/imap';
import type { RequestContext, Response } from '@unireq/core';
import Imap from 'node-imap'; // Alternative to imapflow

class NodeImapConnector implements IMAPConnector {
  private client: Imap | null = null;

  readonly capabilities: IMAPCapabilities = {
    imap: true,
    xoauth2: false, // node-imap doesn't support XOAUTH2 natively
    idle: true,
    append: true,
    search: true,
    move: false, // MOVE extension not supported
    flags: true,
    expunge: true,
  };

  async connect(uri: string): Promise<IMAPSession> {
    const url = new URL(uri);

    return new Promise((resolve, reject) => {
      this.client = new Imap({
        host: url.hostname,
        port: Number(url.port) || 993,
        user: decodeURIComponent(url.username),
        password: decodeURIComponent(url.password),
        tls: url.protocol === 'imaps:',
      });

      this.client.once('ready', () => {
        resolve({
          connected: true,
          host: url.hostname,
          user: url.username,
          usable: true,
          secure: url.protocol === 'imaps:',
        });
      });

      this.client.once('error', reject);
      this.client.connect();
    });
  }

  async request(_session: IMAPSession, ctx: RequestContext): Promise<Response> {
    const operation = ctx['operation'] as string;
    const mailbox = ctx['mailbox'] as string;

    switch (operation) {
      case 'fetch':
        return this.fetchMessages(mailbox, ctx['range'] as string);
      case 'search':
        return this.searchMessages(mailbox, ctx['criteria']);
      // ... implement other operations
      default:
        return { status: 400, statusText: 'Bad Request', headers: {},
                 data: { error: `Unsupported: ${operation}` }, ok: false };
    }
  }

  private async fetchMessages(mailbox: string, range: string): Promise<Response> {
    // Implementation using node-imap API
    // ...
    return { status: 200, statusText: 'OK', headers: {}, data: [], ok: true };
  }

  private async searchMessages(mailbox: string, criteria: unknown): Promise<Response> {
    // Implementation using node-imap API
    // ...
    return { status: 200, statusText: 'OK', headers: {}, data: [], ok: true };
  }

  disconnect(): void {
    this.client?.end();
    this.client = null;
  }
}

Best Practices

1. Handle Connection State

typescript
class ResilientConnector implements HTTPConnector {
  private session: HTTPSession | null = null;

  async ensureConnected(uri: string): Promise<HTTPSession> {
    if (this.session?.connected) {
      return this.session;
    }
    this.session = await this.connect(uri);
    return this.session;
  }

  async request(session: HTTPSession, ctx: RequestContext): Promise<Response> {
    try {
      return await this.doRequest(session, ctx);
    } catch (error) {
      // Reconnect on connection errors
      if (this.isConnectionError(error)) {
        this.session = null;
        throw error; // Let retry policy handle it
      }
      throw error;
    }
  }
}

2. Declare Accurate Capabilities

typescript
readonly capabilities = {
  http: true,
  https: true,
  http2: false, // Be honest about what you don't support
  streaming: this.supportsStreaming(), // Dynamic capability check
};

3. Normalize Response Format

typescript
// Always return Response shape, even for errors
private errorResponse(error: Error): Response {
  return {
    status: 500,
    statusText: 'Internal Error',
    headers: {},
    data: { error: error.message, stack: error.stack },
    ok: false,
  };
}

async request(session: Session, ctx: RequestContext): Promise<Response> {
  try {
    // ... implementation
  } catch (error) {
    return this.errorResponse(error as Error);
  }
}

4. Clean Up Resources

typescript
class ResourceAwareConnector implements HTTPConnector {
  private activeRequests = new Set<AbortController>();

  async request(session: HTTPSession, ctx: RequestContext): Promise<Response> {
    const controller = new AbortController();
    this.activeRequests.add(controller);

    try {
      return await fetch(ctx.url, { signal: controller.signal });
    } finally {
      this.activeRequests.delete(controller);
    }
  }

  disconnect(): void {
    // Cancel all pending requests
    for (const controller of this.activeRequests) {
      controller.abort();
    }
    this.activeRequests.clear();
  }
}

Wiring It All Together

Transport Factory Pattern

All transport factories follow the same signature:

typescript
// HTTP
const { transport, capabilities } = http(baseUrl?, connector?);

// FTP
const { transport, capabilities } = ftp(uri?, connector?);

// IMAP
const { transport, capabilities } = imap(uri?, connector?);

With Presets

typescript
import { preset } from '@unireq/presets';

// HTTP with custom connector
const api = preset.http
  .uri('https://api.example.com')
  .connector(new MyHTTPConnector())
  .retry
  .logging
  .build();

// FTP with custom connector
const ftp = preset.ftp
  .uri('ftp://server.com')
  .connector(new MySFTPConnector())
  .retry
  .build();

// IMAP with custom connector
const mail = preset.imap
  .uri('imap://mail.server.com')
  .connector(new MyIMAPConnector())
  .auth({ tokenSupplier: () => token })
  .build();

Composing with Policies

Custom connectors work seamlessly with all unireq policies:

typescript
import { client, retry, backoff, log, circuitBreaker } from '@unireq/core';
import { http } from '@unireq/http';

const { transport } = http('https://api.example.com', new MyConnector());

const api = client(
  transport,
  retry(shouldRetry, [backoff({ initial: 1000 })], { tries: 3 }),
  circuitBreaker({ threshold: 5, resetAfter: 30000 }),
  log({ level: 'debug' }),
);

TypeScript Support

All connector interfaces are fully typed. Export types for your custom connectors:

typescript
// my-connector.ts
import type { HTTPConnector, HTTPSession, HTTPCapabilities } from '@unireq/http';

export interface MyConnectorOptions {
  timeout?: number;
  retries?: number;
}

export class MyConnector implements HTTPConnector {
  constructor(private options: MyConnectorOptions = {}) {}
  // ...
}

// Export for consumers
export type { HTTPConnector, HTTPSession, HTTPCapabilities };

← Testing with MSW · Performance Tuning →

Released under the MIT License.