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..784b130 100644 --- a/frontend/index.test.ts +++ b/frontend/index.test.ts @@ -1,6 +1,95 @@ -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('throws on non-ok response with status code', 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; + }, + ); +}); + +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('Simple Test', () => { - assert.strictEqual(true, 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/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..d5543e5 --- /dev/null +++ b/frontend/src/apiClient.ts @@ -0,0 +1,27 @@ +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(new URL(path, baseUrl).toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: body != null ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error( + `HTTP ${response.status}${detail ? `: ${detail}` : ''}`, + ); + } + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; + }, + }; +} diff --git a/frontend/src/firebase.ts b/frontend/src/firebase.ts new file mode 100644 index 0000000..b7a9805 --- /dev/null +++ b/frontend/src/firebase.ts @@ -0,0 +1,32 @@ +import { initializeApp } from 'firebase/app'; +import { connectAuthEmulator, getAuth } from 'firebase/auth'; + +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.`, + ); + return value; +} + +const firebaseConfig = { + 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); +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, + }, +});