Partager l'état d'une application sans base de donnée
Tags: typescript, webdev, javascript, french
Published: 2022-02-14 08:49:21

La plupart de mes projets personnels sont des applications web sans serveur derrière. La principale raison c'est qu'en terme d'hébergement il existe plein d'offres gratuites sans restrictions pour du "statique". À l'inverse héberger un serveur web c'est souvent payant ou limité, avec par exemple une mise en veille après un certain délai d'inactivité. C'est pas la mer à boire pour de petits projets mais si je peux éviter, j'aime autant.

S'imposer de ne pas avoir de serveur lorsqu'on développe une application web, ça rajoute un challenge qui demande d'être créatif. Par exemple quand on aurait bien besoin d'une base de donnée. On peut y trouver différentes solutions selon le besoin. Si l'objectif est de conserver des données saisies par l'utilisateur courant, on peut passer par du stockage navigateur. Ça se complique si on souhaite que l'utilisateur puisse partager ces données, c'est à ce besoin que cet article apporte des solutions.

## Exemple d'état à partager

Imaginons que vous créez un jeu de quizz qui génère un nouveau questionnaire avec différents paramètres à chaque fois que l'utilisateur lance une partie. Imaginons maintenant que vous souhaitez que l'utilisateur puisse partager ce challenge unique avec d'autres personnes, en partageant une URL.

Pour l'exemple, voilà à quoi pourrait ressembler un questionnaire :

```typescript
const gameState =
{
  difficulty: {
    id: "normal",
    modifiers: {
      timePerQuestion: 5000
    }
  },
  questions: [
    {
      id: 1,
      title: "Quelle est la couleur du cheval blanc d'Henri IV ?",
      answers: [
        { title: "Bleu", isCorrect: false },
        { title: "Blanc", isCorrect: true },
        { title: "Rouge", isCorrect: false }
      ]
    },
    // Ici on peut imaginer qu'il y ait encore d'autres questions.
  ]
}
```

## La solution simple mais limitée

Si on veut permettre à l'utilisateur de partager sa partie, le plus simple serait de le passer en paramètre d'URL :

```typescript
const gameState = /* le questionnaire présenté un peu plus haut */;

const shareableUrl = `https://mon-questionnaire.com/?gameState=${
  encodeURIComponent(
    JSON.stringify(gameState)
  )
}`;
```

Lorsque cette URL est partagée et qu'on la rencontre, pour restituer le questionnaire il suffirait alors de l'extraire :

```typescript
const searchParams = new URLSearchParams(window.location.search);
const gameState = JSON.parse(searchParams.get("gameState"));
```

C'est simple et potentiellement suffisant mais cette approche a plusieurs limites. La première c'est que les données sont clairement visibles, il n'est pas trop compliqué de trouver les bonnes réponses aux questions. La deuxième c'est qu'on peut se retrouver avec des URLs très longues selon le nombre de questions. Et enfin les données peuvent être éditées et corrompues. J'ajouterai aussi que ça ne fait pas une très jolie URL mais ça c'est mon fétiche.

Dans l'idéal, il faut donc qu'on masque ce qui est partagé, qu'on rende la longueur de la chaîne aussi courte que possible et enfin qu'on s'assure que ce qu'on récupère est valide.

## La solution plus complète

Pour obfusquer les données on peut les chiffrer de façon à ce qu'elle ne soit pas lisible dans l'URL mais déchiffrable par notre application à la réception. Ça fait la première partie du boulot mais ça complique un peu la deuxième, qui consiste à raccourir au maximum la taille de ce qu'on met dans l'URL.

Plutôt que chiffrer les données, on peut les compresser. Ça aura pour résultat de les obfusquer tout en raccourcissant la chaîne :

```typescript
import lzString from "lz-string";

const gameState = /* le questionnaire présenté un peu plus haut */;

const shareableUrl = `https://mon-questionnaire.com/?gameState=${
  lzString.compressToEncodedURIComponent(
    JSON.stringify(gameState)
  )
}`;
```

Cet exemple utilise la librairie [lz-string](https://github.com/pieroxy/lz-string) qui permet de compresser une chaîne de caractère dans un format donné, ici en quelque chose de compatible pour une URL. Ça produit quelque chose du type `NoIgpghgzgniA0wBMAGJAWAbC+BGArErigOzyq6b5mpIDMK65aSAnABx6F3HNL1NcdfriaoGrJHx6sAurKA`, c'est toujours assez long mais plus acceptable.

Ceci étant dit, on peut aller plus loin dans la compression. Jusqu'à maintenant on a compressé la chaîne de caractère résultante de la sérialisation en JSON du questionnaire. Mais on peut également compresser notre questionnaire en lui même. Par exemple le questionnaire donné en exemple plus haut pourrait être transformé comme suit :

```typescript
const compressedGameState =
[
  // difficulty.id :
  "normal",

  // questions :
  [
    [
      // id :
      1,
      // On part du principe que les réponses à une question
      // sont tirées au hasard. Elles ne sont donc pas statiques
      // mais propres à ce questionnaire.
      // answers :
      [
        [
          // title :
          "Bleu",
          // isCorrect :
          false
        ],
        [
          // title :
          "Blanc",
          // isCorrect :
          true
        ],
        [
          // title :
          "Rouge",
          // isCorrect :
          false
        ]
      ]
    ]
  ]
]
```

Pour résumer : on supprime les clés et tout ce qui est statique, qu'on peut retrouver dans notre code. Voilà à quoi pourrait ressembler le code qui permet de passer de l'état non compressé à l'état compressé :

```typescript
function compressGameState(gameState: GameState): CompressedGameState {
  return [
    gameState.difficulty.id,
    gameState.questions.map(question => (
      [
        question.id,
        question.answers.map(answer => (
          [answer.title, answer.isCorrect]
        ))
      ]
    ))
  ];
}
```

Et pour décompresser l'état :

```typescript
import { DIFFICULTIES, QUESTIONS } from "./constants";

function decompressGameState(compressedGameState: CompressedGameState): GameState {
  const [difficultyId, questions] = compressedGameState;

  return {
    difficulty: DIFFICULTIES[difficultyId],
    questions: questions.map(([questionId, answers]) => ({
      id: questionId,
      title: QUESTIONS[questionId],
      answers: answers.map(([title, isCorrect]) => ({
        title,
        isCorrect
      }))
    }))
  };
}
```

Combiné avec la compression de chaîne, ça donne :

```typescript
import lzString from "lz-string";

const gameState = /* le questionnaire présenté un peu plus haut */;

const shareableUrl = `https://mon-questionnaire.com/?gameState=${
  lzString.compressToEncodedURIComponent(
    JSON.stringify(
      compressGameState(gameState)
    )
  )
}`;
```

La dernière chose qui nous manque maintenant c'est de s'assurer qu'on récupère de l'URL quelque chose de valide en se protégeant d'une éventuelle malformation. Il s'agit tout simplement de la validation d'un objet, il existe des librairies bien faites si le cas d'usage est complexe mais sinon ça pourrait donner :

```typescript
function deserializeGameState(compressedString: string): GameState {
  try {
    return (
      decompressGameState(
        JSON.parse(
          lzString.decompressFromEncodedURIComponent(
            compressedString
          )
        )
      )
    );
  } catch(err) {
    throw new Error("Questionnaire corrompu");
  }
}
```

Cet exemple illustre la technique du paresseux mais en cas de besoin d'une gestion d'erreur plus fine, il est tout à fait possible de valider les éléments un par un.

## Exemple réel et complet

J'ai eu l'occasion de mettre en oeuvre cette approche sur [Name the Gwent Card](https://name-the-gwent-card.gabinaureche.com/) :

{% github zhouzi/name-the-gwent-card %}

Toute la logique de compression / décompression et de validation se trouve dans [src/app/GameState.ts](https://github.com/zhouzi/name-the-gwent-card/blob/master/src/app/GameState.ts). Pour voir un exemple, cliquez simplement sur "Play" ou "Jouer" sur la page d'accueil du jeu et observez l'URL.

---

## TLPL

Trop long pas lu, un résumé de l'article en quelques points :

- Compresser l'état en supprimant tout ce qui est statique : clés, constantes. Par exemple `{ answer: "Réponse", isCorrect: true }` devient `["Réponse", true]`.
- Convertir l'état compressé en chaîne de caractères JSON puis la compresser, par exemple avec [lz-string](https://github.com/pieroxy/lz-string).
- Passer le résultat en paramètre d'URL.
- Mettre en place une logique de sérialisation et désérialisation de cette URL vers un état valide.

---

**👋 Restons en contact**

Suis moi sur Twitter pour des conseils et astuces tous les jours : [@gabinaureche](https://twitter.com/gabinaureche).