Skip to content

Commit 2c11719

Browse files
committed
Implementation with sonnet 4.6
1 parent ae72fcb commit 2c11719

29 files changed

Lines changed: 1071 additions & 924 deletions

routes/profile.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { readdir, readFile, writeFile, unlink } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import { buildProfileState } from '../shared/profile-domain.mjs';
4+
5+
const USERNAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/i;
6+
7+
const usernameToPath = (username, profileDir) => {
8+
if (!USERNAME_RE.test(username)) return null;
9+
return path.join(profileDir, `${username}.json`);
10+
};
11+
12+
const readProfile = async (username, profileDir) => {
13+
const filePath = usernameToPath(username, profileDir);
14+
if (!filePath) return null;
15+
const raw = await readFile(filePath, 'utf8');
16+
return JSON.parse(raw);
17+
};
18+
19+
const saveProfileState = async (username, incoming, profileDir) => {
20+
const filePath = usernameToPath(username, profileDir);
21+
if (!filePath) {
22+
return {
23+
status: 422,
24+
payload: { ok: false, errors: { id: 'Invalid username' } },
25+
};
26+
}
27+
28+
const merged = { ...(incoming || {}), id: username };
29+
const state = buildProfileState(merged);
30+
31+
if (!state.valid) {
32+
return {
33+
status: 422,
34+
payload: { ok: false, ...state },
35+
};
36+
}
37+
38+
const data = JSON.stringify(state.profile, null, 2);
39+
await writeFile(filePath, data, 'utf8');
40+
return {
41+
status: 200,
42+
payload: { ok: true, ...state },
43+
};
44+
};
45+
46+
const getProfile = async (
47+
req,
48+
res,
49+
{ username },
50+
{ profileDir, sendJson, serveIndex, staticDir },
51+
) => {
52+
if (!USERNAME_RE.test(username)) {
53+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
54+
res.end('Not found');
55+
return;
56+
}
57+
const accept = req.headers.accept || '';
58+
if (accept.includes('text/html') && !accept.includes('application/json')) {
59+
await serveIndex(res, staticDir);
60+
return;
61+
}
62+
try {
63+
const source = await readProfile(username, profileDir);
64+
const state = buildProfileState(source);
65+
sendJson(res, 200, { ok: true, ...state });
66+
} catch (error) {
67+
if (error?.code === 'ENOENT') {
68+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
69+
res.end('Not found');
70+
return;
71+
}
72+
if (error instanceof SyntaxError) {
73+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
74+
res.end('Corrupt profile JSON');
75+
return;
76+
}
77+
throw error;
78+
}
79+
};
80+
81+
const updateProfile = async (
82+
req,
83+
res,
84+
{ username },
85+
{ profileDir, sendJson, parseJsonBody },
86+
) => {
87+
if (!USERNAME_RE.test(username)) {
88+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
89+
res.end('Not found');
90+
return;
91+
}
92+
let body;
93+
try {
94+
body = await parseJsonBody(req);
95+
} catch (error) {
96+
if (error.message === 'INVALID_JSON') {
97+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
98+
res.end('Invalid JSON');
99+
return;
100+
}
101+
throw error;
102+
}
103+
const result = await saveProfileState(username, body, profileDir);
104+
sendJson(res, result.status, result.payload);
105+
};
106+
107+
const removeProfile = async (
108+
req,
109+
res,
110+
{ username },
111+
{ profileDir, sendJson },
112+
) => {
113+
const target = usernameToPath(username, profileDir);
114+
if (!target) {
115+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
116+
res.end('Not found');
117+
return;
118+
}
119+
try {
120+
await unlink(target);
121+
sendJson(res, 200, { ok: true });
122+
} catch (error) {
123+
if (error?.code === 'ENOENT') {
124+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
125+
res.end('Not found');
126+
return;
127+
}
128+
throw error;
129+
}
130+
};
131+
132+
const listProfiles = async (req, res, _params, { profileDir, sendJson }) => {
133+
const url = new URL(req.url, 'http://localhost');
134+
const nameFilter = url.searchParams.get('name')?.toLowerCase() || '';
135+
const emailFilter = url.searchParams.get('email')?.toLowerCase() || '';
136+
137+
const files = await readdir(profileDir).catch(() => []);
138+
const jsonFiles = files.filter((f) => f.endsWith('.json'));
139+
140+
const profiles = await Promise.all(
141+
jsonFiles.map(async (f) => {
142+
try {
143+
const raw = await readFile(path.join(profileDir, f), 'utf8');
144+
const { profile, computed } = buildProfileState(JSON.parse(raw));
145+
return {
146+
id: profile.id,
147+
displayName: computed.displayName,
148+
email: profile.email,
149+
};
150+
} catch {
151+
return null;
152+
}
153+
}),
154+
);
155+
156+
const items = profiles
157+
.filter(Boolean)
158+
.filter(
159+
(p) => !nameFilter || p.displayName.toLowerCase().includes(nameFilter),
160+
)
161+
.filter((p) => !emailFilter || p.email.toLowerCase().includes(emailFilter))
162+
.sort((a, b) => a.id.localeCompare(b.id));
163+
164+
sendJson(res, 200, { ok: true, items });
165+
};
166+
167+
export { USERNAME_RE, usernameToPath, readProfile };
168+
export const routes = [
169+
{ pattern: '/profile', handlers: { GET: listProfiles } },
170+
{
171+
pattern: '/profile/:username',
172+
handlers: {
173+
GET: getProfile,
174+
POST: updateProfile,
175+
PUT: updateProfile,
176+
DELETE: removeProfile,
177+
},
178+
},
179+
];

routes/profiles.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { buildProfileState } from '../shared/profile-domain.mjs';
2+
import { stat, writeFile } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import { USERNAME_RE } from './profile.js';
5+
6+
const createProfile = async (
7+
req,
8+
res,
9+
_params,
10+
{ profileDir, sendJson, parseJsonBody },
11+
) => {
12+
let body;
13+
try {
14+
body = await parseJsonBody(req);
15+
} catch (error) {
16+
if (error.message === 'INVALID_JSON') {
17+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
18+
res.end('Invalid JSON');
19+
return;
20+
}
21+
throw error;
22+
}
23+
24+
const requestedId = typeof body.id === 'string' ? body.id.trim() : '';
25+
if (!USERNAME_RE.test(requestedId)) {
26+
sendJson(res, 422, { ok: false, errors: { id: 'Invalid username' } });
27+
return;
28+
}
29+
30+
const filePath = path.join(profileDir, `${requestedId}.json`);
31+
try {
32+
await stat(filePath);
33+
sendJson(res, 409, { ok: false, error: 'Profile already exists' });
34+
return;
35+
} catch (error) {
36+
if (error?.code !== 'ENOENT') throw error;
37+
}
38+
39+
const merged = { ...body, id: requestedId };
40+
const state = buildProfileState(merged);
41+
42+
if (!state.valid) {
43+
sendJson(res, 422, { ok: false, ...state });
44+
return;
45+
}
46+
47+
await writeFile(filePath, JSON.stringify(state.profile, null, 2), 'utf8');
48+
sendJson(res, 200, { ok: true, ...state });
49+
};
50+
51+
export default { POST: createProfile };

routes/static.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { readFile, readdir } from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
const MIME_TYPES = {
5+
'.html': 'text/html; charset=utf-8',
6+
'.css': 'text/css; charset=utf-8',
7+
'.mjs': 'application/javascript; charset=utf-8',
8+
'.js': 'application/javascript; charset=utf-8',
9+
'.json': 'application/json; charset=utf-8',
10+
'.txt': 'text/plain; charset=utf-8',
11+
};
12+
13+
const TEMPLATES_PLACEHOLDER = '<!-- {{templates}} -->';
14+
15+
const serveFile = async (res, filePath) => {
16+
try {
17+
const data = await readFile(filePath);
18+
const ext = path.extname(filePath);
19+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
20+
res.writeHead(200, { 'Content-Type': contentType });
21+
res.end(data);
22+
} catch (error) {
23+
if (error?.code === 'ENOENT') {
24+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
25+
res.end('Not found');
26+
return;
27+
}
28+
throw error;
29+
}
30+
};
31+
32+
const serveIndex = async (res, staticDir) => {
33+
const componentsDir = path.join(staticDir, 'components');
34+
const [indexHtml, files] = await Promise.all([
35+
readFile(path.join(staticDir, 'index.html'), 'utf8'),
36+
readdir(componentsDir),
37+
]);
38+
const htmlFiles = files.filter((f) => f.endsWith('.html')).sort();
39+
const parts = await Promise.all(
40+
htmlFiles.map((f) => readFile(path.join(componentsDir, f), 'utf8')),
41+
);
42+
const html = indexHtml.replace(TEMPLATES_PLACEHOLDER, parts.join('\n'));
43+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
44+
res.end(html);
45+
};
46+
47+
export { serveFile, serveIndex };

0 commit comments

Comments
 (0)