Server Pool
Now we need a way to manage multiple Express servers dynamically. The Server Pool:
- Tracks servers by hostname
- Creates new servers on demand
- Assigns ports automatically
- Cleans up all servers when shut down
- Emits events for observability
The Challenge
We need to create servers from inside Express route handlers (regular async functions), but our servers need to be Effection operations (for lifecycle management).
The solution: capture a Scope and use scope.run() to bridge the gap.
Types First
import type { Server } from "http";
import type { Express } from "express";
import type { Task, Stream } from "effection";
export interface ServerInfo {
hostname: string;
port: number;
app: Express;
server: Server;
task: Task<void>;
startedAt: Date;
}
export type ServerEvent =
| { type: "started"; hostname: string; port: number }
| { type: "stopped"; hostname: string }
| { type: "error"; hostname: string; error: Error; message: string };
export interface ServerPool {
getOrCreate(hostname: string): Promise<ServerInfo>;
get(hostname: string): ServerInfo | undefined;
list(): ServerInfo[];
shutdown(hostname: string): Promise<void>;
events: Stream<ServerEvent, void>;
}
Note that getOrCreate returns a Promise, not an Operation. This makes it easy to use from Express handlers.
The Pool Resource
import type { Operation, Task, Context, Scope } from "effection";
import {
resource,
createContext,
useScope,
suspend,
createSignal,
call,
} from "effection";
import type { ServerInfo, ServerPool, ServerEvent } from "./types";
import { useExpressServerDaemon } from "./server-resource";
export const ServerPoolContext: Context<ServerPool> =
createContext<ServerPool>("server-pool");
export interface ServerPoolConfig {
basePort: number;
maxServers?: number;
}
export function useServerPool(config: ServerPoolConfig): Operation<ServerPool> {
return resource<ServerPool>(function* (provide) {
const { basePort, maxServers = 100 } = config;
// State
const servers = new Map<string, ServerInfo>();
const serverTasks = new Map<string, Task<void>>();
const pendingCreations = new Map<string, Promise<ServerInfo>>();
let nextPort = basePort;
// Capture scope for spawning from callbacks
const scope: Scope = yield* useScope();
// Signal for server events
const events = createSignal<ServerEvent, void>();
// ... pool methods ...
const pool: ServerPool = { getOrCreate, get, list, shutdown, events };
yield* ServerPoolContext.set(pool);
try {
yield* provide(pool);
} finally {
console.log(`[Pool] Shutting down all servers...`);
events.close(undefined as void);
}
});
}
Key Pattern: Capturing Scope
The magic is in useScope():
const scope: Scope = yield * useScope();
This captures a reference to the current Effection scope. Later, from any callback or async function, we can use scope.run() to run operations as children of this scope:
// Inside an async function (like Express handler)
async function getOrCreate(hostname: string): Promise<ServerInfo> {
const spawnPromise = scope.run(function* (): Operation<ServerInfo> {
return yield* doSpawnServer(hostname, port);
});
return await spawnPromise;
}
Spawning Servers
The core spawning logic:
function* doSpawnServer(hostname: string, port: number): Operation<ServerInfo> {
// Create placeholder info
const info: ServerInfo = {
hostname,
port,
app: null as any,
server: null as any,
task: null as any,
startedAt: new Date(),
};
servers.set(hostname, info);
// Promise that resolves when server is ready
let resolveReady: (info: ServerInfo) => void;
let rejectReady: (err: Error) => void;
const readyPromise = new Promise<ServerInfo>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
// Start the server as a long-lived task
const task = scope.run(function* (): Operation<void> {
try {
const handle = yield* useExpressServerDaemon(port, hostname);
info.app = handle.app;
info.server = handle.server;
events.send({ type: "started", hostname, port });
resolveReady!(info);
// Keep running until halted
yield* suspend();
} catch (error) {
events.send({
type: "error",
hostname,
error: error as Error,
message: (error as Error).message,
});
rejectReady!(error as Error);
throw error;
} finally {
events.send({ type: "stopped", hostname });
servers.delete(hostname);
serverTasks.delete(hostname);
}
});
info.task = task;
serverTasks.set(hostname, task);
return yield* call(() => readyPromise);
}
Key points:
- We use
scope.run()to start a long-lived task - The task uses
suspend()to stay alive indefinitely - Cleanup happens in
finallywhen the task is halted - We bridge back to caller via a Promise
Deduplication
If two requests come in for the same hostname simultaneously, we don't want to create two servers:
async function getOrCreate(hostname: string): Promise<ServerInfo> {
// Fast path: server already exists
const existing = servers.get(hostname);
if (existing && existing.server) {
return existing;
}
// Check if already creating (deduplication)
const pending = pendingCreations.get(hostname);
if (pending) {
return pending;
}
// Check limit
if (servers.size >= maxServers) {
throw new Error(`Maximum servers (${maxServers}) reached`);
}
// Create the spawn promise
const port = nextPort++;
const spawnPromise = scope.run(function* () {
return yield* doSpawnServer(hostname, port);
});
// Store to deduplicate
pendingCreations.set(hostname, spawnPromise);
try {
return await spawnPromise;
} finally {
pendingCreations.delete(hostname);
}
}
Signal-Based Events
We use a Signal for events because:
- Signals can be sent from anywhere (generators, callbacks, async)
- Multiple subscribers can listen independently
- It's a Stream, so consumers use the familiar
each()pattern
const events = createSignal<ServerEvent, void>();
// Sending (from anywhere)
events.send({ type: "started", hostname, port });
// Receiving (in an operation)
yield *
spawn(function* () {
for (const event of yield* each(pool.events)) {
console.log(`[Event] ${event.type}:`, event);
yield* each.next();
}
});
Context for Sharing
We set the pool as Context so any operation in the tree can access it:
yield * ServerPoolContext.set(pool);
// Later, anywhere in the tree:
function* someOperation(): Operation<void> {
const pool = yield* ServerPoolContext.expect();
const server = yield* call(() => pool.getOrCreate("my-app"));
}
Key Takeaways
useScope()captures the current scope — for spawning from callbacksscope.run()bridges async → Effection — operations become children of the scopesuspend()keeps tasks alive — until explicitly halted- Signals for cross-boundary events — send from anywhere, receive in operations
- Deduplication prevents races — track pending creations
Next Up
Now let's build the Switchboard that routes incoming requests to our pooled servers.