diff --git a/.github/workflows/ambar-task-explorer.yaml b/.github/workflows/ambar-task-explorer.yaml index d6a2081..f426429 100644 --- a/.github/workflows/ambar-task-explorer.yaml +++ b/.github/workflows/ambar-task-explorer.yaml @@ -128,7 +128,6 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | GITHUB_TOKEN=${{ secrets.READ_ACCESS_TO_REPOS }} - VITE_BASE_PATH=/tasks/ cache-from: type=gha,scope=task-explorer cache-to: type=gha,mode=max,scope=task-explorer @@ -149,14 +148,7 @@ jobs: tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.value }} - # VITE_BASE_PATH is baked into the frontend bundle at build time - # (Vite emits absolute /tasks/assets/... refs and TanStack Router - # uses BASE_URL as its basepath). The published image is therefore - # locked to serving at /tasks. AmbarCLI's task-explorer module - # default basePath matches; tenants who override it will get a - # blank page — see ambar_tools registry comment. build-args: | GITHUB_TOKEN=${{ secrets.READ_ACCESS_TO_REPOS }} - VITE_BASE_PATH=/tasks/ cache-from: type=gha,scope=task-explorer cache-to: type=gha,mode=max,scope=task-explorer diff --git a/task-explorer/Dockerfile b/task-explorer/Dockerfile index dfc50c2..a69af33 100644 --- a/task-explorer/Dockerfile +++ b/task-explorer/Dockerfile @@ -21,8 +21,6 @@ COPY ./frontend/src ./src COPY ./frontend/public ./public COPY ./frontend/*.json ./frontend/*.ts ./frontend/*.js ./frontend/*.html ./ -ARG VITE_BASE_PATH=/ -ENV VITE_BASE_PATH=${VITE_BASE_PATH} RUN pnpm run build FROM node:24.0.0-alpine AS backend-builder diff --git a/task-explorer/backend/package.json b/task-explorer/backend/package.json index 7ac5b87..f203141 100644 --- a/task-explorer/backend/package.json +++ b/task-explorer/backend/package.json @@ -1,6 +1,6 @@ { "name": "task-explorer-backend", - "version": "0.1.0", + "version": "0.0.0", "private": true, "type": "module", "scripts": { diff --git a/task-explorer/backend/src/index.ts b/task-explorer/backend/src/index.ts index 3098c84..367b0fc 100644 --- a/task-explorer/backend/src/index.ts +++ b/task-explorer/backend/src/index.ts @@ -7,6 +7,7 @@ import express, { type Express, type Request, type Response, type NextFunction, Router } from "express"; import cookieSession from "cookie-session"; +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { DateTime } from "luxon"; @@ -15,6 +16,7 @@ import { POSIX } from "@ambarltd/core/time"; import { TaskActionId, TaskWorkflowId } from "@ambarltd/tasks/store"; import * as d from "@ambarltd/core/json/decoder"; import { listTasksQueryDecoder, searchTasksQueryDecoder, timelineQueryDecoder } from "@be/lib/decoders"; +import { injectBasePath } from "@be/lib/html-injection"; import { encodeTaskAction, encodeTaskWorkflow, @@ -46,6 +48,24 @@ const PORT = Number(process.env["PORT"] ?? 3000); // Normalize BASE_PATH: remove trailing slashes, default to empty string for root const BASE_PATH = process.env["BASE_PATH"]?.replace(/\/+$/, "") ?? ""; +// Read index.html once at startup and inject BASE_PATH so the frontend bundle +// (built with base:"./") knows its mount path at runtime without baking it in. +let injectedIndexHtml: string | null = null; +try { + const raw = fs.readFileSync(path.join(distDir, "index.html"), "utf-8"); + injectedIndexHtml = injectBasePath(raw, BASE_PATH); +} catch { + // frontend/dist not present — backend-only dev mode, static serving will 404 +} + +function sendIndex(_req: Request, res: Response): void { + if (injectedIndexHtml) { + res.setHeader("Cache-Control", "no-cache").type("html").send(injectedIndexHtml); + } else { + res.status(503).send("Frontend not built"); + } +} + // Constants for pagination and validation const DEFAULT_PAGE_SIZE = 250; const MAX_PAGE_SIZE = 1000; @@ -527,15 +547,20 @@ apiRouter.get( const apiMountPath = BASE_PATH + "/api"; app.use(apiMountPath, apiRouter); -// Serve frontend static files at the base path +if (BASE_PATH) { + app.get("/", (_req: Request, res: Response) => res.redirect(301, BASE_PATH)); +} + +// Serve frontend static files at the base path (index: false — index.html is +// served explicitly below so BASE_PATH injection is always applied) const staticMountPath = BASE_PATH || "/"; -app.use(staticMountPath, express.static(distDir)); +app.use(staticMountPath, express.static(distDir, { index: false })); -// SPA fallback - serve index.html for all non-API routes under the base path +// Serve injected index.html at the exact base path and all nested SPA routes +const indexPath = staticMountPath.replace(/\/$/, "") || "/"; +app.get(indexPath, sendIndex); const spaFallbackPath = BASE_PATH + "/*"; -app.get(spaFallbackPath, (_req: Request, res: Response) => { - res.sendFile(path.join(distDir, "index.html")); -}); +app.get(spaFallbackPath, sendIndex); // ============================================================================= // Error Handling diff --git a/task-explorer/backend/src/lib/html-injection.ts b/task-explorer/backend/src/lib/html-injection.ts new file mode 100644 index 0000000..27f3344 --- /dev/null +++ b/task-explorer/backend/src/lib/html-injection.ts @@ -0,0 +1,9 @@ +export { injectBasePath }; + +function injectBasePath(html: string, basePath: string): string { + const baseHref = basePath ? `${basePath}/` : "/"; + return html.replace( + "", + ``, + ); +} diff --git a/task-explorer/backend/tests/unit/html-injection.test.ts b/task-explorer/backend/tests/unit/html-injection.test.ts new file mode 100644 index 0000000..ac40b9c --- /dev/null +++ b/task-explorer/backend/tests/unit/html-injection.test.ts @@ -0,0 +1,39 @@ +import { group, test, expect } from "@ambarltd/core/test"; +import { injectBasePath } from "@be/lib/html-injection"; + +const BARE_HTML = `T
`; + +export const tests = group("injectBasePath", [ + test("injects base href and window.__BASE_PATH__ when BASE_PATH is set", () => { + const result = injectBasePath(BARE_HTML, "/tasks"); + expect.contains(``, result); + expect.contains(`window.__BASE_PATH__="/tasks"`, result); + }), + + test("injects base href of / and empty string when BASE_PATH is empty", () => { + const result = injectBasePath(BARE_HTML, ""); + expect.contains(``, result); + expect.contains(`window.__BASE_PATH__=""`, result); + }), + + test("injection is placed inside , not outside", () => { + const result = injectBasePath(BARE_HTML, "/tasks"); + const headOpen = result.indexOf(""); + const baseTag = result.indexOf(""); + expect.equals(baseTag > headOpen, true); + expect.equals(baseTag < headClose, true); + }), + + test("appends trailing slash to base href for nested base paths", () => { + const result = injectBasePath(BARE_HTML, "/app/tasks"); + expect.contains(``, result); + expect.contains(`window.__BASE_PATH__="/app/tasks"`, result); + }), + + test("leaves rest of HTML untouched", () => { + const result = injectBasePath(BARE_HTML, "/tasks"); + expect.contains(`T`, result); + expect.contains(`
`, result); + }), +]); diff --git a/task-explorer/backend/tests/unit/main.ts b/task-explorer/backend/tests/unit/main.ts index d67c5f6..ad26361 100644 --- a/task-explorer/backend/tests/unit/main.ts +++ b/task-explorer/backend/tests/unit/main.ts @@ -1,5 +1,7 @@ import { parseArgs, run } from "@ambarltd/core/test"; import * as utilitiesTests from "./utilities.test"; +import * as htmlInjectionTests from "./html-injection.test"; +import * as routingTests from "./routing.test"; async function main() { console.log("Running unit tests"); @@ -7,7 +9,7 @@ async function main() { const options = parseArgs(process.argv.slice(2)); - const testSuites = [utilitiesTests.tests]; + const testSuites = [utilitiesTests.tests, htmlInjectionTests.tests, routingTests.tests]; await run(options, testSuites); } diff --git a/task-explorer/backend/tests/unit/routing.test.ts b/task-explorer/backend/tests/unit/routing.test.ts new file mode 100644 index 0000000..79aa465 --- /dev/null +++ b/task-explorer/backend/tests/unit/routing.test.ts @@ -0,0 +1,118 @@ +import { group, test, expect } from "@ambarltd/core/test"; +import request from "supertest"; +import express, { type Request, type Response } from "express"; +import { injectBasePath } from "@be/lib/html-injection"; + +const STUB_HTML = `T
`; + +function buildApp(basePath: string) { + const app = express(); + const injected = injectBasePath(STUB_HTML, basePath); + + function sendIndex(_req: Request, res: Response): void { + res.setHeader("Cache-Control", "no-cache").type("html").send(injected); + } + + if (basePath) { + app.get("/", (_req: Request, res: Response) => res.redirect(301, basePath)); + } + + const staticMountPath = basePath || "/"; + const indexPath = staticMountPath.replace(/\/$/, "") || "/"; + app.get(indexPath, sendIndex); + app.get(basePath + "/*", sendIndex); + + return app; +} + +const appWithBasePath = buildApp("/tasks"); +const appAtAnotherPath = buildApp("/another-path"); +const appAtRoot = buildApp(""); + +export const tests = group("Routing", [ + group("with BASE_PATH=/tasks", [ + test("GET / redirects 301 to /tasks", async () => { + const res = await request(appWithBasePath).get("/"); + expect.equals(res.status, 301); + expect.equals(res.headers["location"], "/tasks"); + }), + + test("GET /tasks serves HTML with injected base href", async () => { + const res = await request(appWithBasePath).get("/tasks"); + expect.equals(res.status, 200); + expect.equals(res.type, "text/html"); + expect.contains(``, res.text); + expect.contains(`window.__BASE_PATH__="/tasks"`, res.text); + }), + + test("GET /tasks/workflow/abc SPA fallback serves same injected HTML", async () => { + const res = await request(appWithBasePath).get("/tasks/workflow/abc"); + expect.equals(res.status, 200); + expect.contains(``, res.text); + expect.contains(`window.__BASE_PATH__="/tasks"`, res.text); + }), + + test("GET /tasks/ serves injected HTML", async () => { + const res = await request(appWithBasePath).get("/tasks/"); + expect.equals(res.status, 200); + expect.contains(``, res.text); + }), + + test("HTML responses have Cache-Control: no-cache", async () => { + const res = await request(appWithBasePath).get("/tasks"); + expect.equals(res.headers["cache-control"], "no-cache"); + }), + + test("GET /random-path outside base path returns 404", async () => { + const res = await request(appWithBasePath).get("/random-path"); + expect.equals(res.status, 404); + }), + ]), + + group("with BASE_PATH=/another-path", [ + test("GET / redirects 301 to /another-path", async () => { + const res = await request(appAtAnotherPath).get("/"); + expect.equals(res.status, 301); + expect.equals(res.headers["location"], "/another-path"); + }), + + test("GET /another-path serves HTML with injected base href", async () => { + const res = await request(appAtAnotherPath).get("/another-path"); + expect.equals(res.status, 200); + expect.contains(``, res.text); + expect.contains(`window.__BASE_PATH__="/another-path"`, res.text); + }), + + test("GET /another-path/deep/route SPA fallback serves injected HTML", async () => { + const res = await request(appAtAnotherPath).get("/another-path/deep/route"); + expect.equals(res.status, 200); + expect.contains(``, res.text); + expect.contains(`window.__BASE_PATH__="/another-path"`, res.text); + }), + + test("GET /tasks returns 404 (different base path)", async () => { + const res = await request(appAtAnotherPath).get("/tasks"); + expect.equals(res.status, 404); + }), + ]), + + group("with BASE_PATH empty (root deployment)", [ + test("GET / serves HTML directly without redirect", async () => { + const res = await request(appAtRoot).get("/"); + expect.equals(res.status, 200); + expect.equals(res.type, "text/html"); + }), + + test("GET / injects base href of / and empty __BASE_PATH__", async () => { + const res = await request(appAtRoot).get("/"); + expect.contains(``, res.text); + expect.contains(`window.__BASE_PATH__=""`, res.text); + }), + + test("GET /some/route SPA fallback serves HTML with root base href", async () => { + const res = await request(appAtRoot).get("/some/route"); + expect.equals(res.status, 200); + expect.contains(``, res.text); + }), + ]), +]); diff --git a/task-explorer/frontend/package.json b/task-explorer/frontend/package.json index 7b1665a..a51d65c 100644 --- a/task-explorer/frontend/package.json +++ b/task-explorer/frontend/package.json @@ -1,7 +1,7 @@ { "name": "task-explorer-frontend", "private": true, - "version": "0.1.0", + "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/task-explorer/frontend/src/components/LoginForm.tsx b/task-explorer/frontend/src/components/LoginForm.tsx index 26032f9..171d830 100644 --- a/task-explorer/frontend/src/components/LoginForm.tsx +++ b/task-explorer/frontend/src/components/LoginForm.tsx @@ -3,8 +3,7 @@ export { LoginForm }; import { useState, type FormEvent } from "react"; import { useAuth } from "@/lib/AuthContext"; -// Use BASE_URL for non-root deployments (e.g., /task-explorer) -const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); +const basePath = (window.__BASE_PATH__ ?? "").replace(/\/$/, ""); type Props = { onSuccess: () => void; diff --git a/task-explorer/frontend/src/env.d.ts b/task-explorer/frontend/src/env.d.ts new file mode 100644 index 0000000..35bd76c --- /dev/null +++ b/task-explorer/frontend/src/env.d.ts @@ -0,0 +1,3 @@ +interface Window { + __BASE_PATH__: string | undefined; +} diff --git a/task-explorer/frontend/src/lib/AuthContext.tsx b/task-explorer/frontend/src/lib/AuthContext.tsx index a3bc483..01338df 100644 --- a/task-explorer/frontend/src/lib/AuthContext.tsx +++ b/task-explorer/frontend/src/lib/AuthContext.tsx @@ -11,8 +11,7 @@ interface AuthContextType { const AuthContext = createContext(undefined); -// Use BASE_URL for non-root deployments (e.g., /task-explorer) -const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); +const basePath = (window.__BASE_PATH__ ?? "").replace(/\/$/, ""); function AuthProvider({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false); diff --git a/task-explorer/frontend/src/lib/api.ts b/task-explorer/frontend/src/lib/api.ts index b06a242..bfeb2bd 100644 --- a/task-explorer/frontend/src/lib/api.ts +++ b/task-explorer/frontend/src/lib/api.ts @@ -8,12 +8,7 @@ import type { TaskType, } from "@/types/task"; -// API Base URL Configuration -// Uses relative paths for cloud-agnostic routing - infrastructure handles the rest: -// - Dev: Vite proxy forwards /api to localhost:3000 (see vite.config.ts server.proxy) -// - Prod: Ingress/reverse proxy routes requests to the backend -// The base path (e.g., /tasks) is derived from Vite's BASE_URL config -const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); +const basePath = (window.__BASE_PATH__ ?? "").replace(/\/$/, ""); const API_BASE_URL = `${basePath}/api`; // Generic fetch wrapper with error handling diff --git a/task-explorer/frontend/src/main.tsx b/task-explorer/frontend/src/main.tsx index 9407ee7..37fc41e 100644 --- a/task-explorer/frontend/src/main.tsx +++ b/task-explorer/frontend/src/main.tsx @@ -5,7 +5,7 @@ import { routeTree } from "./routeTree.gen"; import "./index.css"; // Set up a Router instance -const basepath = import.meta.env.BASE_URL.replace(/\/$/, "") || "/"; +const basepath = (window.__BASE_PATH__ ?? "").replace(/\/$/, "") || "/"; const router = createRouter({ routeTree, basepath, diff --git a/task-explorer/frontend/vite.config.ts b/task-explorer/frontend/vite.config.ts index bcdd335..9880250 100644 --- a/task-explorer/frontend/vite.config.ts +++ b/task-explorer/frontend/vite.config.ts @@ -5,7 +5,7 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite"; import path from "path"; export default defineConfig({ - base: process.env.VITE_BASE_PATH || "/", + base: "./", plugins: [tanstackRouter({ target: "react", autoCodeSplitting: true }), react(), tailwindcss()], resolve: { alias: { diff --git a/task-explorer/package.json b/task-explorer/package.json index 490c627..68a4c4f 100644 --- a/task-explorer/package.json +++ b/task-explorer/package.json @@ -1,7 +1,7 @@ { "name": "task-explorer", "private": true, - "version": "0.1.2", + "version": "0.1.3", "scripts": { "dev:frontend": "pnpm --filter './frontend' dev -- --host 0.0.0.0", "dev:api": "pnpm --filter './backend' dev",