2.8 KiB
blue-green-slot — Hono-flavoured atomic hot-reload primitive
(no submodules)
Purpose
HonoBlueGreenSlot is the per-plugin atomic flip primitive backing hot-reload for a Hono-backed Router. It holds at most three positions — live, shadow, and a map of draining slots — and lets the host swap a plugin's route table in O(1) by replacing a single mutable reference. The pending-request counter tracks in-flight work on draining slots so the host can wait for traffic to bleed off before the old module is GC'd.
Two invariants govern correctness: every flip() produces a DrainingHandle whose settled promise resolves once release() has matched every acquire(), and dispose(slotId, { timeoutMs }) races a timer against that settle — on timeout it aborts the in-flight requests via the slot's AbortController.
Features
- O(1) atomic flip via a single mutable
liveStatereference. - Pending-request counter (
acquire/release) with FIFO drain-waiters. signalFor(slotId)exposes the per-slotAbortSignalfor streaming handlers.- Slot IDs derived deterministically from
<plugin>.v<version>for readable logs. - Throws
ROUTER.SLOT_FLIP_FAILEDwhen no shadow is set. - Throws
ROUTER.SLOT_DISPOSE_TIMEOUTafter aborting on timeout.
Usage: Basic
import { HonoBlueGreenSlot } from "@suaveplan/api-router-hono/blue-green-slot";
const slot = new HonoBlueGreenSlot("billing", () => "v1");
slot.setShadow(() => "v2");
const draining = slot.flip();
await slot.dispose(draining.slotId, { timeoutMs: 1_000 });
Usage: Advanced
import { HonoBlueGreenSlot } from "@suaveplan/api-router-hono/blue-green-slot";
const slot = new HonoBlueGreenSlot("invoices", () => "v1");
slot.acquire(slot.live.slotId); // long-running request begins
slot.setShadow(() => "v2");
const draining = slot.flip();
const disposal = slot.dispose(draining.slotId, { timeoutMs: 5_000 });
// later, when the long-running request finishes:
slot.release(draining.slotId);
await disposal;
API Reference
new HonoBlueGreenSlot(plugin, handler).slot.live: RouterSlot.slot.shadow: RouterSlot | null.slot.setShadow(handler).slot.flip(): DrainingHandle.slot.dispose(slotId, { timeoutMs }): Promise<void>.slot.acquire(slotId),slot.release(slotId).slot.signalFor(slotId): AbortSignal | null.
Implementation Notes
The pending counter clamps at zero so over-release is a no-op rather than a thrown error — the host is the single owner of the counter and tests that double-release should fail at the host layer, not silently here. signalFor is layered on top of the AbortController per draining slot so handlers can wire req.signal.addEventListener("abort", ...) to terminate streaming responses immediately after a timed-out dispose.