playbook/outfitter-agents/plugins/outfitter-stack/skills/stack-patterns/references/daemon.md

6.2 KiB

Daemon Patterns

Deep dive into @outfitter/daemon patterns.

Creating a Daemon

import {
  createDaemon,
  getLockPath,
} from "@outfitter/daemon";
import { createLogger, createConsoleSink } from "@outfitter/logging";

const logger = createLogger({
  name: "my-daemon",
  level: "info",
  sinks: [createConsoleSink()],
});

const daemon = createDaemon({
  name: "my-daemon",
  pidFile: getLockPath("my-daemon"),
  logger,
  shutdownTimeout: 10000,  // 10s graceful shutdown
});

Lifecycle Hooks

// Called before start
daemon.onBeforeStart(async () => {
  logger.info("Preparing to start...");
  await initializeDatabase();
});

// Called after start
daemon.onAfterStart(async () => {
  logger.info("Daemon started successfully");
});

// Called on shutdown (SIGTERM, SIGINT)
daemon.onShutdown(async () => {
  logger.info("Shutting down...");
  await closeConnections();
  await flushBuffers();
});

// Start the daemon
const result = await daemon.start();
if (result.isErr()) {
  logger.error("Failed to start", { error: result.error });
  process.exit(1);
}

IPC Server

Setting Up IPC

import {
  createIpcServer,
  getSocketPath,
} from "@outfitter/daemon";

const ipcServer = createIpcServer(getSocketPath("my-daemon"));

// Handle messages
ipcServer.onMessage(async (msg) => {
  const message = msg as { type: string; payload?: unknown };

  switch (message.type) {
    case "status":
      return {
        status: "ok",
        uptime: process.uptime(),
        version: "1.0.0",
      };

    case "reload":
      await reloadConfig();
      return { success: true };

    case "metrics":
      return getMetrics();

    default:
      return { error: "Unknown command" };
  }
});

// Register cleanup
daemon.onShutdown(async () => {
  await ipcServer.close();
});

// Start listening
await ipcServer.listen();
logger.info("IPC listening", { socket: getSocketPath("my-daemon") });

IPC Client

import {
  createIpcClient,
  getSocketPath,
} from "@outfitter/daemon";

const client = createIpcClient(getSocketPath("my-daemon"));

await client.connect();

// Send message and get response
const status = await client.send<{
  status: string;
  uptime: number;
}>({ type: "status" });

console.log("Daemon status:", status);

// Clean up
client.close();

Health Checks

Defining Checks

import { createHealthChecker } from "@outfitter/daemon";
import { Result } from "@outfitter/contracts";

const healthChecker = createHealthChecker([
  {
    name: "memory",
    check: async () => {
      const used = process.memoryUsage().heapUsed / 1024 / 1024;
      return used < 500
        ? Result.ok(undefined)
        : Result.err(new Error(`High memory: ${used.toFixed(2)}MB`));
    },
  },
  {
    name: "database",
    check: async () => {
      try {
        await db.ping();
        return Result.ok(undefined);
      } catch (error) {
        return Result.err(new Error("Database unreachable"));
      }
    },
  },
  {
    name: "disk",
    check: async () => {
      const free = await getDiskSpace();
      return free > 100 * 1024 * 1024  // 100MB
        ? Result.ok(undefined)
        : Result.err(new Error("Low disk space"));
    },
  },
]);

Exposing Health via IPC

ipcServer.onMessage(async (msg) => {
  if (msg.type === "health") {
    const result = await healthChecker.check();
    return {
      healthy: result.isOk(),
      checks: result.isOk() ? result.value : result.error,
    };
  }
});

Periodic Health Checks

const HEALTH_INTERVAL = 30000;  // 30 seconds

setInterval(async () => {
  const result = await healthChecker.check();

  if (result.isErr()) {
    logger.warn("Health check failed", { checks: result.error });
  }
}, HEALTH_INTERVAL);

PID File Management

XDG Paths

import { getLockPath, getSocketPath, getLogPath } from "@outfitter/daemon";

// PID file: ~/.local/state/my-daemon/my-daemon.pid
const pidPath = getLockPath("my-daemon");

// Socket: ~/.local/state/my-daemon/my-daemon.sock
const socketPath = getSocketPath("my-daemon");

// Logs: ~/.local/state/my-daemon/logs/
const logDir = getLogPath("my-daemon");

Checking if Running

import { isDaemonRunning, getDaemonPid } from "@outfitter/daemon";

if (await isDaemonRunning("my-daemon")) {
  const pid = await getDaemonPid("my-daemon");
  console.log(`Daemon already running (PID: ${pid})`);
  process.exit(1);
}

CLI Integration

Start Command

export const startCommand = command("start")
  .option("-d, --detach", "Run in background")
  .action(async ({ flags }) => {
    if (await isDaemonRunning("my-daemon")) {
      console.log("Daemon already running");
      return;
    }

    if (flags.detach) {
      // Spawn detached process
      spawn("bun", ["run", "src/daemon.ts"], {
        detached: true,
        stdio: "ignore",
      }).unref();
      console.log("Daemon started in background");
    } else {
      // Run in foreground
      await runDaemon();
    }
  })
  .build();

Stop Command

export const stopCommand = command("stop")
  .action(async () => {
    const client = createIpcClient(getSocketPath("my-daemon"));

    try {
      await client.connect();
      await client.send({ type: "shutdown" });
      console.log("Daemon stopped");
    } catch {
      console.log("Daemon not running");
    } finally {
      client.close();
    }
  })
  .build();

Status Command

export const statusCommand = command("status")
  .action(async () => {
    const client = createIpcClient(getSocketPath("my-daemon"));

    try {
      await client.connect();
      const status = await client.send<Status>({ type: "status" });
      console.log("Status:", status);
    } catch {
      console.log("Daemon not running");
    } finally {
      client.close();
    }
  })
  .build();

Best Practices

  1. Graceful shutdown - Register cleanup handlers with onShutdown
  2. Health checks - Monitor critical dependencies
  3. IPC protocol - Use structured message types
  4. PID files - Use XDG paths for consistency
  5. Logging - Log lifecycle events for debugging
  6. CLI commands - Provide start/stop/status commands