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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ apps/tasks/tasks.cjs
apps/tasks/tasks.css
apps/websocket/wssServer.cjs
apps/websocket/wssServer.css
apps/nextjs/customServer.cjs
apps/nextjs/.million/
packages/cli/cli.cjs

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ 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
COPY --from=builder /app/apps/nextjs/customServer.cjs ./apps/nextjs/customServer.cjs
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 Down
11 changes: 8 additions & 3 deletions apps/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build",
"build": "pnpm with-env next build && pnpm build:custom-server",
"build:custom-server": "esbuild server.ts --bundle --platform=node --outfile=customServer.cjs --external:next --external:bcrypt --external:@opentelemetry/api --external:deasync --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"dev": "pnpm with-env tsx ./server.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
"start": "pnpm with-env node customServer.cjs",
"typecheck": "tsc --noEmit",
"with-env": "dotenv -e ../../.env --"
},
Expand Down Expand Up @@ -87,6 +88,7 @@
"superjson": "2.2.6",
"swagger-ui-react": "^5.31.2",
"use-deep-compare-effect": "^1.8.1",
"ws": "^8.19.0",
"zod": "^4.2.1"
},
"devDependencies": {
Expand All @@ -99,10 +101,13 @@
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@types/ws": "^8.18.1",
"concurrently": "^9.2.1",
"esbuild": "^0.27.3",
"eslint": "^9.39.3",
"node-loader": "^2.1.0",
"prettier": "^3.8.1",
"tsx": "4.20.4",
"typescript": "^5.9.3"
}
}
99 changes: 99 additions & 0 deletions apps/nextjs/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Must be imported first to set up globalThis.AsyncLocalStorage for Next.js
import "next/dist/server/node-environment-baseline";

import { createServer } from "http";
import path from "path";
import next from "next";
import { parse } from "url";
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";

import { appRouter, createTRPCContext } from "@homarr/api/websocket";
import { getSessionFromToken, sessionTokenCookieName } from "@homarr/auth";
import { parseCookies } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";

const logger = createLogger({ module: "customServer" });

const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOSTNAME || "localhost";
const port = parseInt(process.env.PORT || "3000", 10);
// Resolve the directory of this file so Next.js can find the app/pages dirs
const dir = path.resolve(__dirname);

const app = next({ dev, hostname, port, dir });
const handle = app.getRequestHandler();

app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url!, true);
handle(req, res, parsedUrl);
});

// Create WebSocket server in noServer mode so we handle upgrade manually
const wss = new WebSocketServer({ noServer: true });

const wssHandler = applyWSSHandler({
wss,
router: appRouter,
// 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,
});
}
},
keepAlive: {
enabled: true,
pingMs: 30000,
pongWaitMs: 5000,
},
});

// Handle WebSocket upgrade requests on /websockets path
server.on("upgrade", (req, socket, head) => {
if (req.url?.startsWith("/websockets")) {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
}
});

wss.on("connection", (websocket, incomingMessage) => {
logger.info(`➕ Connection (${wss.clients.size}) ${incomingMessage.method} ${incomingMessage.url}`);
websocket.once("close", (code, reason) => {
logger.info(`➖ Connection (${wss.clients.size}) ${code} ${reason.toString()}`);
});
});

server.listen(port, () => {
logger.info(`✅ Custom server ready on http://${hostname}:${port}`);
logger.info(`✅ WebSocket Server integrated on ws://${hostname}:${port}/websockets`);
});

process.on("SIGTERM", () => {
logger.info("SIGTERM");
wssHandler.broadcastReconnectNotification();
wss.close();
server.close();
});
});
5 changes: 2 additions & 3 deletions apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,12 @@ const getWebSocketProtocol = () => {
};

const constructWebsocketUrl = () => {
const fallback = `${getWebSocketProtocol()}://localhost:3001/websockets`;
if (typeof window === "undefined") {
return fallback;
return `${getWebSocketProtocol()}://localhost:3000/websockets`;
}

if (env.NODE_ENV === "development") {
return fallback;
return `${getWebSocketProtocol()}://localhost:3000/websockets`;
}

return `${getWebSocketProtocol()}://${window.location.hostname}:${window.location.port}/websockets`;
Expand Down
2 changes: 1 addition & 1 deletion apps/websocket/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:deasync --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"dev": "echo 'WebSocket server is now integrated into the Next.js custom server'",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit",
Expand Down
4 changes: 2 additions & 2 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ http {
listen 7575;
listen [::]:7575;

# Route websockets traffic to port 3001
# Route websockets traffic to port 3000 (integrated custom server)
location /websockets {
proxy_pass http://${HOSTNAME}:3001;
proxy_pass http://${HOSTNAME}:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"release": "semantic-release",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
Expand Down
37 changes: 22 additions & 15 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading