Skip to content

Commit adc40bb

Browse files
manuel-rwAartSchinkelMeierschlumpf
committed
feat: reduce memory usage
Co-authored-by: AartSchinkel <aartschinkel@iservecloud.com> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
1 parent 99f2842 commit adc40bb

File tree

28 files changed

+1333
-58
lines changed

28 files changed

+1333
-58
lines changed

Dockerfile

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ RUN apk add --no-cache libc6-compat curl bash
1010
RUN apk update
1111
COPY . .
1212

13-
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
13+
RUN corepack enable pnpm && pnpm install --recursive --no-frozen-lockfile
1414

1515
# Copy static data as it is not part of the build
1616
COPY static-data ./static-data
1717
ARG SKIP_ENV_VALIDATION='true'
1818
ARG CI='true'
1919
ARG DISABLE_REDIS_LOGS='true'
2020

21+
# Create database directory for build-time database access
22+
RUN mkdir -p /appdata/db && touch /appdata/db/db.sqlite
23+
2124
RUN corepack enable pnpm && pnpm build
2225

2326
FROM base AS runner
@@ -43,8 +46,10 @@ RUN mkdir -p /var/cache/nginx && \
4346
COPY --from=builder /app/apps/nextjs/next.config.ts .
4447
COPY --from=builder /app/apps/nextjs/package.json .
4548

46-
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
47-
COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
49+
# Tasks worker and WebSocket are now merged into Next.js server, so no separate builds needed
50+
# COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs
51+
# COPY --from=builder /app/apps/websocket/wssServer.cjs ./apps/websocket/wssServer.cjs
52+
COPY --from=builder /app/apps/nextjs/server.js ./apps/nextjs/server.js
4853
COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
4954

5055
COPY --from=builder /app/packages/db/migrations ./db/migrations
@@ -62,7 +67,7 @@ COPY nginx.conf /etc/nginx/templates/nginx.conf
6267

6368
ENV DB_URL='/appdata/db/db.sqlite'
6469
ENV DB_DIALECT='sqlite'
65-
ENV DB_DRIVER='better-sqlite3'
70+
ENV DB_DRIVER='libsql'
6671
ENV AUTH_PROVIDERS='credentials'
6772
ENV REDIS_IS_EXTERNAL='false'
6873
ENV NODE_ENV='production'

apps/nextjs/next.config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,31 @@ const nextConfig: NextConfig = {
3232
experimental: {
3333
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
3434
turbopackFileSystemCacheForDev: true,
35+
// Reduce memory usage by limiting concurrent requests
36+
serverActions: {
37+
bodySizeLimit: "1mb",
38+
},
39+
// Reduce memory usage by limiting worker threads
40+
workerThreads: false,
41+
// Optimize memory usage
42+
memoryBasedWorkersCount: true,
3543
},
44+
// Reduce memory usage in production
45+
compress: true,
46+
poweredByHeader: false,
3647
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
48+
// Reduce memory by limiting image optimization
3749
images: {
3850
localPatterns: [
3951
{
4052
pathname: "/**",
4153
search: "",
4254
},
4355
],
56+
minimumCacheTTL: 60,
57+
dangerouslyAllowSVG: true,
58+
contentDispositionType: "attachment",
59+
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
4460
},
4561
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
4662
async headers() {

apps/nextjs/server.js

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { createServer } from "http";
2+
import next from "next";
3+
import { parse } from "url";
4+
import { WebSocketServer } from "ws";
5+
import { applyWSSHandler } from "@trpc/server/adapters/ws";
6+
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
7+
import fastify from "fastify";
8+
9+
import { appRouter, createTRPCContext } from "@homarr/api/websocket";
10+
import type { JobRouter } from "@homarr/cron-job-api";
11+
import { jobRouter } from "@homarr/cron-job-api";
12+
import { CRON_JOB_API_KEY_HEADER, CRON_JOB_API_PATH } from "@homarr/cron-job-api/constants";
13+
import type { FastifyTRPCPluginOptions } from "@trpc/server/adapters/fastify";
14+
import { getSessionFromToken, sessionTokenCookieName } from "@homarr/auth";
15+
import { parseCookies } from "@homarr/common";
16+
import { jobGroup } from "@homarr/cron-jobs";
17+
import { db } from "@homarr/db";
18+
import { logger } from "@homarr/log";
19+
20+
// Import tasks overrides first (must be before other imports)
21+
import "../tasks/src/overrides";
22+
23+
import { JobManager } from "../tasks/src/job-manager";
24+
import { onStartAsync } from "../tasks/src/on-start";
25+
26+
const port = parseInt(process.env.PORT || "3000", 10);
27+
const dev = process.env.NODE_ENV !== "production";
28+
const hostname = process.env.HOSTNAME || "localhost";
29+
30+
// Create Next.js app
31+
const app = next({ dev, hostname, port });
32+
const handle = app.getRequestHandler();
33+
34+
// Initialize tasks worker (cron jobs) - merged into Next.js
35+
void (async () => {
36+
await onStartAsync();
37+
await jobGroup.initializeAsync();
38+
await jobGroup.startAllAsync();
39+
logger.info("✅ Tasks worker initialized and started");
40+
})();
41+
42+
void app.prepare().then(async () => {
43+
// Create Fastify instance for tasks API (merged into Next.js)
44+
// Create it once and reuse for all requests
45+
const tasksFastify = fastify({
46+
maxParamLength: 5000,
47+
});
48+
49+
await tasksFastify.register(fastifyTRPCPlugin, {
50+
prefix: CRON_JOB_API_PATH,
51+
trpcOptions: {
52+
router: jobRouter,
53+
createContext: ({ req }) => ({
54+
manager: new JobManager(db, jobGroup),
55+
apiKey: req.headers[CRON_JOB_API_KEY_HEADER] as string | undefined,
56+
}),
57+
onError({ path, error }) {
58+
logger.error(new Error(`Error in tasks tRPC handler path="${path}"`, { cause: error }));
59+
},
60+
},
61+
} satisfies FastifyTRPCPluginOptions<JobRouter>["trpcOptions"]);
62+
63+
await tasksFastify.ready();
64+
65+
// Create HTTP server
66+
const server = createServer(async (req, res) => {
67+
try {
68+
const parsedUrl = parse(req.url ?? "", true);
69+
70+
// Route tasks API requests to Fastify handler
71+
if (parsedUrl.pathname?.startsWith(CRON_JOB_API_PATH)) {
72+
// Use Fastify's inject method to handle the request
73+
const response = await tasksFastify.inject({
74+
method: req.method ?? "GET",
75+
url: req.url ?? "",
76+
headers: req.headers as Record<string, string>,
77+
payload: req,
78+
});
79+
80+
// Write response
81+
res.statusCode = response.statusCode;
82+
for (const [key, value] of Object.entries(response.headers)) {
83+
res.setHeader(key, value);
84+
}
85+
res.end(response.body);
86+
return;
87+
}
88+
89+
// All other requests go to Next.js
90+
await handle(req, res, parsedUrl);
91+
} catch (err) {
92+
logger.error(err);
93+
res.statusCode = 500;
94+
res.end("Internal Server Error");
95+
}
96+
});
97+
98+
// Create WebSocket server on the same HTTP server
99+
const wss = new WebSocketServer({
100+
server,
101+
path: "/websockets",
102+
});
103+
104+
// Apply tRPC WebSocket handler
105+
const handler = applyWSSHandler({
106+
wss,
107+
router: appRouter,
108+
// ignore error on next line because the createContext must be set with this name
109+
// eslint-disable-next-line no-restricted-syntax
110+
createContext: async ({ req }) => {
111+
try {
112+
const headers = Object.entries(req.headers).map(
113+
([key, value]) => [key, typeof value === "string" ? value : value?.[0]] as [string, string],
114+
);
115+
const nextHeaders = new Headers(headers);
116+
117+
const store = parseCookies(nextHeaders.get("cookie") ?? "");
118+
const sessionToken = store[sessionTokenCookieName];
119+
120+
const session = await getSessionFromToken(db, sessionToken);
121+
122+
return createTRPCContext({
123+
headers: nextHeaders,
124+
session,
125+
});
126+
} catch (error) {
127+
logger.error(error);
128+
return createTRPCContext({
129+
headers: new Headers(),
130+
session: null,
131+
});
132+
}
133+
},
134+
// Enable heartbeat messages to keep connection open (disabled by default)
135+
keepAlive: {
136+
enabled: true,
137+
// server ping message interval in milliseconds
138+
pingMs: 30000,
139+
// connection is terminated if pong message is not received in this many milliseconds
140+
pongWaitMs: 5000,
141+
},
142+
});
143+
144+
wss.on("connection", (websocket, incomingMessage) => {
145+
// Only log in development to reduce memory overhead
146+
if (process.env.NODE_ENV === "development") {
147+
logger.debug(`➕ Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`);
148+
}
149+
websocket.once("close", (code, reason) => {
150+
if (process.env.NODE_ENV === "development") {
151+
logger.debug(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`);
152+
}
153+
});
154+
});
155+
156+
// Start server
157+
server.listen(port, () => {
158+
logger.info(`✅ Next.js server ready on http://${hostname}:${port}`);
159+
logger.info(`✅ WebSocket server ready on ws://${hostname}:${port}/websockets`);
160+
logger.info(`✅ Tasks API ready on http://${hostname}:${port}${CRON_JOB_API_PATH}`);
161+
});
162+
163+
// Handle graceful shutdown
164+
process.on("SIGTERM", () => {
165+
logger.info("SIGTERM received, shutting down gracefully");
166+
handler.broadcastReconnectNotification();
167+
wss.close();
168+
server.close(() => {
169+
logger.info("Server closed");
170+
process.exit(0);
171+
});
172+
});
173+
});
174+

apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,12 @@ const getWebSocketProtocol = () => {
3232
};
3333

3434
const constructWebsocketUrl = () => {
35-
const fallback = `${getWebSocketProtocol()}://localhost:3001/websockets`;
35+
// WebSocket is now merged into Next.js, so use the same port as the current page
3636
if (typeof window === "undefined") {
37-
return fallback;
38-
}
39-
40-
if (env.NODE_ENV === "development") {
41-
return fallback;
37+
return `${getWebSocketProtocol()}://localhost:3000/websockets`;
4238
}
4339

40+
// Always use the same hostname and port as the current page (WebSocket is on same server)
4441
return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
4542
};
4643

apps/websocket/src/main.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,14 @@ const handler = applyWSSHandler({
5252
});
5353

5454
wss.on("connection", (websocket, incomingMessage) => {
55-
logger.info(`➕ Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`);
55+
// Only log in development to reduce memory overhead
56+
if (process.env.NODE_ENV === "development") {
57+
logger.debug(`➕ Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`);
58+
}
5659
websocket.once("close", (code, reason) => {
57-
logger.info(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`);
60+
if (process.env.NODE_ENV === "development") {
61+
logger.debug(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`);
62+
}
5863
});
5964
});
6065
logger.info("✅ WebSocket Server listening on ws://localhost:3001");

nginx.conf

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,25 @@ http {
77
listen 7575;
88
listen [::]:7575;
99

10-
# Route websockets traffic to port 3001
11-
location /websockets {
12-
proxy_pass http://${HOSTNAME}:3001;
13-
proxy_http_version 1.1;
14-
proxy_set_header Upgrade $http_upgrade;
15-
proxy_set_header Connection "Upgrade";
16-
proxy_set_header Host $http_host;
17-
}
18-
19-
# Route all other traffic to port 3000
10+
# Route all traffic (including WebSocket) to port 3000
11+
# WebSocket is now merged into Next.js server
2012
location / {
2113
proxy_pass http://${HOSTNAME}:3000;
14+
proxy_http_version 1.1;
15+
proxy_set_header Upgrade $http_upgrade;
16+
proxy_set_header Connection $connection_upgrade;
2217
proxy_set_header Host $http_host;
2318
proxy_set_header X-Real-IP $remote_addr;
2419
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
2520
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
21+
proxy_cache_bypass $http_upgrade;
2622
client_max_body_size 32M;
2723
}
2824
}
25+
26+
# WebSocket upgrade support
27+
map $http_upgrade $connection_upgrade {
28+
default upgrade;
29+
'' close;
30+
}
2931
}

packages/api/src/trpc.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
3838
const session = opts.session;
3939
const source = opts.headers.get("x-trpc-source") ?? "unknown";
4040

41-
logger.info("Received tRPC request", { source, userId: session?.user.id, userName: session?.user.name });
41+
// Only log in development to reduce memory overhead from log buffers
42+
// In production, this creates significant overhead from log serialization
43+
if (process.env.NODE_ENV === "development") {
44+
logger.info("Received tRPC request", { source, userId: session?.user.id, userName: session?.user.name });
45+
}
4246

4347
return {
4448
session,

packages/auth/adapter.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@ import type { Adapter } from "@auth/core/adapters";
22
import { DrizzleAdapter } from "@auth/drizzle-adapter";
33

44
import type { Database } from "@homarr/db";
5+
import { getAuthDatabase } from "@homarr/db";
56
import { and, eq } from "@homarr/db";
67
import { accounts, sessions, users } from "@homarr/db/schema";
78
import type { SupportedAuthProvider } from "@homarr/definitions";
89

10+
// Use better-sqlite3 for Auth.js DrizzleAdapter compatibility
11+
// @auth/drizzle-adapter doesn't support libsql yet, so we use better-sqlite3 for the adapter
12+
// But we still use the main db (libsql) for custom queries like getUserByEmail
913
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
10-
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
14+
// Get the actual database instance (not the proxy) for DrizzleAdapter
15+
// DrizzleAdapter needs to inspect the database type, so it needs the real instance
16+
// This is called at runtime when adapter is created, not at module load
17+
const actualAuthDb = getAuthDatabase();
18+
19+
// Use actualAuthDb (better-sqlite3) for DrizzleAdapter - it doesn't support libsql
20+
const drizzleAdapter = DrizzleAdapter(actualAuthDb, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
1121

1222
return {
1323
...drizzleAdapter,

packages/auth/configuration.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { filterProviders } from "./providers/filter-providers";
1717
import { OidcProvider } from "./providers/oidc/oidc-provider";
1818
import { createRedirectUri } from "./redirect";
1919
import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from "./session";
20+
import { getAuthDatabase } from "@homarr/db";
21+
import { sessions } from "@homarr/db/schema";
2022

2123
const logger = createLogger({ module: "authConfiguration" });
2224

@@ -66,16 +68,26 @@ export const createConfiguration = (
6668
}
6769

6870
if (!adapter.createSession || !user.id) {
71+
logger.warn("Cannot create session: adapter.createSession missing or user.id missing");
6972
return false;
7073
}
7174

7275
const expires = expireDateAfter(env.AUTH_SESSION_EXPIRY_TIME);
7376
const sessionToken = generateSessionToken();
74-
await adapter.createSession({
75-
sessionToken,
76-
expires,
77-
userId: user.id,
78-
});
77+
78+
try {
79+
// DrizzleAdapter expects expires as a number (timestamp_ms), not a Date object
80+
// The schema defines expires as int({ mode: "timestamp_ms" })
81+
await adapter.createSession({
82+
sessionToken,
83+
expires: expires.getTime(), // Convert Date to milliseconds timestamp
84+
userId: user.id,
85+
});
86+
logger.info(`Session created successfully for user ${user.id}`);
87+
} catch (error) {
88+
logger.error(new Error(`Failed to create session for user ${user.id}`, { cause: error }));
89+
return false;
90+
}
7991

8092
(await cookies()).set(sessionTokenCookieName, sessionToken, {
8193
path: "/",

0 commit comments

Comments
 (0)