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/server

Sur 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

OptionTypeDéfautCe que ça fait
storeSitepingStore—Obligatoire. Prisma, Drizzle, memory ou le vôtre
apiKeystring—Active l'auth Bearer. Les requêtes envoient Authorization: Bearer <clé>
publicEndpointsSitepingHttpMethod[]["POST", "OPTIONS"] quand apiKey est définiMéthodes qui contournent l'auth. Passer une valeur remplace le défaut — incluez "POST" vous-même, sinon le widget ne peut plus envoyer
requireAuthForDestructivebooleantrueSans apiKey, PATCH et DELETE répondent 401. En NODE_ENV=production, la factory refuse de démarrer sans apiKey
redactUnauthenticatedEmailsbooleantrueVoir ce que les réponses exposent
accessSitepingAccessControl—Votre propre authentification à la place des quatre options ci-dessus — voir accès personnalisé
allowedOriginsstring[]—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)
webhooksWebhookConfig | 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
hooksSitepingLifecycleHooks—onCreated, onUpdated, onDeleting, onDeleted
logger{ error(message, context) }console.errorOù 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 401 sauf si vous définissez un apiKey ou vous en désengagez explicitement avec requireAuthForDestructive: false. En production, la factory lève au démarrage plutôt que de tourner sans clé. Cette vérification lit process.env.NODE_ENV : elle ne tourne que là où process existe ; ailleurs, le 401 s'applique toujours.
  • OPTIONS est 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 projectName qu'ils indiquent via la méthode optionnelle verifyProjectOwnership du store — tous les stores fournis et tout ce qui repose sur createCollectionStore l'implémentent. Un store écrit à la main qui l'omet saute la vérification : quiconque connaît l'id d'un feedback peut alors le modifier quel que soit le projectName, donc implémentez la méthode (voir écrire un adapter) ou placez l'endpoint derrière un apiKey.

Ce que les réponses exposent

  • clientId est retiré de toutes les réponses.
  • authorEmail est vidé sauf si la requête porte un en-tête Authorization: Bearer valide. Seule exception : un POST ré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,
  },
});
  • authenticate tourne sur toutes les méthodes sauf OPTIONS, et résout qui appelle — un objet, une chaîne ou un nombre, jamais un booléen. Toute valeur falsy (null, undefined, "", 0) répond 401. Une vérification true/false est une erreur de type, et un false en JavaScript pur répond 401 lui aussi. Son type de retour type principal partout ailleurs.
  • authorize reçoit l'action (create, list, update, delete, deleteAll), le projectName et, pour update et delete, le feedbackId. 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 un authorize devant un store sans verifyProjectOwnership — tous les stores fournis l'implémentent.
  • canReadAuthorEmail vaut true par défaut.
  • Un callback qui lève répond une 500 journalisé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/json répond 415. Ce type impose un préflight CORS, qu'une page étrangère échoue.
  • L'origine. Avec allowedOrigins, une mutation dont l'Origin n'est ni listée ni celle de l'endpoint répond 403 et est journalisée. Les requêtes sans Origin — 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),
  },
});
  • beforeCreate tourne sur l'envoi validé, avant authorize — qui voit le projet qu'il renvoie.
  • presentFeedback tourne sur chaque enregistrement juste avant son envoi ; le retrait de clientId et le masquage de l'e-mail s'appliquent encore après lui.
  • onCreated, onUpdated et onDeleted sont 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. onCreated tourne une fois par feedback enregistré, jamais pour un clientId rejoué.
  • onDeleting tourne avant la suppression, avec { kind: "single", id, projectName } ou { kind: "project", projectName }. Une exception garde l'enregistrement et répond 502.

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éthodeRôleSuccès
POSTCré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
GETLister les feedbacks200 + { feedbacks, total }, avec Cache-Control: private, max-age=5 (no-store avec access)
PATCHChanger un statut200 + l'enregistrement mis à jour
DELETESupprimer un ou tous200 + { deleted: true }
OPTIONSPréflight CORS204

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.

Modifier sur GitHub

Sur cette page