Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)
This commit is contained in:
+46
@@ -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 };
|
||||
}
|
||||
}
|
||||
Vendored
+104
@@ -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
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+43
@@ -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));
|
||||
}
|
||||
}
|
||||
Vendored
+76
@@ -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));
|
||||
}
|
||||
}
|
||||
Vendored
+32
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user