FrançaisEnglish

Framework web (Mosaic)

Portée. Ce chapitre couvre Mosaic, le framework web côté serveur livré sous forme du package amalgame-web (routage HTTP, middleware, sessions, fichiers statiques, HTTPS). Il ne s'agit pas de amalgame-ui-web, qui est un binding d'interface graphique de bureau au-dessus d'une webview de l'OS. Package différent, cas d'usage différent.

Tout ce qui suit provient de amalgame-web v0.28.0 et de ses dépendances telles que livrées. Les signatures de méthodes sont citées depuis les facades du package.

Mosaic est un framework web petit et explicite : vous créez un WebApp, attachez des handlers de route sous forme de closures, les enveloppez éventuellement dans du middleware (en-têtes de sécurité, CORS, CSRF, limitation de débit, sessions, fichiers statiques), puis démarrez un serveur. Pas d'état global, pas de magie par annotation, pas de réflexion cachée — un handler n'est qu'une Closure<WebContext, HttpResponse>.


Installer / configurer. Cette page est le tour du framework. Pour la mise en place voir Installation ; pour la référence TOML / env complète voir Configuration.


Bonjour, serveur

import Amalgame.Collections
import Amalgame.String
import Amalgame.Net.Http
import Amalgame.Web

public class Program {
    public static void Main(string[] args) {
        let app = new WebApp()

        app.Get("/", ctx => HttpResponse.New().Text("Hello from Mosaic"))

        app.Serve(8080)
    }
}

WebApp.Serve(port) exécute un serveur sériel, traitant une seule connexion à la fois — idéal pour le développement. Il est bloquant. Les quatre imports forment l'ensemble standard : Collections et String pour les types de la stdlib que vos handlers manipulent, Net.Http pour HttpResponse, et Web pour WebApp.

new WebApp() et WebApp.New() sont équivalents — la méthode statique New() ne fait qu'appeler le constructeur. Utilisez celle qui se lit le mieux ; ce chapitre utilise new WebApp().


Routage

Enregistrez un handler par méthode HTTP. Chacun retourne le WebApp, ce qui permet de chaîner les appels :

public WebApp Get(string path, Closure<WebContext, HttpResponse> handler)
public WebApp Post(string path, Closure<WebContext, HttpResponse> handler)
public WebApp Put(string path, Closure<WebContext, HttpResponse> handler)
public WebApp Patch(string path, Closure<WebContext, HttpResponse> handler)
public WebApp Delete(string path, Closure<WebContext, HttpResponse> handler)

Les chemins prennent en charge deux formes de capture :

L'ordre compte. Les routes sont mises en correspondance dans leur ordre d'enregistrement, premier inscrit servi en premier. Enregistrez les chemins statiques avant les chemins paramétrés — placez /users/me avant /users/:id, sinon la route :id avalera me.

app.Get("/users/:id", ctx => {
    let id: string = ctx.Param("id")
    return HttpResponse.New().Json("{\"id\":\"" + id + "\"}")
})

Lire la requête — WebContext

Le handler reçoit un WebContext. Les champs et méthodes que vous utiliserez :

Membre Type Ce qu'il fournit
ctx.Param(name) méthode un segment de chemin :name
ctx.QueryParam(name) méthode une valeur de requête ?name=…
ctx.Query champ Map<string,string> tous les paramètres de requête
ctx.Body champ string corps brut de la requête (texte)
ctx.Method champ string "GET", "POST", …
ctx.Path champ string le chemin sans la chaîne de requête
ctx.State champ Map<string,string> état partagé au niveau de l'app (lecture)
ctx.Session champ Session la session, ou null s'il n'y en a aucune
ctx.IsMultipart() méthode vrai pour un corps multipart/form-data
ctx.Multipart() méthode parse les uploads → un Multipart
app.Post("/echo", ctx => {
    let body: string = ctx.Body
    return HttpResponse.New().Status(201).Text(body)
})

ctx.Body est le corps brut en texte — pratique pour du JSON / des formulaires. Pour les uploads de fichiers (multipart/form-data), utilisez ctx.Multipart(), qui parse les octets bruts (binaire-safe, contrairement à ctx.Body qui s'arrête au premier NUL) :

app.Post("/upload", ctx => {
    let mp = ctx.Multipart()
    let avatar = mp.File("avatar")          // un UploadedFile, ou null
    if (avatar != null) {
        avatar.SaveTo("/var/uploads/" + avatar.Filename)
    }
    let caption: string = mp.Field("caption")   // un champ texte non-fichier
    return HttpResponse.New().Text("reçu " + String_FromInt(mp.AllFiles().Count()) + " fichier(s)")
})

Un UploadedFile expose Name / Filename / ContentType / Size() / Bytes() (un List<int>) / Text() / SaveTo(path). Le parseur vient de amalgame-net-http (Multipart / UploadedFile) et respecte le plafond max_body_bytes du serveur, donc les uploads trop volumineux sont rejetés avant d'atteindre le handler.


Construire la réponse — HttpResponse

HttpResponse (issu de amalgame-net-http) est un builder chaînable :

public HttpResponse Status(int code)              // 200 par défaut
public HttpResponse Text(string s)                // Content-Type: text/plain
public HttpResponse Html(string s)                // Content-Type: text/html
public HttpResponse Json(string jsonText)         // Content-Type: application/json
public HttpResponse Header(string name, string value)   // ajoute/remplace (sûr vis-à-vis CR/LF)
public HttpResponse Redirect(string url, bool permanent) // 302, ou 308 si permanent
public HttpResponse SetCookie(Cookie cookie)      // en-tête Set-Cookie
public HttpResponse File(string path)             // corps de fichier sûr pour le binaire
public HttpResponse Bytes(List<int> data, string ct)  // corps binaire en mémoire
app.Get("/teapot", ctx =>
    HttpResponse.New()
        .Status(418)
        .Header("X-Powered-By", "Mosaic")
        .Json("{\"short_and_stout\":true}"))

Json prend une chaîne — Mosaic n'impose pas d'encodeur JSON, donc construisez le payload avec la stdlib Amalgame.Formats.Json (voir 04-stdlib.fr.md) ou assemblez de petits littéraux par concaténation de chaînes comme ci-dessus.

Bytes(data, ct) envoie un corps binaire en mémoire (un List<int>) avec une longueur explicite — pour des images générées dynamiquement, la sortie gzip, etc. ; tous les en-têtes custom sont préservés sur le fil.


Templates — Template (auto-échappement)

HttpResponse.Html(s) stocke s verbatim : du HTML construit à la main qui interpole des données utilisateur est donc une faille XSS. Le moteur Template (Amalgame.Web, pur-Amalgame) la ferme en échappant chaque valeur par défaut. Rendu directement depuis le contexte :

app.Get("/users/:id", ctx => {
    var data = new Map<string, string>()
    data.Set("name", ctx.Param("id"))     // même s'il contient <script>, c'est échappé
    data.Set("admin", "true")
    return ctx.Render("views/user.html", data)
})

ctx.Render(path, data) lit le fichier template et renvoie une réponse text/html prête ; ctx.RenderString(src, data) rend une chaîne inline. La syntaxe Mustache-like :

Balise Effet
{{x}} valeur de x, échappée HTML (& < > " ' /)
{{{x}}} / {{&x}} valeur de x, brute (opt-out de l'échappement)
{{#x}}…{{/x}} section : rendue une fois si x est truthy
{{^x}}…{{/x}} section inversée : rendue si x est falsy
{{>name}} un partial enregistré
{{! … }} commentaire (supprimé)

Sur un Map<string,string>, une clé est truthy si présente et hors de { "", "false", "0" }. Template.HtmlEscape(s) est aussi disponible directement pour échapper une valeur dans du markup fait main. (L'itération de liste — répéter une section par ligne — demande un contexte plus riche et est sur la roadmap ; les sections sont booléennes pour l'instant.)


Middleware

Le middleware est attaché à l'app via des builders With… et s'exécute autour de chaque handler. Toutes les pièces livrées :

let app = new WebApp()
    .WithSecurityHeaders(SecurityHeaders.StrictApi())
    .WithCors(Cors.AllowAll())          // dev uniquement — origine wildcard
    .WithCsrf(Csrf.Default())
    .WithRateLimit(RateLimit.PerIp(100, 60))   // 100 req / 60 s / IP
    .WithStatic(Static.New("/assets", "./public"))
    .WithCompress(Compression.Default())       // gzip des réponses texte éligibles
    .WithLogging(new LogConfig().WithAccessLog(true))
    .WithState("appName", "demo")

En-têtes de sécurité

SecurityHeaders.StrictHtml() fournit une Content-Security-Policy stricte, X-Frame-Options: DENY, nosniff, etc. — pour les applications HTML. SecurityHeaders.StrictApi() en est la variante pour API JSON (nosniff + politique de referrer, sans règles de framing). Affinez avec .WithHsts(...), .WithCsp(...), .WithFrameOptions(...) et consorts. Les en-têtes propres au handler l'emportent sur les valeurs par défaut.

CORS

Cors.Strict() (aucune origine tant que vous n'en ajoutez pas), Cors.AllowAll() (wildcard — développement uniquement), ou Cors.Disabled(). Ajustez avec .WithAllowedOrigins(list), .WithAllowedMethods(list), .WithAllowCredentials(bool), .WithMaxAge(seconds).

CSRF

Csrf.Default() est une protection par double-submit-cookie : un token de 256 bits, Secure + SameSite=Lax, validé sur les méthodes non sûres (POST/PUT/…) et ignoré sur GET/HEAD/OPTIONS. Csrf.Disabled() le désactive.

Limitation de débit

RateLimit.PerIp(maxRequests, windowSec) — une fenêtre fixe par IP cliente, renvoyant 429 avec Retry-After en cas de dépassement. .WithBackend("redis") + .WithRedisHost(...) partage la limite entre instances ; le backend par défaut "memory" est par processus.

Compression (gzip)

Compression.Default() compresse en gzip les réponses texte éligibles quand le client envoie Accept-Encoding: gzip, en posant Content-Encoding: gzip + Vary: Accept-Encoding. C'est l'étape de réponse finale et elle ne se déclenche que pour les types compressibles (text/*, JSON, JS, SVG, XML) au-dessus d'un seuil de taille (1 Ko) — les petits corps, les réponses déjà encodées et les réponses .File sont laissés intacts (pour ces dernières, servez un .gz pré-compressé à côté de l'asset — voir Fichiers statiques). Compression.Strict() abaisse le seuil et ajoute application/wasm ; ajustez avec .WithMinSize(n) / .WithType(mime). S'appuie sur le package amalgame-compress (zlib).


Sessions

Trois stores sont livrés, exposant tous la même Session (Set/Get/Has/Delete/Clear). Choisissez selon votre topologie de déploiement :

En mémoire (dev / processus unique)

let store = new MemorySessionStore()
let s: Session = store.Create("session-id-here")
s.Set("user", "alice")
let who: string = s.Get("user")     // "alice"
let cookie: Cookie = store.MakeCookie("session-id-here")  // pour resp.SetCookie(...)

Perdu au redémarrage, machine unique uniquement. Thread-safe sous ServeMt.

let store = new SignedCookieSessionStore("a-long-random-secret")
    .WithEncrypted(true)            // AES-256-GCM ; false par défaut = HMAC seul
let value: string = store.Encode(s) // la valeur du cookie
let back: Session  = store.Decode(value)

Le cookie est la session — aucun stockage serveur. La version signée-seule est infalsifiable mais lisible par le client ; .WithEncrypted(true) la rend également confidentielle. (Limitation v0.1 : les clés/valeurs ne peuvent pas encore contenir &, = ou . — pas d'échappement. Convient pour des ids et des flags.)

Redis (distribué, persistant)

let store = new RedisSessionStore()
    .WithHost("127.0.0.1").WithPort(6379)
    .WithKeyPrefix("sess:").WithMaxAgeSec(3600)
let s: Session = store.Create("session-id-here")
store.Save(s)                        // persiste les mutations

Nécessite un Redis accessible (>= 5.0).


Fichiers statiques

app.WithStatic(Static.New("/assets", "./public").WithCacheMaxAge(3600))

Sert ./public/app.css à l'URL /assets/app.css. La traversée de répertoire est bloquée, les répertoires renvoient 403 (pas d'auto-index), les chemins absents retombent sur le routeur. Le type MIME est déterminé par l'extension.

Cache + livraison (v0.27.0) :

Enregistrez les préfixes spécifiques avant les préfixes généraux s'ils se recouvrent.


Servir : HTTP et HTTPS

Mosaic livre plusieurs points d'entrée serveur. Chacun prend un port et bloque :

public int Serve(int port)        // sériel, une connexion à la fois — dev
public int ServeMt(int port)      // un thread OS par connexion
public int ServeAsync(int port)   // un thread, N fibers (Linux/epoll uniquement)

Chacun dispose d'une variante …With(port, cfg) prenant un AmalgameNetHttpServerConfig — utilisez-la pour activer le keep-alive HTTP/1.1 via .WithIdleTimeoutSec(seconds). (Les formes simples Serve/ServeMt traitent une requête par connexion.)

Pour TLS, terminez la connexion en interne (livré à partir de la v0.14.0) :

public int ServeHttps(int port, string certPath, string keyPath)
public int ServeHttpsMt(int port, string certPath, string keyPath)

Ces points d'entrée nécessitent OpenSSL 3.x (ou LibreSSL) sur le système. Pour des certificats Let's Encrypt automatiques, associez-les à la prise en charge ACME de amalgame-tls.


Server-Sent Events (SSE)

Pour du push live unidirectionnel (progression, notifications, tickers) sans le poids d'un WebSocket, enregistrez une route SSE. Le handler tient la connexion et pousse des frames jusqu'à ce que le client se déconnecte :

app.Sse("/events", sc => {
    var n: int = 0
    while (sc.Send("tick " + String_FromInt(n))) {   // false quand le pair est parti
        n = n + 1
        DateTime.SleepSeconds(1)
    }
    return 0
})

Le handler reçoit un SseConn (de amalgame-net-http) : Send(data) (un événement data: ; false signale le départ du client), SendEvent(event, data, id), Comment(s) (un battement : …), Retry(ms), et Close(). Côté navigateur, une ligne suffit :

new EventSource("/events").onmessage = e => console.log(e.data)

Les routes SSE sont matchées (GET + chemin exact) avant le pipeline requête → réponse normal : elles court-circuitent donc les middlewares de réponse (elles possèdent la connexion). Sous ServeMt, chaque flux tient son thread worker pour sa durée de vie — convient pour des dizaines / centaines de clients ; utilisez ServeAsync pour un fan-out élevé.


Un exemple complet

Paramètres de chemin, une route JSON, un corps POST et des en-têtes de sécurité d'API — voici un programme autonome unique :

import Amalgame.Collections
import Amalgame.String
import Amalgame.Net.Http
import Amalgame.Web

public class Program {
    public static void Main(string[] args) {
        let app = new WebApp()

        app.Get("/", ctx => HttpResponse.New().Text("Hello from Mosaic"))

        app.Get("/users/:id", ctx => {
            let id: string = ctx.Param("id")
            return HttpResponse.New().Json("{\"id\":\"" + id + "\"}")
        })

        app.Post("/echo", ctx => {
            let body: string = ctx.Body
            return HttpResponse.New().Status(201).Text(body)
        })

        app.WithSecurityHeaders(SecurityHeaders.StrictApi())
        app.WithCors(Cors.AllowAll())

        app.Serve(8080)
    }
}

Pièges

Pour les patterns de configuration (superposition toml/env/flag) voir mosaic-configuration.md. Pour les types HTTP eux-mêmes, voir le README du package amalgame-net-http.