146 lines
6.3 KiB
JavaScript
146 lines
6.3 KiB
JavaScript
import { Database } from "bun:sqlite";
|
|
import { mkdir, readdir, readFile } from "node:fs/promises";
|
|
import { dirname, extname, join, normalize, relative, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import worker from "../apps/api/src/index.ts";
|
|
import { createSelfHostedStorageAdapter } from "../apps/api/src/self-hosted-storage-adapter.ts";
|
|
import { createS3CompatibleStorageAdapter } from "../apps/api/src/s3-compatible-storage-adapter.ts";
|
|
|
|
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const dataDirectory = resolve(process.env.EDGE_EVER_DATA_DIR ?? join(projectRoot, ".edgeever-data"));
|
|
const databaseFile = resolve(process.env.EDGE_EVER_SQLITE_FILE ?? join(dataDirectory, "edgeever.sqlite"));
|
|
const resourcesDirectory = resolve(process.env.EDGE_EVER_RESOURCES_DIR ?? join(dataDirectory, "resources"));
|
|
const webDirectory = resolve(process.env.EDGE_EVER_WEB_DIR ?? join(projectRoot, "apps/web/dist"));
|
|
const port = Number(process.env.PORT ?? process.env.EDGE_EVER_PORT ?? 8787);
|
|
const configuredIdleTimeout = Number(process.env.EDGE_EVER_IDLE_TIMEOUT_SECONDS ?? 120);
|
|
const idleTimeout = Number.isFinite(configuredIdleTimeout)
|
|
? Math.min(255, Math.max(10, configuredIdleTimeout))
|
|
: 120;
|
|
|
|
await mkdir(dataDirectory, { recursive: true });
|
|
await mkdir(resourcesDirectory, { recursive: true });
|
|
const sqlite = new Database(databaseFile, { create: true });
|
|
sqlite.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;");
|
|
|
|
const migrationFiles = (await readdir(join(projectRoot, "migrations")))
|
|
.filter((name) => name.endsWith(".sql"))
|
|
.sort();
|
|
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS _edgeever_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL
|
|
)
|
|
`);
|
|
|
|
const appliedMigrations = new Set(
|
|
sqlite.query("SELECT name FROM _edgeever_migrations").all().map((row) => row.name),
|
|
);
|
|
|
|
for (const name of migrationFiles) {
|
|
if (appliedMigrations.has(name)) {
|
|
continue;
|
|
}
|
|
|
|
const sql = await readFile(join(projectRoot, "migrations", name), "utf8");
|
|
sqlite.transaction(() => {
|
|
sqlite.exec(sql);
|
|
sqlite.query("INSERT INTO _edgeever_migrations (name, applied_at) VALUES (?, ?)").run(name, new Date().toISOString());
|
|
})();
|
|
console.log(`[self-hosted] applied migration ${name}`);
|
|
}
|
|
|
|
const storageBackend = process.env.EDGE_EVER_STORAGE_BACKEND ?? "local";
|
|
const storage = storageBackend === "s3"
|
|
? createS3CompatibleStorageAdapter(sqlite, {
|
|
bucket: process.env.EDGE_EVER_S3_BUCKET ?? "",
|
|
region: process.env.EDGE_EVER_S3_REGION,
|
|
endpoint: process.env.EDGE_EVER_S3_ENDPOINT,
|
|
accessKeyId: process.env.EDGE_EVER_S3_ACCESS_KEY_ID,
|
|
secretAccessKey: process.env.EDGE_EVER_S3_SECRET_ACCESS_KEY,
|
|
forcePathStyle: process.env.EDGE_EVER_S3_FORCE_PATH_STYLE
|
|
? process.env.EDGE_EVER_S3_FORCE_PATH_STYLE === "true"
|
|
: undefined,
|
|
})
|
|
: createSelfHostedStorageAdapter(sqlite, resourcesDirectory);
|
|
|
|
if (storageBackend === "s3" && !process.env.EDGE_EVER_S3_BUCKET) {
|
|
throw new Error("EDGE_EVER_S3_BUCKET is required when EDGE_EVER_STORAGE_BACKEND=s3");
|
|
}
|
|
const env = {
|
|
DB: storage.db,
|
|
RESOURCES: storage.resources,
|
|
EDGE_EVER_AUTH_USERNAME: process.env.EDGE_EVER_AUTH_USERNAME ?? "admin",
|
|
EDGE_EVER_RUNTIME: "self-hosted-bun",
|
|
EDGE_EVER_AUTH_PASSWORD: process.env.EDGE_EVER_AUTH_PASSWORD,
|
|
EDGE_EVER_AUTH_PASSWORD_HASH: process.env.EDGE_EVER_AUTH_PASSWORD_HASH,
|
|
EDGE_EVER_SESSION_TTL_DAYS: process.env.EDGE_EVER_SESSION_TTL_DAYS ?? "400",
|
|
EDGE_EVER_AUTH_LOGIN_WINDOW_SECONDS: process.env.EDGE_EVER_AUTH_LOGIN_WINDOW_SECONDS,
|
|
EDGE_EVER_AUTH_LOGIN_USERNAME_MAX_ATTEMPTS: process.env.EDGE_EVER_AUTH_LOGIN_USERNAME_MAX_ATTEMPTS,
|
|
EDGE_EVER_AUTH_LOGIN_USERNAME_COOLDOWN_SECONDS: process.env.EDGE_EVER_AUTH_LOGIN_USERNAME_COOLDOWN_SECONDS,
|
|
EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS: process.env.EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS,
|
|
EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS: process.env.EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS,
|
|
EDGE_EVER_STORAGE_ENCRYPTION_KEY: process.env.EDGE_EVER_STORAGE_ENCRYPTION_KEY,
|
|
EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY: process.env.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY,
|
|
EDGE_EVER_DEMO_MODE: process.env.EDGE_EVER_DEMO_MODE,
|
|
EDGE_EVER_ALLOW_UNAUTHENTICATED: process.env.EDGE_EVER_ALLOW_UNAUTHENTICATED,
|
|
};
|
|
|
|
const executionContext = {
|
|
waitUntil: (promise) => Promise.resolve(promise).catch((error) => console.error("[self-hosted] background task failed", error)),
|
|
passThroughOnException: () => undefined,
|
|
};
|
|
|
|
const contentTypes = {
|
|
".css": "text/css; charset=utf-8",
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".png": "image/png",
|
|
".svg": "image/svg+xml",
|
|
".webp": "image/webp",
|
|
".woff2": "font/woff2",
|
|
};
|
|
|
|
const serveStatic = async (request) => {
|
|
const url = new URL(request.url);
|
|
const requestedPath = normalize(url.pathname === "/" ? "/index.html" : url.pathname);
|
|
const candidate = resolve(webDirectory, `.${requestedPath}`);
|
|
const relativeCandidate = relative(webDirectory, candidate);
|
|
|
|
if (relativeCandidate.startsWith("..") || relativeCandidate.includes(".." + "/")) {
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
const file = Bun.file(candidate);
|
|
if (await file.exists()) {
|
|
return new Response(file, {
|
|
headers: { "Content-Type": contentTypes[extname(candidate)] ?? "application/octet-stream" },
|
|
});
|
|
}
|
|
|
|
const index = Bun.file(join(webDirectory, "index.html"));
|
|
return (await index.exists())
|
|
? new Response(index, { headers: { "Content-Type": "text/html; charset=utf-8" } })
|
|
: new Response("Web build not found. Run bun run build:web first.", { status: 503 });
|
|
};
|
|
|
|
const server = Bun.serve({
|
|
port,
|
|
// Model providers may take longer than Bun's 10-second default to emit the
|
|
// first streaming token. Keep the connection alive within Bun's supported range.
|
|
idleTimeout,
|
|
async fetch(request) {
|
|
const pathname = new URL(request.url).pathname;
|
|
if (pathname.startsWith("/api/") || pathname === "/mcp" || pathname.startsWith("/mcp/")) {
|
|
return worker.fetch(request, env, executionContext);
|
|
}
|
|
return serveStatic(request);
|
|
},
|
|
});
|
|
|
|
console.log(`[self-hosted] listening on ${server.url}`);
|
|
console.log(`[self-hosted] data directory: ${dataDirectory}`);
|
|
console.log(`[self-hosted] storage backend: ${storageBackend}`);
|
|
console.log(`[self-hosted] idle timeout: ${idleTimeout}s`);
|