Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions .github/workflows/ambar-task-explorer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
2 changes: 0 additions & 2 deletions task-explorer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion task-explorer/backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "task-explorer-backend",
"version": "0.1.0",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
37 changes: 31 additions & 6 deletions task-explorer/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Comment thread
ctuncay marked this conversation as resolved.
}

// 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
Expand Down
9 changes: 9 additions & 0 deletions task-explorer/backend/src/lib/html-injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export { injectBasePath };

function injectBasePath(html: string, basePath: string): string {
const baseHref = basePath ? `${basePath}/` : "/";
return html.replace(
"<head>",
`<head><base href="${baseHref}"><script>window.__BASE_PATH__=${JSON.stringify(basePath)};</script>`,
);
}
39 changes: 39 additions & 0 deletions task-explorer/backend/tests/unit/html-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { group, test, expect } from "@ambarltd/core/test";
import { injectBasePath } from "@be/lib/html-injection";

const BARE_HTML = `<!doctype html><html><head><title>T</title></head><body><div id="app"></div></body></html>`;

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(`<base href="/tasks/">`, 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(`<base href="/">`, result);
expect.contains(`window.__BASE_PATH__=""`, result);
}),

test("injection is placed inside <head>, not outside", () => {
const result = injectBasePath(BARE_HTML, "/tasks");
const headOpen = result.indexOf("<head>");
const baseTag = result.indexOf("<base href");
const headClose = result.indexOf("</head>");
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(`<base href="/app/tasks/">`, result);
expect.contains(`window.__BASE_PATH__="/app/tasks"`, result);
}),

test("leaves rest of HTML untouched", () => {
const result = injectBasePath(BARE_HTML, "/tasks");
expect.contains(`<title>T</title>`, result);
expect.contains(`<div id="app"></div>`, result);
}),
]);
4 changes: 3 additions & 1 deletion task-explorer/backend/tests/unit/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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");
console.log("");

const options = parseArgs(process.argv.slice(2));

const testSuites = [utilitiesTests.tests];
const testSuites = [utilitiesTests.tests, htmlInjectionTests.tests, routingTests.tests];

await run(options, testSuites);
}
Expand Down
118 changes: 118 additions & 0 deletions task-explorer/backend/tests/unit/routing.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html><html><head><title>T</title></head><body><div id="app"></div></body></html>`;

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(`<base href="/tasks/">`, 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(`<base href="/tasks/">`, 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(`<base href="/tasks/">`, 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(`<base href="/another-path/">`, 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(`<base href="/another-path/">`, 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(`<base href="/">`, 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(`<base href="/">`, res.text);
}),
]),
]);
2 changes: 1 addition & 1 deletion task-explorer/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "task-explorer-frontend",
"private": true,
"version": "0.1.0",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
3 changes: 1 addition & 2 deletions task-explorer/frontend/src/components/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions task-explorer/frontend/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
interface Window {
__BASE_PATH__: string | undefined;
}
3 changes: 1 addition & 2 deletions task-explorer/frontend/src/lib/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ interface AuthContextType {

const AuthContext = createContext<AuthContextType | undefined>(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);
Expand Down
7 changes: 1 addition & 6 deletions task-explorer/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion task-explorer/frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion task-explorer/frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "./",
Comment thread
ctuncay marked this conversation as resolved.
plugins: [tanstackRouter({ target: "react", autoCodeSplitting: true }), react(), tailwindcss()],
resolve: {
alias: {
Expand Down
2 changes: 1 addition & 1 deletion task-explorer/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading