From bbccf5046fc9cdb2478df08283254fd5456bb723 Mon Sep 17 00:00:00 2001 From: Menny Even Danan Date: Mon, 27 Apr 2026 23:03:57 -0400 Subject: [PATCH 1/5] feat: add frontend API client, React scaffold, and Vite dev server - createApiClient factory with injected getToken/baseUrl/fetcher for full testability without Firebase or Vite imports at module level - Firebase Web SDK init with connectAuthEmulator in DEV mode - React 19 app scaffold (App.tsx, main.tsx, index.html) - vite-env.d.ts reference for import.meta.env types under Bazel tsc - 3 Node native test runner tests covering auth header, URL, and error - frontend_lib ts_project and vite_binary dev_server in BUILD.bazel - vite bins declaration in MODULE.bazel npm_translate_lock --- MODULE.bazel | 5 +++ README.md | 4 +-- frontend/BUILD.bazel | 58 +++++++++++++++++++++++++++++++-- frontend/index.html | 12 +++++++ frontend/index.test.ts | 66 ++++++++++++++++++++++++++++++++++++-- frontend/src/App.tsx | 3 ++ frontend/src/apiClient.ts | 21 ++++++++++++ frontend/src/firebase.ts | 15 +++++++++ frontend/src/main.tsx | 9 ++++++ frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 2 ++ frontend/vite.config.ts | 9 ++++++ 12 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 frontend/index.html create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/apiClient.ts create mode 100644 frontend/src/firebase.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/vite.config.ts diff --git a/MODULE.bazel b/MODULE.bazel index 3272461..584c6d5 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -62,6 +62,11 @@ use_repo(node, "nodejs_toolchains") npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") npm.npm_translate_lock( name = "npm_rulesjs", + bins = { + # vite's bin is not auto-detected due to peer-dep version qualifiers; + # declare it explicitly so vite_binary() is generated. + "vite": ["vite=./bin/vite.js"], + }, pnpm_lock = "//:pnpm-lock.yaml", ) use_repo(npm, "npm_rulesjs") diff --git a/README.md b/README.md index 08bd6be..b68d8ed 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,11 @@ GOOGLE_CLOUD_PROJECT=septima-dev \ FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 \ bazel run //backend:dev_server -# Terminal 3 — Vite-powered React frontend (not yet implemented) +# Terminal 3 — Vite-powered React frontend (http://localhost:5173) bazel run //frontend:dev_server ``` -> `//frontend:dev_server` is not yet implemented. Until it lands, only the backend and test targets above are available. +> Copy `frontend/.env.local.example` to `frontend/.env.local` and fill in the Firebase config values before running the frontend dev server. ### Updating dependencies diff --git a/frontend/BUILD.bazel b/frontend/BUILD.bazel index 2afcad7..566196a 100644 --- a/frontend/BUILD.bazel +++ b/frontend/BUILD.bazel @@ -1,11 +1,15 @@ load("@aspect_rules_js//js:defs.bzl", "js_test") load("@aspect_rules_ts//ts:defs.bzl", "ts_project") +load("@npm_rulesjs//:vite/package_json.bzl", vite_bin = "bin") ts_project( name = "frontend", srcs = glob( ["*.ts"], - exclude = ["*.test.ts"], + exclude = [ + "*.test.ts", + "vite.config.ts", + ], ), declaration = True, transpiler = "tsc", @@ -13,17 +17,65 @@ ts_project( visibility = ["//visibility:public"], ) +ts_project( + name = "frontend_lib", + srcs = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + ], + exclude = ["src/**/*.test.*"], + ), + declaration = True, + transpiler = "tsc", + tsconfig = "tsconfig.json", + visibility = ["//visibility:public"], + deps = [ + "//:node_modules/@types/react", + "//:node_modules/@types/react-dom", + "//:node_modules/firebase", + "//:node_modules/react", + "//:node_modules/react-dom", + "//:node_modules/vite", + ], +) + ts_project( name = "tests_lib", srcs = ["index.test.ts"], declaration = True, transpiler = "tsc", tsconfig = "tsconfig.json", - deps = ["//:node_modules/@types/node"], + deps = [ + ":frontend_lib", + "//:node_modules/@types/node", + ], ) js_test( name = "test", - data = [":tests_lib"], + data = [ + ":frontend_lib", + ":tests_lib", + ], entry_point = "index.test.js", ) + +vite_bin.vite_binary( + name = "dev_server", + args = [ + "--port=5173", + "--host", + ], + chdir = package_name(), + data = [ + "index.html", + "tsconfig.json", + "vite.config.ts", + ":frontend_lib", + "//:node_modules/@vitejs/plugin-react", + "//:node_modules/firebase", + "//:node_modules/react", + "//:node_modules/react-dom", + ] + glob(["src/**"]), +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8265a20 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Septima + + +
+ + + diff --git a/frontend/index.test.ts b/frontend/index.test.ts index 16ba7c1..9073af1 100644 --- a/frontend/index.test.ts +++ b/frontend/index.test.ts @@ -1,6 +1,66 @@ -import test from 'node:test'; import assert from 'node:assert'; +import test from 'node:test'; +import { createApiClient } from './src/apiClient.js'; + +test('attaches Authorization Bearer header', async () => { + let capturedInit: RequestInit | undefined; + const mockFetch = async ( + _url: string | URL | Request, + init?: RequestInit, + ): Promise => { + capturedInit = init; + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const client = createApiClient( + async () => 'test-token', + 'http://localhost:8080', + mockFetch as typeof fetch, + ); + await client.post('/ping'); + + const headers = capturedInit?.headers as Record; + assert.strictEqual(headers['Authorization'], 'Bearer test-token'); +}); + +test('sends POST to the correct URL', async () => { + let capturedUrl: string | URL | Request = ''; + const mockFetch = async ( + url: string | URL | Request, + _init?: RequestInit, + ): Promise => { + capturedUrl = url; + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const client = createApiClient( + async () => 'token', + 'http://localhost:8080', + mockFetch as typeof fetch, + ); + await client.post('/ping'); + assert.strictEqual(capturedUrl, 'http://localhost:8080/ping'); +}); -test('Simple Test', () => { - assert.strictEqual(true, true); +test('throws on non-ok response', async () => { + const mockFetch = async (): Promise => + new Response('', { status: 401 }); + const client = createApiClient( + async () => 'token', + 'http://localhost:8080', + mockFetch as typeof fetch, + ); + await assert.rejects( + () => client.post('/ping'), + (err: Error) => { + assert.match(err.message, /HTTP 401/); + return true; + }, + ); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e71a768 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,3 @@ +export function App() { + return
Septima
; +} diff --git a/frontend/src/apiClient.ts b/frontend/src/apiClient.ts new file mode 100644 index 0000000..c3729d2 --- /dev/null +++ b/frontend/src/apiClient.ts @@ -0,0 +1,21 @@ +export function createApiClient( + getToken: () => Promise, + baseUrl: string, + fetcher: typeof fetch = fetch, +) { + return { + async post(path: string, body?: unknown): Promise { + const token = await getToken(); + const response = await fetcher(`${baseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: body != null ? JSON.stringify(body) : undefined, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + }, + }; +} diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts new file mode 100644 index 0000000..4a34159 --- /dev/null +++ b/frontend/src/firebase.ts @@ -0,0 +1,15 @@ +import { initializeApp } from 'firebase/app'; +import { connectAuthEmulator, getAuth } from 'firebase/auth'; + +const firebaseConfig = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, +}; + +export const app = initializeApp(firebaseConfig); +export const auth = getAuth(app); + +if (import.meta.env.DEV) { + connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..9e045b6 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 960e2e2..a3b75fd 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,8 +1,10 @@ { "compilerOptions": { "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "node", + "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..a59de99 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,9 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + build: { + sourcemap: true, + }, +}); From bc6e6d83b0f9f6c53597e011d1756a28d95b393d Mon Sep 17 00:00:00 2001 From: Menny Even Danan Date: Tue, 28 Apr 2026 09:08:53 -0400 Subject: [PATCH 2/5] fix: include server error body in throws, handle empty 204 responses - Read response.text() on non-ok and append to error message so server validation details survive to the caller - Return undefined (not parse JSON) on 204 or content-length: 0 to avoid "Unexpected end of JSON input" from queue-worker trigger endpoints - Add two tests covering the new paths --- frontend/index.test.ts | 31 ++++++++++++++++++++++++++++++- frontend/src/apiClient.ts | 13 ++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/frontend/index.test.ts b/frontend/index.test.ts index 9073af1..784b130 100644 --- a/frontend/index.test.ts +++ b/frontend/index.test.ts @@ -48,7 +48,7 @@ test('sends POST to the correct URL', async () => { assert.strictEqual(capturedUrl, 'http://localhost:8080/ping'); }); -test('throws on non-ok response', async () => { +test('throws on non-ok response with status code', async () => { const mockFetch = async (): Promise => new Response('', { status: 401 }); const client = createApiClient( @@ -64,3 +64,32 @@ test('throws on non-ok response', async () => { }, ); }); + +test('includes server error body in thrown error', async () => { + const mockFetch = async (): Promise => + new Response('Unauthorized: token expired', { status: 401 }); + const client = createApiClient( + async () => 'token', + 'http://localhost:8080', + mockFetch as typeof fetch, + ); + await assert.rejects( + () => client.post('/ping'), + (err: Error) => { + assert.match(err.message, /token expired/); + return true; + }, + ); +}); + +test('returns undefined for 204 No Content', async () => { + const mockFetch = async (): Promise => + new Response(null, { status: 204 }); + const client = createApiClient( + async () => 'token', + 'http://localhost:8080', + mockFetch as typeof fetch, + ); + const result = await client.post('/trigger'); + assert.strictEqual(result, undefined); +}); diff --git a/frontend/src/apiClient.ts b/frontend/src/apiClient.ts index c3729d2..ebea791 100644 --- a/frontend/src/apiClient.ts +++ b/frontend/src/apiClient.ts @@ -14,7 +14,18 @@ export function createApiClient( }, body: body != null ? JSON.stringify(body) : undefined, }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error( + `HTTP ${response.status}${detail ? `: ${detail}` : ''}`, + ); + } + if ( + response.status === 204 || + response.headers.get('content-length') === '0' + ) { + return undefined as T; + } return response.json() as Promise; }, }; From 404b635fa0bd072496630807ce81f9a60a313b6a Mon Sep 17 00:00:00 2001 From: Menny Even Danan Date: Tue, 28 Apr 2026 09:18:50 -0400 Subject: [PATCH 3/5] fix: use URL constructor for path joining, validate required env vars - new URL(path, baseUrl) avoids double-slash / missing-slash edge cases - requireEnv() throws a clear actionable error at startup if any VITE_FIREBASE_* var is missing, instead of cryptic Firebase SDK errors --- frontend/src/apiClient.ts | 2 +- frontend/src/firebase.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/apiClient.ts b/frontend/src/apiClient.ts index ebea791..18b9a5d 100644 --- a/frontend/src/apiClient.ts +++ b/frontend/src/apiClient.ts @@ -6,7 +6,7 @@ export function createApiClient( return { async post(path: string, body?: unknown): Promise { const token = await getToken(); - const response = await fetcher(`${baseUrl}${path}`, { + const response = await fetcher(new URL(path, baseUrl).toString(), { method: 'POST', headers: { Authorization: `Bearer ${token}`, diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index 4a34159..286b224 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -1,10 +1,19 @@ import { initializeApp } from 'firebase/app'; import { connectAuthEmulator, getAuth } from 'firebase/auth'; +function requireEnv(key: string): string { + const value = import.meta.env[key]; + if (!value) + throw new Error( + `Missing required env var: ${key}. Copy frontend/.env.local.example to frontend/.env.local and fill in the values.`, + ); + return value; +} + const firebaseConfig = { - apiKey: import.meta.env.VITE_FIREBASE_API_KEY, - authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, - projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + apiKey: requireEnv('VITE_FIREBASE_API_KEY'), + authDomain: requireEnv('VITE_FIREBASE_AUTH_DOMAIN'), + projectId: requireEnv('VITE_FIREBASE_PROJECT_ID'), }; export const app = initializeApp(firebaseConfig); From 7496086b301378f28787d6b5e6c45d1215eb2e63 Mon Sep 17 00:00:00 2001 From: Menny Even Danan Date: Tue, 28 Apr 2026 09:50:35 -0400 Subject: [PATCH 4/5] fix: parse response via text() to handle empty 200 bodies safely --- frontend/src/apiClient.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/frontend/src/apiClient.ts b/frontend/src/apiClient.ts index 18b9a5d..d5543e5 100644 --- a/frontend/src/apiClient.ts +++ b/frontend/src/apiClient.ts @@ -20,13 +20,8 @@ export function createApiClient( `HTTP ${response.status}${detail ? `: ${detail}` : ''}`, ); } - if ( - response.status === 204 || - response.headers.get('content-length') === '0' - ) { - return undefined as T; - } - return response.json() as Promise; + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; }, }; } From bd6b13b717eb86db1ee2ab4df883a72d52f640c9 Mon Sep 17 00:00:00 2001 From: Menny Even Danan Date: Tue, 28 Apr 2026 10:15:16 -0400 Subject: [PATCH 5/5] fix: use static import.meta.env access for Vite production bundle compatibility --- frontend/src/firebase.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts index 286b224..b7a9805 100644 --- a/frontend/src/firebase.ts +++ b/frontend/src/firebase.ts @@ -1,8 +1,7 @@ import { initializeApp } from 'firebase/app'; import { connectAuthEmulator, getAuth } from 'firebase/auth'; -function requireEnv(key: string): string { - const value = import.meta.env[key]; +function requireEnv(key: string, value: string | undefined): string { if (!value) throw new Error( `Missing required env var: ${key}. Copy frontend/.env.local.example to frontend/.env.local and fill in the values.`, @@ -11,9 +10,18 @@ function requireEnv(key: string): string { } const firebaseConfig = { - apiKey: requireEnv('VITE_FIREBASE_API_KEY'), - authDomain: requireEnv('VITE_FIREBASE_AUTH_DOMAIN'), - projectId: requireEnv('VITE_FIREBASE_PROJECT_ID'), + apiKey: requireEnv( + 'VITE_FIREBASE_API_KEY', + import.meta.env.VITE_FIREBASE_API_KEY, + ), + authDomain: requireEnv( + 'VITE_FIREBASE_AUTH_DOMAIN', + import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + ), + projectId: requireEnv( + 'VITE_FIREBASE_PROJECT_ID', + import.meta.env.VITE_FIREBASE_PROJECT_ID, + ), }; export const app = initializeApp(firebaseConfig);