Skip to content

Commit 4ebbf13

Browse files
committed
Enhance SEO handling and public host resolution for improved metadata accuracy
- Updated .env.example to include SEO_PUBLIC_HOST for better SEO configuration. - Refactored server.js to utilize a new getPublicHost function for accurate host resolution in metadata generation. - Improved apiForwardHeaders and handleSeoRoute functions to ensure correct headers are forwarded based on the public host. - Enhanced seo-core.js to resolve API base URL using the public site host, improving API integration in production environments. - Adjusted vite-seo-plugin.js to leverage public host for metadata fetching, ensuring consistency across requests.
1 parent 1971f73 commit 4ebbf13

4 files changed

Lines changed: 71 additions & 23 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ SITE_NAME=DevCommunity
3131
NODE_ENV=development
3232
# Optional explicit API for SEO fetches (overrides VITE_* when not on a production host):
3333
# SEO_API_URL=https://api.devcommunity.io/api
34+
# Public hostname for SEO when the Node server sees an internal Host header (Docker/nginx):
35+
# SEO_PUBLIC_HOST=devcommunity.io
3436

3537
# Production examples (uncomment when deploying):
3638
# VITE_API_BASE_URL=https://api.devcommunity.io/api

seo/seo-core.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,23 @@ function isLocalApiUrl(url) {
7070
return /localhost|127\.0\.0\.1/i.test(String(url || ''));
7171
}
7272

73+
function hostFromAppUrl(env = {}) {
74+
const candidate = env.SEO_PUBLIC_HOST || env.VITE_APP_URL || env.FRONTEND_URL || '';
75+
if (!candidate) return '';
76+
try {
77+
const parsed = candidate.includes('://') ? new URL(candidate) : new URL(`https://${candidate}`);
78+
return parsed.hostname;
79+
} catch {
80+
return String(candidate).split('/')[0].split(':')[0];
81+
}
82+
}
83+
7384
/**
7485
* Resolve the API base URL from env (and optionally the request host).
7586
* When the public site is served on a production domain, always use the
7687
* matching api.<domain> host — even if VITE_API_BASE_URL points at localhost.
7788
* @param {object} env process.env
78-
* @param {string} [host] request host header
89+
* @param {string} [host] public site host (x-forwarded-host preferred)
7990
*/
8091
export function resolveApiBaseUrl(env = {}, host = '') {
8192
const hostOnly = host ? host.split(':')[0].toLowerCase() : '';
@@ -84,11 +95,22 @@ export function resolveApiBaseUrl(env = {}, host = '') {
8495
return `https://api.${baseDomain}/api`;
8596
}
8697

98+
const appHost = hostFromAppUrl(env);
99+
if (isProductionPublicHost(appHost)) {
100+
const baseDomain = appHost.replace(/^www\./, '');
101+
return `https://api.${baseDomain}/api`;
102+
}
103+
87104
const order = [env.SEO_API_URL, env.SEO_API_BASE_URL, env.VITE_API_BASE_URL, env.VITE_API_URL];
88105
for (const candidate of order) {
89106
const normalized = normalizeApiBase(candidate);
90107
if (normalized && !isLocalApiUrl(normalized)) return normalized;
91108
}
109+
110+
if (env.NODE_ENV === 'production') {
111+
return 'https://api.devcommunity.io/api';
112+
}
113+
92114
for (const candidate of order) {
93115
const normalized = normalizeApiBase(candidate);
94116
if (normalized) return normalized;

seo/vite-seo-plugin.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,23 @@ export function seoDevPlugin(env = {}) {
2626
const apiBaseUrl = resolveApiBaseUrl(env);
2727
const siteName = env.VITE_SITE_NAME || env.SITE_NAME || 'DevCommunity';
2828

29+
const publicHostOf = (req) => {
30+
const forwarded = req.headers['x-forwarded-host'];
31+
if (forwarded) return String(forwarded).split(',')[0].trim();
32+
return req.headers.host || 'localhost';
33+
};
2934
const baseUrlOf = (req) => {
30-
const host = req.headers.host || 'localhost';
35+
const host = publicHostOf(req);
3136
const proto = req.headers['x-forwarded-proto'] || 'http';
3237
return `${proto}://${host}`;
3338
};
3439
const fwd = (req) => {
35-
const h = {};
36-
if (req.headers.host) {
37-
h['x-forwarded-host'] = req.headers.host;
38-
h['x-forwarded-proto'] = req.headers['x-forwarded-proto'] || 'http';
39-
}
40-
return h;
40+
const host = publicHostOf(req);
41+
if (!host) return {};
42+
return {
43+
'x-forwarded-host': host,
44+
'x-forwarded-proto': req.headers['x-forwarded-proto'] || 'http',
45+
};
4146
};
4247

4348
server.middlewares.use(async (req, res, next) => {
@@ -69,12 +74,18 @@ export function seoDevPlugin(env = {}) {
6974
// Apply Vite's HTML transforms so the SPA still boots (HMR, @vite/client …)
7075
template = await server.transformIndexHtml(req.url, template, req.originalUrl);
7176

72-
const metadata = await fetchSeoMetadata({ apiBaseUrl, type, identifier, headers: fwd(req) });
77+
const publicHost = publicHostOf(req);
78+
const metadata = await fetchSeoMetadata({
79+
apiBaseUrl: resolveApiBaseUrl(env, publicHost) || apiBaseUrl,
80+
type,
81+
identifier,
82+
headers: fwd(req),
83+
});
7384
const baseUrl = baseUrlOf(req);
7485
const html = injectMetaTags(
7586
template,
7687
metadata || getFallbackMetadata({ baseUrl, siteName, currentPath: pathname }),
77-
{ baseUrl, currentPath: pathname, host: req.headers.host }
88+
{ baseUrl, currentPath: pathname, host: publicHost }
7889
);
7990
res.setHeader('Content-Type', 'text/html; charset=utf-8');
8091
return res.end(html);

server.js

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,23 +55,35 @@ app.set('trust proxy', true);
5555

5656
const SITE_NAME = process.env.SITE_NAME || process.env.VITE_SITE_NAME || 'DevCommunity';
5757

58+
/** Public site hostname — prefer proxy headers over internal bind address. */
59+
function getPublicHost(req) {
60+
const forwarded = req.get('x-forwarded-host');
61+
if (forwarded) return forwarded.split(',')[0].trim();
62+
return req.get('host') || '';
63+
}
64+
5865
/** Base URL of the public site, from the incoming request. */
5966
function getBaseUrl(req) {
60-
const protocol = req.protocol || (req.secure ? 'https' : 'http');
61-
const host = req.get('host') || `localhost:${PORT}`;
67+
const host = getPublicHost(req) || `localhost:${PORT}`;
68+
const protocol =
69+
req.get('x-forwarded-proto') ||
70+
req.protocol ||
71+
(req.secure ? 'https' : 'http');
6272
return `${protocol}://${host}`;
6373
}
6474

6575
/** Headers forwarded to the API so it builds URLs with the public host. */
6676
function apiForwardHeaders(req) {
67-
const headers = {};
68-
const host = req.get('host');
69-
const proto = req.protocol || (req.secure ? 'https' : 'http');
70-
if (host) {
71-
headers['x-forwarded-host'] = host;
72-
headers['x-forwarded-proto'] = proto;
73-
}
74-
return headers;
77+
const host = getPublicHost(req);
78+
const proto =
79+
req.get('x-forwarded-proto') ||
80+
req.protocol ||
81+
(req.secure ? 'https' : 'http');
82+
if (!host) return {};
83+
return {
84+
'x-forwarded-host': host,
85+
'x-forwarded-proto': proto,
86+
};
7587
}
7688

7789
function readDistIndex() {
@@ -102,12 +114,13 @@ async function handleSeoRoute(req, res, type) {
102114
}
103115

104116
try {
105-
const apiBaseUrl = resolveApiBaseUrl(process.env, req.get('host')) || DEFAULT_API_BASE_URL;
117+
const publicHost = getPublicHost(req);
118+
const apiBaseUrl = resolveApiBaseUrl(process.env, publicHost) || DEFAULT_API_BASE_URL;
106119
const metadata = await fetchSeoMetadata({
107120
apiBaseUrl, type, identifier, headers: apiForwardHeaders(req),
108121
});
109122

110-
const ctx = { baseUrl: getBaseUrl(req), currentPath: req.originalUrl || req.url || '', host: req.get('host') };
123+
const ctx = { baseUrl: getBaseUrl(req), currentPath: req.originalUrl || req.url || '', host: publicHost };
111124
html = injectMetaTags(
112125
html,
113126
metadata || getFallbackMetadata({ baseUrl: ctx.baseUrl, siteName: SITE_NAME, currentPath: ctx.currentPath }),
@@ -119,7 +132,7 @@ async function handleSeoRoute(req, res, type) {
119132
res.setHeader('X-Content-Type-Options', 'nosniff');
120133
return res.send(html);
121134
} catch {
122-
const ctx = { baseUrl: getBaseUrl(req), currentPath: req.originalUrl || '', host: req.get('host') };
135+
const ctx = { baseUrl: getBaseUrl(req), currentPath: req.originalUrl || '', host: getPublicHost(req) };
123136
const fallback = getFallbackMetadata({ baseUrl: ctx.baseUrl, siteName: SITE_NAME, currentPath: ctx.currentPath });
124137
res.setHeader('Content-Type', 'text/html; charset=utf-8');
125138
res.setHeader('Cache-Control', 'public, max-age=300');

0 commit comments

Comments
 (0)