mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
265 lines
8.3 KiB
JavaScript
265 lines
8.3 KiB
JavaScript
import { accessSync, constants, readFileSync } from "node:fs";
|
|
import { request as httpRequest } from "node:http";
|
|
import { createServer, request as httpsRequest } from "node:https";
|
|
import { createRequire } from "node:module";
|
|
|
|
/**
|
|
* Parses a Go-style duration string into milliseconds.
|
|
*
|
|
* This uses the same format as Go's time.ParseDuration (e.g. "30s", "5m",
|
|
* "1m30s") to stay consistent with the ZITADEL backend, which uses
|
|
* time.Duration for its own ZITADEL_DATABASE_POSTGRES_AWAITINITIALCONN
|
|
* setting. Operators configuring both services use the same syntax.
|
|
*
|
|
* Valid units are "ms", "s", "m", and "h".
|
|
* Returns null if the string is empty, zero, or contains invalid syntax.
|
|
*/
|
|
function parseDuration(raw) {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
|
|
let totalMs = 0;
|
|
let remaining = trimmed;
|
|
const unitMap = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
|
|
|
|
while (remaining.length > 0) {
|
|
const match = remaining.match(/^(\d+)(ms|s|m|h)/);
|
|
if (!match) return null;
|
|
totalMs += parseInt(match[1], 10) * unitMap[match[2]];
|
|
remaining = remaining.slice(match[0].length);
|
|
}
|
|
|
|
return totalMs > 0 ? totalMs : null;
|
|
}
|
|
|
|
/**
|
|
* Polls an HTTP(S) URL until it returns HTTP 200 or the deadline is reached.
|
|
*/
|
|
async function pollReady(url, timeoutMs) {
|
|
const parsedUrl = new URL(url);
|
|
const doRequest = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest;
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (Date.now() < deadline) {
|
|
const ok = await new Promise((resolve) => {
|
|
const req = doRequest(parsedUrl, { method: "GET", timeout: 5_000, rejectUnauthorized: false }, (res) => {
|
|
res.resume();
|
|
resolve(res.statusCode === 200);
|
|
});
|
|
req.on("error", () => resolve(false));
|
|
req.on("timeout", () => {
|
|
req.destroy();
|
|
resolve(false);
|
|
});
|
|
req.end();
|
|
});
|
|
|
|
if (ok) return true;
|
|
|
|
await new Promise((r) => setTimeout(r, 1_000));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Waits for the ZITADEL API to become ready before starting the server.
|
|
*
|
|
* When ZITADEL_API_AWAITINITIALCONN is set (e.g. "60s", "5m", "120s"), this
|
|
* function blocks until GET {ZITADEL_API_URL}/debug/ready returns HTTP 200
|
|
* or the timeout expires. This replaces the wait4x init container that was
|
|
* previously used in the Helm chart.
|
|
*
|
|
* If the timeout expires without a successful response, the process exits
|
|
* with code 1.
|
|
*/
|
|
async function awaitApiReady() {
|
|
const raw = process.env.ZITADEL_API_AWAITINITIALCONN;
|
|
if (!raw) return;
|
|
|
|
const apiUrl = process.env.ZITADEL_API_URL;
|
|
if (!apiUrl) {
|
|
console.error("ZITADEL_API_AWAITINITIALCONN is set but ZITADEL_API_URL is not configured.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const timeoutMs = parseDuration(raw);
|
|
if (timeoutMs === null) {
|
|
console.error(
|
|
`Invalid ZITADEL_API_AWAITINITIALCONN value "${raw}". Expected a Go-style duration like "30s", "5m", or "1m30s".`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const readyUrl = new URL("/debug/ready", apiUrl).toString();
|
|
console.log(`Waiting up to ${raw} for API to be ready at ${readyUrl} ...`);
|
|
|
|
const ok = await pollReady(readyUrl, timeoutMs);
|
|
if (ok) {
|
|
console.log("API is ready.");
|
|
} else {
|
|
console.error(`Timed out after ${raw} waiting for API readiness at ${readyUrl}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
await awaitApiReady();
|
|
|
|
if (process.env.ZITADEL_TLS_ENABLED !== "true") {
|
|
// The build script must rename the Next.js-generated server.js to next-server.js.
|
|
// Validate that the file exists before importing to provide a clearer error if the build step fails.
|
|
try {
|
|
accessSync(new URL("./next-server.js", import.meta.url), constants.R_OK);
|
|
} catch (err) {
|
|
console.error(
|
|
'Unable to find or read "./next-server.js". Ensure the build script has renamed Next.js\'s server.js to next-server.js.',
|
|
err,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
await import("./next-server.js");
|
|
} else {
|
|
const certPath = process.env.ZITADEL_TLS_CERTPATH;
|
|
const keyPath = process.env.ZITADEL_TLS_KEYPATH;
|
|
|
|
if (!certPath || !keyPath) {
|
|
console.error(
|
|
"ZITADEL_TLS_ENABLED is set to true but ZITADEL_TLS_CERTPATH or ZITADEL_TLS_KEYPATH are missing. Both must be set when TLS is enabled.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
accessSync(certPath, constants.R_OK);
|
|
} catch (err) {
|
|
console.error(`Certificate file is not readable: ${certPath}`, err);
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
accessSync(keyPath, constants.R_OK);
|
|
} catch (err) {
|
|
console.error(`Key file is not readable: ${keyPath}`, err);
|
|
process.exit(1);
|
|
}
|
|
|
|
const cert = readFileSync(certPath);
|
|
const key = readFileSync(keyPath);
|
|
|
|
// Requires Node.js >= 20.11.0 (or >= 21.2.0) for import.meta.dirname support.
|
|
const appDir = import.meta.dirname;
|
|
|
|
const requiredServerFilesPath = new URL(".next/required-server-files.json", import.meta.url);
|
|
|
|
let requiredServerFiles;
|
|
try {
|
|
accessSync(requiredServerFilesPath, constants.R_OK);
|
|
const requiredServerFilesContent = readFileSync(requiredServerFilesPath, "utf8");
|
|
requiredServerFiles = JSON.parse(requiredServerFilesContent);
|
|
} catch (err) {
|
|
console.error(
|
|
"Failed to load .next/required-server-files.json. The Next.js build may be incomplete or the file may contain invalid JSON.",
|
|
err,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(requiredServerFiles.config);
|
|
|
|
process.env.NODE_ENV = "production";
|
|
process.chdir(appDir);
|
|
|
|
const currentPort = parseInt(process.env.PORT, 10) || 3000;
|
|
const hostname = process.env.HOSTNAME || "0.0.0.0";
|
|
|
|
// KEEP_ALIVE_TIMEOUT is expected to be in milliseconds.
|
|
// Reject values that are negative or unreasonably large to avoid resource issues.
|
|
const MAX_KEEP_ALIVE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
|
|
let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10);
|
|
if (
|
|
Number.isNaN(keepAliveTimeout) ||
|
|
!Number.isFinite(keepAliveTimeout) ||
|
|
keepAliveTimeout < 0 ||
|
|
keepAliveTimeout > MAX_KEEP_ALIVE_TIMEOUT_MS
|
|
) {
|
|
keepAliveTimeout = undefined;
|
|
}
|
|
|
|
const require = createRequire(import.meta.url);
|
|
require("next");
|
|
// NOTE: This imports an internal Next.js API that is not part of the public, semver-stable surface.
|
|
// - Verified with Next.js version 15.5.10 (see package.json).
|
|
// - This may break on Next.js upgrades; if Next.js is updated, re-check that
|
|
// `next/dist/server/lib/start-server` still exists and that `getRequestHandlers` behaves as expected.
|
|
// - This is currently required to support HTTPS with self-signed certificates in production mode,
|
|
// as there is no equivalent public API in Next.js at this time.
|
|
const { getRequestHandlers } = require("next/dist/server/lib/start-server");
|
|
|
|
const httpsServer = createServer({ key, cert });
|
|
|
|
if (keepAliveTimeout !== undefined) {
|
|
httpsServer.keepAliveTimeout = keepAliveTimeout;
|
|
}
|
|
|
|
const { requestHandler, upgradeHandler } = await getRequestHandlers({
|
|
dir: appDir,
|
|
port: currentPort,
|
|
isDev: false,
|
|
server: httpsServer,
|
|
hostname,
|
|
keepAliveTimeout,
|
|
});
|
|
|
|
httpsServer.on("request", async (req, res) => {
|
|
try {
|
|
await requestHandler(req, res);
|
|
} catch (err) {
|
|
res.statusCode = 500;
|
|
res.end("Internal Server Error");
|
|
console.error(`Failed to handle request for ${req.url}`);
|
|
console.error(err);
|
|
}
|
|
});
|
|
|
|
httpsServer.on("upgrade", async (req, socket, head) => {
|
|
try {
|
|
await upgradeHandler(req, socket, head);
|
|
} catch (err) {
|
|
socket.destroy();
|
|
console.error(`Failed to handle upgrade for ${req.url}`);
|
|
console.error(err);
|
|
}
|
|
});
|
|
|
|
httpsServer.on("error", (err) => {
|
|
console.error("Failed to start HTTPS server");
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
|
|
let cleanupStarted = false;
|
|
const cleanup = () => {
|
|
if (cleanupStarted) return;
|
|
cleanupStarted = true;
|
|
httpsServer.close((err) => {
|
|
if (err) {
|
|
console.error(err);
|
|
process.exit(1);
|
|
} else {
|
|
process.exit(0);
|
|
}
|
|
});
|
|
};
|
|
|
|
if (!process.env.NEXT_MANUAL_SIG_HANDLE) {
|
|
process.on("SIGINT", cleanup);
|
|
process.on("SIGTERM", cleanup);
|
|
}
|
|
|
|
httpsServer.listen(currentPort, hostname, () => {
|
|
const formattedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
|
|
const readyUrl = new URL(`https://${formattedHost}:${currentPort}`);
|
|
console.log(`> Ready on ${readyUrl}`);
|
|
});
|
|
}
|