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,
+ },
+});