Skip to content

Protocole appli–plugins : confirmations et reprise ​

Voir le service côté appli pour les scopes et destinataires, le helper V3 pour les signatures, délais, files et erreurs.

État initial et retour après coupure ​

Une page peut s'ouvrir après le début de la partie, survivre à son arrêt ou se recharger indépendamment. Le handshake onReady ne prouve ni la présence d'une appli active, ni la connexion du relais, ni l'achèvement de son initialisation métier. data() lit les stockages V2, pas les variables vivantes de l'appli ; un broadcast déjà parti n'est pas un état initial récupérable.

Définis un contrat de synchronisation explicite :

  1. La page enregistre son callback onSocketEvent une seule fois, puis demande un instantané, par exemple emitWithReply('state:request').
  2. L'appli répond avec l'exécution (kapp.uuid), la manche, une révision croissante, la phase et les données publiques/personnelles autorisées. Ne retourne jamais toute sa mémoire, des secrets ou les données d'autres joueurs.
  3. La page désactive les actions jusqu'à cet instantané. Chaque action renvoie l'exécution et la manche ; l'appli refuse les valeurs anciennes. La révision est un ordre d'état, pas une horloge client.
  4. Dans la même exécution/manche, une réponse ou un broadcast plus ancien ne doit pas écraser un affichage récent. Une nouvelle exécution se confirme par une nouvelle synchronisation, pas par la comparaison lexicale d'UUID. Si la demande d'état échoue, l'interface reste en attente avec une action de resynchronisation.

Le helper V3 n'expose pas de callback public de reconnexion. Prévois une resynchronisation à la réouverture/au retour visible de la page et un bouton manuel, en regroupant les demandes simultanées. Pour une interface durablement ouverte, un contrôle d'état espacé et adapté au besoin peut compléter cela ; évite le polling serré. Une requête d'état sans effet de bord peut être retentée avec temporisation bornée. Une action métier incertaine demande d'abord une vérification de son résultat.

Concurrence et déduplication ​

requestId identifie une attente du helper : un nouvel appel en crée un autre. Choisis une clé métier dont la portée correspond à ton action (exécution + manche + viewer pour un choix unique ; identifiant d'opération persistant pour un achat). L'identité vient de viewer fourni par le SDK, jamais d'un champ envoyé dans data.

Les callbacks d'événements ne sont pas attendus par le bus local. Deux handlers asynchrones peuvent donc vérifier le même solde avant que l'un d'eux l'ait changé. Valide et réserve l'action avant le premier await, ou sérialise les opérations par ressource. Borne les files et l'historique de déduplication ; définis quand ils expirent. Un verrou en mémoire protège seulement cette exécution : une écriture de stockage suivie d'un débit séparé ne crée pas de transaction. N'annonce pas une garantie de débit « exactement une fois » sans support atomique côté serveur.

Un échec de reply ne doit pas annuler une action déjà faite ni la rejouer. Conserve un résultat consultable. L'arrêt de l'appli efface ses Maps : pour reprendre une opération après redémarrage, il faut un état durable et une procédure de réconciliation.

Confirmation facultative d'une action (helper V3 3.0.0 + SDK app V2) ​

await KappappsHost.emitWithReply(event, data = {}, {timeoutMs = 10000} = {}) attend un objet JSON choisi par l'appli. Ce n'est pas request(), qui reste un client HTTP générique. emit() garde son comportement d'émission simple. Un plugin HTML autonome ou utilisant un autre serveur n'a aucune obligation d'utiliser ce mécanisme.

  • Le helper génère un identifiant aléatoire par appel et le SDK présente l'événement métier habituel à PLUGIN_VIEWERS ou PLUGIN_STREAMER, avec deux champs supplémentaires : requestId et reply(data): Promise<void>. Ces champs sont absents pour un emit() simple. Le SDK ne répond jamais automatiquement, même si le callback se termine.
  • Le développeur valide les droits et l'état, effectue l'action, puis appelle explicitement await reply({...}). Une réponse {ok: false, reason: 'closed'} résout la Promise du plugin : c'est une décision métier à interpréter, pas une panne du transport. Aucun détail d'exception de l'appli n'est envoyé automatiquement.
  • Le délai est un entier entre 1 et 60 000 ms, 10 000 ms par défaut, à partir de l'appel : initialisation, file, HTTP et attente de réponse compris. L'initialisation conserve aussi son plafond de 5 s et chaque tentative HTTP celui de 5 s. Une demande expirée encore en attente n'est jamais envoyée ensuite. Une demande déjà partie peut encore être traitée : le timeout n'annule pas l'action et ne prouve pas un échec. La première réponse valide reçue à temps gagne ; réponses tardives et doublons sont ignorés.
  • Au plus 20 confirmations en attente par iframe, en plus des limites communes de file, concurrence, taille et quotas. Aucun ping périodique ni nouvelle socket. Aucun renvoi après timeout, erreur réseau ou 5xx. Le retry 429 existant reste limité à une fois et n'a lieu que si la demande n'a pas expiré. requestId assure la corrélation ; il ne remplace pas un identifiant métier durable pour dédupliquer un achat ou vérifier son résultat après un rechargement.
  • Une réponse viewer est ciblée sur le viewer authentifié fourni par Kappapps, avec le scope app-plugin-viewers:write. Une réponse streamer exige app-plugin-streamer:write et emprunte la diffusion streamer/admin existante. Plusieurs onglets d'un viewer, ou tous les opérateurs streamer/admin autorisés, peuvent donc recevoir l'enveloppe ; seul le helper qui attend cet identifiant la consomme. La corrélation n'est pas une isolation de confidentialité entre ces onglets/opérateurs. Les réponses internes ne sont pas transmises à onSocketEvent.

Exemple minimal de confirmation d'un vote remplaçable en mémoire. Il illustre reply ; pour une partie avec phase, ouverture tardive et refus des anciens votes, utilise le quiz complet. La recette suivante précise une autre règle : un seul choix définitif par joueur.

js
// Dans l'appli : le même gestionnaire accepte emit() et emitWithReply().
const votes = new Map();
kapp.plugin.events.on(kapp.plugin.EVENTS.PLUGIN_VIEWERS, ({viewer, event, data, reply}) => {
    if (event !== 'vote') return;
    void (async () => {
        const choice = data?.choice;
        if (!['left', 'right'].includes(choice)) {
            if (reply) await reply({ok: false, reason: 'invalid_choice'});
            return;
        }
        votes.set(viewer.id, choice); // validation et traitement avant confirmation
        if (reply) await reply({ok: true, choice});
    })().catch(error => {
        // L'action peut être faite alors que sa réponse n'a pas pu être envoyée.
        console.error('Réponse non transmise', error.code, error.status);
    });
});

// Dans le plugin V3, lors du clic utilisateur :
async function vote(choice) {
    try {
        const result = await KappappsHost.emitWithReply('vote', {choice}, {timeoutMs: 10000});
        console.log(result.ok ? 'Vote enregistré' : 'Vote refusé', result);
    } catch (error) {
        console.error('Confirmation indisponible', error.code);
        // REPLY_TIMEOUT : résultat inconnu. Ne pas renvoyer automatiquement le vote.
    }
}

Une ancienne version du SDK app qui ne comprend pas ce protocole, une appli fermée ou un gestionnaire qui n'appelle pas reply ne produit aucune confirmation. Le plugin expire sans diagnostiquer laquelle de ces situations s'est produite.

Recette : choix unique, état récupérable et réponse perdue ​

Bloc à ajouter à script.js après AppliLoaded. Il suppose le plugin viewers déclaré et le scope app-plugin-viewers:write. Il ne gère ni monnaie ni récompenses. Une exécution représente une manche, limitée à 100 participants ; ses choix restent en mémoire. Le cycle de vie de l'appli décide quand terminer.

La page commence par await KappappsHost.emitWithReply('state:request'). Elle utilise result.state.execution et result.state.round dans emitWithReply('choose', {execution, round, choice: 'left'}). Après une confirmation perdue, elle redemande l'état : choice révèle son choix déjà accepté. Un doublon du même choix est sans effet ; un choix différent est refusé. Les instantanés contiennent uniquement le choix du demandeur et le nombre total de participants.

js
(() => {
    const execution = kapp.uuid;
    const round = 1;
    const choices = new Map();
    let revision = 0;
    let closed = false;

    function state(viewer) {
        return {execution, round, revision, phase: closed ? 'closed' : 'open',
            choice: choices.get(viewer.id) ?? null, participants: choices.size};
    }

    function receive({viewer, event, data, reply}) {
        if (closed || !reply || !['state:request', 'choose'].includes(event)) return;
        let result;
        if (event === 'state:request') {
            result = {ok: true};
        } else if (data?.execution !== execution || data?.round !== round) {
            result = {ok: false, reason: 'stale_round'};
        } else if (!['left', 'right'].includes(data?.choice)) {
            result = {ok: false, reason: 'invalid_choice'};
        } else if (choices.has(viewer.id)) {
            result = choices.get(viewer.id) === data.choice
                ? {ok: true} : {ok: false, reason: 'already_chosen'};
        } else if (choices.size >= 100) {
            result = {ok: false, reason: 'full'};
        } else {
            choices.set(viewer.id, data.choice); // décision avant toute attente réseau
            revision++;
            result = {ok: true};
        }
        void reply({...result, state: state(viewer)}).catch(error => {
            console.error('Confirmation perdue ; état conservé', error?.code, error?.status);
        });
    }

    kapp.plugin.events.on(kapp.plugin.EVENTS.PLUGIN_VIEWERS, receive);
    kapp.onBeforeExit('*', () => {
        closed = true;
        kapp.plugin.events.off(kapp.plugin.EVENTS.PLUGIN_VIEWERS, receive);
    });
})();

Cette recette traite un choix unique par viewer, même avec plusieurs onglets. Elle n'essaie pas d'ordonner des changements de choix concurrents. Si le produit autorise les modifications, définis une règle explicite (révision attendue, priorité d'opérateur, etc.) et renvoie l'état retenu.

Tester avec deux viewers réels ​

Utilise les pages viewers authentifiées pour obtenir deux identités distinctes, puis vérifie :

ScénarioVérification attendue
Deux comptes, deux choixChaque compte retrouve son propre choix ; deux participants.
Second onglet du même compteMême choix personnel ; répéter ce choix ne change ni la révision ni le nombre de participants. Un choix différent est refusé.
Message ciblé puis message publicemitViewers([viewer], ...) atteint les onglets de ce compte, sans apparaître chez l'autre compte ; emitViewers(null, ...) atteint les deux. Les confirmations internes ne deviennent pas des événements ordinaires du helper.
Rechargement d'une pageLes variables de la page sont perdues, puis l'instantané retrouve le choix dans la même exécution. Un ancien broadcast ne réapparaît pas comme état initial.
Réponse volontairement tardiveSur une action témoin sans conséquence, répondre après le délai du helper. Vérifier le timeout, puis consulter le résultat conservé sans renvoyer l'action. Ce délai contrôlé n'est pas une panne réseau.
Coupure réseau de la page, sans la rechargerCouper seulement le navigateur de recette, laisser l'appli et un autre viewer connectés, émettre un témoin pendant l'absence, puis rétablir le réseau. Vérifier les nouveaux échanges et demander un instantané ; ne supposer ni replay des messages manqués ni callback onReady à la reconnexion.
Arrêt puis nouvelle exécution de l'appliLa page peut rester ouverte. Son ancienne exécution doit être refusée ; une nouvelle synchronisation retrouve une manche neuve, sans les Maps de l'ancienne.

Le transport ciblé isole les comptes, pas les onglets d'un même compte. Un compteur global ne rafraîchit pas les autres pages : ajoute une diffusion ou une resynchronisation si leur affichage doit suivre. Distingue dans ton compte rendu les scénarios réellement exécutés et ceux testés avec doublures.

Absorber les vagues sans casser les interactions ​

  • Émets une intention utilisateur, pas un événement par mousemove, frame, tick, frappe ou changement visuel. Debounce les saisies et regroupe les mises à jour naturellement regroupables.
  • Pour un état continu (curseur, position, volume), préfère latest wins : une nouvelle valeur remplace la précédente encore en attente.
  • Pour des actions comptables (achat, vote, récompense), ne fusionne pas aveuglément : donne un identifiant métier et déduplique côté appli.
  • Utilise emitViewers(null, ...) pour une donnée commune. Ne fabrique pas une requête ciblée par viewer quand un broadcast suffit.
  • Si une partie accepte moins de joueurs que de viewers, laisse le plugin chargé en mode spectateur/lecture seule et fais gérer l'inscription par l'appli. Ne bloque pas arbitrairement le chargement des viewers suivants.
  • Garde les payloads petits. Les données privées d'un joueur doivent rester ciblées ; ne les diffuse jamais avec users = null.