Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)

This commit is contained in:
bot-claude
2026-08-25 09:55:13 +02:00
commit 5174c37a31
61 changed files with 7117 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import type { ExternalHealth, ExternalItem, ExternalListResult, ExternalService } from "../types";
const DEMO_ITEMS: ExternalItem[] = [
{
id: "demo-1",
title: "Exemple : tâche en cours",
subtitle: "Ceci est un élément de démonstration de l'adaptateur « example ».",
status: "running",
},
{
id: "demo-2",
title: "Exemple : tâche terminée",
subtitle: "Rajoutez ici le détail de ce que votre service REST gère.",
status: "done",
},
{
id: "demo-3",
title: "Exemple : tâche en erreur",
subtitle: "Le pipeline complet (API → cache → UI) fonctionne avec cet adaptateur.",
status: "error",
},
];
/**
* Adaptateur de démonstration : ne contacte aucun service réel.
* Permet de valider tout le pipeline (auth → API → cache → UI) avant
* d'intégrer le service REST cible. À remplacer/configurer via
* SEICHE_EXTERNAL_ADAPTER=http + variables SEICHE_EXTERNAL_*.
*/
export class ExampleExternalService implements ExternalService {
readonly name = "example";
async health(): Promise<ExternalHealth> {
return {
ok: true,
status: 200,
message: "adaptateur de démonstration",
latencyMs: 0,
checkedAt: new Date().toISOString(),
};
}
async list(): Promise<ExternalListResult> {
return { items: DEMO_ITEMS, fetchedAt: new Date().toISOString(), fromCache: false };
}
}
+104
View File
@@ -0,0 +1,104 @@
import { HttpClient } from "../http-client";
import type { ExternalHealth, ExternalItem, ExternalListResult, ExternalService } from "../types";
export interface HttpExternalServiceOptions {
baseUrl: string;
apiKey: string;
healthPath: string;
listPath: string;
timeoutMs: number;
retries: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Adaptateur générique vers une API REST HTTP/HTTPS.
*
* Convention de réponse pour /items (tolérante) :
* - un tableau JSON, ou un objet contenant une clé items/data/results (tableau)
* - chaque élément : id|uuid|slug, title|name|label, description|summary, status|state
*
* À ajuster/étendre quand les détails du service cible sont connus
* (voir docs/service-adapter.md).
*/
export class HttpExternalService implements ExternalService {
readonly name = "http";
private readonly client: HttpClient;
private readonly options: HttpExternalServiceOptions;
constructor(options: HttpExternalServiceOptions) {
this.options = options;
this.client = new HttpClient(options);
}
async health(): Promise<ExternalHealth> {
const startedAt = Date.now();
try {
const res = await this.client.getJson(this.options.healthPath);
const body = isRecord(res.body) ? res.body : undefined;
return {
ok: res.status >= 200 && res.status < 300,
status: res.status,
message:
(body && typeof body.status === "string" ? body.status : undefined) ??
(body && typeof body.message === "string" ? body.message : undefined),
latencyMs: res.latencyMs,
checkedAt: new Date().toISOString(),
};
} catch (err) {
return {
ok: false,
status: 0,
message: err instanceof Error ? err.message : "erreur inconnue",
latencyMs: Date.now() - startedAt,
checkedAt: new Date().toISOString(),
};
}
}
async list(): Promise<ExternalListResult> {
const res = await this.client.getJson(this.options.listPath);
if (res.status < 200 || res.status >= 300) {
throw new Error(`Le service externe a répondu HTTP ${res.status}`);
}
const items = normalizeItems(res.body);
return { items, fetchedAt: new Date().toISOString(), fromCache: false };
}
}
function normalizeItems(body: unknown): ExternalItem[] {
let array: unknown[] | null = null;
if (Array.isArray(body)) {
array = body;
} else if (isRecord(body)) {
for (const key of ["items", "data", "results", "entries"]) {
const value = body[key];
if (Array.isArray(value)) {
array = value;
break;
}
}
}
if (array === null) {
throw new Error("Réponse externe inattendue : tableau JSON attendu");
}
return array.map((element, index) => {
if (!isRecord(element)) {
return { id: String(index), title: String(element), raw: element };
}
const id = element.id ?? element.uuid ?? element.slug ?? element.key ?? String(index);
const title = element.title ?? element.name ?? element.label ?? element.id ?? String(index);
const subtitle = element.description ?? element.summary ?? element.subtitle;
const status = element.status ?? element.state;
return {
id: String(id),
title: String(title),
subtitle: subtitle !== undefined ? String(subtitle) : undefined,
status: status !== undefined ? String(status) : undefined,
raw: element,
};
});
}
+30
View File
@@ -0,0 +1,30 @@
import type { Config } from "../../config";
import type { ExternalService } from "../types";
import { ExampleExternalService } from "./example";
import { HttpExternalService, type HttpExternalServiceOptions } from "./http";
/** Fabrique : instancie l'adaptateur configuré (SEICHE_EXTERNAL_ADAPTER). */
export function createExternalService(config: Config): ExternalService {
switch (config.external.adapter) {
case "http": {
if (config.external.baseUrl === "") {
throw new Error("SEICHE_EXTERNAL_BASE_URL est requis quand SEICHE_EXTERNAL_ADAPTER=http");
}
const options: HttpExternalServiceOptions = {
baseUrl: config.external.baseUrl,
apiKey: config.external.apiKey,
healthPath: config.external.healthPath,
listPath: config.external.listPath,
timeoutMs: config.external.timeoutMs,
retries: config.external.retries,
};
return new HttpExternalService(options);
}
case "example":
return new ExampleExternalService();
default: {
const exhaustive: never = config.external.adapter;
throw new Error(`Adaptateur externe inconnu : ${String(exhaustive)}`);
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import { eq } from "drizzle-orm";
import type { Database } from "../db/client";
import { externalCache } from "../db/schema";
/**
* Cache serveur (SQLite) des réponses du service externe.
* Évite de solliciter le service à chaque interaction UI et donne une
* date de fraîcheur cohérente.
*/
export class ExternalCache {
constructor(
private readonly db: Database,
private readonly ttlMs: number,
) {}
async get<T>(key: string): Promise<T | null> {
const rows = await this.db
.select()
.from(externalCache)
.where(eq(externalCache.key, key))
.limit(1);
const row = rows[0];
if (!row) return null;
if (new Date() >= row.expiresAt) {
await this.db.delete(externalCache).where(eq(externalCache.key, key));
return null;
}
return row.value as T;
}
async set(key: string, value: unknown): Promise<void> {
const now = new Date();
const expiresAt = new Date(now.getTime() + this.ttlMs);
await this.db
.insert(externalCache)
.values({ key, value, fetchedAt: now, expiresAt })
.onConflictDoUpdate({ target: externalCache.key, set: { value, fetchedAt: now, expiresAt } });
}
async invalidate(key: string): Promise<void> {
await this.db.delete(externalCache).where(eq(externalCache.key, key));
}
}
+76
View File
@@ -0,0 +1,76 @@
export interface HttpClientOptions {
baseUrl: string;
apiKey: string;
timeoutMs: number;
retries: number;
}
export class HttpClient {
constructor(private readonly options: HttpClientOptions) {}
/**
* GET avec timeout et retries (backoff exponentiel + jitter) sur
* erreurs réseau / 5xx. Le body est renvoyé tel quel (JSON parsé
* à la main par l'appelant si nécessaire).
*/
async getJson(
path: string,
headers: Record<string, string> = {},
): Promise<{ status: number; body: unknown; latencyMs: number }> {
const url = new URL(path, this.options.baseUrl);
const allHeaders: Record<string, string> = { accept: "application/json", ...headers };
if (this.options.apiKey !== "") {
allHeaders.authorization = `Bearer ${this.options.apiKey}`;
}
let lastError: unknown = null;
for (let attempt = 0; attempt <= this.options.retries; attempt++) {
const startedAt = Date.now();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: allHeaders,
signal: controller.signal,
});
const latencyMs = Date.now() - startedAt;
const text = await res.text();
let body: unknown = null;
if (text !== "") {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
// On ne retente que sur les erreurs transitoires.
if (res.status >= 500) {
lastError = new Error(`HTTP ${res.status} de ${url.toString()}`);
if (attempt < this.options.retries) {
await this.backoff(attempt);
continue;
}
return { status: res.status, body, latencyMs };
}
return { status: res.status, body, latencyMs };
} finally {
clearTimeout(timeout);
}
} catch (err) {
lastError = err;
if (attempt < this.options.retries) {
await this.backoff(attempt);
}
}
}
throw lastError instanceof Error ? lastError : new Error("Échec de la requête externe");
}
private backoff(attempt: number): Promise<void> {
const base = 300 * 2 ** attempt;
const delay = base + Math.floor(Math.random() * 100);
return new Promise((resolve) => setTimeout(resolve, delay));
}
}
+32
View File
@@ -0,0 +1,32 @@
export interface ExternalHealth {
ok: boolean;
status: number;
message?: string;
latencyMs: number;
checkedAt: string;
}
export interface ExternalItem {
id: string;
title: string;
subtitle?: string;
status?: string;
/** Payload brut du service (affiché dans l'UI, utile pour le débogage) */
raw?: unknown;
}
export interface ExternalListResult {
items: ExternalItem[];
fetchedAt: string;
fromCache: boolean;
}
/**
* Contrat d'un adaptateur vers le service REST externe.
* Chaque implémentation est auto-suffisante et ne connaît que sa config.
*/
export interface ExternalService {
readonly name: string;
health(): Promise<ExternalHealth>;
list(): Promise<ExternalListResult>;
}