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 deamalgame-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.0et 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()etWebApp.New()sont équivalents — la méthode statiqueNew()ne fait qu'appeler le constructeur. Utilisez celle qui se lit le mieux ; ce chapitre utilisenew 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 :
:name— un segment unique./users/:idcorrespond à/users/42, et captureid = "42".*name— une fin de chemin (wildcard)./files/*pathcorrespond à/files/css/app.css, capturantpath = "css/app.css"(slashes inclus).
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/meavant/users/:id, sinon la route:idavalerame.
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.
Cookie signé (sans état, scale horizontalement)
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) :
- GET conditionnel —
ETag+Last-Modifiedsont émis ; unIf-None-MatchouIf-Modified-Sincecorrespondant renvoie304 Not Modified. - Requêtes Range —
Accept-Ranges: bytesest annoncé ; un en-têteRange:est servi en206 Partial Content(range unique :0-1023,1024-, ou suffixe-512), avec416+Content-Range: bytes */Nsi non satisfiable. Idéal pour la reprise de vidéos / gros fichiers. - Variantes pré-compressées — quand le client accepte gzip et qu'un
<fichier>.gzest présent à côté de l'asset, il est servi avecContent-Encoding: gzip(ignoré pour les requêtes Range). C'est le complément « fichier » du middlewareCompressionen mémoire.
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
amalgame-net-httpdoit être>= 0.11.1. Les versions plus anciennes perdent tous les en-têtes de réponse personnalisés sur le réseau — vos cookiesSet-Cookie, CSP, CORS et CSRF disparaissent silencieusement.amc package add webrésout un plancher correct ; ne fixez pas une version plus ancienne de net-http à la main.- L'ordre des routes est significatif — statique avant paramétré, préfixes de fichiers statiques spécifiques avant les généraux.
ctx.Bodyest du texte (s'arrête à un NUL) — pour les uploads binaires, utilisezctx.Multipart(), qui parcourt les octets bruts.Cors.AllowAll()est destiné au développement. En production, listez des origines explicites avecCors.Strict().WithAllowedOrigins([...]).- HTTPS nécessite OpenSSL 3.x sur le système. Les points d'entrée
ServeHttps*s'y lient.
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.