feat(web): support GITNEXUS_BACKEND_URL env var for Docker deployments

This commit is contained in:
alaa 2026-05-02 18:50:35 +01:00
parent 368049576b
commit 397488df02
6 changed files with 152 additions and 13 deletions

View file

@ -30,6 +30,13 @@ services:
container_name: ${WEB_CONTAINER_NAME:-gitnexus-web}
ports:
- '${WEB_HOST_PORT:-4173}:4173'
# Optional: override the backend URL served to the browser.
# Required when the gitnexus-server is not reachable at http://localhost:4747
# from the user's browser (e.g. remote server deployments).
# Use http://host.docker.internal:4747 on Docker Desktop (Mac/Windows),
# or the server's LAN IP for Linux hosts.
# environment:
# - GITNEXUS_BACKEND_URL=http://host.docker.internal:4747
depends_on:
gitnexus-server:
condition: service_healthy

View file

@ -1,5 +1,5 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { readFile, stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import { extname, join, normalize, sep } from 'node:path';
@ -7,6 +7,26 @@ const host = '0.0.0.0';
const port = Number(process.env.PORT || '4173');
const root = join(process.cwd(), 'dist');
function isValidUrl(value) {
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null;
if (rawBackendUrl && !isValidUrl(rawBackendUrl)) {
console.warn(
`[gitnexus-web] GITNEXUS_BACKEND_URL "${rawBackendUrl}" is not a valid http/https URL — ignoring.`,
);
}
const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null;
const configScript = backendUrl
? `<script>window.__GITNEXUS_CONFIG__=${JSON.stringify({ backendUrl })};</script>`
: '';
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
@ -59,17 +79,38 @@ const server = createServer(async (req, res) => {
return;
}
res.writeHead(200, {
'Cache-Control': filePath.includes('/assets/')
? 'public, max-age=31536000, immutable'
: 'no-cache',
'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
const stream = createReadStream(filePath);
stream.on('error', () => res.destroy());
stream.pipe(res);
const isHtml = extname(filePath) === '.html' || !extname(filePath);
const cacheControl = filePath.includes('/assets/')
? 'public, max-age=31536000, immutable'
: 'no-cache';
const contentType = contentTypes[extname(filePath)] || 'application/octet-stream';
if (isHtml && configScript) {
const raw = await readFile(filePath, 'utf8');
if (!raw.includes('</head>')) {
console.warn('[gitnexus-web] Could not inject config: no </head> tag found in HTML');
}
const html = raw.includes('</head>') ? raw.replace('</head>', `${configScript}</head>`) : raw;
const buf = Buffer.from(html, 'utf8');
res.writeHead(200, {
'Cache-Control': cacheControl,
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': buf.length,
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
res.end(buf);
} else {
res.writeHead(200, {
'Cache-Control': cacheControl,
'Content-Type': contentType,
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
const stream = createReadStream(filePath);
stream.on('error', () => res.destroy());
stream.pipe(res);
}
} catch (error) {
res.writeHead(500);
res.end(error instanceof Error ? error.message : 'Internal server error');

View file

@ -105,3 +105,61 @@ it('returns 404 when dist/index.html is missing', async () => {
const res = await rawGet(serverPort, '/nonexistent-page');
assert.equal(res.status, 404);
});
// ── Config injection logic tests (inline, independent of server process) ─────
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
function isValidUrl(value) {
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
function makeInjectedHtml(envBackendUrl) {
const rawHtml = '<!doctype html><html><head></head><body></body></html>';
const backendUrl = envBackendUrl && isValidUrl(envBackendUrl) ? envBackendUrl : null;
const configScript = backendUrl
? `<script>window.__GITNEXUS_CONFIG__=${JSON.stringify({ backendUrl })};</script>`
: '';
return configScript ? rawHtml.replace('</head>', `${configScript}</head>`) : rawHtml;
}
it('injects __GITNEXUS_CONFIG__ when GITNEXUS_BACKEND_URL is a valid URL', () => {
const html = makeInjectedHtml('http://10.0.0.1:4747');
assert.ok(
html.includes(
'<script>window.__GITNEXUS_CONFIG__={"backendUrl":"http://10.0.0.1:4747"};</script>',
),
'Expected config script to be injected into index.html',
);
});
it('does not inject when GITNEXUS_BACKEND_URL is not set', () => {
const raw = '<!doctype html><html><head></head><body></body></html>';
const html = makeInjectedHtml(null);
assert.equal(html, raw, 'Expected index.html to be unchanged when no env var is set');
});
it('injects config script before </head>', () => {
const html = makeInjectedHtml('http://10.0.0.1:4747');
const headCloseIdx = html.indexOf('</head>');
const scriptIdx = html.indexOf('<script>window.__GITNEXUS_CONFIG__');
assert.ok(scriptIdx !== -1, 'Script tag must be present');
assert.ok(scriptIdx < headCloseIdx, 'Script must appear before </head>');
});
it('does not inject when GITNEXUS_BACKEND_URL is not a valid URL', () => {
const raw = '<!doctype html><html><head></head><body></body></html>';
const html = makeInjectedHtml('not-a-url');
assert.equal(html, raw, 'Expected index.html to be unchanged for an invalid URL');
});
it('does not inject when GITNEXUS_BACKEND_URL uses a non-http protocol', () => {
const raw = '<!doctype html><html><head></head><body></body></html>';
const html = makeInjectedHtml('ftp://somehost:21');
assert.equal(html, raw, 'Expected index.html to be unchanged for non-http protocol');
});

View file

@ -2,7 +2,9 @@
export const ERROR_RESET_DELAY_MS = 3000;
export const BACKEND_URL_DEBOUNCE_MS = 500;
export const DEFAULT_BACKEND_URL = 'http://localhost:4747';
export const DEFAULT_BACKEND_URL =
(typeof window !== 'undefined' && window.__GITNEXUS_CONFIG__?.backendUrl) ||
'http://localhost:4747';
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';

View file

@ -1 +1,7 @@
/// <reference types="vite/client" />
interface Window {
__GITNEXUS_CONFIG__?: {
backendUrl?: string;
};
}

View file

@ -165,3 +165,28 @@ describe('fetchGraph', () => {
});
});
});
describe('DEFAULT_BACKEND_URL resolution', () => {
afterEach(() => {
delete window.__GITNEXUS_CONFIG__;
vi.resetModules();
});
it('falls back to localhost:4747 when no config is injected', async () => {
delete window.__GITNEXUS_CONFIG__;
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747');
});
it('uses window.__GITNEXUS_CONFIG__.backendUrl when set', async () => {
window.__GITNEXUS_CONFIG__ = { backendUrl: 'http://10.0.0.1:4747' };
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
expect(DEFAULT_BACKEND_URL).toBe('http://10.0.0.1:4747');
});
it('falls back to localhost:4747 when config object has no backendUrl', async () => {
window.__GITNEXUS_CONFIG__ = {};
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747');
});
});