{"slug": "artefact-canvas-frame-heberger-vous-meme-la-page-generee", "title": "Artefact, canvas, frame : héberger vous-même la page générée", "summary": "Zerolith, a serverless platform, now lets developers host AI-generated HTML pages directly from functions, giving them a permanent URL, a private SQLite database, and four access-control options (API key, signed URL, public, custom domain). The feature addresses the limitation of AI artifacts like Claude's Artefact, ChatGPT's Canvas, and Dust's Frame, which lack self-hosting capabilities. A live dashboard example using Open-Meteo data is available at demo-dashboard-fn-539b8643bc44462c815a5ad5a423d976.api.zerolith.io.", "body_md": "# Artefact, canvas, frame : héberger vous-même la page générée\n\nUn artefact montre une idée en dix secondes, puis reste dans la conversation. Une fonction rend la même page HTML, avec une URL qui est la vôtre, une base SQLite privée, et quatre façons de choisir qui peut l'ouvrir : clé d'API, URL signée, public, domaine sur mesure.\n\nVous avez sans doute déjà fait ça : décrire un graphique à un assistant, et recevoir dix secondes plus tard une page qui marche, avec ses courbes, ses couleurs et son survol. Chaque éditeur a son nom pour cet objet. C'est un artefact chez Claude, un canvas chez ChatGPT, chez Gemini et dans Le Chat de Mistral, Grok Studio chez xAI, un Lab chez Perplexity, une Frame chez Dust. Tous font très bien la même chose : montrer une idée avant de décider si elle vaut un projet.\n\nPuis vient la question suivante, et elle tue la démo : « tu peux m'envoyer le lien ? »\n\nUn lien, il y en a parfois un. On publie un artefact, on partage une Frame par un lien à jeton, l'app d'un Lab se déploie sur sa propre page. Mais cette page vit chez l'éditeur, dans un bac à sable : les chiffres qu'elle affiche sont ceux qu'on a écrits dedans, elle n'a aucun endroit à elle où écrire, et « qui peut l'ouvrir » se limite à ce que le produit propose, publique ou réservée à votre espace de travail.\n\nLe manque n'est pas dans le rendu, il est dans l'hébergement. Or une fonction sait très bien renvoyer une page, et pas seulement du JSON. Avec, en plus, les trois choses qui manquent à un artefact : une URL qui est la vôtre, une base de données, et une porte dont vous choisissez la serrure.\n\n## Une fonction peut répondre autre chose que du JSON\n\nLe contrat du handler laisse choisir le type de contenu. Un dictionnaire part en `application/json`\n\n,\nune chaîne en `text/plain`\n\n, mais ce sont des défauts, pas des impositions : un `Content-Type`\n\ndans\nles en-têtes les remplace.\n\n``` python\ndef handler(request):\n    return 200, \"<!doctype html><h1>Bonjour</h1>\", {\n        \"Content-Type\": \"text/html; charset=utf-8\",\n    }\n```\n\nC'est tout ce qu'il faut. Déployez, ouvrez l'URL : votre navigateur affiche une page. Pas de bundler,\npas de `npm install`\n\n, pas d'étape de build, pas de serveur statique à côté ; un fichier et une réponse\nHTTP suffisent.\n\nLe même levier sert pour un SVG (`image/svg+xml`\n\n), un flux RSS (`application/rss+xml`\n\n), un `.ics`\n\n, un\nCSV que le navigateur propose d'enregistrer. Une fonction n'est pas condamnée à être une API.\n\n## Le vrai exemple : un tableau de bord avec ses données\n\nUne page statique n'apporte rien de plus qu'un fichier posé quelque part. L'intérêt commence quand la page va chercher ses données au moment où on la charge.\n\nL'exemple ci-dessous interroge open-meteo (aucune clé d'API), calcule la géométrie de deux graphiques,\net renvoie un document autonome. `httpx`\n\nest déjà dans l'image du runtime.\n\nLa fonction tourne, et vous pouvez l'ouvrir tout de suite : [le tableau de bord, en direct](https://demo-dashboard-fn-539b8643bc44462c815a5ad5a423d976.api.zerolith.io/?city=Paris).\nChangez `?city=`\n\n, ajoutez `&format=json`\n\npour les données brutes ; le pied de page vous dit quel pod\nvous a répondu et s'il venait de démarrer à froid. Le handler complet est aussi dans\n[la galerie d'exemples](/docs#examples) de la documentation, avec les autres fonctions publiques.\n\n``` python\nimport httpx\n\ndef fetch(city, days):\n    with httpx.Client(timeout=8) as client:\n        geo = client.get(\"https://geocoding-api.open-meteo.com/v1/search\",\n                         params={\"name\": city, \"count\": 1, \"language\": \"fr\"})\n        hits = geo.json().get(\"results\") or []\n        if not hits:\n            return None\n        place = hits[0]\n\n        forecast = client.get(\"https://api.open-meteo.com/v1/forecast\", params={\n            \"latitude\": place[\"latitude\"], \"longitude\": place[\"longitude\"],\n            \"current\": \"temperature_2m,apparent_temperature,wind_speed_10m\",\n            \"hourly\": \"temperature_2m\",\n            \"daily\": \"temperature_2m_max,temperature_2m_min\",\n            \"forecast_days\": days, \"timezone\": \"auto\",\n        })\n        return place, forecast.json()\n```\n\nEt le graphique. Pas de bibliothèque : une courbe SVG est une liste de points transformée en chaîne de caractères, et cette transformation tient en quinze lignes de Python.\n\n``` python\ndef line_chart(points, width=760, height=220):\n    pad_l, pad_r, pad_t, pad_b = 44, 16, 18, 34\n    temps = [p[\"temp\"] for p in points]\n    lo, hi, step = nice_scale(min(temps), max(temps))   # une échelle à valeurs rondes\n    plot_w, plot_h = width - pad_l - pad_r, height - pad_t - pad_b\n\n    def x_of(i):  return pad_l + plot_w * i / max(len(points) - 1, 1)\n    def y_of(v):  return pad_t + plot_h - plot_h * (v - lo) / (hi - lo)\n\n    coords = [(x_of(i), y_of(p[\"temp\"])) for i, p in enumerate(points)]\n    path = \" \".join(f\"{'M' if i == 0 else 'L'}{x:.1f},{y:.1f}\"\n                    for i, (x, y) in enumerate(coords))\n\n    return (f'<svg viewBox=\"0 0 {width} {height}\" role=\"img\" aria-label=\"Température\">'\n            f'<path d=\"{path}\" fill=\"none\" stroke=\"#1fa3a7\" stroke-width=\"2\" '\n            f'stroke-linejoin=\"round\"/></svg>')\n```\n\nLe handler assemble : quatre tuiles de valeurs courantes, la courbe des vingt-quatre prochaines heures, des barres d'amplitude jour par jour, le CSS et une vingtaine de lignes de JavaScript pour le survol.\n\n```\nHTML = {\"Content-Type\": \"text/html; charset=utf-8\", \"Cache-Control\": \"no-store\"}\n\ndef handler(request):\n    city = (request.query.get(\"city\") or \"Paris\").strip()[:80]\n    data = fetch(city, 7)\n    if data is None:\n        return 404, page_introuvable(city), HTML\n    if request.query.get(\"format\") == \"json\":\n        return 200, data                       # les mêmes données, brutes\n    return 200, page(data), HTML\n```\n\nDeux détails qui comptent plus qu'ils n'en ont l'air.\n\n**Les graphiques sont déjà dessinés quand la page arrive.** Le SVG est dans le HTML, pas construit par\ndu JavaScript au chargement. Il n'y a donc ni écran de chargement, ni saut de mise en page, ni page\nblanche si un script échoue ; le JavaScript n'ajoute que l'infobulle au survol. Un crawler, un lecteur\nd'écran ou un `curl`\n\nvoient le même contenu que vous.\n\n**La même fonction sert la page et les données.** `?format=json`\n\nrenvoie le dictionnaire brut. Ce\nn'est pas une coquetterie : c'est ce qui fait que le tableau de bord n'est pas un cul-de-sac le jour\noù quelqu'un veut brancher autre chose dessus.\n\n## Ce que ça coûte\n\nDessiner les graphiques sur le serveur ne coûte rien de mesurable : un SVG est une chaîne de caractères, et l'assemblage des 15 Ko de la page disparaît devant l'appel à l'API tierce. Les sortir du serveur pour les faire dessiner par le navigateur ne ferait donc rien gagner, au prix d'un bundle à charger.\n\nCôté facture, `small`\n\nréserve 0,125 Gio et 0,1 vCPU, aux tarifs publics de **0,000004 €/Gio-seconde**\net **0,0000125 €/vCPU-seconde** :\n\n```\nmémoire     0,125 Gio × 0,000004 €  =  0,0000005  €/s\nprocesseur    0,1 vCPU × 0,0000125 € =  0,00000125 €/s\n                                       ────────────────\n                                        0,00000175 €/s\n```\n\nEt là, comme pour [un cron](/blog/cron-serverless), il faut compter honnêtement : vous ne payez pas la\nseule durée du handler. Un pod ne s'éteint pas dès la réponse envoyée. L'autoscaler observe une\nfenêtre de stabilité (60 s par défaut) puis laisse un délai de grâce, si bien qu'une visite isolée\ncoûte de l'ordre de 90 secondes de pod, soit **0,00016 €**.\n\nC'est la mauvaise nouvelle et la bonne à la fois : un tableau de bord regardé une fois par jour coûte un demi-centime par mois, et un tableau de bord consulté vingt fois dans l'après-midi ne coûte presque rien de plus, parce que les vingt visites tiennent dans le même pod. Le pire cas est la visite solitaire, pas l'affluence.\n\n## Trois pièges, payés en le faisant\n\n**Échapper tout ce qui vient de la requête.** Le nom de ville arrive du client et finit dans le HTML.\nSans `html.escape`\n\n, `?city=<script>…`\n\nest une faille XSS réfléchie servie depuis votre domaine. C'est\nla différence la plus importante entre une page rendue par un serveur et une page rendue dans une\nconversation : la vôtre a des visiteurs que vous ne choisissez pas.\n\n```\ncity = html.escape(f\"{place['name']}, {place['country']}\")\n```\n\n**La géométrie qui marche aujourd'hui casse sur d'autres données.** Les étiquettes du pic et du creux\nse posaient proprement sur la météo du jour. Sur une série plate, les deux tombaient au même pixel ; à\nTromsø, l'étiquette du minimum passait sous la colonne des graduations. Aucune de ces deux villes\nn'était dans le jeu d'essai. Rejouer le rendu sur des cas extrêmes (série plate, températures\nnégatives, amplitude de 60 degrés, deux jours au lieu de dix) coûte cinq minutes et trouve ce qu'un\ncoup d'œil ne trouve pas.\n\n**Le Content-Type est la moitié du sujet.** Il vaut la peine de le vérifier avec\n\n`curl -I`\n\nplutôt que\ndans le navigateur : un `text/plain`\n\naffiche votre page comme du code source, et le seul symptôme est\n« ça ne marche pas ». Nous avons appris celui-là de l'intérieur. Jusqu'au 16 août 2026, le runtime\nPython écrasait silencieusement l'en-tête du handler, et le runtime Node en émettait deux. C'est\ncorrigé, et c'est exactement le genre de défaut qu'aucun test unitaire ne voit : il est dans l'octet\nqui part sur le réseau.## Contrôler qui peut ouvrir la page\n\nUn artefact partagé, c'est un réglage à deux positions : privé, ou accessible à qui a le lien. Une fonction est privée par défaut, puisqu'elle veut une clé d'API, ce qu'un navigateur ne sait pas envoyer quand on lui donne un simple lien. Vous choisissez comment l'ouvrir, et il y a quatre régimes, un par usage, réglables fonction par fonction.\n\n**La clé d'API, dans l'en-tête.** `Authorization: Bearer <clé>`\n\n. C'est le régime par défaut, celui des\nappels de machine à machine : un script, un backend, un agent. La clé n'est stockée que sous forme de\nhachage chez nous et n'est montrée qu'une fois.\n\n**Une URL signée.** La fonction reste privée et vous émettez un lien à échéance, révocable, valable\npour cette fonction seulement. C'est le seul moyen d'envoyer une page à quelqu'un qui n'a rien à\ninstaller :\n\n```\ncurl -X POST -H \"Authorization: Bearer $JWT\" \\\n     -d '{\"name\": \"rapport-client\", \"expires_at\": \"2026-08-23T09:00:00Z\"}' \\\n     https://zerolith.io/api/functions/<id>/tokens\n# -> {\"token\": \"faast_…\", \"url\": \"https://…/?faas_token=faast_…\"}\n#    Le secret n'est montré qu'ici, une seule fois. Il n'existe pas d'échéance « jamais » :\n#    ça, c'est une clé d'API.\n```\n\nC'est ce qu'il faut pour un tableau de bord envoyé à un client : le lien s'ouvre dans n'importe quel\nnavigateur, expire tout seul, et se révoque sans toucher au reste. Le décompte de débit d'un lien\nsigné est tenu par `(compte, fonction)`\n\n, donc un lien qui part en boucle ne peut pas étouffer vos\nautres fonctions.\n\n**Publique.** La fonction répond à tout le monde, sans identification, avec la même limitation de\ndébit cloisonnée. C'est le bon choix pour une démo qu'on met dans un billet ou une conférence.\n\n**Votre propre domaine.** `rapport.macompagnie.com`\n\npointé sur la fonction : un enregistrement TXT pour\nprouver que le domaine est à vous, un CNAME pour le trafic, et le certificat est émis puis renouvelé\ntout seul. La page cesse de ressembler à une démo hébergée ailleurs. C'est facturé au domaine et au\nmois ; le tarif est sur [la page tarifs](/pricing), la procédure dans\n[la documentation](/docs#domains).\n\nLes serrures se combinent : un domaine à vous et une URL signée, une fonction publique et une autre privée dans le même compte.\n\n## Brancher une base de données sur la page\n\nUn artefact ne retient rien : rechargez-le, il repart de l'état où il a été écrit. Une fonction, elle, peut avoir sa propre base : une base SQLite privée, dans votre espace, qui dort quand personne ne l'interroge et se réveille à la première requête. On l'attache en une commande, et deux variables d'environnement apparaissent dans le pod :\n\n``` python\nimport os, libsql_client\n\ndef handler(request):\n    db = libsql_client.create_client_sync(\n        url=os.environ[\"DATABASE_URL\"],\n        auth_token=os.environ[\"DATABASE_AUTH_TOKEN\"],\n    )\n    with db:\n        db.execute(\"INSERT INTO visites(vue_le) VALUES (datetime('now'))\")\n        total = db.execute(\"SELECT count(*) FROM visites\").rows[0][0]\n    return 200, page(total), HTML\n```\n\nÀ partir de là, la page ne se contente plus d'afficher, elle enregistre : un formulaire de réponse, un\nvote d'équipe, une file d'attente, l'historique de ce que le [cron](/blog/cron-serverless) a relevé\ncette nuit. Le tableau de bord montre alors vos données plutôt que celles d'une API publique. La\nmarche à suivre complète est dans [l'article précédent](/blog/api-base-de-donnees), et la\n[base de données privée](/base-de-donnees) a sa page.\n\n## Ce que ça ne remplace pas\n\nLes limites sont réelles :\n\n**Il n'y a pas de CDN.** Chaque chargement traverse la fonction. Pour un tableau de bord interne ou une démo, très bien ; pour une page d'accueil à fort trafic, non.**Ce n'est pas un framework.** Pas de routeur, pas de composants, pas d'état client. Au-delà de quelques écrans, vous voudrez un vrai front, et vous le déploierez ailleurs.**Il faut déployer.** Un artefact apparaît dans la conversation ; ici il y a une fonction à créer et un domaine à pointer si vous en voulez un. C'est quelques minutes, et c'est le prix de tout le reste.\n\nLe point d'équilibre est assez net : une page, des données fraîches, un lien à envoyer. Un rapport\nhebdomadaire, un tableau de bord d'astreinte, la visualisation d'une table, une démo client, la sortie\nd'un [cron](/blog/cron-serverless) rendue lisible.\n\n## Et l'assistant, dans tout ça\n\nIl ne disparaît pas de l'histoire, il change de place. Le [serveur MCP](/mcp-server) expose le\ndéploiement comme un outil : un agent écrit le handler, le déploie, l'appelle, lit les logs et corrige,\ndans la même conversation. La fonction de cet article a été déployée comme ça.\n\nGardez donc l'artefact, le canvas ou la Frame pour ce qu'ils font le mieux : écrire la page en dix secondes et la regarder. Ce qui change ensuite, c'est ce qui survit à la conversation, une URL qui est la vôtre, une serrure que vous choisissez, une base qui retient, un tarif à la seconde, et une page qui affichera les données de demain sans qu'on la réécrive.\n\nLes tarifs sont sur [la page tarifs](/pricing) et le contrat complet du handler dans\n[la documentation](/docs#handler).\n\n## Essayez sur votre propre compte\n\nL'inscription vient avec du crédit, de quoi déployer, planifier et mesurer tout ce qui précède sans sortir de carte bancaire.\n\n[Commencer »](/register)", "url": "https://wpnews.pro/news/artefact-canvas-frame-heberger-vous-meme-la-page-generee", "canonical_source": "https://zerolith.io/blog/tableau-de-bord-html", "published_at": "2026-08-16 00:00:00+00:00", "updated_at": "2026-08-27 19:19:36.168498+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["Zerolith", "Claude", "ChatGPT", "Gemini", "Le Chat de Mistral", "Grok Studio", "Perplexity", "Dust"], "alternates": {"html": "https://wpnews.pro/news/artefact-canvas-frame-heberger-vous-meme-la-page-generee", "markdown": "https://wpnews.pro/news/artefact-canvas-frame-heberger-vous-meme-la-page-generee.md", "text": "https://wpnews.pro/news/artefact-canvas-frame-heberger-vous-meme-la-page-generee.txt", "jsonld": "https://wpnews.pro/news/artefact-canvas-frame-heberger-vous-meme-la-page-generee.jsonld"}}