Docs
Tout ce qu'il faut pour déployer et appeler une fonction : le contrat du handler, l'invocation HTTP, les variables d'environnement, les quotas, les métriques et le pilotage par agent. Chaque exemple correspond au comportement réel de la plateforme.
Démarrage rapide
- Créez un compte et vérifiez votre adresse email.
- Déployez une fonction depuis le tableau de bord. Un exemple qui marche est prérempli ; choisissez Python ou Node.js.
- Testez-la depuis sa page avec le bouton « Tester la fonction ».
- Créez une clé API pour l'appeler depuis l'extérieur (les fonctions publiques s'appellent sans clé).
Le crédit offert à l'inscription couvre largement ces premiers pas : une fonction en veille ne coûte rien.
Le contrat du handler
Une fonction est un fichier unique qui expose un handler. Il reçoit un objet requête et retourne la réponse : une chaîne, un objet JSON, ou un tuple (statut, corps, en-têtes).
# main.py — handler: main.handler
def handler(request):
# request.method -> "GET", "POST", ...
# request.path -> str
# request.headers -> dict[str, str]
# request.query -> dict[str, str]
# request.body -> bytes
# request.json() -> parsed JSON body
name = request.query.get("name", "world")
return {"message": f"hello, {name}"}
# other return shapes:
# "text" -> 200 text/plain
# {"a": 1} | [1, 2] -> 200 application/json
# (404, "not found") -> (status, body)
# (201, body, {"X-My": "hdr"}) -> (status, body, headers)// main.js — handler: main.handler
exports.handler = (request) => {
// request.method -> "GET", "POST", ...
// request.path -> string
// request.headers -> object (lower-cased names)
// request.query -> object (first value per key)
// request.body -> Buffer
// request.json() -> parsed JSON body
const name = request.query.name || 'world';
return { message: `hello, ${name}` };
// other return shapes:
// 'text' -> 200 text/plain
// { a: 1 } -> 200 application/json
// [404, 'not found'] -> [status, body]
// [201, body, { 'X-My': 'hdr' }] -> [status, body, headers]
};Le champ « Handler » du formulaire désigne module.fonction (par défaut main.handler). Les runtimes embarquent déjà les bibliothèques courantes : requests, httpx, pydantic, PyYAML côté Python ; axios, lodash, zod, dayjs côté Node.
Invoquer une fonction
Une fonction privée s'appelle avec une clé API (créée dans l'app, affichée une seule fois) passée en en-tête Authorization :
curl -H "Authorization: Bearer $ZEROLITH_KEY" \
"https://<function-url>?name=neo"Une fonction publique s'appelle sans authentification, ce qui est pratique pour un webhook :
# public function — no key needed curl "https://<function-url>?name=neo"
Une URL présignée appelle une seule fonction privée sans clé côté appelant, jusqu'à la date que vous fixez. Créez-la depuis la page de la fonction ; elle n'est affichée qu'une fois, et vous pouvez la révoquer avant son expiration :
# presigned URL — one function, until its expiry date curl "https://<function-url>?faas_token=faast_..."
Le jeton n'autorise que cette fonction, ni votre compte ni vos autres fonctions. Les appels passés par cette URL vous sont facturés comme n'importe quelle invocation, et vos garde-fous s'appliquent toujours (fonction suspendue ou solde épuisé : l'appel est refusé).
L'authentification est vérifiée en périphérie, avant même que votre fonction ne se réveille, donc un appel non autorisé ne vous coûte rien. Toutes les méthodes HTTP (GET, POST, PUT, PATCH, DELETE) et tous les chemins sont transmis au handler.
Exemples en direct
Quatre fonctions réellement déployées sur la plateforme, publiques et en scale-to-zero. Le code affiché est le vrai handler, et le bouton « Appeler en direct » interroge vraiment l'URL publique : ce que vous voyez est la réponse de la fonction, rien n'est simulé.
Un tableau de bord météo (Python) : la fonction va chercher ses données côté serveur (open-meteo, sans clé), calcule la géométrie de ses deux graphiques en Python et renvoie une page HTML complète. Le SVG est déjà dessiné quand le document arrive — ni bundler, ni étape de build, ni écran de chargement. La même URL avec ?format=json renvoie les mêmes données brutes. C'est la fonction de l'article « Artefact, canvas, frame ».
main.py
"""zerolith · exemple « artefact hébergé ».
Une seule fonction Python qui rend une PAGE, pas un JSON : elle va chercher ses données
côté serveur (open-meteo, sans clé), calcule la géométrie de deux graphiques, et renvoie
un document HTML autonome — SVG en ligne, CSS en ligne, aucune dépendance externe, aucune
étape de build, aucun bundler.
C'est le même geste qu'un artefact de chatbot, sauf que le résultat a une URL stable, que
les données sont fraîches à chaque chargement, et qu'aucun pod ne tourne entre deux
visites.
GET /?city=Paris
GET /?city=Tokyo&days=7
GET /?city=Paris&format=json -> les mêmes données, brutes
"""
import html
import math
import os
import time
from datetime import datetime
from urllib.parse import quote
import httpx
TIMEOUT = float(os.environ.get("DEMO_TIMEOUT_SECONDS", "8"))
UA = {"User-Agent": "zerolith-demo/1.0 (+https://zerolith.io)"}
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
# Palette de marque, mais ramenée dans la bande de luminosité qui reste lisible sur un
# fond quasi noir : des néons purs à 2 px de trait « bavent » et deux séries voisines
# cessent d'être distinguables en vision daltonienne.
CYAN = "#1fa3a7"
AMBER = "#d26a10"
INK = "#c9d6e2"
INK_DIM = "#8497a6"
INK_FAINT = "#5b6c7b"
GRID = "#16212e"
SURFACE = "#070b11"
PANEL = "#0c121b"
_BOOT = time.monotonic()
_POD = os.environ.get("HOSTNAME", "inconnu")
_SERVED = 0
JOURS = ["lun", "mar", "mer", "jeu", "ven", "sam", "dim"]
# ─────────────────────────────────────────────────────────────────────────────
# Données
# ─────────────────────────────────────────────────────────────────────────────
def fetch(city, days):
with httpx.Client(headers=UA, timeout=TIMEOUT) as client:
geo = client.get(
GEOCODE_URL, params={"name": city, "count": 1, "language": "fr"}
)
geo.raise_for_status()
hits = geo.json().get("results") or []
if not hits:
return None
place = hits[0]
forecast = client.get(
FORECAST_URL,
params={
"latitude": place["latitude"],
"longitude": place["longitude"],
"current": "temperature_2m,apparent_temperature,wind_speed_10m,relative_humidity_2m",
"hourly": "temperature_2m",
"daily": "temperature_2m_max,temperature_2m_min",
"forecast_days": days,
"timezone": "auto",
},
)
forecast.raise_for_status()
data = forecast.json()
hourly = data["hourly"]
daily = data["daily"]
# Une fenêtre de 24 h CENTRÉE SUR MAINTENANT (6 h de passé, 18 h à venir) : open-meteo
# renvoie les heures à partir de minuit, et à 23 h un graphe « aujourd'hui » n'aurait
# plus rien à montrer.
now = data["current"]["time"][:13]
cursor = next((i for i, t in enumerate(hourly["time"]) if t[:13] >= now), 0)
start = max(0, cursor - 6)
return {
"city": place["name"],
"country": place.get("country"),
"timezone": data.get("timezone"),
"current": data["current"],
"hourly": [
{"t": t, "temp": v}
for t, v in zip(
hourly["time"][start : start + 24],
hourly["temperature_2m"][start : start + 24],
)
if v is not None
],
"daily": [
{"date": d, "min": lo, "max": hi}
for d, lo, hi in zip(
daily["time"], daily["temperature_2m_min"], daily["temperature_2m_max"]
)
],
}
# ─────────────────────────────────────────────────────────────────────────────
# Géométrie : tout le calcul des graphiques se fait ici, en Python.
# ─────────────────────────────────────────────────────────────────────────────
def nice_scale(lo, hi, ticks=4):
"""Une échelle qui tombe sur des valeurs rondes, avec un peu d'air en haut/bas."""
span = max(hi - lo, 1.0)
raw = span / ticks
magnitude = 10 ** (len(str(int(raw))) - 1) if raw >= 1 else 0.1
step = next(m * magnitude for m in (1, 2, 2.5, 5, 10) if m * magnitude >= raw)
bottom = step * math.floor(lo / step)
top = bottom + step * ticks
while top < hi:
top += step
return bottom, top, step
def line_chart(points, width=760, height=220):
"""Courbe horaire : une seule série, donc pas de légende — le titre la nomme."""
pad_l, pad_r, pad_t, pad_b = 44, 16, 18, 34
temps = [p["temp"] for p in points]
lo, hi, step = nice_scale(min(temps), max(temps))
plot_w = width - pad_l - pad_r
plot_h = height - pad_t - pad_b
def x_of(i):
return pad_l + (plot_w * i / max(len(points) - 1, 1))
def y_of(v):
return pad_t + plot_h - plot_h * (v - lo) / (hi - lo)
coords = [(x_of(i), y_of(p["temp"])) for i, p in enumerate(points)]
line = " ".join(f"{'M' if i == 0 else 'L'}{x:.1f},{y:.1f}" for i, (x, y) in enumerate(coords))
area = (
f"{line} L{coords[-1][0]:.1f},{pad_t + plot_h:.1f} "
f"L{coords[0][0]:.1f},{pad_t + plot_h:.1f} Z"
)
out = []
# Grille horizontale seulement : les repères verticaux n'aident pas une courbe.
v = lo
while v <= hi + 1e-9:
y = y_of(v)
out.append(
f'<line x1="{pad_l}" y1="{y:.1f}" x2="{width - pad_r}" y2="{y:.1f}" '
f'stroke="{GRID}" stroke-width="1"/>'
)
out.append(
f'<text x="{pad_l - 8}" y="{y + 4:.1f}" text-anchor="end" class="tick">{v:g}°</text>'
)
v += step
out.append(f'<path d="{area}" fill="url(#fade)"/>')
out.append(
f'<path d="{line}" fill="none" stroke="{CYAN}" stroke-width="2" '
'stroke-linejoin="round" stroke-linecap="round"/>'
)
# Étiquettes directes sur les seuls points qui méritent un chiffre : le pic et le creux.
# Une seule si la série est plate — sinon les deux se superposent au même endroit.
marked = [("max", temps.index(max(temps)))]
if max(temps) != min(temps):
marked.append(("min", temps.index(min(temps))))
for label, idx in marked:
x, y = coords[idx]
# Le creux s'étiquette EN DESSOUS, sauf s'il touche le bas du cadre : là, l'étiquette
# tomberait dans la rangée des heures. Une échelle arrondie rend le cas rare, pas
# impossible — et « rare » est exactement ce qui n'est jamais testé.
dy = -12 if label == "max" or y > pad_t + plot_h - 22 else 20
# Près d'un bord, une étiquette centrée déborde sur la colonne des graduations.
if x < pad_l + 24:
anchor, dx = "start", 8
elif x > width - pad_r - 24:
anchor, dx = "end", -8
else:
anchor, dx = "middle", 0
out.append(
f'<circle cx="{x:.1f}" cy="{y:.1f}" r="4" fill="{CYAN}" '
f'stroke="{SURFACE}" stroke-width="2"/>'
)
out.append(
f'<text x="{x + dx:.1f}" y="{y + dy:.1f}" text-anchor="{anchor}" class="peak">'
f"{temps[idx]:g}°</text>"
)
# Heures, une graduation sur trois.
for i, p in enumerate(points):
if i % 3:
continue
hour = p["t"][11:16]
out.append(
f'<text x="{x_of(i):.1f}" y="{height - 8}" text-anchor="middle" class="tick">{hour}</text>'
)
# Couche de survol : une bande par point, plus large que la marque elle-même.
band = plot_w / max(len(points) - 1, 1)
for i, (x, y) in enumerate(coords):
out.append(
f'<rect class="hit" x="{x - band / 2:.1f}" y="{pad_t}" width="{band:.1f}" '
f'height="{plot_h}" fill="transparent" data-x="{x:.1f}" data-y="{y:.1f}" '
f'data-label="{points[i]["t"][11:16]}" data-value="{points[i]["temp"]:g} °C"/>'
)
return (
f'<svg viewBox="0 0 {width} {height}" class="chart" role="img" '
f'aria-label="Température heure par heure">'
f'<defs><linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">'
f'<stop offset="0%" stop-color="{CYAN}" stop-opacity="0.22"/>'
f'<stop offset="100%" stop-color="{CYAN}" stop-opacity="0"/>'
f"</linearGradient></defs>"
+ "".join(out)
# Le viseur est émis EN DERNIER : en SVG il n'y a pas de z-index, seul l'ordre
# du document décide de ce qui passe au-dessus.
+ f'<g class="crosshair" style="display:none" pointer-events="none">'
f'<line y1="{pad_t}" y2="{pad_t + plot_h}" stroke="{INK_FAINT}" stroke-width="1"/>'
f'<circle r="5" fill="{CYAN}" stroke="{SURFACE}" stroke-width="2"/></g>'
+ "</svg>"
)
def range_chart(days, width=760, height=200):
"""Barres d'amplitude : une barre = l'intervalle min→max d'un jour, pas une magnitude.
C'est pourquoi elle ne part pas de zéro — elle encode un segment, pas une quantité."""
# pad_b tient DEUX rangées de texte sous le cadre : le minimum de chaque barre, puis le
# nom du jour.
pad_l, pad_r, pad_t, pad_b = 44, 16, 26, 44
lo, hi, step = nice_scale(
min(d["min"] for d in days), max(d["max"] for d in days)
)
plot_w = width - pad_l - pad_r
plot_h = height - pad_t - pad_b
slot = plot_w / len(days)
bar_w = min(26, slot - 14) # l'espace entre deux barres reste ≥ 14 px
def y_of(v):
return pad_t + plot_h - plot_h * (v - lo) / (hi - lo)
out = []
v = lo
while v <= hi + 1e-9:
y = y_of(v)
out.append(
f'<line x1="{pad_l}" y1="{y:.1f}" x2="{width - pad_r}" y2="{y:.1f}" '
f'stroke="{GRID}" stroke-width="1"/>'
)
out.append(
f'<text x="{pad_l - 8}" y="{y + 4:.1f}" text-anchor="end" class="tick">{v:g}°</text>'
)
v += step
for i, d in enumerate(days):
cx = pad_l + slot * (i + 0.5)
y_hi, y_lo = y_of(d["max"]), y_of(d["min"])
date = datetime.fromisoformat(d["date"])
out.append(
f'<rect class="hit bar" x="{cx - bar_w / 2:.1f}" y="{y_hi:.1f}" width="{bar_w:.1f}" '
f'height="{max(y_lo - y_hi, 4):.1f}" rx="4" fill="{AMBER}" '
f'data-label="{JOURS[date.weekday()]} {date.day}" '
f'data-value="{d["min"]:g} → {d["max"]:g} °C"/>'
)
out.append(
f'<text x="{cx:.1f}" y="{y_hi - 8:.1f}" text-anchor="middle" class="peak">{d["max"]:g}°</text>'
)
out.append(
f'<text x="{cx:.1f}" y="{y_lo + 15:.1f}" text-anchor="middle" class="tick">{d["min"]:g}°</text>'
)
out.append(
f'<text x="{cx:.1f}" y="{height - 8}" text-anchor="middle" class="tick">'
f"{JOURS[date.weekday()]}</text>"
)
return (
f'<svg viewBox="0 0 {width} {height}" class="chart" role="img" '
f'aria-label="Amplitude quotidienne, minimum au maximum">' + "".join(out) + "</svg>"
)
# ─────────────────────────────────────────────────────────────────────────────
# Page
# ─────────────────────────────────────────────────────────────────────────────
CSS = f"""
*{{box-sizing:border-box}}
body{{margin:0;background:{SURFACE};color:{INK};
font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}}
.wrap{{max-width:860px;margin:0 auto;padding:32px 20px 64px}}
h1{{font-size:26px;letter-spacing:.04em;margin:0 0 4px;font-weight:700}}
h2{{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:{INK_DIM};
margin:36px 0 10px;font-weight:700}}
.sub{{color:{INK_DIM};margin:0 0 28px;font-size:13px}}
.tiles{{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}}
.tile{{background:{PANEL};border:1px solid {GRID};border-left:2px solid {CYAN};
border-radius:2px;padding:14px 16px}}
.tile .v{{font-size:28px;font-weight:700;color:{INK};line-height:1.2}}
.tile .k{{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:{INK_DIM}}}
.panel{{background:{PANEL};border:1px solid {GRID};border-radius:2px;padding:8px 4px;
position:relative}}
.chart{{width:100%;height:auto;display:block;overflow:visible}}
.tick{{fill:{INK_FAINT};font-size:11px}}
.peak{{fill:{INK};font-size:12px;font-weight:700}}
.hit:hover{{cursor:crosshair}}
.bar{{transition:opacity .12s}}
.panel:hover .bar{{opacity:.55}}
.bar:hover{{opacity:1}}
.tip{{position:absolute;pointer-events:none;opacity:0;transition:opacity .1s;
background:{SURFACE};border:1px solid {CYAN};border-radius:2px;padding:6px 9px;
font-size:12px;white-space:nowrap;transform:translate(-50%,-140%);z-index:5}}
.tip b{{color:{CYAN};font-weight:700}}
details{{margin-top:12px}}
summary{{color:{INK_DIM};font-size:12px;cursor:pointer}}
table{{border-collapse:collapse;margin-top:10px;font-size:12px;width:100%}}
th,td{{text-align:right;padding:4px 10px;border-bottom:1px solid {GRID};
font-variant-numeric:tabular-nums}}
th:first-child,td:first-child{{text-align:left}}
th{{color:{INK_DIM};font-weight:400;font-size:10px;letter-spacing:.14em;text-transform:uppercase}}
footer{{margin-top:44px;padding-top:18px;border-top:1px solid {GRID};
color:{INK_FAINT};font-size:12px}}
a{{color:{CYAN};text-decoration:none}} a:hover{{color:{AMBER}}}
@media (prefers-reduced-motion:reduce){{*{{transition:none!important}}}}
"""
JS = """
document.querySelectorAll('.panel').forEach(function (panel) {
var tip = document.createElement('div');
tip.className = 'tip';
panel.appendChild(tip);
var cross = panel.querySelector('.crosshair');
var svg = panel.querySelector('svg');
panel.querySelectorAll('.hit').forEach(function (hit) {
hit.addEventListener('pointerenter', function () {
var box = hit.getBoundingClientRect(), p = panel.getBoundingClientRect();
tip.innerHTML = hit.dataset.label + ' <b>' + hit.dataset.value + '</b>';
tip.style.left = (box.left + box.width / 2 - p.left) + 'px';
tip.style.top = (hit.dataset.y
? svg.getBoundingClientRect().top - p.top
+ (+hit.dataset.y) * svg.getBoundingClientRect().height / svg.viewBox.baseVal.height
: box.top - p.top) + 'px';
tip.style.opacity = 1;
if (cross && hit.dataset.x) {
cross.style.display = '';
cross.querySelector('line').setAttribute('x1', hit.dataset.x);
cross.querySelector('line').setAttribute('x2', hit.dataset.x);
cross.querySelector('circle').setAttribute('cx', hit.dataset.x);
cross.querySelector('circle').setAttribute('cy', hit.dataset.y);
}
});
});
panel.addEventListener('pointerleave', function () {
tip.style.opacity = 0;
if (cross) cross.style.display = 'none';
});
});
"""
def page(d, meta):
"""Assemble le document. `html.escape` sur TOUT ce qui vient de la requête : le nom de
ville arrive du client, et il finit dans le HTML."""
cur = d["current"]
city = html.escape(f"{d['city']}, {d['country']}")
tiles = [
("Température", f"{cur['temperature_2m']:g}°"),
("Ressenti", f"{cur['apparent_temperature']:g}°"),
("Vent", f"{cur['wind_speed_10m']:g} km/h"),
("Humidité", f"{cur['relative_humidity_2m']:g} %"),
]
rows = "".join(
f"<tr><td>{html.escape(x['date'])}</td><td>{x['min']:g} °C</td>"
f"<td>{x['max']:g} °C</td></tr>"
for x in d["daily"]
)
return f"""<!doctype html>
<html lang="fr"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>{city} — tableau de bord</title>
<style>{CSS}</style></head>
<body><div class="wrap">
<h1>{city}</h1>
<p class="sub">Fuseau {html.escape(d["timezone"] or "?")} · relevé
{html.escape(cur["time"])} · page assemblée en {meta["render_ms"]} ms</p>
<div class="tiles">
{"".join(f'<div class="tile"><div class="v">{v}</div><div class="k">{k}</div></div>'
for k, v in tiles)}
</div>
<h2>Température, heure par heure</h2>
<div class="panel">{line_chart(d["hourly"][:24])}</div>
<h2>Amplitude quotidienne (min → max)</h2>
<div class="panel">{range_chart(d["daily"])}
<details><summary>Voir les données</summary>
<table><thead><tr><th>Jour</th><th>Min</th><th>Max</th></tr></thead>
<tbody>{rows}</tbody></table></details></div>
<footer>
Rendu par une fonction zerolith · pod <code>{html.escape(meta["pod"])}</code> ·
{"démarrage à froid" if meta["cold_start"] else f'pod en vie depuis {meta["pod_age_s"]} s'} ·
requête n° {meta["served"]} de ce pod<br>
Données <a href="https://open-meteo.com">open-meteo</a>, récupérées côté serveur en
{meta["fetch_ms"]} ms · <a href="?city={quote(d["city"])}&format=json">les mêmes
données en JSON</a>
</footer>
</div><script>{JS}</script></body></html>"""
HTML_HEADERS = {"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store"}
def handler(request):
global _SERVED
started = time.perf_counter()
cold = _SERVED == 0
_SERVED += 1
city = (request.query.get("city") or "Paris").strip()[:80]
try:
days = min(max(int(request.query.get("days", 7)), 2), 10)
except ValueError:
days = 7
fetch_started = time.perf_counter()
data = fetch(city, days)
fetch_ms = round((time.perf_counter() - fetch_started) * 1000)
if data is None:
body = (
f"<!doctype html><meta charset=utf-8><style>{CSS}</style>"
f'<div class=wrap><h1>Ville introuvable</h1><p class=sub>Aucun résultat pour '
f"« {html.escape(city)} ». Essayez <a href=?city=Paris>Paris</a>.</p></div>"
)
return 404, body, HTML_HEADERS
meta = {
"render_ms": 0,
"fetch_ms": fetch_ms,
"pod": _POD,
"cold_start": cold,
"pod_age_s": round(time.monotonic() - _BOOT, 1),
"served": _SERVED,
}
if request.query.get("format") == "json":
meta["render_ms"] = round((time.perf_counter() - started) * 1000)
return 200, {**data, "meta": meta}
meta["render_ms"] = round((time.perf_counter() - started) * 1000)
return 200, page(data, meta), HTML_HEADERSUne page web de compte à rebours (Node.js) : on saisit une durée et elle décompte jusqu'à 00:00. Le handler renvoie la page HTML complète ; toute l'interactivité tourne côté navigateur.
main.js
'use strict';
const PAGE = `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Timer</title>
<style>
:root { --bg:#0b0d12; --fg:#f4f6fb; --accent:#ffb020; --muted:#8a93a6; --panel:#151925; }
* { box-sizing:border-box; }
body {
margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
background:radial-gradient(1200px 800px at 50% -10%, #1b2130, var(--bg));
color:var(--fg); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
}
.card {
background:var(--panel); border:1px solid #222839; border-radius:20px;
padding:40px 44px; width:min(92vw,440px); text-align:center;
box-shadow:0 20px 60px rgba(0,0,0,.45);
}
h1 { margin:0 0 6px; font-size:20px; font-weight:600; letter-spacing:.3px; }
.sub { color:var(--muted); font-size:13px; margin-bottom:28px; }
.display {
font-size:72px; font-weight:700; font-variant-numeric:tabular-nums;
letter-spacing:2px; margin:18px 0 26px; line-height:1;
transition:color .2s;
}
.display.done { color:var(--accent); animation:pulse 1s infinite; }
@keyframes pulse { 50% { opacity:.4; } }
.inputs { display:flex; gap:8px; justify-content:center; margin-bottom:22px; }
.field { display:flex; flex-direction:column; align-items:center; gap:6px; }
.field label { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:1px; }
input {
width:80px; padding:12px; font-size:22px; text-align:center; border-radius:12px;
border:1px solid #2a3143; background:#0e121b; color:var(--fg); font-variant-numeric:tabular-nums;
}
input:focus { outline:none; border-color:var(--accent); }
.btns { display:flex; gap:10px; }
button {
flex:1; padding:14px; font-size:15px; font-weight:600; border-radius:12px; cursor:pointer;
border:none; transition:transform .05s, background .2s;
}
button:active { transform:translateY(1px); }
.start { background:var(--accent); color:#1a1200; }
.reset { background:#222839; color:var(--fg); }
.presets { display:flex; gap:8px; justify-content:center; margin-bottom:20px; flex-wrap:wrap; }
.presets button { flex:none; padding:8px 14px; font-size:13px; background:#1b2130; color:var(--muted); font-weight:500; }
.presets button:hover { color:var(--fg); }
</style>
</head>
<body>
<div class="card">
<h1>⏱️ Timer</h1>
<div class="sub">Entrez une durée, comptez jusqu'à zéro</div>
<div id="display" class="display">00:00</div>
<div class="presets">
<button data-s="60">1 min</button>
<button data-s="300">5 min</button>
<button data-s="600">10 min</button>
<button data-s="1500">25 min</button>
</div>
<div class="inputs">
<div class="field"><label>Min</label><input id="min" type="number" min="0" max="999" value="0"></div>
<div class="field"><label>Sec</label><input id="sec" type="number" min="0" max="59" value="30"></div>
</div>
<div class="btns">
<button id="start" class="start">Démarrer</button>
<button id="reset" class="reset">Réinitialiser</button>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const display = $('display'), minI = $('min'), secI = $('sec');
let remaining = 0, tick = null, running = false;
function fmt(t) {
const m = Math.floor(t / 60), s = t % 60;
return String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
}
function render() {
display.textContent = fmt(remaining);
display.classList.toggle('done', remaining === 0 && !running);
}
function readInput() {
const m = Math.max(0, parseInt(minI.value || '0', 10));
const s = Math.max(0, Math.min(59, parseInt(secI.value || '0', 10)));
return m * 60 + s;
}
function stop() { clearInterval(tick); tick = null; running = false; }
function start() {
if (running) { stop(); $('start').textContent = 'Démarrer'; render(); return; }
if (remaining <= 0) remaining = readInput();
if (remaining <= 0) return;
running = true; $('start').textContent = 'Pause';
display.classList.remove('done');
tick = setInterval(() => {
remaining--;
render();
if (remaining <= 0) {
stop(); $('start').textContent = 'Démarrer';
display.classList.add('done');
try { new AudioContext(); } catch (e) {}
beep();
}
}, 1000);
}
function reset() { stop(); $('start').textContent = 'Démarrer'; remaining = readInput(); render(); }
function beep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator(), g = ctx.createGain();
o.connect(g); g.connect(ctx.destination);
o.frequency.value = 880; o.type = 'sine';
g.gain.setValueAtTime(0.001, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.3, ctx.currentTime + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
o.start(); o.stop(ctx.currentTime + 0.6);
} catch (e) {}
}
$('start').onclick = start;
$('reset').onclick = reset;
document.querySelectorAll('.presets button').forEach((b) => {
b.onclick = () => { const t = +b.dataset.s; minI.value = Math.floor(t/60); secI.value = t%60; reset(); };
});
[minI, secI].forEach((el) => el.oninput = () => { if (!running) reset(); });
reset();
</script>
</body>
</html>`;
exports.handler = (request) => {
return [200, PAGE, { 'content-type': 'text/html; charset=utf-8' }];
};Un agrégateur d'API (Python) : un géocodage puis trois appels concurrents (météo, qualité de l'air, Wikipédia) fusionnés en une seule réponse JSON. La latence totale est celle de l'API la plus lente, pas leur somme.
main.py
"""zerolith · exemple « agrégateur d'API ».
Un cas d'usage courant : une requête cliente déclenche un géocodage, puis un appel
simultané à trois APIs publiques (météo, qualité de l'air, Wikipédia), le tout fusionné
en une seule réponse JSON. La latence rendue est celle de l'API la plus lente, pas la
somme des trois.
Aucune clé d'API, aucun état conservé : la fonction est purement calculatoire. C'est le
profil idéal pour le scale-to-zero — elle ne coûte rien tant que personne ne l'appelle,
et la réponse expose son propre temps de démarrage pour que vous puissiez le constater.
GET /?city=Paris
GET /?city=Tokyo&lang=en
"""
import asyncio
import os
import time
from urllib.parse import quote
import httpx
TIMEOUT = float(os.environ.get("DEMO_TIMEOUT_SECONDS", "6"))
UA = {"User-Agent": "zerolith-demo/1.0 (+https://zerolith.io)"}
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
AIR_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
# Échelle European AQI (bornes hautes -> libellé).
AQI_BANDS = [
(20, "bon"),
(40, "correct"),
(60, "moyen"),
(80, "mauvais"),
(100, "très mauvais"),
(float("inf"), "extrêmement mauvais"),
]
# Cette fonction est appelée depuis un navigateur : sans cet en-tête, le navigateur
# refuse de livrer la réponse à la page. « * » est ici sans risque — la fonction est
# publique et ne lit aucun cookie, elle n'expose donc rien de plus qu'un appel anonyme.
CORS = {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
}
# Renseigné à l'import du module, donc au démarrage du pod : les compteurs ci-dessous
# mesurent la vraie durée de vie de l'instance, pas celle de la requête.
_BOOT = time.monotonic()
_POD = os.environ.get("HOSTNAME", "inconnu")
_SERVED = 0
def _aqi_label(value):
if value is None:
return None
return next(label for ceiling, label in AQI_BANDS if value < ceiling)
async def _fetch(client, name, url, params):
"""Retourne (nom, résultat) sans jamais lever : une source indisponible dégrade la
réponse au lieu de la casser.
Les erreurs de connexion transitoires sont réessayées quelques fois, très brièvement.
Le géocodage est une dépendance dure de la réponse : sans ces essais, un aléa réseau
d'une fraction de seconde transformerait une requête parfaitement valide en 404.
"""
started = time.perf_counter()
last = None
for attempt in range(3):
try:
response = await client.get(url, params=params, headers=UA, timeout=TIMEOUT)
response.raise_for_status()
return name, {
"ok": True,
"ms": round((time.perf_counter() - started) * 1000),
"data": response.json(),
}
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
last = exc
await asyncio.sleep(0.25 * (attempt + 1))
except Exception as exc:
last = exc
break
return name, {
"ok": False,
"ms": round((time.perf_counter() - started) * 1000),
"error": f"{type(last).__name__}: {last}",
}
async def _geocode(client, city):
name, result = await _fetch(
client, "geocoding", GEOCODE_URL, {"name": city, "count": 1, "language": "fr"}
)
if not result["ok"]:
return None, result
hits = result["data"].get("results") or []
if not hits:
return None, {**result, "ok": False, "error": f"ville introuvable : {city!r}"}
return hits[0], result
async def _aggregate(city, lang):
timings = {}
async with httpx.AsyncClient() as client:
place, timings["geocoding"] = await _geocode(client, city)
if place is None:
return None, timings
coords = {"latitude": place["latitude"], "longitude": place["longitude"]}
fanout_started = time.perf_counter()
# Le cœur de l'exemple : trois APIs indépendantes interrogées en parallèle, donc
# la latence totale est celle de la plus lente et non la somme des trois.
results = await asyncio.gather(
_fetch(
client,
"weather",
FORECAST_URL,
{**coords, "current": "temperature_2m,wind_speed_10m,weather_code"},
),
_fetch(client, "air_quality", AIR_URL, {**coords, "current": "european_aqi"}),
_fetch(
client,
"wikipedia",
f"https://{lang}.wikipedia.org/api/rest_v1/page/summary/{quote(place['name'])}",
None,
),
)
fanout_ms = round((time.perf_counter() - fanout_started) * 1000)
sources = dict(results)
timings.update(sources)
weather = sources["weather"].get("data", {}).get("current", {})
air = sources["air_quality"].get("data", {}).get("current", {})
aqi = air.get("european_aqi")
body = {
"city": place["name"],
"country": place.get("country"),
"coordinates": {"lat": place["latitude"], "lon": place["longitude"]},
"timezone": place.get("timezone"),
"weather": {
"temperature_c": weather.get("temperature_2m"),
"wind_kmh": weather.get("wind_speed_10m"),
"observed_at": weather.get("time"),
},
"air_quality": {"european_aqi": aqi, "label": _aqi_label(aqi)},
"about": sources["wikipedia"].get("data", {}).get("extract"),
"fanout_ms": fanout_ms,
}
return body, timings
def handler(request):
global _SERVED
started = time.perf_counter()
cold_start = _SERVED == 0
_SERVED += 1
city = (request.query.get("city") or "Paris").strip()
lang = (request.query.get("lang") or "fr").strip().lower()
if lang not in ("fr", "en", "de", "es", "it"):
lang = "fr"
body, timings = asyncio.run(_aggregate(city, lang))
if body is None:
error = timings["geocoding"].get("error", "géocodage impossible")
return 404, {"error": error, "city": city}, CORS
body["meta"] = {
"total_ms": round((time.perf_counter() - started) * 1000),
"sources": {name: {"ok": r["ok"], "ms": r["ms"]} for name, r in timings.items()},
# Le nom du pod change à chaque remontée depuis zéro : de quoi vérifier vous-même
# le scale-to-zero en rappelant la fonction après quelques minutes d'inactivité.
"pod": _POD,
"cold_start": cold_start,
"pod_age_seconds": round(time.monotonic() - _BOOT, 1),
"requests_served_by_this_pod": _SERVED,
}
return 200, body, CORSUne API d'inventaire (Python) adossée à une base de données privée : catalogue, fiche article avec historique, entrées et sorties de stock. Les données survivent à la mise en veille, puisque la base dort avec la fonction et se réveille à la première requête. Cette instance publique est en lecture seule.
main.py
"""zerolith · exemple « API de stock », branchée sur une base de données privée.
Un inventaire de magasin servi par une fonction : consultation du catalogue, fiche
article avec son historique, entrées et sorties, et quelques agrégats. Les données
vivent dans une base privée attachée à la fonction — SQLite en réseau, répliquée en
continu vers un stockage objet.
Vous n'avez rien à configurer : quand une base est attachée, la plateforme injecte
DATABASE_URL et DATABASE_AUTH_TOKEN dans le pod. Le jeton n'est jamais affiché nulle
part, ni dans l'interface ni dans l'API — il est monté depuis un secret en écriture
seule, et les identifiants du stockage objet ne quittent jamais le serveur de base.
La base se met en veille comme la fonction : au repos, ni l'une ni l'autre ne coûte
de temps de calcul, seul le palier de stockage réservé reste facturé. Le premier appel
après une période creuse réveille les deux et paie un démarrage à froid.
Le stock n'est PAS une colonne que l'on incrémente, mais la somme d'un registre de
mouvements. Un registre donne l'historique gratuitement et évite que deux écritures
simultanées s'écrasent — la contrepartie étant une agrégation à la lecture, négligeable
à cette échelle et indexée.
Le schéma est posé par une fonction compagnon désignée comme migrateur de la base
(voir la section « Bases de données » de la documentation) :
products(id, sku, name, category, price_cents, reorder_level, created_at)
movements(id, product_id, delta, reason, created_at)
Routes
GET / ce message et les agrégats
GET /products catalogue + stock courant (?category= ?low_stock=1)
GET /products/<sku> fiche article + 20 derniers mouvements
POST /products {sku, name, category, price_cents, reorder_level, quantity}
POST /movements {sku, delta, reason} delta négatif = sortie
GET /stats agrégats et valeur du stock
"""
import os
import libsql_client
# Cette instance est une vitrine publique : les écritures y sont désactivées pour que
# les données restent lisibles par tous. Retirez cette variable d'environnement (ou
# passez-la à "0") pour obtenir l'API complète sur votre propre déploiement.
READ_ONLY = os.environ.get("STOCK_READ_ONLY", "1").lower() in ("1", "true", "yes")
# La fonction est appelée depuis un navigateur : sans cet en-tête, le navigateur refuse
# de livrer la réponse à la page. Sans risque ici — la fonction est publique et ne lit
# aucun cookie, elle n'expose donc rien de plus qu'un appel anonyme.
CORS = {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-store",
}
# Le stock courant se calcule à la lecture : une jointure sur le registre de mouvements.
STOCK_SELECT = """
SELECT p.id, p.sku, p.name, p.category, p.price_cents, p.reorder_level,
CAST(COALESCE(SUM(m.delta), 0) AS INTEGER) AS qty
FROM products p
LEFT JOIN movements m ON m.product_id = p.id
"""
def _client():
"""Ouvre une connexion à la base privée attachée à cette fonction.
Le client libSQL parle HTTP : c'est ce qui permet à la base de dormir et de se
réveiller à la demande, exactement comme la fonction.
"""
return libsql_client.create_client_sync(
url=os.environ["DATABASE_URL"],
auth_token=os.environ["DATABASE_AUTH_TOKEN"],
)
def _product_row(row):
qty, reorder = int(row[6]), int(row[5])
return {
"sku": row[1],
"name": row[2],
"category": row[3],
"price_eur": round(int(row[4]) / 100, 2),
"quantity": qty,
"reorder_level": reorder,
"low_stock": qty <= reorder,
}
def _list_products(db, query):
where, params = [], []
if query.get("category"):
where.append("p.category = ?")
params.append(query["category"])
sql = STOCK_SELECT
if where:
sql += " WHERE " + " AND ".join(where)
sql += " GROUP BY p.id ORDER BY p.category, p.sku"
items = [_product_row(r) for r in db.execute(sql, params).rows]
if query.get("low_stock") in ("1", "true", "yes"):
items = [i for i in items if i["low_stock"]]
return {"count": len(items), "products": items}
def _get_product(db, sku):
rows = db.execute(STOCK_SELECT + " WHERE p.sku = ? GROUP BY p.id", [sku]).rows
if not rows:
return 404, {"error": "sku inconnu", "sku": sku}
product = _product_row(rows[0])
movements = db.execute(
"SELECT delta, reason, created_at FROM movements WHERE product_id = ? "
"ORDER BY id DESC LIMIT 20",
[rows[0][0]],
).rows
product["movements"] = [
{"delta": int(m[0]), "reason": m[1], "at": m[2]} for m in movements
]
return 200, product
def _create_product(db, body):
sku = (body.get("sku") or "").strip()
name = (body.get("name") or "").strip()
if not sku or not name:
return 400, {"error": "sku et name sont obligatoires"}
if db.execute("SELECT COUNT(*) FROM products WHERE sku = ?", [sku]).rows[0][0]:
return 409, {"error": "sku deja utilise", "sku": sku}
db.execute(
"INSERT INTO products (sku, name, category, price_cents, reorder_level) "
"VALUES (?, ?, ?, ?, ?)",
[
sku,
name,
body.get("category") or "divers",
int(body.get("price_cents") or 0),
int(body.get("reorder_level") or 0),
],
)
pid = db.execute("SELECT id FROM products WHERE sku = ?", [sku]).rows[0][0]
qty = int(body.get("quantity") or 0)
if qty:
db.execute(
"INSERT INTO movements (product_id, delta, reason) VALUES (?, ?, 'reception')",
[pid, qty],
)
return 201, {"created": sku, "quantity": qty}
def _move(db, body):
"""Enregistre une entrée (delta positif) ou une sortie (delta négatif)."""
sku = (body.get("sku") or "").strip()
try:
delta = int(body.get("delta"))
except (TypeError, ValueError):
return 400, {"error": "delta doit etre un entier non nul"}
if not sku or delta == 0:
return 400, {"error": "sku et delta (non nul) sont obligatoires"}
rows = db.execute("SELECT id FROM products WHERE sku = ?", [sku]).rows
if not rows:
return 404, {"error": "sku inconnu", "sku": sku}
pid = rows[0][0]
def stock():
return int(
db.execute(
"SELECT COALESCE(SUM(delta), 0) FROM movements WHERE product_id = ?", [pid]
).rows[0][0]
)
before = stock()
if before + delta < 0:
return 409, {
"error": "stock insuffisant",
"sku": sku,
"quantity": before,
"requested": delta,
}
# Le contrôle est rejoué dans le SQL lui-même : entre la lecture ci-dessus et cette
# écriture, une autre requête a pu vider le stock. La condition WHERE rend l'insertion
# conditionnelle côté base, ce qui règle la concurrence sans verrou applicatif.
db.execute(
"INSERT INTO movements (product_id, delta, reason) "
"SELECT ?, ?, ? WHERE (SELECT COALESCE(SUM(delta), 0) FROM movements "
"WHERE product_id = ?) + ? >= 0",
[pid, delta, (body.get("reason") or "manual")[:40], pid, delta],
)
after = stock()
if after == before:
return 409, {"error": "stock insuffisant (concurrence)", "sku": sku, "quantity": after}
return 200, {"sku": sku, "delta": delta, "quantity": after}
def _stats(db):
row = db.execute(
"SELECT COUNT(*), COALESCE(SUM(qty), 0), COALESCE(SUM(qty * price_cents), 0), "
"COALESCE(SUM(CASE WHEN qty <= reorder_level THEN 1 ELSE 0 END), 0) "
"FROM (" + STOCK_SELECT + " GROUP BY p.id)"
).rows[0]
by_cat = db.execute(
"SELECT category, COUNT(*), COALESCE(SUM(qty), 0) FROM ("
+ STOCK_SELECT
+ " GROUP BY p.id) GROUP BY category ORDER BY category"
).rows
movements = db.execute("SELECT COUNT(*) FROM movements").rows[0][0]
return {
"references": int(row[0]),
"articles_en_stock": int(row[1]),
"valeur_stock_eur": round(int(row[2]) / 100, 2),
"references_a_reapprovisionner": int(row[3]),
"mouvements_enregistres": int(movements),
"par_categorie": [
{"category": c[0], "references": int(c[1]), "quantity": int(c[2])} for c in by_cat
],
}
def handler(request):
parts = [p for p in request.path.split("/") if p]
method = request.method.upper()
if READ_ONLY and method not in ("GET", "HEAD"):
return 403, {"error": "instance de démonstration en lecture seule"}, CORS
db = _client()
try:
if not parts:
return 200, {
"service": "stock-api",
"database": "base privée attachée (libSQL, répliquée)",
"read_only": READ_ONLY,
"routes": [
"GET /products?category=&low_stock=1",
"GET /products/<sku>",
"POST /products {sku,name,category,price_cents,reorder_level,quantity}",
"POST /movements {sku,delta,reason}",
"GET /stats",
],
"stats": _stats(db),
}, CORS
if parts[0] == "products":
if len(parts) == 1:
if method == "GET":
return 200, _list_products(db, request.query), CORS
if method == "POST":
status, body = _create_product(db, request.json() or {})
return status, body, CORS
return 405, {"error": "methode non autorisee"}, CORS
if method == "GET":
status, body = _get_product(db, parts[1])
return status, body, CORS
return 405, {"error": "methode non autorisee"}, CORS
if parts[0] == "movements" and len(parts) == 1:
if method == "POST":
status, body = _move(db, request.json() or {})
return status, body, CORS
return 405, {"error": "methode non autorisee"}, CORS
if parts[0] == "stats" and method == "GET":
return 200, _stats(db), CORS
return 404, {"error": "route inconnue", "path": request.path}, CORS
finally:
db.close()Rien n'est appelé (ni facturé) tant que vous ne cliquez pas : c'est aussi ce qui permet à ces fonctions de rester à zéro instance. Le premier appel provoque un démarrage à froid de quelques instants, les suivants sont servis à chaud.
Le dépôt d'exemples rassemble ces handlers et d'autres, prêts à déployer, avec les commandes qui les mettent en ligne : github.com/zerolith-faas/zerolith-examples
Variables d'environnement
Définissez des variables réutilisables (config ou secret) au niveau du compte, puis attachez-les fonction par fonction. Elles sont injectées comme variables d'environnement classiques :
# Python # Node.js import os process.env.MY_VAR os.environ["MY_VAR"]
Tout se pilote aussi par l'API : créez, listez, modifiez et supprimez vos variables, puis attachez-les à une fonction via son champ env_var_ids :
# create a secret — its value is write-only, it can never be read back
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"name": "API_TOKEN", "kind": "secret", "value": "s3cr3t"}' \
https://zerolith.io/api/env-vars
# attach it to a function (rolls a new revision)
curl -X PATCH -H "Authorization: Bearer $JWT" \
-d '{"env_var_ids": ["<env-var-id>"]}' \
https://zerolith.io/api/functions/<id>Modifier la valeur d'une variable déploie automatiquement une nouvelle révision de chaque fonction qui l'utilise. Supprimer une variable encore attachée à une fonction est refusé (409) tant qu'elle n'est pas détachée.
Les valeurs des secrets sont en écriture seule : stockées uniquement dans le cluster, jamais relisibles via l'API. Le préfixe FAAS_ est réservé à la plateforme.
Bases de données
Chaque compte peut provisionner une base de données privée : du SQLite accessible en réseau, répliqué en continu vers un stockage objet. Elle vit dans votre espace isolé, n'est jamais exposée publiquement, et n'est joignable que par vos propres fonctions.
Elle se met en veille comme une fonction. Sans requête pendant la durée d'inactivité configurée (10 minutes par défaut, réglable de 6 secondes à 1 heure), l'instance s'arrête : vous ne payez plus aucun temps de calcul, seul le palier de stockage réservé reste facturé, puisque vos données sont dans le stockage objet et n'en bougent pas. La requête suivante réveille la base automatiquement et paie un démarrage à froid de quelques secondes, restauration comprise. Allonger la durée d'inactivité évite de repayer ce réveil entre deux requêtes espacées. Si vous préférez une latence toujours constante, le mode « toujours active » maintient une instance en permanence, au prix du calcul correspondant. Les deux réglages se changent à tout moment, sans déplacer les données.
Attachez la base à une fonction : la plateforme injecte alors ces deux variables dans le pod. Le jeton n'est jamais affiché, il est monté depuis un secret en écriture seule.
# Python # Node.js import os const url = process.env.DATABASE_URL; url = os.environ["DATABASE_URL"] const tok = process.env.DATABASE_AUTH_TOKEN; token = os.environ["DATABASE_AUTH_TOKEN"]
# main.py — the libSQL client ships with the runtime
import os, libsql_client
def handler(request):
db = libsql_client.create_client_sync(
url=os.environ["DATABASE_URL"],
auth_token=os.environ["DATABASE_AUTH_TOKEN"],
)
db.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)")
db.execute("INSERT INTO notes (body) VALUES (?)", ["hello"])
rows = db.execute("SELECT body FROM notes").rows
db.close()
return {"notes": [r[0] for r in rows]}Une base attachée à une fonction ne peut pas être supprimée (409) : détachez-la d'abord. Le palier de stockage est un plafond ferme ; les écritures échouent au-delà.
Le serveur de base ne peut pas appeler vos fonctions : les migrations sont explicites. Déployez le migrateur avec le type « migration » — sans service, sans route ni nom d'hôte, donc privé par construction — désignez-le, puis déclenchez-le à la demande depuis l'interface, l'API ou un agent MCP.
Facturation : l'instance de la base coûte le même prix par Go-seconde qu'une fonction de même taille (et rien pendant son sommeil), plus le palier de stockage détenu, au prorata du mois. Une augmentation de palier prend effet immédiatement ; aucune réduction n'est possible.
Sauvegarde et rétention : générez à tout moment un export SQL portable de votre base, téléchargeable pendant 24 h et restaurable partout (gunzip -c export.sql.gz | sqlite3 base.db). Le service est en bêta, gardez vos propres sauvegardes. Si votre solde reste à zéro, les données sont conservées 7 jours puis SUPPRIMÉES définitivement ; recharger avant l'échéance annule la suppression. Supprimer une base efface aussi ses données du stockage objet, mais vos exports, eux, sont conservés.
Page dédiée : fonctionnement, sécurité d'accès, sauvegarde et coût des bases de données →
Domaines personnalisés
Servez une fonction sur un nom de domaine qui vous appartient : api.exemple.com au lieu de l'adresse de la plateforme. La plateforme y termine le TLS et obtient elle-même le certificat, vous n'en téléversez aucun.
Ajoutez le domaine depuis la page de la fonction, puis publiez les deux enregistrements qu'elle vous indique :
- un TXT sous _zerolith-challenge.votre-domaine, portant un jeton qui prouve que la zone est à vous ;
- un CNAME de votre nom d'hôte vers l'adresse plateforme de la fonction ; aucun nouvel enregistrement de notre part, et la cible suit la plateforme si cette adresse change un jour.
Rien n'est routé sur une revendication non prouvée. La plateforme revérifie régulièrement ; dès que le TXT est vu, elle met la route en place et commande le certificat, prêt en général en moins d'une minute. Ce n'est qu'ensuite que le domaine se déclare en service.
Une fois vérifié, le TXT a fait son travail et peut être supprimé, il n'est lu qu'une seule fois. Le CNAME, lui, porte le trafic et doit rester.
Jusqu'à 10 domaines par compte. Les jokers ne sont pas pris en charge : le certificat est obtenu via HTTP-01, qui ne sait pas valider un nom en `*`. Les noms d'hôtes déjà servis par la plateforme ne peuvent pas être revendiqués.
Une revendication jamais vérifiée est libérée au bout de 14 jours : un nom d'hôte dont vous ne voulez plus ne reste pas réservé indéfiniment.
Libérer un domaine l'arrête immédiatement. Comme le rajouter implique de commander un nouveau certificat, le nom d'hôte ne peut pas être repris avant 60 minutes.
Tailles et limites
Les tailles sont des presets de ressources (mémoire × CPU). Le prix suit la mémoire et le processeur réservés, multipliés par le temps d'exécution :
Timeout d'exécution configurable de 1 à 600 s. Échelle de 0 (scale-to-zero) à l'échelle max de votre fonction ; min ≥ 1 garde des instances chaudes, facturées en continu.
Quotas par compte :
| Fonctions déployées | 50 |
| Taille du code d'une fonction | 768 KiB |
| Variables d'environnement | 100 |
| … dont secrets | 50 |
| Valeur d'une variable / budget total | 4 KiB / 512 KiB |
| Corps d'une requête API | 2 MiB |
| Invocations par compte (en périphérie) | 600 / min |
Le débit est contrôlé en périphérie, avant que votre fonction ne se réveille. Les fonctions publiques appelées sans clé ont leur propre compteur, par fonction : un pic sur un webhook public n'épuise pas le budget du reste du compte.
Cycle de vie & mise à l'échelle
Une fonction monte et descend en charge automatiquement (Knative). Au repos, elle tourne à zéro instance et ne coûte rien ; la première requête provoque un démarrage à froid de quelques instants, puis les appels suivants sont servis à chaud.
timeout = durée max d'une seule exécution ; fenêtre stable = durée d'inactivité avant le retour à zéro instance.
Chaque déploiement (nouveau code, changement de config ou de variable d'environnement) crée une nouvelle révision immuable de la fonction. Le trafic ne bascule vers elle que lorsqu'elle est prête à servir, donc une mise à jour ne provoque aucune interruption.
Réglages depuis le formulaire de la fonction : le timeout borne la durée d'une seule exécution (1–600 s) ; la fenêtre stable est le temps d'inactivité avant le retour à zéro instance ; l'échelle min à 0 active le scale-to-zero (à la demande), ≥ 1 garde des instances chaudes (facturées en continu) ; l'échelle max plafonne la concurrence. La facturation ne court que pendant l'exécution (mémoire réservée × temps), plus les invocations.
Observabilité & métriques
Chaque fonction expose ses métriques en direct : requêtes par seconde, latence (p50 / p95 / p99), instances actives, démarrages à froid, CPU et mémoire. La page « Statut » de l'app les affiche, et la même API est ouverte à vos scripts :
# live snapshot for all your functions (rolling window, default 5 min)
curl -H "Authorization: Bearer $JWT" \
"https://zerolith.io/api/functions/metrics?window_seconds=3600"
# time series for one function — one point per step (feeds the Status graphs)
curl -H "Authorization: Bearer $JWT" \
"https://zerolith.io/api/functions/<id>/series?window_seconds=3600&step_seconds=60"Champs retournés : rps, latency_p50_ms / p95 / p99, instances, cold_starts, cpu_millicores, memory_mb. Fenêtre glissante réglable de 1 minute à 7 jours (5 min par défaut). Ces métriques sont mesurées en direct, rien n'est stocké.
Côté facturation, l'API d'usage donne les totaux du compte (requêtes, GB-secondes, coût) et le détail de chaque fenêtre facturée :
# billed requests, GB-seconds and cost — CURRENT MONTH by default
curl -H "Authorization: Bearer $JWT" https://zerolith.io/api/usage/summary
# account lifetime, or any explicit range (since/until are half-open)
curl -H "Authorization: Bearer $JWT" "https://zerolith.io/api/usage/summary?period=all"
curl -H "Authorization: Bearer $JWT" \
"https://zerolith.io/api/usage/summary?since=2026-06-01T00:00:00Z&until=2026-07-01T00:00:00Z"
# every billed window, filterable per function, paginated with limit/offset
curl -H "Authorization: Bearer $JWT" \
"https://zerolith.io/api/usage/windows?function_id=<id>&limit=100&offset=100"Les fenêtres d'usage sont la référence de facturation : elles sont conservées même après suppression de la fonction.
Déclenchement cron
Chaque fonction peut être déclenchée à heure fixe par une expression cron (UTC), via l'API :
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"cron": "*/15 * * * *"}' \
https://zerolith.io/api/functions/<id>/schedulesLes invocations cron passent par le même contrôle de crédit que les appels externes : un compte à sec ne réveille rien.
Pilotage par agent (MCP)
zerolith.io est aussi un serveur MCP avec OAuth 2.1 : connectez Claude ou tout client MCP à votre compte et déployez, modifiez et invoquez vos fonctions en langage naturel, sans clé à copier.
Le serveur expose 41 outils, tous limités à votre compte. Les outils en lecture demandent le scope mcp:read ; les autres demandent mcp:write et un compte vérifié :
| Outil | Scope | Description |
|---|---|---|
| whoami | mcp:read | Compte authentifié et solde de crédit. |
| get_catalog | mcp:read | Langages, tailles, valeurs par défaut et tarifs. |
| list_functions | mcp:read | Liste de vos fonctions déployées. |
| get_function | mcp:read | Une fonction : config, URL, variables attachées et code source actuel. |
| deploy_function | mcp:write | Déployer une nouvelle fonction. |
| update_function | mcp:write | Modifier une fonction (code et/ou config). |
| delete_function | mcp:write | Supprimer une fonction. |
| invoke_function | mcp:write | Invoquer une fonction déployée (soumis au crédit). |
| get_function_metrics | mcp:read | Métriques en direct (rps, latence, instances, CPU/mémoire). |
| get_function_logs | mcp:read | Sortie standard récente d'une fonction, la plus récente en premier ; filtrage par sous-chaîne. |
| get_database_logs | mcp:read | Journal récent du moteur sqld d'une base ; les identifiants de stockage de la plateforme sont masqués. |
| get_usage_summary | mcp:read | Solde et totaux facturés (requêtes, GB-secondes, coût). |
| list_env_vars | mcp:read | Liste des variables d'environnement du compte. |
| get_env_var | mcp:read | Une variable (valeur lisible si config ; jamais pour un secret). |
| create_env_var | mcp:write | Créer une variable (config ou secret). |
| update_env_var | mcp:write | Remplacer la valeur d'une variable. |
| delete_env_var | mcp:write | Supprimer une variable (409 si encore attachée à une fonction). |
| list_custom_domains | mcp:read | Lister vos domaines personnalisés et les enregistrements DNS à publier. |
| add_custom_domain | mcp:write | Rattacher un nom de domaine à une fonction (renvoie les enregistrements DNS à créer). |
| delete_custom_domain | mcp:write | Détacher un domaine personnalisé et supprimer sa route. |
| create_presigned_url | mcp:write | Créer une URL qui appelle une fonction jusqu'à une date, sans clé d'API. |
| list_presigned_urls | mcp:read | Lister les URLs présignées d'une fonction (jamais les jetons eux-mêmes). |
| revoke_presigned_url | mcp:write | Révoquer une URL présignée avant sa date d'expiration. |
| list_databases | mcp:read | Lister vos bases de données privées. |
| get_database | mcp:read | Une base : palier, taille, mode de veille et fonctions attachées. |
| create_database | mcp:write | Créer une base privée (le jeton n'est jamais renvoyé). |
| delete_database | mcp:write | Supprimer une base (409 si elle est encore attachée). |
| attach_database | mcp:write | Attacher une base à une fonction (injecte les variables). |
| detach_database | mcp:write | Détacher une base d'une fonction. |
| get_database_metrics | mcp:read | Statut live : CPU, mémoire, instances et disque utilisé vs le palier. |
| set_database_size | mcp:write | Changer le préréglage CPU/mémoire d'une base. |
| set_database_tier | mcp:write | Augmenter le palier de stockage d'une base (hausse uniquement — aucune réduction possible). |
| rotate_database_token | mcp:write | Renouveler le jeton d'accès d'une base : l'ancien est retiré et les fonctions attachées redéployées. |
| set_database_always_on | mcp:write | Garder une base allumée en permanence, ou la laisser s'endormir. |
| set_database_stable_window | mcp:write | Changer le délai d'inactivité avant mise en veille d'une base. |
| export_database | mcp:write | Lancer un dump SQL de la base (un export par heure et par compte). |
| get_database_export | mcp:write | État du dernier export, avec son lien de téléchargement quand il est prêt. |
| list_database_exports | mcp:write | Historique des exports d'une base, le plus récent étant le seul téléchargeable. |
| set_database_migrator | mcp:write | Désigner une de vos fonctions comme migrateur de schéma. |
| unset_database_migrator | mcp:write | Retirer la désignation de migrateur (la fonction reste attachée). |
| run_migration | mcp:write | Exécuter la fonction de migration désignée, à la demande. |
La séquence complète, du branchement du serveur MCP à l'appel de la fonction. Le dernier temps appelle réellement une fonction déployée ; rien ne part tant que vous n'avez pas lancé la démo.
Sécurité & isolation
Chaque compte s'exécute dans son propre espace Kubernetes, cloisonné par une politique réseau qui refuse tout par défaut. Vos fonctions, vos variables d'environnement et votre base de données y vivent ensemble, et n'en sortent pas : les pods d'un autre compte n'ont aucune route vers les vôtres.
Les appels sont authentifiés en bordure, avant même qu'un conteneur ne démarre : une clé invalide, un compte suspendu ou un solde épuisé sont refusés sans jamais réveiller votre code, donc sans rien vous facturer. Vos secrets ne sont relus par personne, pas même par vous : ni l'API, ni l'interface, ni un agent IA ne renvoient leur valeur. Vos clés API sont stockées hachées et ne s'affichent qu'une fois, à la création.
Page dédiée : isolation, authentification en bordure, secrets, agents et données →