Files
edgeever/scripts/self-hosted-server.mjs
T
tianma-if b94903fb52 feat: add instance-level S3-compatible object storage
Let instance owners route new uploads to an external OSS while keeping
existing attachments on their original store. Secrets are encrypted with
EDGE_EVER_STORAGE_ENCRYPTION_KEY before being stored in D1.
2026-08-05 12:27:10 +08:00

137 lines
5.8 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);
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_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,
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}`);