Skip to content

Commit ebaa6ba

Browse files
Improve workouts worker resilience (#37)
## Summary - add KV binding validation and safe JSON parsing to avoid crashes when storage is empty or misconfigured - wrap worker handler with error handling to return structured JSON errors instead of failing silently ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_69235bd1c5bc8325bf6263d0035fd5fd)
1 parent 6c801bd commit ebaa6ba

1 file changed

Lines changed: 134 additions & 101 deletions

File tree

cloudflare-workers/workouts/worker.js

Lines changed: 134 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ function unauthorizedResponse() {
2525
return jsonResponse({ error: "Unauthorized" }, 401);
2626
}
2727

28+
function serverErrorResponse(message = "Internal Server Error") {
29+
return jsonResponse({ error: message }, 500);
30+
}
31+
2832
function isAuthorized(request, env) {
2933
const auth = request.headers.get("Authorization") || "";
3034
const expected = `Bearer ${env.WORKOUT_API_TOKEN}`;
@@ -43,22 +47,46 @@ async function readJson(request) {
4347
}
4448
}
4549

50+
function assertKvBinding(env) {
51+
if (!env.WORKOUTS || typeof env.WORKOUTS.get !== "function" || typeof env.WORKOUTS.put !== "function") {
52+
throw new Error("WORKOUTS KV binding is not configured");
53+
}
54+
}
55+
56+
function safeParseArray(raw) {
57+
if (!raw) {
58+
return [];
59+
}
60+
61+
try {
62+
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
63+
return Array.isArray(parsed) ? parsed : [];
64+
} catch (error) {
65+
console.error("Failed to parse stored array", error);
66+
return [];
67+
}
68+
}
69+
4670
async function getWorkouts(env) {
47-
const workouts = await env.WORKOUTS.get(WORKOUTS_KEY, "json");
48-
return Array.isArray(workouts) ? workouts : [];
71+
assertKvBinding(env);
72+
const workouts = await env.WORKOUTS.get(WORKOUTS_KEY);
73+
return safeParseArray(workouts);
4974
}
5075

5176
async function saveWorkouts(env, workouts) {
77+
assertKvBinding(env);
5278
await env.WORKOUTS.put(WORKOUTS_KEY, JSON.stringify(workouts));
5379
}
5480

5581
async function getWorkoutLogs(env, workoutId) {
56-
const logs = await env.WORKOUTS.get(`${WORKOUT_LOGS_PREFIX}${workoutId}`, "json");
57-
const parsed = Array.isArray(logs) ? logs : [];
82+
assertKvBinding(env);
83+
const logs = await env.WORKOUTS.get(`${WORKOUT_LOGS_PREFIX}${workoutId}`);
84+
const parsed = safeParseArray(logs);
5885
return parsed.sort((a, b) => new Date(b.finishedAt) - new Date(a.finishedAt));
5986
}
6087

6188
async function saveWorkoutLogs(env, workoutId, logs) {
89+
assertKvBinding(env);
6290
const sorted = [...logs].sort((a, b) => new Date(b.finishedAt) - new Date(a.finishedAt));
6391
await env.WORKOUTS.put(`${WORKOUT_LOGS_PREFIX}${workoutId}`, JSON.stringify(sorted));
6492
}
@@ -166,129 +194,134 @@ function buildLogEntry(template, payload) {
166194

167195
export default {
168196
async fetch(request, env) {
169-
const url = new URL(request.url);
170-
const pathParts = url.pathname.replace(/^\/+/, "").split("/");
171-
172-
if (request.method === "OPTIONS") {
173-
return handleOptions();
174-
}
175-
176-
if (!isAuthorized(request, env)) {
177-
return unauthorizedResponse();
178-
}
197+
try {
198+
const url = new URL(request.url);
199+
const pathParts = url.pathname.replace(/^\/+/, "").split("/");
179200

180-
if (url.pathname === "/api/workouts" && request.method === "GET") {
181-
const workouts = await getWorkouts(env);
182-
return jsonResponse(workouts);
183-
}
201+
if (request.method === "OPTIONS") {
202+
return handleOptions();
203+
}
184204

185-
if (url.pathname === "/api/workouts" && request.method === "POST") {
186-
const body = await readJson(request);
187-
if (body.error) {
188-
return jsonResponse({ error: body.error }, 400);
205+
if (!isAuthorized(request, env)) {
206+
return unauthorizedResponse();
189207
}
190208

191-
const name = typeof body.name === "string" ? body.name.trim() : "";
192-
if (!name) {
193-
return jsonResponse({ error: "Workout name is required" }, 400);
209+
if (url.pathname === "/api/workouts" && request.method === "GET") {
210+
const workouts = await getWorkouts(env);
211+
return jsonResponse(workouts);
194212
}
195213

196-
const exerciseBlocksInput = Array.isArray(body.exerciseBlocks)
197-
? body.exerciseBlocks
198-
: [];
199-
const normalizedBlocks = exerciseBlocksInput
200-
.map(normalizeExerciseBlock)
201-
.filter(Boolean);
214+
if (url.pathname === "/api/workouts" && request.method === "POST") {
215+
const body = await readJson(request);
216+
if (body.error) {
217+
return jsonResponse({ error: body.error }, 400);
218+
}
202219

203-
if (normalizedBlocks.length === 0) {
204-
return jsonResponse({ error: "At least one exercise block is required" }, 400);
205-
}
220+
const name = typeof body.name === "string" ? body.name.trim() : "";
221+
if (!name) {
222+
return jsonResponse({ error: "Workout name is required" }, 400);
223+
}
206224

207-
const workouts = await getWorkouts(env);
208-
const workout = updateTimestamps({
209-
id: body.id || crypto.randomUUID(),
210-
name,
211-
exerciseBlocks: normalizedBlocks,
212-
createdAt: undefined,
213-
updatedAt: undefined,
214-
});
215-
216-
workouts.push(workout);
217-
await saveWorkouts(env, workouts);
218-
return jsonResponse(workout, 201);
219-
}
225+
const exerciseBlocksInput = Array.isArray(body.exerciseBlocks)
226+
? body.exerciseBlocks
227+
: [];
228+
const normalizedBlocks = exerciseBlocksInput
229+
.map(normalizeExerciseBlock)
230+
.filter(Boolean);
231+
232+
if (normalizedBlocks.length === 0) {
233+
return jsonResponse({ error: "At least one exercise block is required" }, 400);
234+
}
220235

221-
if (pathParts[0] === "api" && pathParts[1] === "workouts" && pathParts[2]) {
222-
const id = decodeURIComponent(pathParts[2]);
223-
const workouts = await getWorkouts(env);
224-
const existing = findWorkout(workouts, id);
236+
const workouts = await getWorkouts(env);
237+
const workout = updateTimestamps({
238+
id: body.id || crypto.randomUUID(),
239+
name,
240+
exerciseBlocks: normalizedBlocks,
241+
createdAt: undefined,
242+
updatedAt: undefined,
243+
});
225244

226-
if (!existing) {
227-
return jsonResponse({ error: "Workout not found" }, 404);
245+
workouts.push(workout);
246+
await saveWorkouts(env, workouts);
247+
return jsonResponse(workout, 201);
228248
}
229249

230-
if (pathParts[3] === "logs") {
231-
if (request.method === "GET" && pathParts[4] === "latest") {
232-
const logs = await getWorkoutLogs(env, id);
233-
const latest = logs[0] || null;
234-
return jsonResponse({ log: latest });
250+
if (pathParts[0] === "api" && pathParts[1] === "workouts" && pathParts[2]) {
251+
const id = decodeURIComponent(pathParts[2]);
252+
const workouts = await getWorkouts(env);
253+
const existing = findWorkout(workouts, id);
254+
255+
if (!existing) {
256+
return jsonResponse({ error: "Workout not found" }, 404);
257+
}
258+
259+
if (pathParts[3] === "logs") {
260+
if (request.method === "GET" && pathParts[4] === "latest") {
261+
const logs = await getWorkoutLogs(env, id);
262+
const latest = logs[0] || null;
263+
return jsonResponse({ log: latest });
264+
}
265+
266+
if (request.method === "GET") {
267+
const logs = await getWorkoutLogs(env, id);
268+
return jsonResponse({ logs });
269+
}
270+
271+
if (request.method === "POST") {
272+
const body = await readJson(request);
273+
if (body.error) {
274+
return jsonResponse({ error: body.error }, 400);
275+
}
276+
277+
const logEntry = buildLogEntry(existing, body);
278+
const logs = await getWorkoutLogs(env, id);
279+
logs.push(logEntry);
280+
await saveWorkoutLogs(env, id, logs);
281+
return jsonResponse(logEntry, 201);
282+
}
235283
}
236284

237285
if (request.method === "GET") {
238-
const logs = await getWorkoutLogs(env, id);
239-
return jsonResponse({ logs });
286+
return jsonResponse(existing);
240287
}
241288

242-
if (request.method === "POST") {
289+
if (request.method === "DELETE") {
290+
const updated = workouts.filter((workout) => workout.id !== id);
291+
await saveWorkouts(env, updated);
292+
return jsonResponse({ success: true });
293+
}
294+
295+
if (request.method === "PUT") {
243296
const body = await readJson(request);
244297
if (body.error) {
245298
return jsonResponse({ error: body.error }, 400);
246299
}
247300

248-
const logEntry = buildLogEntry(existing, body);
249-
const logs = await getWorkoutLogs(env, id);
250-
logs.push(logEntry);
251-
await saveWorkoutLogs(env, id, logs);
252-
return jsonResponse(logEntry, 201);
253-
}
254-
}
255-
256-
if (request.method === "GET") {
257-
return jsonResponse(existing);
258-
}
259-
260-
if (request.method === "DELETE") {
261-
const updated = workouts.filter((workout) => workout.id !== id);
262-
await saveWorkouts(env, updated);
263-
return jsonResponse({ success: true });
264-
}
265-
266-
if (request.method === "PUT") {
267-
const body = await readJson(request);
268-
if (body.error) {
269-
return jsonResponse({ error: body.error }, 400);
270-
}
301+
const name = typeof body.name === "string" ? body.name.trim() : existing.name;
302+
const exerciseBlocksInput = Array.isArray(body.exerciseBlocks)
303+
? body.exerciseBlocks
304+
: existing.exerciseBlocks;
305+
const normalizedBlocks = exerciseBlocksInput
306+
.map(normalizeExerciseBlock)
307+
.filter(Boolean);
271308

272-
const name = typeof body.name === "string" ? body.name.trim() : existing.name;
273-
const exerciseBlocksInput = Array.isArray(body.exerciseBlocks)
274-
? body.exerciseBlocks
275-
: existing.exerciseBlocks;
276-
const normalizedBlocks = exerciseBlocksInput
277-
.map(normalizeExerciseBlock)
278-
.filter(Boolean);
309+
if (normalizedBlocks.length === 0) {
310+
return jsonResponse({ error: "At least one exercise block is required" }, 400);
311+
}
279312

280-
if (normalizedBlocks.length === 0) {
281-
return jsonResponse({ error: "At least one exercise block is required" }, 400);
313+
existing.name = name;
314+
existing.exerciseBlocks = normalizedBlocks;
315+
updateTimestamps(existing);
316+
await saveWorkouts(env, workouts);
317+
return jsonResponse(existing);
282318
}
283-
284-
existing.name = name;
285-
existing.exerciseBlocks = normalizedBlocks;
286-
updateTimestamps(existing);
287-
await saveWorkouts(env, workouts);
288-
return jsonResponse(existing);
289319
}
290-
}
291320

292-
return jsonResponse({ error: "Not found" }, 404);
321+
return jsonResponse({ error: "Not found" }, 404);
322+
} catch (error) {
323+
console.error("Unhandled error in workouts worker", error);
324+
return serverErrorResponse(error.message);
325+
}
293326
},
294327
};

0 commit comments

Comments
 (0)