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",