Effection Logo

Switchboard

The Switchboard is the front door—it receives all incoming HTTP requests and proxies them to the appropriate backend server.

What It Does

Client Request                     Switchboard                    Backend
     │                                 │                            │
     │  GET / Host: app-a.localhost    │                            │
     │ ──────────────────────────────> │                            │
     │                                 │  1. Extract hostname       │
     │                                 │  2. pool.getOrCreate()     │
     │                                 │                            │
     │                                 │  GET / ─────────────────>  │ (port 3001)
     │                                 │ <───────────────── 200 OK  │
     │ <────────────────── 200 OK      │                            │

The Resource

import type { Operation } from "effection";
import { resource, call } from "effection";
import express, {
  type Express,
  type Request,
  type Response,
  type NextFunction,
} from "express";
import httpProxy from "http-proxy";
import type { Server } from "http";
import type { SwitchboardConfig, ServerPool } from "./types";

export interface SwitchboardHandle {
  app: Express;
  server: Server;
  port: number;
}

export function useSwitchboard(
  config: SwitchboardConfig,
  pool: ServerPool,
): Operation<SwitchboardHandle> {
  return resource<SwitchboardHandle>(function* (provide) {
    const { port, defaultHostname = "default" } = config;

    const app: Express = express();

    // Create the proxy
    const proxy = httpProxy.createProxyServer({
      changeOrigin: false,
      ws: true, // WebSocket support
    });

    // ... routes and handlers ...

    // Start listening
    const server: Server = yield* call(
      () =>
        new Promise<Server>((resolve, reject) => {
          const srv = app.listen(port, () => {
            console.log(`[Switchboard] Listening on port ${port}`);
            resolve(srv);
          });
          srv.on("error", reject);
        }),
    );

    try {
      yield* provide({ app, server, port });
    } finally {
      proxy.close();
      server.close();
      yield* call(
        () =>
          new Promise<void>((resolve) => {
            server.on("close", resolve);
          }),
      );
    }
  });
}

Extracting the Hostname

We need to figure out which backend to route to. Multiple options:

function extractHostname(req: Request, defaultHostname: string): string {
  const host = req.get("host") || "";
  const hostWithoutPort = host.split(":")[0] ?? "";

  // Handle "app-a.localhost" -> "app-a"
  if (hostWithoutPort.includes(".")) {
    const parts = hostWithoutPort.split(".");
    return parts[0] ?? defaultHostname;
  }

  // Check for X-App-Name header as alternative
  const appHeader = req.get("x-app-name");
  if (appHeader) {
    return appHeader;
  }

  return defaultHostname;
}

This supports:

  • app-a.localhost:8000app-a
  • myapp.localhostmyapp
  • Header X-App-Name: customcustom
  • Plain localhostdefault

The Proxy Handler

The main handler uses the pool to get/create servers:

app.use(async (req: Request, res: Response, next: NextFunction) => {
  try {
    const hostname = extractHostname(req, defaultHostname);

    console.log(`[Switchboard] Request for hostname: "${hostname}"`);

    // Get or create the backend server
    const serverInfo = await pool.getOrCreate(hostname);

    // Proxy to the backend
    const target = `http://localhost:${serverInfo.port}`;

    proxy.web(req, res, { target }, (err) => {
      if (err) {
        console.error(`[Switchboard] Proxy failed:`, err.message);
        if (!res.headersSent) {
          res.status(502).json({
            error: "Bad Gateway",
            message: `Failed to proxy to ${hostname}: ${err.message}`,
          });
        }
      }
    });
  } catch (error) {
    next(error);
  }
});

Notice:

  • pool.getOrCreate() is Promise-based, so we can await it directly
  • The server is created on-demand if it doesn't exist
  • Proxy errors return 502 Bad Gateway

Admin Endpoints

Useful for debugging:

// Health check for the switchboard itself
app.get("/__switchboard/health", (_req: Request, res: Response) => {
  res.json({
    status: "ok",
    servers: pool.list().map((s) => ({
      hostname: s.hostname,
      port: s.port,
      uptime: Date.now() - s.startedAt.getTime(),
    })),
  });
});

// List all running servers
app.get("/__switchboard/servers", (_req: Request, res: Response) => {
  res.json({
    servers: pool.list().map((s) => ({
      hostname: s.hostname,
      port: s.port,
      startedAt: s.startedAt.toISOString(),
    })),
  });
});

Error Handling

Handle proxy errors gracefully:

proxy.on("error", (err, _req, res) => {
  console.error(`[Switchboard] Proxy error:`, err.message);
  if (res && "writeHead" in res && !res.headersSent) {
    (res as Response).status(502).json({
      error: "Bad Gateway",
      message: err.message,
    });
  }
});

// Express error handler
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error(`[Switchboard] Unhandled error:`, err);
  if (!res.headersSent) {
    res.status(500).json({
      error: "Internal Server Error",
      message: err.message,
    });
  }
});

WebSocket Support

The proxy also handles WebSocket upgrades:

server.on("upgrade", async (req, socket, head) => {
  try {
    const hostname = extractHostname(
      req as unknown as Request,
      defaultHostname,
    );
    const serverInfo = await pool.getOrCreate(hostname);
    const target = `http://localhost:${serverInfo.port}`;

    proxy.ws(req, socket, head, { target });
  } catch (error) {
    console.error(`[Switchboard] WebSocket upgrade failed:`, error);
    socket.destroy();
  }
});

Key Takeaways

  1. http-proxy for proxying — handles the HTTP proxying details
  2. Pool's Promise API — easy to use from Express handlers with await
  3. Hostname extraction — flexible routing based on Host header or custom header
  4. Admin endpoints — helpful for debugging and monitoring
  5. Graceful error handling — return proper HTTP errors, don't crash

Next Up

Time to put it all together! Let's see the Final Assembly.

  • PreviousServer Pool
  • NextFinal Assembly