feat: bff moodle client with swr cache

This commit is contained in:
electron-rare
2026-06-10 21:21:41 +02:00
parent 2971b270bc
commit 83d71850bb
5 changed files with 208 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
export interface CourseDef {
id: number; // Moodle course id
slug: string;
title: string;
blurb: string;
published: boolean;
}
export const COURSES: CourseDef[] = [
{ id: 2, slug: 'esp32-ia', title: 'ESP32 + IA Embarquée', blurb: "Capteurs, TinyML et LLM connectés sur ESP32-S3.", published: true },
{ id: 3, slug: 'kicad-makers', title: 'KiCad pour Makers', blurb: "Du schéma au PCB fabriqué, projet fil rouge ESP32-Breakout.", published: true },
{ id: 5, slug: 'freertos', title: "FreeRTOS pour l'embarqué", blurb: "Tâches, files, priorités et patterns temps réel.", published: true },
{ id: 4, slug: 'llm-locaux', title: 'Déployer des LLM locaux', blurb: "Choisir, servir et intégrer des modèles open-weights.", published: false },
{ id: 6, slug: 'iot-az', title: 'IoT de A à Z', blurb: "Du capteur connecté au dashboard Grafana en production.", published: false },
{ id: 7, slug: 'docker-selfhosting', title: 'Docker et Self-Hosting', blurb: "Conteneurs, Compose, Traefik et mise en production.", published: false },
];
export const bySlug = (slug: string) => COURSES.find((c) => c.slug === slug);
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest';
import { swrCache } from './cache';
describe('swrCache', () => {
it('caches within ttl', async () => {
const fn = vi.fn().mockResolvedValue('a');
const get = swrCache(fn, 1000);
expect(await get('k')).toBe('a');
expect(await get('k')).toBe('a');
expect(fn).toHaveBeenCalledTimes(1);
});
it('serves stale on refresh failure', async () => {
vi.useFakeTimers();
const fn = vi.fn().mockResolvedValueOnce('fresh').mockRejectedValue(new Error('down'));
const get = swrCache(fn, 1000);
expect(await get('k')).toBe('fresh');
vi.advanceTimersByTime(2000);
expect(await get('k')).toBe('fresh');
vi.useRealTimers();
});
it('propagates error when cache is cold', async () => {
const fn = vi.fn().mockRejectedValue(new Error('down'));
const get = swrCache(fn, 1000);
await expect(get('k')).rejects.toThrow('down');
});
});
+23
View File
@@ -0,0 +1,23 @@
type Entry<T> = { value: T; at: number };
/** Stale-while-revalidate in-memory cache: within ttl → cached; past ttl →
* try refresh, fall back to stale value if refresh fails; cold → propagate. */
export function swrCache<T>(fetcher: (key: string) => Promise<T>, ttlMs: number) {
const store = new Map<string, Entry<T>>();
return async (key: string): Promise<T> => {
const hit = store.get(key);
const now = Date.now();
if (hit && now - hit.at < ttlMs) return hit.value;
try {
const value = await fetcher(key);
store.set(key, { value, at: now });
return value;
} catch (err) {
if (hit) {
console.error(`[cache] refresh failed for ${key}, serving stale:`, err);
return hit.value;
}
throw err;
}
};
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { parseCourseContents } from './moodle';
const fixture = JSON.parse(readFileSync('fixtures/course-contents-3.json', 'utf8'));
describe('parseCourseContents', () => {
it('extracts 4 book modules in order', () => {
const mods = parseCourseContents(fixture);
expect(mods).toHaveLength(4);
expect(mods.map((m) => m.id)).toEqual([27, 28, 29, 30]);
expect(mods[0].title).toBe('Module 1 : Découverte de KiCad');
expect(mods[1].title).toBe('Module 2 : Schématique');
expect(mods[2].title).toBe('Module 3 : Du schéma au PCB');
expect(mods[3].title).toBe('Module 4 : Fabrication');
});
it('extracts chapters with real titles, in structure order', () => {
const mods = parseCourseContents(fixture);
expect(mods.map((m) => m.chapters.length)).toEqual([13, 16, 18, 19]);
expect(mods[0].chapters[0].title).toBe('Introduction');
expect(mods[0].chapters[1].title).toBe('1.1 Introduction et installation');
// subitems are flattened depth-first: 1.1's first child comes right after it
expect(mods[0].chapters[2].title).toBe(
"Pourquoi KiCad plutôt qu'Eagle, EasyEDA ou Altium ?",
);
expect(mods[0].chapters[12].title).toBe('Récapitulatif');
for (const m of mods) {
for (const ch of m.chapters) {
expect(ch.title).toBeTruthy();
expect(ch.fileUrl).toContain('pluginfile.php');
}
}
});
it('derives order from the structure TOC, not file-entry order', () => {
const shuffled = JSON.parse(JSON.stringify(fixture));
for (const section of shuffled) {
for (const mod of section.modules ?? []) {
if (mod.modname !== 'book') continue;
const files = mod.contents.filter(
(c: { type: string }) => c.type === 'file',
);
const rest = mod.contents.filter(
(c: { type: string }) => c.type !== 'file',
);
mod.contents = [...rest, ...files.reverse()];
}
}
const mods = parseCourseContents(shuffled);
expect(mods[0].chapters[0].title).toBe('Introduction');
expect(mods[0].chapters[1].title).toBe('1.1 Introduction et installation');
expect(mods[0].chapters[12].title).toBe('Récapitulatif');
});
});
+84
View File
@@ -0,0 +1,84 @@
import { swrCache } from './cache';
export interface Chapter { title: string; fileUrl: string; }
export interface BookModule { id: number; title: string; chapters: Chapter[]; }
const BASE = process.env.MOODLE_BASE_URL || 'https://moodle.saillant.cc';
const TOKEN = process.env.MOODLE_WS_TOKEN || '';
const TTL = Number(process.env.MOODLE_CACHE_TTL_MS || 5 * 60_000);
async function ws(fn: string, params: Record<string, string>): Promise<unknown> {
const body = new URLSearchParams({
wstoken: TOKEN, wsfunction: fn, moodlewsrestformat: 'json', ...params,
});
const res = await fetch(`${BASE}/webservice/rest/server.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body, signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`moodle ws ${fn}: HTTP ${res.status}`);
const data = await res.json();
if (data && typeof data === 'object' && 'exception' in data)
throw new Error(`moodle ws ${fn}: ${(data as { message?: string }).message}`);
return data;
}
interface StructureItem { title: string; href: string; level: number; subitems?: StructureItem[]; }
interface ModContent {
type?: string; filename?: string; filepath?: string;
fileurl?: string; content?: string;
}
interface Mod { id: number; modname?: string; name: string; contents?: ModContent[]; }
interface Section { modules?: Mod[]; }
/** Shape source of truth: fixtures/course-contents-3.json (real instance).
* Order comes from the book's structure TOC (depth-first through subitems);
* titles from per-chapter file entries (fallback: structure title). */
export function parseCourseContents(sections: Section[]): BookModule[] {
const books: BookModule[] = [];
for (const section of sections) {
for (const mod of section.modules ?? []) {
if (mod.modname !== 'book') continue;
const files = new Map<string, { title: string; fileUrl: string }>();
let structure: StructureItem[] = [];
for (const c of mod.contents ?? []) {
if (c.type === 'content' && c.filename === 'structure') {
try { structure = JSON.parse(c.content ?? '[]'); } catch { structure = []; }
} else if (c.type === 'file' && c.filename === 'index.html' && c.fileurl) {
const id = (c.filepath ?? '').replaceAll('/', '');
files.set(id, { title: c.content || 'Chapitre', fileUrl: c.fileurl });
}
}
const ordered: Chapter[] = [];
const walk = (items: StructureItem[]) => {
for (const it of items) {
const id = it.href.split('/')[0] ?? '';
const f = files.get(id);
if (f) { ordered.push({ title: f.title || it.title, fileUrl: f.fileUrl }); files.delete(id); }
if (it.subitems?.length) walk(it.subitems);
}
};
walk(structure);
for (const f of files.values()) ordered.push({ title: f.title, fileUrl: f.fileUrl }); // leftovers
books.push({ id: mod.id, title: mod.name, chapters: ordered });
}
}
return books;
}
const contentsCached = swrCache(async (courseId: string) => {
const sections = (await ws('core_course_get_contents', { courseid: courseId })) as Section[];
return parseCourseContents(sections);
}, TTL);
export const getCourseBooks = (courseId: number) => contentsCached(String(courseId));
const chapterCached = swrCache(async (fileUrl: string) => {
const sep = fileUrl.includes('?') ? '&' : '?';
const res = await fetch(`${fileUrl}${sep}token=${TOKEN}`, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error(`chapter fetch: HTTP ${res.status}`);
return res.text();
}, TTL);
export const getChapterHtml = (fileUrl: string) => chapterCached(fileUrl);