From d29603275a941098831ee5a6433a4dab3f9eb7a8 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 26 Jun 2024 14:44:08 -1000 Subject: [PATCH] refactor(api): :recycle: Use Web Workers instead of spawning the same process once for each thread --- app.ts | 97 ++++++++++++++++++++++ cli/commands/start.ts | 15 ++-- index.ts | 154 ++--------------------------------- setup.ts | 54 ++++++++++++ tests/utils.ts | 6 +- utils/loggers.ts | 51 ++++++++++-- server.ts => utils/server.ts | 0 7 files changed, 213 insertions(+), 164 deletions(-) create mode 100644 app.ts create mode 100644 setup.ts rename server.ts => utils/server.ts (100%) diff --git a/app.ts b/app.ts new file mode 100644 index 00000000..3fd5caf7 --- /dev/null +++ b/app.ts @@ -0,0 +1,97 @@ +import { errorResponse, response } from "@/response"; +import { getLogger } from "@logtape/logtape"; +import { config } from "config-manager"; +import { Hono } from "hono"; +import { agentBans } from "./middlewares/agent-bans"; +import { bait } from "./middlewares/bait"; +import { boundaryCheck } from "./middlewares/boundary-check"; +import { ipBans } from "./middlewares/ip-bans"; +import { logger } from "./middlewares/logger"; +import { handleGlitchRequest } from "./packages/glitch-server/main"; +import { routes } from "./routes"; +import type { ApiRouteExports } from "./types/api"; + +export const appFactory = async () => { + const serverLogger = getLogger("server"); + + const app = new Hono({ + strict: false, + }); + + app.use(ipBans); + app.use(agentBans); + app.use(bait); + app.use(logger); + app.use(boundaryCheck); + // Disabled as federation now checks for this + // app.use(urlCheck); + + // Inject own filesystem router + for (const [, path] of Object.entries(routes)) { + // use app.get(path, handler) to add routes + const route: ApiRouteExports = await import(path); + + if (!(route.meta && route.default)) { + throw new Error(`Route ${path} does not have the correct exports.`); + } + + route.default(app); + } + + app.options("*", () => { + return response(null); + }); + + app.all("*", async (context) => { + if (config.frontend.glitch.enabled) { + const glitch = await handleGlitchRequest(context.req.raw); + + if (glitch) { + return glitch; + } + } + + const replacedUrl = new URL( + new URL(context.req.url).pathname, + config.frontend.url, + ).toString(); + + serverLogger.debug`Proxying ${replacedUrl}`; + + const proxy = await fetch(replacedUrl, { + headers: { + // Include for SSR + "X-Forwarded-Host": `${config.http.bind}:${config.http.bind_port}`, + "Accept-Encoding": "identity", + }, + redirect: "manual", + }).catch((e) => { + serverLogger.error`${e}`; + serverLogger.error`The Frontend is not running or the route is not found: ${replacedUrl}`; + return null; + }); + + proxy?.headers.set("Cache-Control", "max-age=31536000"); + + if (!proxy || proxy.status === 404) { + return errorResponse( + "Route not found on proxy or API route. Are you using the correct HTTP method?", + 404, + ); + } + + // Disable CSP upgrade-insecure-requests if an .onion domain is used + if (new URL(context.req.url).hostname.endsWith(".onion")) { + proxy.headers.set( + "Content-Security-Policy", + proxy.headers + .get("Content-Security-Policy") + ?.replace("upgrade-insecure-requests;", "") ?? "", + ); + } + + return proxy; + }); + + return app; +}; diff --git a/cli/commands/start.ts b/cli/commands/start.ts index 250f80f3..1149ea15 100644 --- a/cli/commands/start.ts +++ b/cli/commands/start.ts @@ -33,21 +33,18 @@ export default class Start extends BaseCommand { public async run(): Promise { const { flags } = await this.parse(Start); - const numCpUs = flags["all-threads"] ? os.cpus().length : flags.threads; + const numCpus = flags["all-threads"] ? os.cpus().length : flags.threads; // Check if index is a JS or TS file (depending on the environment) const index = (await Bun.file("index.ts").exists()) ? "index.ts" : "index.js"; - for (let i = 0; i < numCpUs; i++) { - const args = ["bun", index]; - if (i !== 0 || flags.silent) { - args.push("--silent"); - } - Bun.spawn(args, { - stdio: ["inherit", "inherit", "inherit"], - env: { ...process.env }, + await import("../../setup"); + + for (let i = 0; i < numCpus; i++) { + new Worker(index, { + type: "module", }); } } diff --git a/index.ts b/index.ts index b7556b1c..1a686d86 100644 --- a/index.ts +++ b/index.ts @@ -1,152 +1,12 @@ -import { checkConfig } from "@/init"; -import { configureLoggers } from "@/loggers"; -import { connectMeili } from "@/meilisearch"; -import { errorResponse, response } from "@/response"; -import { getLogger } from "@logtape/logtape"; +import { createServer } from "@/server"; import { config } from "config-manager"; -import { Hono } from "hono"; -import { setupDatabase } from "~/drizzle/db"; -import { agentBans } from "~/middlewares/agent-bans"; -import { bait } from "~/middlewares/bait"; -import { boundaryCheck } from "~/middlewares/boundary-check"; -import { ipBans } from "~/middlewares/ip-bans"; -import { logger } from "~/middlewares/logger"; -import { Note } from "~/packages/database-interface/note"; -import { handleGlitchRequest } from "~/packages/glitch-server/main"; -import { routes } from "~/routes"; -import { createServer } from "~/server"; -import type { ApiRouteExports } from "~/types/api"; +import { appFactory } from "~/app"; +import { setupDatabase } from "./drizzle/db"; -const timeAtStart = performance.now(); - -const isEntry = - import.meta.path === Bun.main && !process.argv.includes("--silent"); -await configureLoggers(isEntry); - -const serverLogger = getLogger("server"); - -serverLogger.info`Starting Lysand...`; +if (import.meta.main) { + await import("./setup"); +} await setupDatabase(); -if (config.meilisearch.enabled) { - await connectMeili(); -} - -process.on("SIGINT", () => { - process.exit(); -}); - -// Check if database is reachable -const postCount = await Note.getCount(); - -if (isEntry) { - await checkConfig(config); -} - -const app = new Hono({ - strict: false, -}); - -app.use(ipBans); -app.use(agentBans); -app.use(bait); -app.use(logger); -app.use(boundaryCheck); -// Disabled as federation now checks for this -// app.use(urlCheck); - -// Inject own filesystem router -for (const [, path] of Object.entries(routes)) { - // use app.get(path, handler) to add routes - const route: ApiRouteExports = await import(path); - - if (!(route.meta && route.default)) { - throw new Error(`Route ${path} does not have the correct exports.`); - } - - route.default(app); -} - -app.options("*", () => { - return response(null); -}); - -app.all("*", async (context) => { - if (config.frontend.glitch.enabled) { - const glitch = await handleGlitchRequest(context.req.raw); - - if (glitch) { - return glitch; - } - } - - const replacedUrl = new URL( - new URL(context.req.url).pathname, - config.frontend.url, - ).toString(); - - serverLogger.debug`Proxying ${replacedUrl}`; - - const proxy = await fetch(replacedUrl, { - headers: { - // Include for SSR - "X-Forwarded-Host": `${config.http.bind}:${config.http.bind_port}`, - "Accept-Encoding": "identity", - }, - redirect: "manual", - }).catch((e) => { - serverLogger.error`${e}`; - serverLogger.error`The Frontend is not running or the route is not found: ${replacedUrl}`; - return null; - }); - - proxy?.headers.set("Cache-Control", "max-age=31536000"); - - if (!proxy || proxy.status === 404) { - return errorResponse( - "Route not found on proxy or API route. Are you using the correct HTTP method?", - 404, - ); - } - - // Disable CSP upgrade-insecure-requests if an .onion domain is used - if (new URL(context.req.url).hostname.endsWith(".onion")) { - proxy.headers.set( - "Content-Security-Policy", - proxy.headers - .get("Content-Security-Policy") - ?.replace("upgrade-insecure-requests;", "") ?? "", - ); - } - - return proxy; -}); - -createServer(config, app); - -serverLogger.info`Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`; - -serverLogger.info`Database is online, now serving ${postCount} posts`; - -if (config.frontend.enabled) { - if (!URL.canParse(config.frontend.url)) { - serverLogger.error`Frontend URL is not a valid URL: ${config.frontend.url}`; - // Hang until Ctrl+C is pressed - await Bun.sleep(Number.POSITIVE_INFINITY); - } - - // Check if frontend is reachable - const response = await fetch(new URL("/", config.frontend.url)) - .then((res) => res.ok) - .catch(() => false); - - if (!response) { - serverLogger.error`Frontend is unreachable at ${config.frontend.url}`; - serverLogger.error`Please ensure the frontend is online and reachable`; - } -} else { - serverLogger.warn`Frontend is disabled, skipping check`; -} - -export { app }; +createServer(config, await appFactory()); diff --git a/setup.ts b/setup.ts new file mode 100644 index 00000000..f468bec3 --- /dev/null +++ b/setup.ts @@ -0,0 +1,54 @@ +import { checkConfig } from "@/init"; +import { configureLoggers } from "@/loggers"; +import { connectMeili } from "@/meilisearch"; +import { getLogger } from "@logtape/logtape"; +import { config } from "config-manager"; +import { setupDatabase } from "~/drizzle/db"; +import { Note } from "~/packages/database-interface/note"; + +const timeAtStart = performance.now(); + +await configureLoggers(); + +const serverLogger = getLogger("server"); + +serverLogger.info`Starting Lysand...`; + +await setupDatabase(); + +if (config.meilisearch.enabled) { + await connectMeili(); +} + +process.on("SIGINT", () => { + process.exit(); +}); + +// Check if database is reachable +const postCount = await Note.getCount(); + +await checkConfig(config); + +serverLogger.info`Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`; + +serverLogger.info`Database is online, now serving ${postCount} posts`; + +if (config.frontend.enabled) { + if (!URL.canParse(config.frontend.url)) { + serverLogger.error`Frontend URL is not a valid URL: ${config.frontend.url}`; + // Hang until Ctrl+C is pressed + await Bun.sleep(Number.POSITIVE_INFINITY); + } + + // Check if frontend is reachable + const response = await fetch(new URL("/", config.frontend.url)) + .then((res) => res.ok) + .catch(() => false); + + if (!response) { + serverLogger.error`Frontend is unreachable at ${config.frontend.url}`; + serverLogger.error`Please ensure the frontend is online and reachable`; + } +} else { + serverLogger.warn`Frontend is disabled, skipping check`; +} diff --git a/tests/utils.ts b/tests/utils.ts index 60630a0e..265133c8 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -2,11 +2,11 @@ import { generateChallenge } from "@/challenges"; import { randomString } from "@/math"; import { solveChallenge } from "altcha-lib"; import { asc, inArray, like } from "drizzle-orm"; +import { appFactory } from "~/app"; import type { Status } from "~/database/entities/status"; import { db } from "~/drizzle/db"; import { setupDatabase } from "~/drizzle/db"; import { Notes, Tokens, Users } from "~/drizzle/schema"; -import { app } from "~/index"; import { Note } from "~/packages/database-interface/note"; import { User } from "~/packages/database-interface/user"; @@ -17,9 +17,9 @@ await setupDatabase(); * @param req Request to send * @returns Response from the server */ -export function sendTestRequest(req: Request): Promise { +export async function sendTestRequest(req: Request): Promise { // return fetch(req); - return Promise.resolve(app.fetch(req)); + return Promise.resolve((await appFactory()).fetch(req)); } export function wrapRelativeUrl(url: string, baseUrl: string) { diff --git a/utils/loggers.ts b/utils/loggers.ts index 4b74d82a..ed6ee743 100644 --- a/utils/loggers.ts +++ b/utils/loggers.ts @@ -10,6 +10,8 @@ import { import { type LogLevel, type LogRecord, + type RotatingFileSinkOptions, + type Sink, configure, getConsoleSink, getLevelFilter, @@ -21,9 +23,48 @@ import { config } from "~/packages/config-manager"; // HACK: This is a workaround for the lack of type exports in the Logtape package. type RotatingFileSinkDriver = import("../node_modules/@logtape/logtape/logtape/sink").RotatingFileSinkDriver; -const getBaseRotatingFileSink = ( - await import("../node_modules/@logtape/logtape/logtape/sink") -).getRotatingFileSink; + +// HACK: Stolen +export function getBaseRotatingFileSink( + path: string, + options: RotatingFileSinkOptions & RotatingFileSinkDriver, +): Sink & Disposable { + const formatter = options.formatter ?? defaultTextFormatter; + const encoder = options.encoder ?? new TextEncoder(); + const maxSize = options.maxSize ?? 1024 * 1024; + const maxFiles = options.maxFiles ?? 5; + let { size: offset } = options.statSync(path); + let fd = options.openSync(path); + function shouldRollover(bytes: Uint8Array): boolean { + return offset + bytes.length > maxSize; + } + function performRollover(): void { + options.closeSync(fd); + for (let i = maxFiles - 1; i > 0; i--) { + const oldPath = `${path}.${i}`; + const newPath = `${path}.${i + 1}`; + try { + options.renameSync(oldPath, newPath); + } catch (_) { + // Continue if the file does not exist. + } + } + options.renameSync(path, `${path}.1`); + offset = 0; + fd = options.openSync(path); + } + const sink: Sink & Disposable = (record: LogRecord) => { + const bytes = encoder.encode(formatter(record)); + if (shouldRollover(bytes)) { + performRollover(); + } + options.writeSync(fd, bytes); + options.flushSync(fd); + offset += bytes.length; + }; + sink[Symbol.dispose] = () => options.closeSync(fd); + return sink; +} const levelAbbreviations: Record = { debug: "DBG", @@ -149,8 +190,8 @@ export const configureLoggers = (silent = false) => }, filters: { configFilter: silent - ? getLevelFilter(config.logging.log_level) - : getLevelFilter(null), + ? getLevelFilter(null) + : getLevelFilter(config.logging.log_level), }, loggers: [ { diff --git a/server.ts b/utils/server.ts similarity index 100% rename from server.ts rename to utils/server.ts