Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

<p align="center">
<img src="https://github.com/pablopunk/doce.dev/blob/main/public/icon-128.png?raw=true" alt="doce.dev logo" width="64" />
<br />
Expand Down
3 changes: 2 additions & 1 deletion astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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],
Expand Down
96 changes: 96 additions & 0 deletions src/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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: <T>(middleware: T) => middleware,
sequence:
(
...middleware: Array<
(context: unknown, next: () => Promise<Response>) => Promise<Response>
>
) =>
(context: unknown, next: () => Promise<Response>) => {
const run = (index: number): Promise<Response> => {
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<void>((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();
});
});
111 changes: 7 additions & 104 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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<void> | 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<void> {
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<boolean> {
// 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) {
Expand Down
26 changes: 26 additions & 0 deletions src/server/bootstrap/astro-integration.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
22 changes: 22 additions & 0 deletions src/server/bootstrap/astro-integration.ts
Original file line number Diff line number Diff line change
@@ -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<BootstrapModule> | null = null;

return {
name: "doce-server-bootstrap",
hooks: {
"astro:server:setup": async ({ server }) => {
bootstrapModule ??= server.ssrLoadModule(
"/src/server/bootstrap/index.ts",
) as Promise<BootstrapModule>;
(await bootstrapModule).startServerBootstrap();
},
},
};
}
Loading
Loading