Skip to content

Commit dbdd9d5

Browse files
committed
fix: replace Docker env var placeholders with server-side EnvProvider context
Next.js inlines NEXT_PUBLIC_* as truthy string literals at build time, causing the minifier to dead-code-eliminate all fallback branches. Runtime sed replacement with empty strings left functions returning "". Introduces buildClientEnv() in the server layout and a useEnv() React context so client components read real env values at request time, removing the need for placeholder/sed machinery for all vars except NEXT_PUBLIC_API_URL (still needed by packages/core).
1 parent 80411d6 commit dbdd9d5

21 files changed

Lines changed: 167 additions & 144 deletions

apps/web/Dockerfile

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,11 @@ COPY packages/i18n/ packages/i18n/
2222
COPY apps/web/ apps/web/
2323
COPY turbo.json ./
2424

25-
# Build with placeholders — replaced at runtime by docker-entrypoint.sh
25+
# Placeholder for packages/core which reads process.env.NEXT_PUBLIC_API_URL
26+
# directly (inlined at build time). Replaced at runtime by docker-entrypoint.sh.
27+
# All other NEXT_PUBLIC_* vars are provided via the EnvProvider React context
28+
# (built server-side from real process.env at request time) and need no placeholders.
2629
ENV NEXT_PUBLIC_API_URL=__NEXT_PUBLIC_API_URL__
27-
ENV NEXT_PUBLIC_MAPTILER_KEY=__NEXT_PUBLIC_MAPTILER_KEY__
28-
ENV NEXT_PUBLIC_MAPILLARY_TOKEN=__NEXT_PUBLIC_MAPILLARY_TOKEN__
29-
ENV NEXT_PUBLIC_MAP_STYLE_URL=__NEXT_PUBLIC_MAP_STYLE_URL__
30-
ENV NEXT_PUBLIC_TILES_URL=__NEXT_PUBLIC_TILES_URL__
31-
ENV NEXT_PUBLIC_STYLE_PROVIDER=__NEXT_PUBLIC_STYLE_PROVIDER__
32-
ENV NEXT_PUBLIC_TRAFFIC_MIN_ZOOM=__NEXT_PUBLIC_TRAFFIC_MIN_ZOOM__
33-
ENV NEXT_PUBLIC_TRAFFIC_TILE_URL_TEMPLATE=__NEXT_PUBLIC_TRAFFIC_TILE_URL_TEMPLATE__
34-
ENV NEXT_PUBLIC_CYCLOSM_TILE_URL_TEMPLATE=__NEXT_PUBLIC_CYCLOSM_TILE_URL_TEMPLATE__
3530
ENV NEXT_TELEMETRY_DISABLED=1
3631

3732
WORKDIR /app/apps/web

apps/web/docker-entrypoint.sh

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,16 @@
11
#!/bin/sh
22
set -e
33

4-
# Replace build-time placeholders with runtime environment variables.
5-
# Next.js inlines NEXT_PUBLIC_* as string literals during build — this script
6-
# patches the compiled JS bundles so the image works with any configuration.
4+
# Replace the NEXT_PUBLIC_API_URL build-time placeholder with the runtime value.
5+
#
6+
# packages/core reads process.env.NEXT_PUBLIC_API_URL directly in client code,
7+
# so Next.js inlines it at build time. This is the only env var that still needs
8+
# the placeholder/sed approach — all other NEXT_PUBLIC_* vars are served via the
9+
# EnvProvider React context (built server-side from real process.env per request).
710

8-
replace_env() {
9-
local placeholder="$1"
10-
local value="$2"
11-
# Always replace — use empty string for unset vars so JS fallback logic works
12-
find /app/apps/web/.next -name '*.js' -exec sed -i "s|${placeholder}|${value}|g" {} + 2>/dev/null || true
13-
find /app/apps/web/.next -name '*.html' -exec sed -i "s|${placeholder}|${value}|g" {} + 2>/dev/null || true
14-
}
15-
16-
replace_env "__NEXT_PUBLIC_API_URL__" "$NEXT_PUBLIC_API_URL"
17-
replace_env "__NEXT_PUBLIC_MAPTILER_KEY__" "$NEXT_PUBLIC_MAPTILER_KEY"
18-
replace_env "__NEXT_PUBLIC_MAPILLARY_TOKEN__" "$NEXT_PUBLIC_MAPILLARY_TOKEN"
19-
replace_env "__NEXT_PUBLIC_MAP_STYLE_URL__" "$NEXT_PUBLIC_MAP_STYLE_URL"
20-
replace_env "__NEXT_PUBLIC_TILES_URL__" "$NEXT_PUBLIC_TILES_URL"
21-
replace_env "__NEXT_PUBLIC_STYLE_PROVIDER__" "$NEXT_PUBLIC_STYLE_PROVIDER"
22-
replace_env "__NEXT_PUBLIC_TRAFFIC_MIN_ZOOM__" "$NEXT_PUBLIC_TRAFFIC_MIN_ZOOM"
23-
replace_env "__NEXT_PUBLIC_TRAFFIC_TILE_URL_TEMPLATE__" "$NEXT_PUBLIC_TRAFFIC_TILE_URL_TEMPLATE"
24-
replace_env "__NEXT_PUBLIC_CYCLOSM_TILE_URL_TEMPLATE__" "$NEXT_PUBLIC_CYCLOSM_TILE_URL_TEMPLATE"
11+
if [ -n "$NEXT_PUBLIC_API_URL" ]; then
12+
find /app/apps/web/.next -name '*.js' -exec sed -i "s|__NEXT_PUBLIC_API_URL__|${NEXT_PUBLIC_API_URL}|g" {} + 2>/dev/null || true
13+
find /app/apps/web/.next -name '*.html' -exec sed -i "s|__NEXT_PUBLIC_API_URL__|${NEXT_PUBLIC_API_URL}|g" {} + 2>/dev/null || true
14+
fi
2515

2616
exec "$@"

apps/web/src/app/layout.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import "mapillary-js/dist/mapillary.css";
88
import "maplibre-theme/icons.default.css";
99
import "maplibre-theme/classic.css";
1010
import "./globals.css";
11+
import { EnvProvider } from "@/lib/EnvProvider";
12+
import { buildClientEnv } from "@/lib/env";
1113
import { Providers } from "./providers";
1214

1315
const plusJakartaSans = Plus_Jakarta_Sans({
@@ -25,14 +27,17 @@ export const metadata: Metadata = {
2527
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
2628
const locale = await getLocale();
2729
const messages = await getMessages();
30+
const clientEnv = buildClientEnv();
2831

2932
return (
3033
<html lang={locale} className={plusJakartaSans.variable} suppressHydrationWarning>
3134
<body className="h-dvh overflow-hidden antialiased">
3235
<InitColorSchemeScript attribute="class" defaultMode="system" />
3336
<AppRouterCacheProvider>
3437
<NextIntlClientProvider locale={locale} messages={messages}>
35-
<Providers>{children}</Providers>
38+
<EnvProvider config={clientEnv}>
39+
<Providers>{children}</Providers>
40+
</EnvProvider>
3641
</NextIntlClientProvider>
3742
</AppRouterCacheProvider>
3843
</body>

apps/web/src/app/status/StatusDashboard.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useCallback, useEffect, useState } from "react";
4+
import { useEnv } from "@/lib/EnvProvider";
45

56
interface ServiceStatus {
67
id: string;
@@ -17,8 +18,6 @@ interface StatusResponse {
1718
services: ServiceStatus[];
1819
}
1920

20-
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "";
21-
2221
const CATEGORY_ORDER = [
2322
"Infrastructure",
2423
"Geocoding",
@@ -54,6 +53,7 @@ const STATUS_LABELS: Record<string, string> = {
5453
};
5554

5655
export default function StatusDashboard() {
56+
const { apiUrl } = useEnv();
5757
const [data, setData] = useState<StatusResponse | null>(null);
5858
const [loading, setLoading] = useState(true);
5959
const [error, setError] = useState<string | null>(null);
@@ -63,15 +63,15 @@ export default function StatusDashboard() {
6363
setLoading(true);
6464
setError(null);
6565
try {
66-
const res = await fetch(`${API_URL}/api/status`);
66+
const res = await fetch(`${apiUrl}/api/status`);
6767
if (!res.ok) throw new Error(`HTTP ${res.status}`);
6868
setData(await res.json());
6969
} catch (err) {
7070
setError(err instanceof Error ? err.message : String(err));
7171
} finally {
7272
setLoading(false);
7373
}
74-
}, []);
74+
}, [apiUrl]);
7575

7676
useEffect(() => {
7777
fetchStatus();

apps/web/src/components/map/MapCanvas.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { useMapStore } from "@openmapx/core";
66
import type maplibregl from "maplibre-gl";
77
import { useLocale } from "next-intl";
88
import { useEffect, useRef } from "react";
9+
import { useEnv } from "@/lib/EnvProvider";
910
import { useMap } from "@/lib/MapContext";
10-
import { loadOpenMapXStyle, maptilerStyleUrl, STYLE_PROVIDER } from "@/lib/map";
11+
import { loadOpenMapXStyle, maptilerStyleUrl } from "@/lib/map";
1112

1213
export function MapCanvas() {
1314
const containerRef = useRef<HTMLDivElement>(null);
1415
const { mapRef, mapReady, notifyMapReady, notifyStyleReload } = useMap();
16+
const env = useEnv();
1517
const locale = useLocale();
1618
const { mode, systemMode } = useColorScheme();
1719
const resolvedMode = mode === "system" ? systemMode : mode;
@@ -36,7 +38,9 @@ export function MapCanvas() {
3638
if (destroyed || !containerRef.current) return;
3739

3840
const style =
39-
STYLE_PROVIDER === "openmapx" ? await loadOpenMapXStyle() : maptilerStyleUrl(mapStyle);
41+
env.styleProvider === "openmapx"
42+
? await loadOpenMapXStyle(env)
43+
: maptilerStyleUrl(mapStyle, env);
4044

4145
if (destroyed || !containerRef.current) return;
4246

@@ -100,7 +104,7 @@ export function MapCanvas() {
100104
mapRef.current?.remove();
101105
mapRef.current = null;
102106
};
103-
}, [mapRef, notifyMapReady, setBearing, setCenter, setPitch, setUserLocation, setZoom]);
107+
}, [env, mapRef, notifyMapReady, setBearing, setCenter, setPitch, setUserLocation, setZoom]);
104108

105109
// Swap map tile style when dark/light mode changes
106110
const initialStyleRef = useRef(mapStyle);
@@ -111,17 +115,17 @@ export function MapCanvas() {
111115
if (mapStyle === initialStyleRef.current) return;
112116
initialStyleRef.current = mapStyle;
113117

114-
if (STYLE_PROVIDER === "openmapx") {
115-
loadOpenMapXStyle().then((s) => {
118+
if (env.styleProvider === "openmapx") {
119+
loadOpenMapXStyle(env).then((s) => {
116120
map.setStyle(s as maplibregl.StyleSpecification);
117121
map.once("style.load", () => notifyStyleReload());
118122
});
119123
} else {
120-
const newUrl = maptilerStyleUrl(mapStyle);
124+
const newUrl = maptilerStyleUrl(mapStyle, env);
121125
map.setStyle(newUrl);
122126
map.once("style.load", () => notifyStyleReload());
123127
}
124-
}, [mapStyle, mapRef, mapReady, notifyStyleReload]);
128+
}, [env, mapStyle, mapRef, mapReady, notifyStyleReload]);
125129

126130
// Update map label language when locale changes
127131
useEffect(() => {

apps/web/src/components/map/StreetViewViewerInner.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ import type { Viewer as MapillaryViewer, ViewerImageEvent } from "mapillary-js";
2222
import { useLocale, useTranslations } from "next-intl";
2323
import { useEffect, useRef, useState } from "react";
2424
import { SearchBar } from "@/components/search/SearchBar";
25+
import { useEnv } from "@/lib/EnvProvider";
2526
import { useMap } from "@/lib/MapContext";
2627

2728
export default function StreetViewViewerInner() {
2829
const t = useTranslations("streetView");
2930
const tc = useTranslations("common");
3031
const locale = useLocale();
32+
const env = useEnv();
3133
const activeImageId = useStreetViewStore((s) => s.activeImageId);
3234
const closeViewer = useStreetViewStore((s) => s.closeViewer);
3335
const selectedPlace = usePlaceStore((s) => s.selectedPlace);
@@ -85,7 +87,7 @@ export default function StreetViewViewerInner() {
8587

8688
// Initialize viewer once on mount
8789
useEffect(() => {
88-
const token = process.env.NEXT_PUBLIC_MAPILLARY_TOKEN ?? "";
90+
const token = env.mapillaryToken;
8991
const container = containerRef.current;
9092
if (!container) return;
9193

@@ -114,7 +116,7 @@ export default function StreetViewViewerInner() {
114116
viewerRef.current?.remove();
115117
viewerRef.current = null;
116118
};
117-
}, []);
119+
}, [env.mapillaryToken]);
118120

119121
// Navigate when activeImageId changes after mount
120122
useEffect(() => {

apps/web/src/components/map/layer-selector/DesktopQuickSelector.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Typography from "@mui/material/Typography";
1010
import { OVERLAY_REGISTRY, toggleOverlay, useCapabilities, useLayerStore } from "@openmapx/core";
1111
import { useTranslations } from "next-intl";
1212
import type { MouseEvent } from "react";
13-
import { TRAFFIC_MIN_ZOOM } from "../layers/trafficConfig";
13+
import { useEnv } from "@/lib/EnvProvider";
1414
import { LayerPreviewTile } from "./LayerPreviewTile";
1515
import { globePreview } from "./layerPreviewSvgs";
1616
import { BASE_LAYER_OPTIONS, DETAIL_OPTIONS } from "./layerSelectorConfig";
@@ -28,6 +28,7 @@ function DetailOptionTile({
2828
trafficZoomTooLow: boolean;
2929
}) {
3030
const t = useTranslations("layers");
31+
const { trafficMinZoom } = useEnv();
3132
const overlayEntry = option.overlayId
3233
? OVERLAY_REGISTRY.find((r) => r.id === option.overlayId)
3334
: undefined;
@@ -51,7 +52,7 @@ function DetailOptionTile({
5152
>
5253
{disabled ? (
5354
<Typography sx={{ mt: 0.2, fontSize: 9, color: "text.secondary" }}>
54-
Zoom {TRAFFIC_MIN_ZOOM}+
55+
Zoom {trafficMinZoom}+
5556
</Typography>
5657
) : null}
5758
</LayerPreviewTile>

apps/web/src/components/map/layer-selector/LayerSelector.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,16 @@ import {
1919
import { useTranslations } from "next-intl";
2020
import type { FocusEvent, MouseEvent } from "react";
2121
import { useEffect, useRef, useState } from "react";
22+
import { useEnv } from "@/lib/EnvProvider";
2223
import { useMap } from "@/lib/MapContext";
23-
import { TRAFFIC_MIN_ZOOM } from "../layers/trafficConfig";
2424
import { DesktopMorePanel } from "./DesktopMorePanel";
2525
import { DesktopQuickSelector } from "./DesktopQuickSelector";
2626
import { BASE_LAYER_OPTIONS } from "./layerSelectorConfig";
2727
import { MobileLayerPanel } from "./MobileLayerPanel";
2828

2929
export function LayerSelector() {
3030
const t = useTranslations("layers");
31+
const { trafficMinZoom } = useEnv();
3132
const theme = useTheme();
3233
const desktopDock = useMediaQuery(theme.breakpoints.up("sm"));
3334
const { mapReady, mapRef, styleVersion } = useMap();
@@ -121,7 +122,7 @@ export function LayerSelector() {
121122
if (hiddenByFloatingCard) return null;
122123

123124
const open = Boolean(anchorEl);
124-
const trafficZoomTooLow = zoomLevel !== null && zoomLevel < TRAFFIC_MIN_ZOOM;
125+
const trafficZoomTooLow = zoomLevel !== null && zoomLevel < trafficMinZoom;
125126

126127
return (
127128
<>

apps/web/src/components/map/layers/AirQualityLayer.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useAirQualityStore, useDebouncedCallback, useOverlayExclusion } from "@
44
import type { GeoJSONSource, MapLayerMouseEvent } from "maplibre-gl";
55
import maplibregl from "maplibre-gl";
66
import { useCallback, useEffect, useRef } from "react";
7+
import { useEnv } from "@/lib/EnvProvider";
78
import { escapeHtml, sanitizeUrl } from "@/lib/escapeHtml";
89
import { useMap } from "@/lib/MapContext";
910
import { getFirstSymbolLayerId } from "./layerStyleUtils";
@@ -81,6 +82,7 @@ function buildGeoJson(stations: AQStation[]) {
8182

8283
export function AirQualityLayer() {
8384
const { mapRef, mapReady, styleVersion } = useMap();
85+
const env = useEnv();
8486
const layerVisible = useAirQualityStore((s) => s.layerVisible);
8587
const setLoading = useAirQualityStore((s) => s.setLoading);
8688
useOverlayExclusion("air-quality", layerVisible);
@@ -93,7 +95,7 @@ export function AirQualityLayer() {
9395
if (!map) return;
9496

9597
const bounds = map.getBounds();
96-
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "";
98+
const { apiUrl } = env;
9799
const url = `${apiUrl}/api/air-quality/stations?south=${bounds.getSouth()}&west=${bounds.getWest()}&north=${bounds.getNorth()}&east=${bounds.getEast()}`;
98100

99101
setLoading(true);
@@ -112,7 +114,7 @@ export function AirQualityLayer() {
112114
} finally {
113115
setLoading(false);
114116
}
115-
}, [mapRef, setLoading]);
117+
}, [env, mapRef, setLoading]);
116118

117119
useEffect(() => {
118120
void styleVersion;

apps/web/src/components/map/layers/CyclingBaseLayer.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useLayerStore } from "@openmapx/core";
44
import { useEffect, useRef, useState } from "react";
5+
import { useEnv } from "@/lib/EnvProvider";
56
import { RasterBaseLayer } from "./RasterBaseLayer";
67

78
const CYCLOSM_ATTRIBUTION =
@@ -10,13 +11,9 @@ const CYCLOSM_ATTRIBUTION =
1011
const THUNDERFOREST_ATTRIBUTION =
1112
'© <a href="https://www.thunderforest.com/maps/opencyclemap/" target="_blank">Thunderforest OpenCycleMap</a> · © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors (<a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>)';
1213

13-
const tileUrl =
14-
process.env.NEXT_PUBLIC_CYCLOSM_TILE_URL_TEMPLATE ||
15-
(process.env.NEXT_PUBLIC_API_URL
16-
? `${process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, "")}/api/tiles/cyclosm/{z}/{x}/{y}.png`
17-
: "/api/tiles/cyclosm/{z}/{x}/{y}.png");
18-
1914
export function CyclingBaseLayer() {
15+
const env = useEnv();
16+
const tileUrl = env.cyclOsmTileUrlTemplate;
2017
const activeLayer = useLayerStore((s) => s.activeLayer);
2118
const [attribution, setAttribution] = useState(CYCLOSM_ATTRIBUTION);
2219
const probed = useRef(false);
@@ -35,7 +32,7 @@ export function CyclingBaseLayer() {
3532
}
3633
})
3734
.catch(() => {});
38-
}, [activeLayer]);
35+
}, [activeLayer, tileUrl]);
3936

4037
return (
4138
<RasterBaseLayer

0 commit comments

Comments
 (0)