diff --git a/README.md b/README.md
index 693e3dee..cec6c78d 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,14 @@
# doce.dev - Self-Hosted AI Website Builder
+## Server startup
+
+Development starts the server bootstrap from Astro's `astro:server:setup` hook.
+With the pinned Astro/Node standalone adapter, the production entrypoint has no
+application runtime-start hook, so `node ./dist/server/entry.mjs` starts the
+same bootstrap when middleware is first resolved. That fallback is fire-and-
+forget: requests wait only for database readiness needed by setup/auth routing,
+never for Docker, OpenCode, or other background startup work.
+
diff --git a/astro.config.ts b/astro.config.ts
index 1c8e12df..01d688b7 100644
--- a/astro.config.ts
+++ b/astro.config.ts
@@ -3,6 +3,7 @@ import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";
import { execSync } from "child_process";
+import { serverBootstrapIntegration } from "./src/server/bootstrap/astro-integration";
const getGitVersion = (): string => {
try {
@@ -28,7 +29,7 @@ export default defineConfig({
host: true,
allowedHosts: true,
},
- integrations: [react()],
+ integrations: [react(), serverBootstrapIntegration()],
vite: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
plugins: [tailwindcss() as any],
diff --git a/src/middleware.test.ts b/src/middleware.test.ts
new file mode 100644
index 00000000..2aff25a0
--- /dev/null
+++ b/src/middleware.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ ensureDatabaseReady: vi.fn(),
+ selectUsers: vi.fn(),
+ startServerBootstrap: vi.fn(() => new Promise(() => {})),
+ validateSession: vi.fn(),
+}));
+
+vi.mock("@/server/bootstrap", () => ({
+ startServerBootstrap: mocks.startServerBootstrap,
+}));
+vi.mock("@/server/db/ensure-db", () => ({
+ ensureDatabaseReady: mocks.ensureDatabaseReady,
+}));
+vi.mock("@/server/db/client", () => ({
+ db: { select: () => ({ from: () => ({ limit: mocks.selectUsers }) }) },
+}));
+vi.mock("@/server/auth/sessions", () => ({
+ validateSession: mocks.validateSession,
+}));
+vi.mock("@/server/logger", () => ({
+ logger: { error: vi.fn(), info: vi.fn() },
+}));
+vi.mock("astro:middleware", () => ({
+ defineMiddleware: (middleware: T) => middleware,
+ sequence:
+ (
+ ...middleware: Array<
+ (context: unknown, next: () => Promise) => Promise
+ >
+ ) =>
+ (context: unknown, next: () => Promise) => {
+ const run = (index: number): Promise => {
+ const current = middleware[index];
+ return current ? current(context, () => run(index + 1)) : next();
+ };
+ return run(0);
+ },
+}));
+
+const { onRequest } = await import("./middleware");
+
+function deferred() {
+ let resolve!: () => void;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+describe("middleware bootstrap fallback", () => {
+ it("starts bootstrap once at module evaluation and waits only for database readiness", async () => {
+ const database = deferred();
+ mocks.ensureDatabaseReady.mockReturnValueOnce(database.promise);
+ mocks.selectUsers.mockResolvedValueOnce([]);
+ const next = vi.fn(() => Promise.resolve(new Response("next")));
+ const context = {
+ url: new URL("http://doce.test/"),
+ cookies: { get: vi.fn(), delete: vi.fn() },
+ locals: {},
+ redirect: (location: string) =>
+ new Response(null, { status: 302, headers: { location } }),
+ };
+
+ const responsePromise = onRequest(context as never, next);
+ await Promise.resolve();
+ expect(next).not.toHaveBeenCalled();
+ expect(mocks.startServerBootstrap).toHaveBeenCalledTimes(1);
+ database.resolve();
+ const response = await responsePromise;
+ if (!(response instanceof Response)) throw new Error("missing response");
+ expect(response.headers.get("location")).toBe("/setup");
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it("keeps existing-user auth routing responsive while bootstrap remains pending", async () => {
+ const afterSetupCacheExpiry = Date.now() + 2_000;
+ vi.spyOn(Date, "now").mockReturnValue(afterSetupCacheExpiry);
+ mocks.ensureDatabaseReady.mockResolvedValueOnce(undefined);
+ mocks.selectUsers.mockResolvedValueOnce([{ id: "existing-user" }]);
+ const next = vi.fn(() => Promise.resolve(new Response("next")));
+ const context = {
+ url: new URL("http://doce.test/"),
+ cookies: { get: vi.fn(), delete: vi.fn() },
+ locals: {},
+ redirect: (location: string) =>
+ new Response(null, { status: 302, headers: { location } }),
+ };
+
+ const response = await onRequest(context as never, next);
+ if (!(response instanceof Response)) throw new Error("missing response");
+ expect(response.headers.get("location")).toBe("/login");
+ expect(next).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/middleware.ts b/src/middleware.ts
index 9e43d6ed..fa780940 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,120 +1,23 @@
import { defineMiddleware, sequence } from "astro:middleware";
import { SESSION_COOKIE_NAME } from "@/server/auth/constants";
-import {
- cleanupExpiredSessions,
- validateSession,
-} from "@/server/auth/sessions";
-import { initConfig } from "@/server/config";
+import { validateSession } from "@/server/auth/sessions";
+import { startServerBootstrap } from "@/server/bootstrap";
import { db } from "@/server/db/client";
import { ensureDatabaseReady } from "@/server/db/ensure-db";
import { users } from "@/server/db/schema";
-import { startDefaultsBootstrap } from "@/server/defaults/bootstrap";
-import {
- ensureDoceSharedNetwork,
- ensureGlobalPnpmVolume,
-} from "@/server/docker/compose";
-import { startImagePrewarm } from "@/server/docker/prewarm";
-import { ensureEffectQueueWorkerStarted } from "@/server/effect";
import { logger } from "@/server/logger";
-import { ensureGlobalOpencodeStarted } from "@/server/opencode/runtime";
-import {
- installShutdownHandlers,
- registerDatabaseForShutdown,
- registerOpencodeRuntimeForShutdown,
-} from "@/server/shutdown";
-import { ensureTailscaleStarted } from "@/server/tailscale/startup";
-
-// Initialize config system early (sync, no dependencies)
-initConfig();
-
-// Install shutdown handlers once
-let shutdownHandlersInstalled = false;
-if (!shutdownHandlersInstalled) {
- installShutdownHandlers();
- registerOpencodeRuntimeForShutdown();
- registerDatabaseForShutdown();
- shutdownHandlersInstalled = true;
-}
-
-const STARTUP_RETRY_COOLDOWN_MS = 5_000;
-
-let startupInitialized = false;
-let startupPromise: Promise | null = null;
-let lastStartupFailureAt = 0;
-
-function getStartupErrorMessage(error: unknown): string {
- if (error instanceof Error) {
- return error.message;
- }
-
- try {
- return JSON.stringify(error);
- } catch {
- return String(error);
- }
-}
-
-async function runInitialization(): Promise {
- await ensureDatabaseReady();
- ensureEffectQueueWorkerStarted();
- await ensureDoceSharedNetwork();
- await ensureGlobalPnpmVolume();
- await ensureGlobalOpencodeStarted();
- startImagePrewarm();
- startDefaultsBootstrap();
- ensureTailscaleStarted().catch((error) => {
- logger.warn({ error }, "Tailscale auto-start failed");
- });
- startupInitialized = true;
-}
-
-async function ensureInitialized() {
- if (startupInitialized) return;
- if (startupPromise) {
- return startupPromise;
- }
-
- const now = Date.now();
- if (now - lastStartupFailureAt < STARTUP_RETRY_COOLDOWN_MS) {
- return;
- }
- startupPromise = runInitialization()
- .catch((error) => {
- lastStartupFailureAt = Date.now();
- logger.error(
- { error, message: getStartupErrorMessage(error) },
- "[Middleware] Initial startup failed",
- );
- throw error;
- })
- .finally(() => {
- startupPromise = null;
- });
-
- return startupPromise;
-}
-
-// Cleanup expired sessions on startup and every hour
-cleanupExpiredSessions().catch((error) => {
- logger.error({ error }, "Failed to cleanup expired sessions");
-});
-setInterval(
- () => {
- cleanupExpiredSessions().catch((error) => {
- logger.error({ error }, "Failed to cleanup expired sessions");
- });
- },
- 60 * 60 * 1000,
-);
+// The standalone adapter has no application runtime-start hook. Its lazy
+// middleware evaluation is a one-time, fire-and-forget fallback.
+startServerBootstrap();
const SETUP_CHECK_INTERVAL_PENDING_MS = 1_000;
const SETUP_CHECK_INTERVAL_DONE_MS = 60_000;
let setupCheckCache: { needsSetup: boolean; timestamp: number } | null = null;
async function getSetupNeeded(): Promise {
- // Await initialization here instead of at the top level
- await ensureInitialized();
+ // Setup/auth only needs migrations; heavy bootstrap never blocks a response.
+ await ensureDatabaseReady();
const now = Date.now();
if (setupCheckCache) {
diff --git a/src/server/bootstrap/astro-integration.test.ts b/src/server/bootstrap/astro-integration.test.ts
new file mode 100644
index 00000000..992f2341
--- /dev/null
+++ b/src/server/bootstrap/astro-integration.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it, vi } from "vitest";
+import { serverBootstrapIntegration } from "./astro-integration";
+
+describe("server bootstrap Astro integration", () => {
+ it("loads the real SSR module once and never awaits its detached boot", async () => {
+ let resolveModule!: (module: { startServerBootstrap: () => void }) => void;
+ const modulePromise = new Promise<{ startServerBootstrap: () => void }>(
+ (resolve) => {
+ resolveModule = resolve;
+ },
+ );
+ const startServerBootstrap = vi.fn();
+ const ssrLoadModule = vi.fn(() => modulePromise);
+ const integration = serverBootstrapIntegration();
+ const setup = integration.hooks["astro:server:setup"];
+ if (!setup) throw new Error("missing astro:server:setup hook");
+
+ const first = setup({ server: { ssrLoadModule } } as never);
+ const second = setup({ server: { ssrLoadModule } } as never);
+ expect(ssrLoadModule).toHaveBeenCalledTimes(1);
+ resolveModule({ startServerBootstrap });
+ await Promise.all([first, second]);
+ expect(startServerBootstrap).toHaveBeenCalledTimes(2);
+ expect(integration.hooks["astro:server:done"]).toBeUndefined();
+ });
+});
diff --git a/src/server/bootstrap/astro-integration.ts b/src/server/bootstrap/astro-integration.ts
new file mode 100644
index 00000000..2f3e465a
--- /dev/null
+++ b/src/server/bootstrap/astro-integration.ts
@@ -0,0 +1,22 @@
+import type { AstroIntegration } from "astro";
+
+interface BootstrapModule {
+ startServerBootstrap: () => void;
+}
+
+/** Development-only startup trigger. Standalone relies on middleware fallback. */
+export function serverBootstrapIntegration(): AstroIntegration {
+ let bootstrapModule: Promise | null = null;
+
+ return {
+ name: "doce-server-bootstrap",
+ hooks: {
+ "astro:server:setup": async ({ server }) => {
+ bootstrapModule ??= server.ssrLoadModule(
+ "/src/server/bootstrap/index.ts",
+ ) as Promise;
+ (await bootstrapModule).startServerBootstrap();
+ },
+ },
+ };
+}
diff --git a/src/server/bootstrap/coordinator.test.ts b/src/server/bootstrap/coordinator.test.ts
new file mode 100644
index 00000000..21d50c8e
--- /dev/null
+++ b/src/server/bootstrap/coordinator.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ type BootstrapOperations,
+ createBootstrapCoordinator,
+} from "./coordinator";
+import { createBootstrapProcessState } from "./process-state";
+
+function deferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+async function flushPromises(): Promise {
+ for (let index = 0; index < 10; index += 1) {
+ await Promise.resolve();
+ }
+ await new Promise((resolve) => setImmediate(resolve));
+}
+
+function operations(overrides: Partial = {}) {
+ const calls: string[] = [];
+ const operation = (name: string) =>
+ vi.fn(async () => {
+ calls.push(name);
+ });
+ return {
+ calls,
+ operations: {
+ ensureDatabaseReady: operation("database"),
+ startPostDatabaseHousekeeping: vi.fn(() => calls.push("housekeeping")),
+ ensureEffectQueueWorkerStarted: operation("queue"),
+ ensureDoceSharedNetwork: operation("network"),
+ ensureGlobalPnpmVolume: operation("volume"),
+ ensureGlobalOpencodeStarted: operation("opencode"),
+ startImagePrewarm: operation("prewarm"),
+ startDefaultsBootstrap: operation("defaults"),
+ ensureTailscaleStarted: operation("tailscale"),
+ logError: vi.fn(),
+ ...overrides,
+ } satisfies BootstrapOperations,
+ };
+}
+
+describe("bootstrap coordinator", () => {
+ it("shares the exact in-flight promise and retains startup ordering", async () => {
+ const openCode = deferred();
+ const fixture = operations({
+ ensureGlobalOpencodeStarted: () => openCode.promise,
+ });
+ const coordinator = createBootstrapCoordinator(
+ createBootstrapProcessState(),
+ fixture.operations,
+ );
+
+ const first = coordinator.ensureServerBootstrap();
+ expect(coordinator.ensureServerBootstrap()).toBe(first);
+ await flushPromises();
+ expect(fixture.calls).toEqual([
+ "database",
+ "housekeeping",
+ "queue",
+ "network",
+ "volume",
+ ]);
+ openCode.resolve();
+ await expect(first).resolves.toBe("ready");
+ await flushPromises();
+ expect(fixture.calls).toEqual([
+ "database",
+ "housekeeping",
+ "queue",
+ "network",
+ "volume",
+ "prewarm",
+ "defaults",
+ "tailscale",
+ ]);
+ });
+
+ it("contains detached and network/volume failures while OpenCode remains fatal", async () => {
+ const fixture = operations({
+ ensureEffectQueueWorkerStarted: () => {
+ throw new Error("queue");
+ },
+ ensureDoceSharedNetwork: async () => {
+ throw new Error("network");
+ },
+ ensureGlobalPnpmVolume: async () => {
+ throw new Error("volume");
+ },
+ ensureGlobalOpencodeStarted: async () => {
+ throw new Error("opencode");
+ },
+ });
+ const coordinator = createBootstrapCoordinator(
+ createBootstrapProcessState(),
+ fixture.operations,
+ );
+
+ await expect(coordinator.ensureServerBootstrap()).rejects.toThrow(
+ "opencode",
+ );
+ expect(fixture.operations.startImagePrewarm).not.toHaveBeenCalled();
+ expect(fixture.operations.startDefaultsBootstrap).not.toHaveBeenCalled();
+ expect(fixture.operations.ensureTailscaleStarted).not.toHaveBeenCalled();
+ expect(fixture.operations.logError).toHaveBeenCalled();
+ });
+
+ it("resolves cooldown calls and schedules only one automatic retry", async () => {
+ let clock = 0;
+ const timers: Array<{ callback: () => void; cancelled: boolean }> = [];
+ const fixture = operations({
+ ensureGlobalOpencodeStarted: vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(new Error("first attempt"))
+ .mockResolvedValueOnce(undefined),
+ });
+ const state = createBootstrapProcessState();
+ const coordinator = createBootstrapCoordinator(state, fixture.operations, {
+ now: () => clock,
+ setTimeout: ((callback: () => void) => {
+ const timer = { callback, cancelled: false };
+ timers.push(timer);
+ return timer as unknown as ReturnType;
+ }) as typeof setTimeout,
+ clearTimeout: ((timer: { cancelled: boolean }) => {
+ timer.cancelled = true;
+ }) as unknown as typeof clearTimeout,
+ });
+
+ await expect(coordinator.ensureServerBootstrap()).rejects.toThrow(
+ "first attempt",
+ );
+ expect(timers).toHaveLength(1);
+ expect(await coordinator.ensureServerBootstrap()).toBe("cooldown");
+ expect(timers).toHaveLength(1);
+
+ clock = 5_000;
+ const retryTimer = timers[0];
+ if (!retryTimer) throw new Error("missing retry timer");
+ retryTimer.callback();
+ await flushPromises();
+ expect(
+ fixture.operations.ensureGlobalOpencodeStarted,
+ ).toHaveBeenCalledTimes(2);
+ expect(state.initialized).toBe(true);
+ });
+});
diff --git a/src/server/bootstrap/coordinator.ts b/src/server/bootstrap/coordinator.ts
new file mode 100644
index 00000000..57be0096
--- /dev/null
+++ b/src/server/bootstrap/coordinator.ts
@@ -0,0 +1,153 @@
+import type { BootstrapProcessState, BootstrapResult } from "./process-state";
+
+export const STARTUP_RETRY_COOLDOWN_MS = 5_000;
+
+type MaybePromise = void | Promise;
+
+export interface BootstrapOperations {
+ ensureDatabaseReady: () => Promise;
+ startPostDatabaseHousekeeping: () => void;
+ ensureEffectQueueWorkerStarted: () => MaybePromise;
+ ensureDoceSharedNetwork: () => Promise;
+ ensureGlobalPnpmVolume: () => Promise;
+ ensureGlobalOpencodeStarted: () => Promise;
+ startImagePrewarm: () => MaybePromise;
+ startDefaultsBootstrap: () => MaybePromise;
+ ensureTailscaleStarted: () => MaybePromise;
+ logError: (message: string, error: unknown) => void;
+}
+
+export interface BootstrapCoordinatorOptions {
+ now?: () => number;
+ setTimeout?: typeof setTimeout;
+ clearTimeout?: typeof clearTimeout;
+ cooldownMs?: number;
+}
+
+export interface BootstrapCoordinator {
+ ensureServerBootstrap: () => Promise;
+ startServerBootstrap: () => void;
+}
+
+function unref(timer: ReturnType): void {
+ (timer as { unref?: () => void }).unref?.();
+}
+
+/**
+ * Coordinates startup without coupling it to an HTTP request. Only database and
+ * OpenCode failures fail an attempt; the remaining background work retains its
+ * former detached/non-fatal boundaries.
+ */
+export function createBootstrapCoordinator(
+ state: BootstrapProcessState,
+ operations: BootstrapOperations,
+ options: BootstrapCoordinatorOptions = {},
+): BootstrapCoordinator {
+ const now = options.now ?? Date.now;
+ const scheduleTimeout = options.setTimeout ?? setTimeout;
+ const cancelTimeout = options.clearTimeout ?? clearTimeout;
+ const cooldownMs = options.cooldownMs ?? STARTUP_RETRY_COOLDOWN_MS;
+
+ const detached = (name: string, operation: () => MaybePromise): void => {
+ void Promise.resolve()
+ .then(operation)
+ .catch((error) => operations.logError(`${name} failed`, error));
+ };
+
+ const nonFatal = async (name: string, operation: () => Promise) => {
+ try {
+ await Promise.resolve().then(operation);
+ } catch (error) {
+ operations.logError(`${name} failed`, error);
+ }
+ };
+
+ const clearRetryTimer = (): void => {
+ if (state.retryTimer) {
+ cancelTimeout(state.retryTimer);
+ state.retryTimer = null;
+ }
+ };
+
+ let ensureServerBootstrap: () => Promise;
+ const scheduleRetry = (): void => {
+ if (state.retryTimer || state.lastFailureAt === null || state.initialized) {
+ return;
+ }
+
+ const remaining = Math.max(0, state.lastFailureAt + cooldownMs - now());
+ state.retryTimer = scheduleTimeout(() => {
+ state.retryTimer = null;
+ startServerBootstrap();
+ }, remaining);
+ unref(state.retryTimer);
+ };
+
+ const runOrderedStartup = async (): Promise => {
+ await operations.ensureDatabaseReady();
+ operations.startPostDatabaseHousekeeping();
+ detached(
+ "Effect queue worker startup",
+ operations.ensureEffectQueueWorkerStarted,
+ );
+ await nonFatal(
+ "Shared Docker network startup",
+ operations.ensureDoceSharedNetwork,
+ );
+ await nonFatal(
+ "Global pnpm volume startup",
+ operations.ensureGlobalPnpmVolume,
+ );
+ await operations.ensureGlobalOpencodeStarted();
+ detached("Image prewarm", operations.startImagePrewarm);
+ detached("Defaults bootstrap", operations.startDefaultsBootstrap);
+ detached("Tailscale auto-start", operations.ensureTailscaleStarted);
+ };
+
+ ensureServerBootstrap = (): Promise => {
+ if (state.initialized) return Promise.resolve("ready");
+ if (state.attemptPromise) return state.attemptPromise;
+
+ if (
+ state.lastFailureAt !== null &&
+ now() < state.lastFailureAt + cooldownMs
+ ) {
+ scheduleRetry();
+ return Promise.resolve("cooldown");
+ }
+
+ clearRetryTimer();
+ const attempt = Promise.resolve()
+ .then(runOrderedStartup)
+ .then(
+ () => {
+ state.initialized = true;
+ state.lastFailureAt = null;
+ clearRetryTimer();
+ state.attemptPromise = null;
+ return "ready" as const;
+ },
+ (error) => {
+ state.initialized = false;
+ state.lastFailureAt = now();
+ scheduleRetry();
+ operations.logError("Server bootstrap failed", error);
+ state.attemptPromise = null;
+ throw error;
+ },
+ );
+
+ state.attemptPromise = attempt;
+ return attempt;
+ };
+
+ const startServerBootstrap = (): void => {
+ void Promise.resolve()
+ .then(() => ensureServerBootstrap())
+ // ensureServerBootstrap records failed attempts; this handler only keeps
+ // the detached entrypoint from becoming an unhandled rejection.
+ .catch(() => {});
+ };
+
+ return { ensureServerBootstrap, startServerBootstrap };
+}
diff --git a/src/server/bootstrap/housekeeping.ts b/src/server/bootstrap/housekeeping.ts
new file mode 100644
index 00000000..d6c6fb55
--- /dev/null
+++ b/src/server/bootstrap/housekeeping.ts
@@ -0,0 +1,28 @@
+import { logger } from "@/server/logger";
+import type { BootstrapProcessState } from "./process-state";
+
+const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
+
+function unref(timer: ReturnType): void {
+ (timer as { unref?: () => void }).unref?.();
+}
+
+export function startPostDatabaseHousekeeping(
+ state: BootstrapProcessState,
+ cleanupExpiredSessions: () => Promise,
+): void {
+ if (state.housekeepingStarted) return;
+ state.housekeepingStarted = true;
+
+ const cleanup = (): void => {
+ void Promise.resolve()
+ .then(cleanupExpiredSessions)
+ .catch((error) =>
+ logger.error({ error }, "Failed to cleanup expired sessions"),
+ );
+ };
+
+ cleanup();
+ state.sessionCleanupTimer = setInterval(cleanup, SESSION_CLEANUP_INTERVAL_MS);
+ unref(state.sessionCleanupTimer);
+}
diff --git a/src/server/bootstrap/index.ts b/src/server/bootstrap/index.ts
new file mode 100644
index 00000000..ac36819a
--- /dev/null
+++ b/src/server/bootstrap/index.ts
@@ -0,0 +1,87 @@
+import { cleanupExpiredSessions } from "@/server/auth/sessions";
+import { initConfig } from "@/server/config";
+import { ensureDatabaseReady } from "@/server/db/ensure-db";
+import {
+ ensureDoceSharedNetwork,
+ ensureGlobalPnpmVolume,
+} from "@/server/docker/compose";
+import { startImagePrewarm } from "@/server/docker/prewarm";
+import { ensureEffectQueueWorkerStarted } from "@/server/effect";
+import { logger } from "@/server/logger";
+import { ensureGlobalOpencodeStarted } from "@/server/opencode/runtime";
+import {
+ installShutdownHandlers,
+ registerDatabaseForShutdown,
+ registerOpencodeRuntimeForShutdown,
+ registerShutdownHandler,
+} from "@/server/shutdown";
+import { ensureTailscaleStarted } from "@/server/tailscale/startup";
+import { createBootstrapCoordinator } from "./coordinator";
+import { startPostDatabaseHousekeeping } from "./housekeeping";
+import {
+ type BootstrapResult,
+ getBootstrapProcessState,
+} from "./process-state";
+
+const state = getBootstrapProcessState();
+
+function installProcessOwnership(): void {
+ if (!state.configInitialized) {
+ initConfig();
+ state.configInitialized = true;
+ }
+ if (!state.shutdownHandlersInstalled) {
+ installShutdownHandlers();
+ state.shutdownHandlersInstalled = true;
+ }
+ if (!state.opencodeShutdownRegistered) {
+ registerOpencodeRuntimeForShutdown();
+ state.opencodeShutdownRegistered = true;
+ }
+ if (!state.databaseShutdownRegistered) {
+ registerDatabaseForShutdown();
+ state.databaseShutdownRegistered = true;
+ }
+ if (!state.bootstrapShutdownRegistered) {
+ registerShutdownHandler("server-bootstrap", () => {
+ if (state.retryTimer) clearTimeout(state.retryTimer);
+ if (state.sessionCleanupTimer) clearInterval(state.sessionCleanupTimer);
+ state.retryTimer = null;
+ state.sessionCleanupTimer = null;
+ });
+ state.bootstrapShutdownRegistered = true;
+ }
+}
+
+const coordinator = createBootstrapCoordinator(state, {
+ ensureDatabaseReady,
+ startPostDatabaseHousekeeping: () =>
+ startPostDatabaseHousekeeping(state, cleanupExpiredSessions),
+ ensureEffectQueueWorkerStarted,
+ ensureDoceSharedNetwork,
+ ensureGlobalPnpmVolume,
+ ensureGlobalOpencodeStarted,
+ startImagePrewarm,
+ startDefaultsBootstrap: async () => {
+ const { startDefaultsBootstrap } = await import(
+ "@/server/defaults/bootstrap"
+ );
+ startDefaultsBootstrap();
+ },
+ ensureTailscaleStarted,
+ logError: (message, error) => logger.error({ error }, message),
+});
+
+/** Explicit callers get failure visibility; cooldown calls resolve without work. */
+export function ensureServerBootstrap(): Promise {
+ installProcessOwnership();
+ return coordinator.ensureServerBootstrap();
+}
+
+/** Safe for entry hooks and middleware module evaluation: it never blocks requests. */
+export function startServerBootstrap(): void {
+ void Promise.resolve()
+ .then(installProcessOwnership)
+ .then(coordinator.startServerBootstrap)
+ .catch((error) => logger.error({ error }, "Server bootstrap failed"));
+}
diff --git a/src/server/bootstrap/process-lifecycle.test.ts b/src/server/bootstrap/process-lifecycle.test.ts
new file mode 100644
index 00000000..9a3786d4
--- /dev/null
+++ b/src/server/bootstrap/process-lifecycle.test.ts
@@ -0,0 +1,114 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ cleanupExpiredSessions: vi.fn(),
+ ensureDatabaseReady: vi.fn(),
+ ensureDoceSharedNetwork: vi.fn(),
+ ensureEffectQueueWorkerStarted: vi.fn(),
+ ensureGlobalOpencodeStarted: vi.fn(),
+ ensureGlobalPnpmVolume: vi.fn(),
+ ensureTailscaleStarted: vi.fn(),
+ initConfig: vi.fn(),
+ installShutdownHandlers: vi.fn(),
+ logger: { error: vi.fn() },
+ registerDatabaseForShutdown: vi.fn(),
+ registerOpencodeRuntimeForShutdown: vi.fn(),
+ registerShutdownHandler: vi.fn(),
+ startImagePrewarm: vi.fn(),
+}));
+
+vi.mock("@/server/auth/sessions", () => ({
+ cleanupExpiredSessions: mocks.cleanupExpiredSessions,
+}));
+vi.mock("@/server/config", () => ({ initConfig: mocks.initConfig }));
+vi.mock("@/server/db/ensure-db", () => ({
+ ensureDatabaseReady: mocks.ensureDatabaseReady,
+}));
+vi.mock("@/server/docker/compose", () => ({
+ ensureDoceSharedNetwork: mocks.ensureDoceSharedNetwork,
+ ensureGlobalPnpmVolume: mocks.ensureGlobalPnpmVolume,
+}));
+vi.mock("@/server/docker/prewarm", () => ({
+ startImagePrewarm: mocks.startImagePrewarm,
+}));
+vi.mock("@/server/effect", () => ({
+ ensureEffectQueueWorkerStarted: mocks.ensureEffectQueueWorkerStarted,
+}));
+vi.mock("@/server/logger", () => ({ logger: mocks.logger }));
+vi.mock("@/server/opencode/runtime", () => ({
+ ensureGlobalOpencodeStarted: mocks.ensureGlobalOpencodeStarted,
+}));
+vi.mock("@/server/shutdown", () => ({
+ installShutdownHandlers: mocks.installShutdownHandlers,
+ registerDatabaseForShutdown: mocks.registerDatabaseForShutdown,
+ registerOpencodeRuntimeForShutdown: mocks.registerOpencodeRuntimeForShutdown,
+ registerShutdownHandler: mocks.registerShutdownHandler,
+}));
+vi.mock("@/server/tailscale/startup", () => ({
+ ensureTailscaleStarted: mocks.ensureTailscaleStarted,
+}));
+
+async function flushPromises(): Promise {
+ for (let index = 0; index < 12; index += 1) await Promise.resolve();
+}
+
+describe("bootstrap process lifecycle", () => {
+ afterEach(() => {
+ globalThis.__DOCE_SERVER_BOOTSTRAP_STATE__ = undefined;
+ vi.unstubAllGlobals();
+ vi.resetModules();
+ vi.clearAllMocks();
+ });
+
+ it("owns registrations and timers across HMR-like reloads after a later startup failure", async () => {
+ const retryTimer = { unref: vi.fn() };
+ const sessionTimer = { unref: vi.fn() };
+ const clearTimeout = vi.fn();
+ const clearInterval = vi.fn();
+ vi.stubGlobal(
+ "setTimeout",
+ (() => retryTimer) as unknown as typeof setTimeout,
+ );
+ vi.stubGlobal(
+ "setInterval",
+ (() => sessionTimer) as unknown as typeof setInterval,
+ );
+ vi.stubGlobal("clearTimeout", clearTimeout);
+ vi.stubGlobal("clearInterval", clearInterval);
+ mocks.ensureDatabaseReady.mockResolvedValue(undefined);
+ mocks.cleanupExpiredSessions.mockResolvedValue(undefined);
+ mocks.ensureDoceSharedNetwork.mockResolvedValue(undefined);
+ mocks.ensureGlobalPnpmVolume.mockResolvedValue(undefined);
+ mocks.ensureGlobalOpencodeStarted.mockRejectedValue(new Error("OpenCode"));
+
+ const bootstrap = await import("./index");
+ await expect(bootstrap.ensureServerBootstrap()).rejects.toThrow("OpenCode");
+ await flushPromises();
+
+ expect(mocks.initConfig).toHaveBeenCalledTimes(1);
+ expect(mocks.installShutdownHandlers).toHaveBeenCalledTimes(1);
+ expect(mocks.registerOpencodeRuntimeForShutdown).toHaveBeenCalledTimes(1);
+ expect(mocks.registerDatabaseForShutdown).toHaveBeenCalledTimes(1);
+ expect(mocks.registerShutdownHandler).toHaveBeenCalledTimes(1);
+ expect(mocks.cleanupExpiredSessions).toHaveBeenCalledTimes(1);
+ expect(sessionTimer.unref).toHaveBeenCalledTimes(1);
+ expect(retryTimer.unref).toHaveBeenCalledTimes(1);
+
+ vi.resetModules();
+ const reloadedBootstrap = await import("./index");
+ expect(await reloadedBootstrap.ensureServerBootstrap()).toBe("cooldown");
+ expect(mocks.installShutdownHandlers).toHaveBeenCalledTimes(1);
+ expect(mocks.cleanupExpiredSessions).toHaveBeenCalledTimes(1);
+
+ const shutdown = mocks.registerShutdownHandler.mock.calls[0]?.[1];
+ if (typeof shutdown !== "function")
+ throw new Error("missing shutdown handler");
+ shutdown();
+ expect(clearTimeout).toHaveBeenCalledWith(retryTimer);
+ expect(clearInterval).toHaveBeenCalledWith(sessionTimer);
+ expect(globalThis.__DOCE_SERVER_BOOTSTRAP_STATE__?.retryTimer).toBeNull();
+ expect(
+ globalThis.__DOCE_SERVER_BOOTSTRAP_STATE__?.sessionCleanupTimer,
+ ).toBeNull();
+ });
+});
diff --git a/src/server/bootstrap/process-state.ts b/src/server/bootstrap/process-state.ts
new file mode 100644
index 00000000..7f3a72e0
--- /dev/null
+++ b/src/server/bootstrap/process-state.ts
@@ -0,0 +1,42 @@
+export type BootstrapResult = "ready" | "cooldown";
+
+export interface BootstrapProcessState {
+ initialized: boolean;
+ attemptPromise: Promise | null;
+ lastFailureAt: number | null;
+ retryTimer: ReturnType | null;
+ housekeepingStarted: boolean;
+ sessionCleanupTimer: ReturnType | null;
+ configInitialized: boolean;
+ shutdownHandlersInstalled: boolean;
+ opencodeShutdownRegistered: boolean;
+ databaseShutdownRegistered: boolean;
+ bootstrapShutdownRegistered: boolean;
+}
+
+declare global {
+ // This survives Vite's server-module reloads. Do not put module-local state
+ // here: this is deliberately the one owner of process-wide boot resources.
+ var __DOCE_SERVER_BOOTSTRAP_STATE__: BootstrapProcessState | undefined;
+}
+
+export function createBootstrapProcessState(): BootstrapProcessState {
+ return {
+ initialized: false,
+ attemptPromise: null,
+ lastFailureAt: null,
+ retryTimer: null,
+ housekeepingStarted: false,
+ sessionCleanupTimer: null,
+ configInitialized: false,
+ shutdownHandlersInstalled: false,
+ opencodeShutdownRegistered: false,
+ databaseShutdownRegistered: false,
+ bootstrapShutdownRegistered: false,
+ };
+}
+
+export function getBootstrapProcessState(): BootstrapProcessState {
+ globalThis.__DOCE_SERVER_BOOTSTRAP_STATE__ ??= createBootstrapProcessState();
+ return globalThis.__DOCE_SERVER_BOOTSTRAP_STATE__;
+}