Serveur
Un endpoint HTTP devant n'importe quel store, pour n'importe quel framework — auth, CORS, validation, redaction, hooks et webhooks sur la Fetch API.
@siteping/server transforme n'importe quel store en l'API HTTP à laquelle parlent le widget et le dashboard. Il ne parle que la Fetch API (Request → Response) et n'importe aucun module natif de Node : le même handler tourne sur Node 20+, Bun, Deno et les workers edge, monté par Next.js, Hono, Express ou n'importe quoi d'autre.
npm i @siteping/serverSur Prisma, @siteping/adapter-prisma vous donne ce handler avec le store Prisma intégré — toutes les options ci-dessous y fonctionnent aussi.
Le montage
createSitepingHandler renvoie un handler par méthode HTTP : GET, POST, PATCH, DELETE et OPTIONS. Montez les cinq sur une même URL.
Next.js (App Router)
// app/api/siteping/route.ts
import { createSitepingHandler } from "@siteping/server";
import { store } from "@/lib/siteping-store";
export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({
store,
apiKey: process.env.SITEPING_API_KEY,
});Hono
import { createSitepingHandler } from "@siteping/server";
import { Hono } from "hono";
import { store } from "./siteping-store";
const siteping = createSitepingHandler({ store, apiKey: process.env.SITEPING_API_KEY });
const app = new Hono();
app.on(["GET", "POST", "PATCH", "DELETE", "OPTIONS"], "/api/siteping", (c) =>
siteping[c.req.method as keyof typeof siteping](c.req.raw),
);
export default app;La même répartition fonctionne partout où arrive une Request : Bun.serve, Deno.serve, le fetch d'un Worker Cloudflare, les endpoints Remix ou SvelteKit.
Express
Express vous passe les req/res de Node, pas une Request : convertissez à l'entrée.
import { createSitepingHandler, type SitepingHandler } from "@siteping/server";
import express from "express";
import { store } from "./siteping-store";
const siteping = createSitepingHandler({ store, apiKey: process.env.SITEPING_API_KEY });
const app = express();
// Des octets bruts en entrée : le handler lit et valide le JSON lui-même. Les
// captures voyagent en ligne, d'où une limite au-delà des 100 ko de body-parser.
app.all("/api/siteping", express.raw({ type: () => true, limit: "2mb" }), async (req, res) => {
const handle = siteping[req.method as keyof SitepingHandler];
if (!handle) {
res.sendStatus(405);
return;
}
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(", ") : value);
}
const response = await handle(
new Request(`${req.protocol}://${req.get("host")}${req.originalUrl}`, {
method: req.method,
headers,
body: Buffer.isBuffer(req.body) && req.body.length > 0 ? new Uint8Array(req.body) : null,
}),
);
res.status(response.status);
response.headers.forEach((value, name) => res.setHeader(name, value));
res.send(Buffer.from(await response.arrayBuffer()));
});La limitation de débit n'est pas gérée ici : appliquez-la dans votre framework ou votre reverse proxy, sur le POST avant tout — le widget l'appelle depuis des navigateurs anonymes.
Options
| Option | Type | Défaut | Ce que ça fait |
|---|---|---|---|
store | SitepingStore | — | Obligatoire. Prisma, Drizzle, memory ou le vôtre |
apiKey | string | — | Active l'auth Bearer. Les requêtes envoient Authorization: Bearer <clé> |
publicEndpoints | SitepingHttpMethod[] | ["POST", "OPTIONS"] quand apiKey est défini | Méthodes qui contournent l'auth. Passer une valeur remplace le défaut — incluez "POST" vous-même, sinon le widget ne peut plus envoyer |
requireAuthForDestructive | boolean | true | Sans apiKey, PATCH et DELETE répondent 401. En NODE_ENV=production, la factory refuse de démarrer sans apiKey |
redactUnauthenticatedEmails | boolean | true | Voir ce que les réponses exposent |
access | SitepingAccessControl | — | Votre propre authentification à la place des quatre options ci-dessus — voir accès personnalisé |
allowedOrigins | string[] | — | Liste blanche CORS en correspondance exacte. Aucun joker — ["*"] ne correspond à rien ; non défini signifie aucun en-tête CORS du tout. Les origines listées peuvent envoyer Content-Type, Authorization et tout en-tête personnalisé demandé par leur préflight (par exemple via l'option headers du widget) |
webhooks | WebhookConfig | WebhookConfig[] | — | Déclenchés à chaque nouveau feedback — voir webhooks |
waitUntil | (promise) => void | — | Garde les envois de webhooks en vie après la réponse sur les runtimes serverless et edge — voir webhooks |
beforeCreate | (input, context) => input | — | Réécrire un envoi avant son enregistrement — voir points d'extension |
presentFeedback | (feedback, context) => feedback | — | Transformer chaque enregistrement avant son envoi |
hooks | SitepingLifecycleHooks | — | onCreated, onUpdated, onDeleting, onDeleted |
logger | { error(message, context) } | console.error | Où vont les échecs inattendus, avec la méthode et le chemin de la requête |
describeError | (error) => string | undefined | — | Le message d'une 500 au lieu de "Internal server error", par exemple une indication « lancez vos migrations ». Ne renvoyez jamais les détails de l'erreur elle-même |
Le modèle de sécurité, franchement
- Les lectures sont publiques par défaut. Sans
apiKey, quiconque connaît l'URL peut lister les feedbacks (avec les e-mails masqués — voir plus bas). - Les appels destructifs, non. PATCH et DELETE répondent
401sauf si vous définissez unapiKeyou vous en désengagez explicitement avecrequireAuthForDestructive: false. En production, la factory lève au démarrage plutôt que de tourner sans clé. Cette vérification litprocess.env.NODE_ENV: elle ne tourne que là oùprocessexiste ; ailleurs, le401s'applique toujours. OPTIONSest toujours public — les préflights doivent fonctionner.- Les vérifications inter-projets passent par le store. PATCH et DELETE vérifient que l'enregistrement appartient au
projectNamequ'ils indiquent via la méthode optionnelleverifyProjectOwnershipdu store — tous les stores fournis et tout ce qui repose surcreateCollectionStorel'implémentent. Un store écrit à la main qui l'omet saute la vérification : quiconque connaît l'idd'un feedback peut alors le modifier quel que soit leprojectName, donc implémentez la méthode (voir écrire un adapter) ou placez l'endpoint derrière unapiKey.
Ce que les réponses exposent
clientIdest retiré de toutes les réponses.authorEmailest vidé sauf si la requête porte un en-têteAuthorization: Bearervalide. Seule exception : unPOSTréussi renvoie l'e-mail à son auteur.
Avec access, c'est canReadAuthorEmail qui décide, pour toutes les réponses — POST compris.
Accès personnalisé
Une clé partagée convient à un site et à son dashboard. Quand les relecteurs se connectent, passez plutôt access : le handler demande à votre code qui appelle et ce qu'il peut faire, à partir de la Request standard — un cookie de session, un JWT, un en-tête posé par votre proxy.
export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({
store,
allowedOrigins: ["https://site-client.com"],
access: {
// null (ou toute valeur falsy) → 401
authenticate: (request) => getSessionUser(request),
// false → 403
authorize: ({ principal, action, projectName }) =>
action === "create" || principal.projects.includes(projectName),
// les e-mails des relecteurs sont des données personnelles
canReadAuthorEmail: (principal) => principal.isStaff,
},
});authenticatetourne sur toutes les méthodes saufOPTIONS, et résout qui appelle — un objet, une chaîne ou un nombre, jamais un booléen. Toute valeur falsy (null,undefined,"",0) répond401. Une vérificationtrue/falseest une erreur de type, et unfalseen JavaScript pur répond401lui aussi. Son type de retour typeprincipalpartout ailleurs.authorizereçoit l'action(create,list,update,delete,deleteAll), leprojectNameet, pourupdateetdelete, lefeedbackId. Par défaut, il laisse passer tout appelant authentifié. PATCH et DELETE désignent les enregistrements par id : la factory refuse donc de démarrer avec unauthorizedevant un store sansverifyProjectOwnership— tous les stores fournis l'implémentent.canReadAuthorEmailvauttruepar défaut.- Un callback qui lève répond une
500journalisée.
access remplace apiKey, publicEndpoints, requireAuthForDestructive et redactUnauthenticatedEmails : passer les deux est une erreur de type. Depuis une page d'une autre origine, envoyez l'identifiant par l'option headers du widget — le widget n'envoie pas de cookies en cross-origin.
Protection CSRF
Un navigateur joint aussi les cookies à une requête cross-site forgée : avec access, POST, PATCH et DELETE passent donc deux contrôles avant tout le reste :
- Du JSON uniquement. Un corps sans
Content-Type: application/jsonrépond415. Ce type impose un préflight CORS, qu'une page étrangère échoue. - L'origine. Avec
allowedOrigins, une mutation dont l'Originn'est ni listée ni celle de l'endpoint répond403et est journalisée. Les requêtes sansOrigin— appels serveur à serveur, curl — passent. Derrière un proxy qui réécrit l'URL de la requête, listez aussi votre origine publique, sinon les appels de même origine paraissent étrangers.
Les listes partent aussi avec Cache-Control: no-store, puisqu'elles dépendent de qui demande. La politique apiKey n'a besoin ni de l'un ni de l'autre : sa clé voyage dans un en-tête qu'aucune requête forgée ne peut poser.
Points d'extension
createSitepingHandler({
store,
access,
// L'auteur vient de la session, et les jetons collés dans le message sont masqués.
beforeCreate: (input, { principal }) => ({
...input,
authorName: principal.name,
authorEmail: principal.email,
message: input.message.replace(/token=\S+/g, "token=[redacted]"),
}),
hooks: {
onCreated: (feedback) => tracker.openIssue(feedback),
// Levez pour garder l'enregistrement : le DELETE répond 502 et peut être rejoué.
onDeleting: (target) => tracker.closeIssues(target),
},
});beforeCreatetourne sur l'envoi validé, avantauthorize— qui voit le projet qu'il renvoie.presentFeedbacktourne sur chaque enregistrement juste avant son envoi ; le retrait declientIdet le masquage de l'e-mail s'appliquent encore après lui.onCreated,onUpdatedetonDeletedsont attendus avant la réponse, pour que les runtimes serverless ne les coupent pas. Une exception est journalisée et ne fait jamais échouer la requête : l'écriture a déjà eu lieu.onCreatedtourne une fois par feedback enregistré, jamais pour unclientIdrejoué.onDeletingtourne avant la suppression, avec{ kind: "single", id, projectName }ou{ kind: "project", projectName }. Une exception garde l'enregistrement et répond502.
Ces callbacks reçoivent { request, principal } comme contexte ; principal vaut null sous la politique apiKey. Les hooks écrits comme méthodes de classe gardent leur this.
Référence HTTP
Un endpoint, cinq méthodes. Tous les corps sont en JSON.
| Méthode | Rôle | Succès |
|---|---|---|
POST | Créer un feedback (envois du widget) | 201 + l'enregistrement. Un clientId rejoué renvoie l'enregistrement existant au lieu d'échouer (et ne notifie jamais deux fois les webhooks ni onCreated) ; un clientId déjà utilisé par un autre projet est refusé avec 409 |
GET | Lister les feedbacks | 200 + { feedbacks, total }, avec Cache-Control: private, max-age=5 (no-store avec access) |
PATCH | Changer un statut | 200 + l'enregistrement mis à jour |
DELETE | Supprimer un ou tous | 200 + { deleted: true } |
OPTIONS | Préflight CORS | 204 |
Paramètres de requête du GET : projectName (obligatoire), page (défaut 1), limit (défaut 50, max 100), type, status, statuses (liste séparée par des virgules, max 4 — ex. statuses=open,in_progress), search, url, urlPattern.
Corps du PATCH : { id, projectName, status } — les trois sont obligatoires. status vaut open, in_progress, resolved ou wont_fix. Le serveur déduit resolvedAt automatiquement : renseigné quand le statut est clos (resolved/wont_fix), effacé sinon.
Corps du DELETE : { id, projectName } pour un enregistrement, ou { projectName, deleteAll: true } pour tout le projet.
Erreurs : JSON invalide → 400 { error } ; échec de validation → 400 { errors: [{ field, message }] } ; identifiants absents ou faux → 401 ; refus d'authorize ou d'un contrôle CSRF → 403 ; corps non JSON avec access → 415 ; id inconnu → 404 ; clientId appartenant à un autre projet → 409 { error } ; onDeleting a levé → 502 ; tout le reste → 500 { error: "Internal server error" }, ou le message que renvoie describeError.
Les limites de validation à connaître
message ≤ 5000 caractères · annotations ≤ 50 par feedback · screenshotDataUrl ≤ 1,5 Mo, JPEG/PNG/WebP uniquement · diagnostics ≤ 50 entrées console + 20 réseau · clientId doit correspondre à [a-zA-Z0-9_-]+ · authorEmail suit le même motif Unicode que la fenêtre d'identité du widget (françois@exemple.fr passe), donc une adresse acceptée par la fenêtre n'est jamais refusée ici.
Webhooks
Soyez notifié à chaque nouveau feedback :
createSitepingHandler({
store,
webhooks: [
{ url: process.env.SLACK_WEBHOOK_URL!, type: "slack" },
{ url: "https://mon-api.dev/hooks/siteping", type: "generic", headers: { "x-secret": "…" } },
],
});type vaut "slack", "discord" ou "generic" (le défaut — il poste l'enregistrement en JSON avec l'e-mail de l'auteur non masqué : traitez donc les cibles de webhook générique comme de confiance ; le clientId est retiré comme partout ailleurs). Chaque appel a un timeout de 5 secondes, les échecs ne bloquent jamais l'envoi, et un onError(err, feedbackId) optionnel permet de les journaliser. Un clientId rejoué ne les redéclenche jamais, et deux envois simultanés du même clientId (le widget qui relance une requête expirée) ne notifient qu'une fois lorsqu'ils atteignent le même processus serveur. Entre plusieurs instances, seul le store peut les départager : le @unique de Prisma sur clientId le fait, tout comme l'index unique du store Drizzle. Les stores memory et localStorage, comme tout store construit sur createCollectionStore, ne dédoublonnent qu'au sein d'une même instance de store, et un store personnalisé qui renvoie l'enregistrement existant sur un doublon peut notifier deux fois s'il n'implémente pas createFeedbackIfAbsent de façon atomique — voir Écrire un adapter.
Les envois ne sont pas attendus : le widget reçoit sa réponse d'abord. Un runtime serverless ou edge peut geler ou annuler ce travail une fois la réponse partie : confiez-le-lui avec waitUntil :
import { after } from "next/server";
createSitepingHandler({ store, webhooks, waitUntil: after });Le waitUntil de @vercel/functions ou de cloudflare:workers fonctionne de la même façon — passez une fonction autonome, pas une méthode détachée de son objet.
Le texte des feedbacks est saisi par des visiteurs anonymes, les charges utiles de chat sont donc durcies : le texte Slack est échappé (un commentaire contenant <!channel> s'affiche tel quel, jamais comme une notification) et les messages Discord partent avec l'analyse des mentions désactivée (allowed_mentions: { parse: [] }) et leur markdown échappé (un lien masqué [Réinitialisez votre mot de passe](https://…) s'affiche tel quel, jamais comme un lien déguisé) — un formulaire public ne peut servir ni à notifier tout votre serveur, ni à l'hameçonner. Les valeurs trop longues sont tronquées aux limites de taille de Slack et de Discord (échappement compris), pour qu'une URL de page de 2000 caractères ne fasse jamais rejeter toute la notification.