From 397488df021e88380da24b68bb6b495fc098b637 Mon Sep 17 00:00:00 2001 From: alaa Date: Sat, 2 May 2026 18:50:35 +0100 Subject: [PATCH] feat(web): support GITNEXUS_BACKEND_URL env var for Docker deployments --- docker-compose.yaml | 7 ++ docker-server.mjs | 65 +++++++++++++++---- docker-server.test.mjs | 58 +++++++++++++++++ gitnexus-web/src/config/ui-constants.ts | 4 +- gitnexus-web/src/vite-env.d.ts | 6 ++ .../test/unit/server-connection.test.ts | 25 +++++++ 6 files changed, 152 insertions(+), 13 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 297eab85e..7491ccdd3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/docker-server.mjs b/docker-server.mjs index adf84a07b..6ba3ddfe5 100644 --- a/docker-server.mjs +++ b/docker-server.mjs @@ -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 + ? `` + : ''; + 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('')) { + console.warn('[gitnexus-web] Could not inject config: no tag found in HTML'); + } + const html = raw.includes('') ? raw.replace('', `${configScript}`) : 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'); diff --git a/docker-server.test.mjs b/docker-server.test.mjs index a1005b0e4..f1118d5c3 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -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 = ''; + const backendUrl = envBackendUrl && isValidUrl(envBackendUrl) ? envBackendUrl : null; + const configScript = backendUrl + ? `` + : ''; + return configScript ? rawHtml.replace('', `${configScript}`) : 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( + '', + ), + 'Expected config script to be injected into index.html', + ); +}); + +it('does not inject when GITNEXUS_BACKEND_URL is not set', () => { + const raw = ''; + 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 ', () => { + const html = makeInjectedHtml('http://10.0.0.1:4747'); + const headCloseIdx = html.indexOf(''); + const scriptIdx = html.indexOf('