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. +

doce.dev logo
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__; +}