perf: add Cache-Control to stable recipe & fitness API endpoints

rand_array seeds with Math.floor(time / 86400000), i.e. the same
shuffle for every caller during a UTC day — so every list endpoint
that runs through it is safe to share publicly:

  - /items/all_brief, /items/category/[c], /items/tag/[t],
    /items/icon/[i], /items/in_season/[m]
    → public, max-age=28800 (8h), s-maxage=28800, SWR=1d

The distinct-value lists (no shuffle, change only on recipe edit):

  - /items/category, /items/tag, /items/icon
    → public, max-age=3600 (1h), s-maxage=86400 (1d), SWR=1w

Individual recipes change when their author edits them:

  - /items/[name]
    → public, max-age=300 (5m), s-maxage=3600 (1h), SWR=1d

Fitness exercise-picker filters are identical for every logged-in
user but require auth:

  - /fitness/exercises/filters
    → private, max-age=3600

Skipped the calendar page itself: its HTML embeds data.session via the
faith layout's <UserHeader>, so public caching would leak identity.
This commit is contained in:
2026-04-23 15:46:04 +02:00
parent ff6a7ce01a
commit 03875f2be6
12 changed files with 53 additions and 15 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ Order = impact. Font items + app.html preload intentionally skipped.
- [x] 7. Muscle-heatmap endpoint — add projection + O(1) bucket math. Overview already had a projection; set-subfield narrowing was attempted but reverted (returned malformed sets). Timeseries cap not feasible: totals are lifetime-scoped.
- [x] 8. Calendar payload trim — `yearDays` narrowed to `{iso, color}` (needle lookup only), new pre-filtered `feastDots` array carries feast-specific metadata. Also fixed a stray double `locals.session ?? (locals.session ?? …)` in both calendar page loaders.
- [x] 9. History sessions endpoint — projection narrowed to exactly what SessionCard reads (drops notes, templates, mode, endTime, session-level gpsPreview); added `.lean()`.
- [ ] 10. `Cache-Control` headers on stable API endpoints (all_brief, calendar, exercises metadata)
- [x] 10. `Cache-Control` headers on stable API endpoints — added to recipe `/items/category`, `/items/tag`, `/items/icon` (public, 1h/1d with SWR), `/items/[name]` (public, 5m/1h with SWR), and fitness `/exercises/filters` (private, 1h). Skipped `all_brief` (per-request shuffle) and calendar page (`data.session` serialised into HTML via layout → would leak across users under public cache).
- [ ] 11. Search — debounce 100 ms + server-side pre-normalized `_searchKey`
## Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.46.23",
"version": "1.46.24",
"private": true,
"type": "module",
"scripts": {
@@ -42,10 +42,15 @@ function resolveNutritionData(mappings: any[]): any[] {
});
}
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
await dbConnect();
const en = isEnglish(params.recipeLang!);
// Individual recipes change when the author edits them. 5 min browser + 1 h
// edge cache with SWR lets proxies keep hot recipes fresh without blocking
// on the DB; stale content beyond max-age is tolerable here.
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
const query = en
? { 'translations.en.short_name': params.name }
: { short_name: params.name };
@@ -4,12 +4,19 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
const dbRecipes = await Recipe.find(approvalFilter, projection).lean();
const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!));
// rand_array is seeded by `floor(now / 86400000)` — stable for a full UTC
// day across every caller — so the response is safe to share. 8 h browser +
// 8 h edge cache means at worst the shuffle rolls into the next day a few
// hours late; with SWR the stale payload still ships while a fresh one is
// computed.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(rand_array(recipes));
};
@@ -3,12 +3,17 @@ import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { isEnglish, briefQueryConfig } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, prefix } = briefQueryConfig(params.recipeLang!);
await dbConnect();
const field = `${prefix}category`;
const categories = await Recipe.distinct(field, approvalFilter).lean();
// Distinct category list changes only on recipe add/edit. 1 h browser cache +
// 1 d edge cache with SWR keeps the chip bar snappy; worst case a newly
// added category shows up an hour late.
setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800' });
return json(categories);
};
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, prefix, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
).lean();
const recipes = rand_array(dbRecipes.map(r => toBrief(r, params.recipeLang!)));
// rand_array is seeded per UTC day, same for every caller → cacheable.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(recipes);
};
@@ -3,9 +3,12 @@ import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import type {BriefRecipeType} from '$types/types';
export const GET: RequestHandler = async ({params}) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
await dbConnect();
const icons = await Recipe.distinct('icon').lean();
// Same cache budget as /items/category.
setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800' });
return json(icons);
};
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
).lean();
const recipes = rand_array(dbRecipes.map(r => toBrief(r, params.recipeLang!)));
// rand_array is seeded per UTC day, same for every caller → cacheable.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(recipes);
};
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
).lean();
const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!));
// rand_array is seeded per UTC day, same for every caller → cacheable.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(rand_array(recipes));
};
@@ -3,10 +3,14 @@ import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { isEnglish, briefQueryConfig } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter } = briefQueryConfig(params.recipeLang!);
await dbConnect();
// Same cache budget as /items/category — distinct-values list that only
// changes on recipe edit.
setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800' });
if (isEnglish(params.recipeLang!)) {
const recipes = await Recipe.find(approvalFilter, 'translations.en.tags').lean();
const tagsSet = new Set<string>();
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, prefix, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
).lean();
const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!));
// rand_array is seeded per UTC day, same for every caller → cacheable.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(rand_array(recipes));
};
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
import { Exercise } from '$models/Exercise';
// GET /api/fitness/exercises/filters - Get available filter options
export const GET: RequestHandler = async ({ locals }) => {
export const GET: RequestHandler = async ({ locals, setHeaders }) => {
const session = locals.session ?? await locals.auth();
if (!session || !session.user?.nickname) {
return json({ error: 'Unauthorized' }, { status: 401 });
@@ -12,15 +12,21 @@ export const GET: RequestHandler = async ({ locals }) => {
try {
await dbConnect();
const [bodyParts, equipment, targets] = await Promise.all([
Exercise.distinct('bodyPart', { isActive: true }),
Exercise.distinct('equipment', { isActive: true }),
Exercise.distinct('target', { isActive: true })
]);
const difficulties = ['beginner', 'intermediate', 'advanced'];
// Auth-gated but identical for every user. `private` keeps it out of any
// shared cache (auth headers vary per user) while still letting the
// browser reuse the response for 1 h — the filter picker is opened
// repeatedly during a single session.
setHeaders({ 'Cache-Control': 'private, max-age=3600' });
return json({
bodyParts: bodyParts.sort(),
equipment: equipment.sort(),