Skip to content

Commit 6a1bfed

Browse files
authored
Merge pull request #5 from AnySoftKeyboard/feat/phase-1-frontend-client
feat: frontend API client, React scaffold, and Vite dev server
2 parents 92ec045 + bd6b13b commit 6a1bfed

12 files changed

Lines changed: 249 additions & 8 deletions

File tree

MODULE.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ use_repo(node, "nodejs_toolchains")
6262
npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
6363
npm.npm_translate_lock(
6464
name = "npm_rulesjs",
65+
bins = {
66+
# vite's bin is not auto-detected due to peer-dep version qualifiers;
67+
# declare it explicitly so vite_binary() is generated.
68+
"vite": ["vite=./bin/vite.js"],
69+
},
6570
pnpm_lock = "//:pnpm-lock.yaml",
6671
)
6772
use_repo(npm, "npm_rulesjs")

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,11 @@ GOOGLE_CLOUD_PROJECT=septima-dev \
7878
FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 \
7979
bazel run //backend:dev_server
8080

81-
# Terminal 3 — Vite-powered React frontend (not yet implemented)
81+
# Terminal 3 — Vite-powered React frontend (http://localhost:5173)
8282
bazel run //frontend:dev_server
8383
```
8484

85-
> `//frontend:dev_server` is not yet implemented. Until it lands, only the backend and test targets above are available.
85+
> Copy `frontend/.env.local.example` to `frontend/.env.local` and fill in the Firebase config values before running the frontend dev server.
8686
8787
### Updating dependencies
8888

frontend/BUILD.bazel

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,81 @@
11
load("@aspect_rules_js//js:defs.bzl", "js_test")
22
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
3+
load("@npm_rulesjs//:vite/package_json.bzl", vite_bin = "bin")
34

45
ts_project(
56
name = "frontend",
67
srcs = glob(
78
["*.ts"],
8-
exclude = ["*.test.ts"],
9+
exclude = [
10+
"*.test.ts",
11+
"vite.config.ts",
12+
],
913
),
1014
declaration = True,
1115
transpiler = "tsc",
1216
tsconfig = "tsconfig.json",
1317
visibility = ["//visibility:public"],
1418
)
1519

20+
ts_project(
21+
name = "frontend_lib",
22+
srcs = glob(
23+
[
24+
"src/**/*.ts",
25+
"src/**/*.tsx",
26+
],
27+
exclude = ["src/**/*.test.*"],
28+
),
29+
declaration = True,
30+
transpiler = "tsc",
31+
tsconfig = "tsconfig.json",
32+
visibility = ["//visibility:public"],
33+
deps = [
34+
"//:node_modules/@types/react",
35+
"//:node_modules/@types/react-dom",
36+
"//:node_modules/firebase",
37+
"//:node_modules/react",
38+
"//:node_modules/react-dom",
39+
"//:node_modules/vite",
40+
],
41+
)
42+
1643
ts_project(
1744
name = "tests_lib",
1845
srcs = ["index.test.ts"],
1946
declaration = True,
2047
transpiler = "tsc",
2148
tsconfig = "tsconfig.json",
22-
deps = ["//:node_modules/@types/node"],
49+
deps = [
50+
":frontend_lib",
51+
"//:node_modules/@types/node",
52+
],
2353
)
2454

2555
js_test(
2656
name = "test",
27-
data = [":tests_lib"],
57+
data = [
58+
":frontend_lib",
59+
":tests_lib",
60+
],
2861
entry_point = "index.test.js",
2962
)
63+
64+
vite_bin.vite_binary(
65+
name = "dev_server",
66+
args = [
67+
"--port=5173",
68+
"--host",
69+
],
70+
chdir = package_name(),
71+
data = [
72+
"index.html",
73+
"tsconfig.json",
74+
"vite.config.ts",
75+
":frontend_lib",
76+
"//:node_modules/@vitejs/plugin-react",
77+
"//:node_modules/firebase",
78+
"//:node_modules/react",
79+
"//:node_modules/react-dom",
80+
] + glob(["src/**"]),
81+
)

frontend/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Septima</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

frontend/index.test.ts

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,95 @@
1-
import test from 'node:test';
21
import assert from 'node:assert';
2+
import test from 'node:test';
3+
import { createApiClient } from './src/apiClient.js';
4+
5+
test('attaches Authorization Bearer header', async () => {
6+
let capturedInit: RequestInit | undefined;
7+
const mockFetch = async (
8+
_url: string | URL | Request,
9+
init?: RequestInit,
10+
): Promise<Response> => {
11+
capturedInit = init;
12+
return new Response(JSON.stringify({}), {
13+
status: 200,
14+
headers: { 'Content-Type': 'application/json' },
15+
});
16+
};
17+
18+
const client = createApiClient(
19+
async () => 'test-token',
20+
'http://localhost:8080',
21+
mockFetch as typeof fetch,
22+
);
23+
await client.post('/ping');
24+
25+
const headers = capturedInit?.headers as Record<string, string>;
26+
assert.strictEqual(headers['Authorization'], 'Bearer test-token');
27+
});
28+
29+
test('sends POST to the correct URL', async () => {
30+
let capturedUrl: string | URL | Request = '';
31+
const mockFetch = async (
32+
url: string | URL | Request,
33+
_init?: RequestInit,
34+
): Promise<Response> => {
35+
capturedUrl = url;
36+
return new Response(JSON.stringify({}), {
37+
status: 200,
38+
headers: { 'Content-Type': 'application/json' },
39+
});
40+
};
41+
42+
const client = createApiClient(
43+
async () => 'token',
44+
'http://localhost:8080',
45+
mockFetch as typeof fetch,
46+
);
47+
await client.post('/ping');
48+
assert.strictEqual(capturedUrl, 'http://localhost:8080/ping');
49+
});
50+
51+
test('throws on non-ok response with status code', async () => {
52+
const mockFetch = async (): Promise<Response> =>
53+
new Response('', { status: 401 });
54+
const client = createApiClient(
55+
async () => 'token',
56+
'http://localhost:8080',
57+
mockFetch as typeof fetch,
58+
);
59+
await assert.rejects(
60+
() => client.post('/ping'),
61+
(err: Error) => {
62+
assert.match(err.message, /HTTP 401/);
63+
return true;
64+
},
65+
);
66+
});
67+
68+
test('includes server error body in thrown error', async () => {
69+
const mockFetch = async (): Promise<Response> =>
70+
new Response('Unauthorized: token expired', { status: 401 });
71+
const client = createApiClient(
72+
async () => 'token',
73+
'http://localhost:8080',
74+
mockFetch as typeof fetch,
75+
);
76+
await assert.rejects(
77+
() => client.post('/ping'),
78+
(err: Error) => {
79+
assert.match(err.message, /token expired/);
80+
return true;
81+
},
82+
);
83+
});
384

4-
test('Simple Test', () => {
5-
assert.strictEqual(true, true);
85+
test('returns undefined for 204 No Content', async () => {
86+
const mockFetch = async (): Promise<Response> =>
87+
new Response(null, { status: 204 });
88+
const client = createApiClient(
89+
async () => 'token',
90+
'http://localhost:8080',
91+
mockFetch as typeof fetch,
92+
);
93+
const result = await client.post('/trigger');
94+
assert.strictEqual(result, undefined);
695
});

frontend/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function App() {
2+
return <div>Septima</div>;
3+
}

frontend/src/apiClient.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export function createApiClient(
2+
getToken: () => Promise<string>,
3+
baseUrl: string,
4+
fetcher: typeof fetch = fetch,
5+
) {
6+
return {
7+
async post<T>(path: string, body?: unknown): Promise<T> {
8+
const token = await getToken();
9+
const response = await fetcher(new URL(path, baseUrl).toString(), {
10+
method: 'POST',
11+
headers: {
12+
Authorization: `Bearer ${token}`,
13+
'Content-Type': 'application/json',
14+
},
15+
body: body != null ? JSON.stringify(body) : undefined,
16+
});
17+
if (!response.ok) {
18+
const detail = await response.text().catch(() => '');
19+
throw new Error(
20+
`HTTP ${response.status}${detail ? `: ${detail}` : ''}`,
21+
);
22+
}
23+
const text = await response.text();
24+
return (text ? JSON.parse(text) : undefined) as T;
25+
},
26+
};
27+
}

frontend/src/firebase.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { initializeApp } from 'firebase/app';
2+
import { connectAuthEmulator, getAuth } from 'firebase/auth';
3+
4+
function requireEnv(key: string, value: string | undefined): string {
5+
if (!value)
6+
throw new Error(
7+
`Missing required env var: ${key}. Copy frontend/.env.local.example to frontend/.env.local and fill in the values.`,
8+
);
9+
return value;
10+
}
11+
12+
const firebaseConfig = {
13+
apiKey: requireEnv(
14+
'VITE_FIREBASE_API_KEY',
15+
import.meta.env.VITE_FIREBASE_API_KEY,
16+
),
17+
authDomain: requireEnv(
18+
'VITE_FIREBASE_AUTH_DOMAIN',
19+
import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
20+
),
21+
projectId: requireEnv(
22+
'VITE_FIREBASE_PROJECT_ID',
23+
import.meta.env.VITE_FIREBASE_PROJECT_ID,
24+
),
25+
};
26+
27+
export const app = initializeApp(firebaseConfig);
28+
export const auth = getAuth(app);
29+
30+
if (import.meta.env.DEV) {
31+
connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true });
32+
}

frontend/src/main.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { StrictMode } from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
import { App } from './App';
4+
5+
createRoot(document.getElementById('root')!).render(
6+
<StrictMode>
7+
<App />
8+
</StrictMode>,
9+
);

frontend/src/vite-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="vite/client" />

0 commit comments

Comments
 (0)