-
-
Notifications
You must be signed in to change notification settings - Fork 187
feat: reduce memory usage #4740
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,14 +10,17 @@ RUN apk add --no-cache libc6-compat curl bash | |
| RUN apk update | ||
| COPY . . | ||
|
|
||
| RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile | ||
| RUN corepack enable pnpm && pnpm install --recursive --no-frozen-lockfile | ||
|
|
||
| # Copy static data as it is not part of the build | ||
| COPY static-data ./static-data | ||
| ARG SKIP_ENV_VALIDATION='true' | ||
| ARG CI='true' | ||
| ARG DISABLE_REDIS_LOGS='true' | ||
|
|
||
| # Create database directory for build-time database access | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment is wrong, why touch an empty file? Remove? |
||
| RUN mkdir -p /appdata/db && touch /appdata/db/db.sqlite | ||
|
|
||
| RUN corepack enable pnpm && pnpm build | ||
|
|
||
| FROM base AS runner | ||
|
|
@@ -43,8 +46,10 @@ RUN mkdir -p /var/cache/nginx && \ | |
| COPY --from=builder /app/apps/nextjs/next.config.ts . | ||
| COPY --from=builder /app/apps/nextjs/package.json . | ||
|
|
||
| COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs | ||
| COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs | ||
| # Tasks worker and WebSocket are now merged into Next.js server, so no separate builds needed | ||
| # COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove comments |
||
| # COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs | ||
| COPY --from=builder /app/apps/nextjs/server.js ./apps/nextjs/server.js | ||
| COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node | ||
|
|
||
| COPY --from=builder /app/packages/db/migrations ./db/migrations | ||
|
|
@@ -62,7 +67,7 @@ COPY nginx.conf /etc/nginx/templates/nginx.conf | |
|
|
||
| ENV DB_URL='/appdata/db/db.sqlite' | ||
| ENV DB_DIALECT='sqlite' | ||
| ENV DB_DRIVER='better-sqlite3' | ||
| ENV DB_DRIVER='libsql' | ||
| ENV AUTH_PROVIDERS='credentials' | ||
| ENV REDIS_IS_EXTERNAL='false' | ||
| ENV NODE_ENV='production' | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,15 +32,31 @@ const nextConfig: NextConfig = { | |
| experimental: { | ||
| optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], | ||
| turbopackFileSystemCacheForDev: true, | ||
| // Reduce memory usage by limiting concurrent requests | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comments are not very useful, remove |
||
| serverActions: { | ||
| bodySizeLimit: "1mb", | ||
| }, | ||
| // Reduce memory usage by limiting worker threads | ||
| workerThreads: false, | ||
| // Optimize memory usage | ||
| memoryBasedWorkersCount: true, | ||
| }, | ||
| // Reduce memory usage in production | ||
| compress: true, | ||
| poweredByHeader: false, | ||
| transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], | ||
| // Reduce memory by limiting image optimization | ||
| images: { | ||
| localPatterns: [ | ||
| { | ||
| pathname: "/**", | ||
| search: "", | ||
| }, | ||
| ], | ||
| minimumCacheTTL: 60, | ||
| dangerouslyAllowSVG: true, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove |
||
| contentDispositionType: "attachment", | ||
| contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not needed, we already have a CSP |
||
| }, | ||
| // eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax | ||
| async headers() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| import { createServer } from "http"; | ||
| import next from "next"; | ||
| import { parse } from "url"; | ||
| import { WebSocketServer } from "ws"; | ||
| import { applyWSSHandler } from "@trpc/server/adapters/ws"; | ||
| import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify"; | ||
| import fastify from "fastify"; | ||
|
|
||
| import { appRouter, createTRPCContext } from "@homarr/api/websocket"; | ||
| import type { JobRouter } from "@homarr/cron-job-api"; | ||
| import { jobRouter } from "@homarr/cron-job-api"; | ||
| import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH } from "@homarr/cron-job-api/constants"; | ||
| import type { FastifyTRPCPluginOptions } from "@trpc/server/adapters/fastify"; | ||
| import { getSessionFromToken, sessionTokenCookieName } from "@homarr/auth"; | ||
| import { parseCookies } from "@homarr/common"; | ||
| import { jobGroup } from "@homarr/cron-jobs"; | ||
| import { db } from "@homarr/db"; | ||
| import { logger } from "@homarr/log"; | ||
|
|
||
| // Import tasks overrides first (must be before other imports) | ||
| import "../tasks/src/overrides"; | ||
|
|
||
| import { JobManager } from "../tasks/src/job-manager"; | ||
| import { onStartAsync } from "../tasks/src/on-start"; | ||
|
|
||
| const port = parseInt(process.env.PORT || "3000", 10); | ||
| const dev = process.env.NODE_ENV !== "production"; | ||
| const hostname = process.env.HOSTNAME || "localhost"; | ||
|
|
||
| // Create Next.js app | ||
| const app = next({ dev, hostname, port }); | ||
| const handle = app.getRequestHandler(); | ||
|
|
||
| // Initialize tasks worker (cron jobs) - merged into Next.js | ||
| void (async () => { | ||
| await onStartAsync(); | ||
| await jobGroup.initializeAsync(); | ||
| await jobGroup.startAllAsync(); | ||
| logger.info("✅ Tasks worker initialized and started"); | ||
| })(); | ||
|
|
||
| void app.prepare().then(async () => { | ||
| // Create Fastify instance for tasks API (merged into Next.js) | ||
| // Create it once and reuse for all requests | ||
| const tasksFastify = fastify({ | ||
| maxParamLength: 5000, | ||
| }); | ||
|
|
||
| await tasksFastify.register(fastifyTRPCPlugin, { | ||
| prefix: CRON_JOB_API_PATH, | ||
| trpcOptions: { | ||
| router: jobRouter, | ||
| createContext: ({ req }) => ({ | ||
| manager: new JobManager(db, jobGroup), | ||
| apiKey: req.headers[CRON_JOB_API_KEY_HEADER] as string | undefined, | ||
| }), | ||
| onError({ path, error }) { | ||
| logger.error(new Error(`Error in tasks tRPC handler path="${path}"`, { cause: error })); | ||
| }, | ||
| }, | ||
| } satisfies FastifyTRPCPluginOptions<JobRouter>["trpcOptions"]); | ||
|
|
||
| await tasksFastify.ready(); | ||
|
|
||
| // Create HTTP server | ||
| const server = createServer(async (req, res) => { | ||
| try { | ||
| const parsedUrl = parse(req.url ?? "", true); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we really want to fallback to an empty string? |
||
|
|
||
| // Route tasks API requests to Fastify handler | ||
| if (parsedUrl.pathname?.startsWith(CRON_JOB_API_PATH)) { | ||
| // Use Fastify's inject method to handle the request | ||
| const response = await tasksFastify.inject({ | ||
| method: req.method ?? "GET", | ||
| url: req.url ?? "", | ||
| headers: req.headers as Record<string, string>, | ||
| payload: req, | ||
| }); | ||
|
|
||
| // Write response | ||
| res.statusCode = response.statusCode; | ||
| for (const [key, value] of Object.entries(response.headers)) { | ||
| res.setHeader(key, value); | ||
| } | ||
| res.end(response.body); | ||
| return; | ||
| } | ||
|
|
||
| // All other requests go to Next.js | ||
| await handle(req, res, parsedUrl); | ||
| } catch (err) { | ||
| logger.error(err); | ||
| res.statusCode = 500; | ||
| res.end("Internal Server Error"); | ||
| } | ||
| }); | ||
|
|
||
| // Create WebSocket server on the same HTTP server | ||
| const wss = new WebSocketServer({ | ||
| server, | ||
| path: "/websockets", | ||
| }); | ||
|
|
||
| // Apply tRPC WebSocket handler | ||
| const handler = applyWSSHandler({ | ||
| wss, | ||
| router: appRouter, | ||
| // ignore error on next line because the createContext must be set with this name | ||
| // eslint-disable-next-line no-restricted-syntax | ||
| createContext: async ({ req }) => { | ||
| try { | ||
| const headers = Object.entries(req.headers).map( | ||
| ([key, value]) => [key, typeof value === "string" ? value : value?.[0]] as [string, string], | ||
| ); | ||
| const nextHeaders = new Headers(headers); | ||
|
|
||
| const store = parseCookies(nextHeaders.get("cookie") ?? ""); | ||
| const sessionToken = store[sessionTokenCookieName]; | ||
|
|
||
| const session = await getSessionFromToken(db, sessionToken); | ||
|
|
||
| return createTRPCContext({ | ||
| headers: nextHeaders, | ||
| session, | ||
| }); | ||
| } catch (error) { | ||
| logger.error(error); | ||
| return createTRPCContext({ | ||
| headers: new Headers(), | ||
| session: null, | ||
| }); | ||
| } | ||
| }, | ||
| // Enable heartbeat messages to keep connection open (disabled by default) | ||
| keepAlive: { | ||
| enabled: true, | ||
| // server ping message interval in milliseconds | ||
| pingMs: 30000, | ||
| // connection is terminated if pong message is not received in this many milliseconds | ||
| pongWaitMs: 5000, | ||
| }, | ||
| }); | ||
|
|
||
| wss.on("connection", (websocket, incomingMessage) => { | ||
| // Only log in development to reduce memory overhead | ||
| if (process.env.NODE_ENV === "development") { | ||
| logger.debug(`➕ Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`); | ||
| } | ||
| websocket.once("close", (code, reason) => { | ||
| if (process.env.NODE_ENV === "development") { | ||
| logger.debug(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| // Start server | ||
| server.listen(port, () => { | ||
| logger.info(`✅ Next.js server ready on http://${hostname}:${port}`); | ||
| logger.info(`✅ WebSocket server ready on ws://${hostname}:${port}/websockets`); | ||
| logger.info(`✅ Tasks API ready on http://${hostname}:${port}${CRON_JOB_API_PATH}`); | ||
| }); | ||
|
|
||
| // Handle graceful shutdown | ||
| process.on("SIGTERM", () => { | ||
| logger.info("SIGTERM received, shutting down gracefully"); | ||
| handler.broadcastReconnectNotification(); | ||
| wss.close(); | ||
| server.close(() => { | ||
| logger.info("Server closed"); | ||
| process.exit(0); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,15 +32,12 @@ const getWebSocketProtocol = () => { | |
| }; | ||
|
|
||
| const constructWebsocketUrl = () => { | ||
| const fallback = `${getWebSocketProtocol()}://localhost:3001/websockets`; | ||
| // WebSocket is now merged into Next.js, so use the same port as the current page | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove comment |
||
| if (typeof window === "undefined") { | ||
| return fallback; | ||
| } | ||
|
|
||
| if (env.NODE_ENV === "development") { | ||
| return fallback; | ||
| return `${getWebSocketProtocol()}://localhost:3000/websockets`; | ||
| } | ||
|
|
||
| // Always use the same hostname and port as the current page (WebSocket is on same server) | ||
| return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`; | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,9 +52,14 @@ const handler = applyWSSHandler({ | |
| }); | ||
|
|
||
| wss.on("connection", (websocket, incomingMessage) => { | ||
| logger.info(`➕ Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`); | ||
| // Only log in development to reduce memory overhead | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shouldn't make any difference, check this claim |
||
| if (process.env.NODE_ENV === "development") { | ||
| logger.debug(`➕ Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`); | ||
| } | ||
| websocket.once("close", (code, reason) => { | ||
| logger.info(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`); | ||
| if (process.env.NODE_ENV === "development") { | ||
| logger.debug(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`); | ||
| } | ||
| }); | ||
| }); | ||
| logger.info("✅ WebSocket Server listening on ws://localhost:3001"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,23 +7,25 @@ http { | |
| listen 7575; | ||
| listen [::]:7575; | ||
|
|
||
| # Route websockets traffic to port 3001 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If everything was merged, we wouldn't need the nginx anymore. |
||
| location /websockets { | ||
| proxy_pass http://${HOSTNAME}:3001; | ||
| proxy_http_version 1.1; | ||
| proxy_set_header Upgrade $http_upgrade; | ||
| proxy_set_header Connection "Upgrade"; | ||
| proxy_set_header Host $http_host; | ||
| } | ||
|
|
||
| # Route all other traffic to port 3000 | ||
| # Route all traffic (including WebSocket) to port 3000 | ||
| # WebSocket is now merged into Next.js server | ||
| location / { | ||
| proxy_pass http://${HOSTNAME}:3000; | ||
| proxy_http_version 1.1; | ||
| proxy_set_header Upgrade $http_upgrade; | ||
| proxy_set_header Connection $connection_upgrade; | ||
| proxy_set_header Host $http_host; | ||
| proxy_set_header X-Real-IP $remote_addr; | ||
| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | ||
| proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; | ||
| proxy_cache_bypass $http_upgrade; | ||
| client_max_body_size 32M; | ||
| } | ||
| } | ||
|
|
||
| # WebSocket upgrade support | ||
| map $http_upgrade $connection_upgrade { | ||
| default upgrade; | ||
| '' close; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,16 +66,26 @@ export const createConfiguration = ( | |
| } | ||
|
|
||
| if (!adapter.createSession || !user.id) { | ||
| logger.warn("Cannot create session: adapter.createSession missing or user.id missing"); | ||
| return false; | ||
| } | ||
|
|
||
| const expires = expireDateAfter(env.AUTH_SESSION_EXPIRY_TIME); | ||
| const sessionToken = generateSessionToken(); | ||
| await adapter.createSession({ | ||
| sessionToken, | ||
| expires, | ||
| userId: user.id, | ||
| }); | ||
|
|
||
| try { | ||
| // DrizzleAdapter expects expires as a number (timestamp_ms), not a Date object | ||
| // The schema defines expires as int({ mode: "timestamp_ms" }) | ||
| await adapter.createSession({ | ||
| sessionToken, | ||
| expires: expires.getTime(), // Convert Date to milliseconds timestamp | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't compile, vibe coded? |
||
| userId: user.id, | ||
| }); | ||
| logger.info(`Session created successfully for user ${user.id}`); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove or reduce log level |
||
| } catch (error) { | ||
| logger.error(new Error(`Failed to create session for user ${user.id}`, { cause: error })); | ||
| return false; | ||
| } | ||
|
|
||
| (await cookies()).set(sessionTokenCookieName, sessionToken, { | ||
| path: "/", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Revert