mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
feat: docker-compose for production frontend (#664)
* docker-compose for production frontend * fix: Remove external Redis port mapping for Coolify compatibility Redis should only be accessible within the internal Docker network in Coolify deployments to avoid port conflicts with other applications. * fix: Remove external port mapping for web service in Coolify Coolify handles port exposure through its proxy (Traefik), so services should not expose ports directly in the docker-compose file. * server side client envs * missing vars * nextjs experimental * fix claude 'fix' * remove build env vars compose * docker * remove ports for coolify * review * cleanup --------- Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
This commit is contained in:
@@ -3,21 +3,19 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "../reflector-api";
|
||||
import createFetchClient from "openapi-react-query";
|
||||
import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils";
|
||||
import { parseNonEmptyString } from "./utils";
|
||||
import { isBuildPhase } from "./next";
|
||||
import { getSession } from "next-auth/react";
|
||||
import { assertExtendedToken } from "./types";
|
||||
import { getClientEnv } from "./clientEnv";
|
||||
|
||||
export const API_URL = !isBuildPhase
|
||||
? assertExistsAndNonEmptyString(
|
||||
process.env.NEXT_PUBLIC_API_URL,
|
||||
"NEXT_PUBLIC_API_URL required",
|
||||
)
|
||||
? getClientEnv().API_URL
|
||||
: "http://localhost";
|
||||
|
||||
// TODO decide strict validation or not
|
||||
export const WEBSOCKET_URL =
|
||||
process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
|
||||
export const WEBSOCKET_URL = !isBuildPhase
|
||||
? getClientEnv().WEBSOCKET_URL || "ws://127.0.0.1:1250"
|
||||
: "ws://localhost";
|
||||
|
||||
export const client = createClient<paths>({
|
||||
baseUrl: API_URL,
|
||||
@@ -44,7 +42,7 @@ client.use({
|
||||
if (token !== null) {
|
||||
request.headers.set(
|
||||
"Authorization",
|
||||
`Bearer ${parseNonEmptyString(token)}`,
|
||||
`Bearer ${parseNonEmptyString(token, true, "panic! token is required")}`,
|
||||
);
|
||||
}
|
||||
// XXX Only set Content-Type if not already set (FormData will set its own boundary)
|
||||
|
||||
@@ -18,26 +18,25 @@ import {
|
||||
deleteTokenCache,
|
||||
} from "./redisTokenCache";
|
||||
import { tokenCacheRedis, redlock } from "./redisClient";
|
||||
import { isBuildPhase } from "./next";
|
||||
import { sequenceThrows } from "./errorUtils";
|
||||
import { featureEnabled } from "./features";
|
||||
import { getNextEnvVar } from "./nextBuild";
|
||||
|
||||
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
|
||||
const getAuthentikClientId = () =>
|
||||
assertExistsAndNonEmptyString(
|
||||
process.env.AUTHENTIK_CLIENT_ID,
|
||||
"AUTHENTIK_CLIENT_ID required",
|
||||
);
|
||||
const getAuthentikClientSecret = () =>
|
||||
assertExistsAndNonEmptyString(
|
||||
process.env.AUTHENTIK_CLIENT_SECRET,
|
||||
"AUTHENTIK_CLIENT_SECRET required",
|
||||
);
|
||||
const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID");
|
||||
const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET");
|
||||
const getAuthentikRefreshTokenUrl = () =>
|
||||
assertExistsAndNonEmptyString(
|
||||
process.env.AUTHENTIK_REFRESH_TOKEN_URL,
|
||||
"AUTHENTIK_REFRESH_TOKEN_URL required",
|
||||
);
|
||||
getNextEnvVar("AUTHENTIK_REFRESH_TOKEN_URL");
|
||||
|
||||
const getAuthentikIssuer = () => {
|
||||
const stringUrl = getNextEnvVar("AUTHENTIK_ISSUER");
|
||||
try {
|
||||
new URL(stringUrl);
|
||||
} catch (e) {
|
||||
throw new Error("AUTHENTIK_ISSUER is not a valid URL: " + stringUrl);
|
||||
}
|
||||
return stringUrl;
|
||||
};
|
||||
|
||||
export const authOptions = (): AuthOptions =>
|
||||
featureEnabled("requireLogin")
|
||||
@@ -45,16 +44,17 @@ export const authOptions = (): AuthOptions =>
|
||||
providers: [
|
||||
AuthentikProvider({
|
||||
...(() => {
|
||||
const [clientId, clientSecret] = sequenceThrows(
|
||||
const [clientId, clientSecret, issuer] = sequenceThrows(
|
||||
getAuthentikClientId,
|
||||
getAuthentikClientSecret,
|
||||
getAuthentikIssuer,
|
||||
);
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
issuer,
|
||||
};
|
||||
})(),
|
||||
issuer: process.env.AUTHENTIK_ISSUER,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid email profile offline_access",
|
||||
|
||||
88
www/app/lib/clientEnv.ts
Normal file
88
www/app/lib/clientEnv.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
assertExists,
|
||||
assertExistsAndNonEmptyString,
|
||||
NonEmptyString,
|
||||
parseNonEmptyString,
|
||||
} from "./utils";
|
||||
import { isBuildPhase } from "./next";
|
||||
import { getNextEnvVar } from "./nextBuild";
|
||||
|
||||
export const FEATURE_REQUIRE_LOGIN_ENV_NAME = "FEATURE_REQUIRE_LOGIN" as const;
|
||||
export const FEATURE_PRIVACY_ENV_NAME = "FEATURE_PRIVACY" as const;
|
||||
export const FEATURE_BROWSE_ENV_NAME = "FEATURE_BROWSE" as const;
|
||||
export const FEATURE_SEND_TO_ZULIP_ENV_NAME = "FEATURE_SEND_TO_ZULIP" as const;
|
||||
export const FEATURE_ROOMS_ENV_NAME = "FEATURE_ROOMS" as const;
|
||||
|
||||
const FEATURE_ENV_NAMES = [
|
||||
FEATURE_REQUIRE_LOGIN_ENV_NAME,
|
||||
FEATURE_PRIVACY_ENV_NAME,
|
||||
FEATURE_BROWSE_ENV_NAME,
|
||||
FEATURE_SEND_TO_ZULIP_ENV_NAME,
|
||||
FEATURE_ROOMS_ENV_NAME,
|
||||
] as const;
|
||||
|
||||
export type EnvFeaturePartial = {
|
||||
[key in (typeof FEATURE_ENV_NAMES)[number]]: boolean;
|
||||
};
|
||||
|
||||
// CONTRACT: isomorphic with JSON.stringify
|
||||
export type ClientEnvCommon = EnvFeaturePartial & {
|
||||
API_URL: NonEmptyString;
|
||||
WEBSOCKET_URL: NonEmptyString | null;
|
||||
};
|
||||
|
||||
let clientEnv: ClientEnvCommon | null = null;
|
||||
export const getClientEnvClient = (): ClientEnvCommon => {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error(
|
||||
"getClientEnv() called during SSR - this should only be called in browser environment",
|
||||
);
|
||||
}
|
||||
if (clientEnv) return clientEnv;
|
||||
clientEnv = assertExists(
|
||||
JSON.parse(
|
||||
assertExistsAndNonEmptyString(
|
||||
document.body.dataset.env,
|
||||
"document.body.dataset.env is missing",
|
||||
),
|
||||
),
|
||||
"document.body.dataset.env is parsed to nullish",
|
||||
);
|
||||
return clientEnv!;
|
||||
};
|
||||
|
||||
const parseBooleanString = (str: string | undefined): boolean => {
|
||||
return str === "true";
|
||||
};
|
||||
|
||||
export const getClientEnvServer = (): ClientEnvCommon => {
|
||||
if (typeof window !== "undefined") {
|
||||
throw new Error(
|
||||
"getClientEnv() not called during SSR - this should only be called in server environment",
|
||||
);
|
||||
}
|
||||
if (clientEnv) return clientEnv;
|
||||
|
||||
const features = FEATURE_ENV_NAMES.reduce((acc, x) => {
|
||||
acc[x] = parseBooleanString(process.env[x]);
|
||||
return acc;
|
||||
}, {} as EnvFeaturePartial);
|
||||
|
||||
if (isBuildPhase) {
|
||||
return {
|
||||
API_URL: getNextEnvVar("API_URL"),
|
||||
WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"),
|
||||
...features,
|
||||
};
|
||||
}
|
||||
|
||||
clientEnv = {
|
||||
API_URL: getNextEnvVar("API_URL"),
|
||||
WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"),
|
||||
...features,
|
||||
};
|
||||
return clientEnv;
|
||||
};
|
||||
|
||||
export const getClientEnv =
|
||||
typeof window === "undefined" ? getClientEnvServer : getClientEnvClient;
|
||||
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
FEATURE_BROWSE_ENV_NAME,
|
||||
FEATURE_PRIVACY_ENV_NAME,
|
||||
FEATURE_REQUIRE_LOGIN_ENV_NAME,
|
||||
FEATURE_ROOMS_ENV_NAME,
|
||||
FEATURE_SEND_TO_ZULIP_ENV_NAME,
|
||||
} from "./clientEnv";
|
||||
|
||||
export const FEATURES = [
|
||||
"requireLogin",
|
||||
"privacy",
|
||||
@@ -26,26 +34,25 @@ function parseBooleanEnv(
|
||||
return value.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
|
||||
const features: Features = {
|
||||
requireLogin: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
|
||||
process.env[FEATURE_REQUIRE_LOGIN_ENV_NAME],
|
||||
DEFAULT_FEATURES.requireLogin,
|
||||
),
|
||||
privacy: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
|
||||
process.env[FEATURE_PRIVACY_ENV_NAME],
|
||||
DEFAULT_FEATURES.privacy,
|
||||
),
|
||||
browse: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_BROWSE,
|
||||
process.env[FEATURE_BROWSE_ENV_NAME],
|
||||
DEFAULT_FEATURES.browse,
|
||||
),
|
||||
sendToZulip: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
|
||||
process.env[FEATURE_SEND_TO_ZULIP_ENV_NAME],
|
||||
DEFAULT_FEATURES.sendToZulip,
|
||||
),
|
||||
rooms: parseBooleanEnv(
|
||||
process.env.NEXT_PUBLIC_FEATURE_ROOMS,
|
||||
process.env[FEATURE_ROOMS_ENV_NAME],
|
||||
DEFAULT_FEATURES.rooms,
|
||||
),
|
||||
};
|
||||
|
||||
17
www/app/lib/nextBuild.ts
Normal file
17
www/app/lib/nextBuild.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { isBuildPhase } from "./next";
|
||||
import { assertExistsAndNonEmptyString, NonEmptyString } from "./utils";
|
||||
|
||||
const _getNextEnvVar = (name: string, e?: string): NonEmptyString =>
|
||||
isBuildPhase
|
||||
? (() => {
|
||||
throw new Error(
|
||||
"panic! getNextEnvVar called during build phase; we don't support build envs",
|
||||
);
|
||||
})()
|
||||
: assertExistsAndNonEmptyString(
|
||||
process.env[name],
|
||||
`${name} is required; ${e}`,
|
||||
);
|
||||
|
||||
export const getNextEnvVar = (name: string, e?: string): NonEmptyString =>
|
||||
_getNextEnvVar(name, e);
|
||||
@@ -1,7 +1,3 @@
|
||||
export function isDevelopment() {
|
||||
return process.env.NEXT_PUBLIC_ENV === "development";
|
||||
}
|
||||
|
||||
// Function to calculate WCAG contrast ratio
|
||||
export const getContrastRatio = (
|
||||
foreground: [number, number, number],
|
||||
@@ -145,8 +141,15 @@ export const parseMaybeNonEmptyString = (
|
||||
s = trim ? s.trim() : s;
|
||||
return s.length > 0 ? (s as NonEmptyString) : null;
|
||||
};
|
||||
export const parseNonEmptyString = (s: string, trim = true): NonEmptyString =>
|
||||
assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string");
|
||||
export const parseNonEmptyString = (
|
||||
s: string,
|
||||
trim = true,
|
||||
e?: string,
|
||||
): NonEmptyString =>
|
||||
assertExists(
|
||||
parseMaybeNonEmptyString(s, trim),
|
||||
"Expected non-empty string" + (e ? `: ${e}` : ""),
|
||||
);
|
||||
|
||||
export const assertExists = <T>(
|
||||
value: T | null | undefined,
|
||||
@@ -173,4 +176,8 @@ export const assertExistsAndNonEmptyString = (
|
||||
value: string | null | undefined,
|
||||
err?: string,
|
||||
): NonEmptyString =>
|
||||
parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));
|
||||
parseNonEmptyString(
|
||||
assertExists(value, err || "Expected non-empty string"),
|
||||
true,
|
||||
err,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user