Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert


# 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
Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand All @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand All @@ -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'
Expand Down
16 changes: 16 additions & 0 deletions apps/nextjs/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The 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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove

contentDispositionType: "attachment",
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
Copy link
Member Author

Choose a reason for hiding this comment

The 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() {
Expand Down
174 changes: 174 additions & 0 deletions apps/nextjs/server.js
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);
Copy link
Member Author

Choose a reason for hiding this comment

The 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);
});
});
});

9 changes: 3 additions & 6 deletions apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The 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`;
};

Expand Down
9 changes: 7 additions & 2 deletions apps/websocket/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The 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");
Expand Down
22 changes: 12 additions & 10 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,25 @@ http {
listen 7575;
listen [::]:7575;

# Route websockets traffic to port 3001
Copy link
Member Author

Choose a reason for hiding this comment

The 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;
}
}
6 changes: 5 additions & 1 deletion packages/api/src/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
const session = opts.session;
const source = opts.headers.get("x-trpc-source") ?? "unknown";

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

return {
session,
Expand Down
12 changes: 11 additions & 1 deletion packages/auth/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@ import type { Adapter } from "@auth/core/adapters";
import { DrizzleAdapter } from "@auth/drizzle-adapter";

import type { Database } from "@homarr/db";
import { getAuthDatabase } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { accounts, sessions, users } from "@homarr/db/schema";
import type { SupportedAuthProvider } from "@homarr/definitions";

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

// Use actualAuthDb (better-sqlite3) for DrizzleAdapter - it doesn't support libsql
const drizzleAdapter = DrizzleAdapter(actualAuthDb, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });

return {
...drizzleAdapter,
Expand Down
20 changes: 15 additions & 5 deletions packages/auth/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The 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}`);
Copy link
Member Author

Choose a reason for hiding this comment

The 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: "/",
Expand Down
Loading
Loading