Démarrage rapide

Créez une expérience fonctionnelle, lancez-la à côté d’un petit hôte de test et observez votre première sauvegarde. Comptez environ quinze minutes.

Ce qu’il vous faut

  • Node.js 22 ou version ultérieure.
  • Le package @zenclub/sdk. Écrivez-nous pour le recevoir.
  • Deux ports locaux libres entre 58000 et 65535 : un pour votre expérience et un pour l’hôte de test.

1. Créer un projet

Installez le package, puis laissez son outil en ligne de commande créer la structure du projet. --port indique où votre expérience s’exécutera et --host l’origine exacte autorisée à l’intégrer.

shell
npm install ./zenclub-sdk-0.9.0.tgz
npx zenclub-plugin init my-garden --id=my-garden --port=58301 --host=http://localhost:58297
cd my-garden
npm run dev

Le projet contient un manifeste, un petit serveur statique, la page et son script, ainsi qu’une copie du SDK dans public/vendor/sdk. Il n’importe rien depuis la plateforme.

2. Se connecter à l’hôte

Ouvrez public/app.js. Toute expérience suit les mêmes étapes essentielles : se connecter, commencer le chargement, appliquer le thème, lire la sauvegarde, puis signaler qu’elle est prête et en cours.

public/app.js
import {connect, applyTheme} from './vendor/sdk/client.js'

const sdk = await connect({
  pluginId: 'my-garden',
  version: '0.1.0',
  hostApi: {major: 1, minMinor: 2},
  allowedHostOrigins: ['http://localhost:58297'],
})
await sdk.loading.start()
sdk.theme.subscribe(theme => applyTheme(theme))

const save = await sdk.storage.read('primary')
let count = save?.data.count ?? 0, savedCount = count
let revision = save?.revision ?? 0

let paused = false, hidden = false
const report = () => sdk.session.report({state: paused || hidden ? 'paused' : 'running', mode: 'watch'})
sdk.onLifecycle(command => {
  if (command.type === 'pause') paused = true
  if (command.type === 'resume') paused = false
  if (command.type === 'visibility') hidden = !command.visible
  return report()
})

await sdk.loading.ready()
await report()
setInterval(report, 4000)

allowedHostOrigins doit énumérer les origines exactes qui peuvent vous intégrer. Utilisez votre hôte de test pendant le développement et https://zenclub.app pour la publication. Un cadre ouvert depuis une autre origine est rejeté avec HOST_DENIED.

3. Sauvegarder la progression

Les sauvegardes utilisent des révisions. Transmettez la dernière révision lue dans expectedRevision et conservez un requestId par modification afin qu’une nouvelle tentative ne puisse jamais écrire deux fois. La sauvegarde automatique rassemble les changements fréquents et les écrit à un rythme mesuré.

public/app.js
let pending
const autosave = sdk.storage.createAutosave(async () => {
  if (!pending && savedCount === count) return
  // One request id per change, kept for every retry of that change.
  pending ??= {count, requestId: crypto.randomUUID()}
  const saved = await sdk.storage.write('primary', {count: pending.count}, {
    expectedRevision: revision,
    schemaVersion: 'counter-v1',
    requestId: pending.requestId,
  })
  revision = saved.revision
  savedCount = pending.count
  const latest = pending.count === count
  pending = undefined
  return latest
}, {onError: error => console.warn('Save not confirmed', error.code)})

const button = document.querySelector('#count')
button.disabled = false
button.onclick = () => {
  if (paused || hidden) return
  count++
  document.querySelector('#value').textContent = count
  autosave.schedule()
}

4. Essayer dans un hôte de test

Vous n’avez pas besoin de la plateforme ZenClub pendant le développement. L’entrée host du SDK monte un plugin comme le fait la plateforme ; une page avec un magasin de sauvegardes en mémoire peut donc la remplacer. Placez ces deux fichiers dans un dossier test-host de votre projet.

test-host/index.html
<!doctype html>
<meta charset="utf-8">
<title>Test host</title>
<style>body{margin:0;font:14px system-ui}#stage{height:80vh}#stage iframe{width:100%;height:100%;border:0}</style>
<p id="status">Loading…</p>
<button id="pause">Pause</button> <button id="resume">Resume</button>
<div id="stage"></div>
<script type="module">
import {mountPlugin} from './sdk/host.js'

const manifest = await (await fetch('./plugin.manifest.json')).json()
const steps = Array.from({length: 12}, (_, i) => '#' + (0xf4 - i * 18).toString(16).repeat(3))
const theme = {
  themeId: 'test', mode: 'light', reducedMotion: false,
  tokens: {
    background: '#f4f1e9', surface: '#fbf9f4', ink: '#293b32', muted: '#66756d',
    accent: '#375c47', accentInk: '#ffffff', border: '#d9d4c7', soft: '#e9e4d8',
    radius: '12px', font: 'Georgia, serif',
    ...Object.fromEntries(steps.map((c, i) => ['step' + (i + 1), c])),
    onStepLow: '#293b32', onStepHigh: '#ffffff',
  },
}
const saves = new Map()
const plugin = mountPlugin({
  container: document.querySelector('#stage'),
  manifest,
  grantedPermissions: manifest.permissions,
  context: {sessionId: crypto.randomUUID(), storageMode: 'memory', theme, locale: 'en', timeZone: 'UTC'},
  onStatus(state, detail) {
    document.querySelector('#status').textContent = state + (detail ? ': ' + detail : '')
    // Like the platform: tell the plugin whether it can be seen, right after it connects.
    if (state === 'connected') showVisibility()
  },
  async handle(method, input) {
    console.log(method, input)
    if (method === 'storage.read') return saves.get(input.slot) ?? null
    if (method === 'storage.write') {
      const current = saves.get(input.slot)
      if ((current?.revision ?? 0) !== input.expectedRevision) throw Object.assign(new Error('Revision changed'), {code: 'CONFLICT'})
      const next = {slot: input.slot, schemaVersion: input.schemaVersion, revision: input.expectedRevision + 1, data: input.data, updatedAt: new Date().toISOString()}
      saves.set(input.slot, next)
      return next
    }
    return {ok: true}
  },
})
function showVisibility() { plugin.command({type: 'visibility', visible: !document.hidden}) }
document.addEventListener('visibilitychange', showVisibility)
document.querySelector('#pause').onclick = () => plugin.command({type: 'pause'})
document.querySelector('#resume').onclick = () => plugin.command({type: 'resume'})
</script>
test-host/serve.mjs
import http from 'node:http'
import {readFile} from 'node:fs/promises'
import path from 'node:path'

const files = {
  '/': 'index.html',
  '/plugin.manifest.json': '../plugin.manifest.json',
}
const sdk = path.resolve('../public/vendor/sdk')
const types = {'.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json'}

http.createServer(async (req, res) => {
  try {
    const url = new URL(req.url, 'http://localhost').pathname
    const file = url.startsWith('/sdk/') ? path.join(sdk, path.basename(url)) : files[url]
    if (!file) throw new Error('not found')
    res.writeHead(200, {'Content-Type': types[path.extname(file)] ?? 'text/plain'})
    res.end(await readFile(file))
  } catch {
    res.writeHead(404).end('Not found')
  }
}).listen(58297, '127.0.0.1', () => console.log('Test host: http://localhost:58297'))

Lancez les deux serveurs, puis ouvrez l’hôte de test. Modifiez quelque chose et appuyez sur Pause ; la console affiche la sauvegarde vidée par le SDK.

shell
npm run dev                         # your experience on :58301
cd test-host && node serve.mjs      # the test host on :58297
# open http://localhost:58297

L’hôte de test accorde toutes les autorisations du manifeste et garde les sauvegardes en mémoire. C’est une aide au développement, pas une reproduction des limites d’examen et de débit de la plateforme.

Étapes suivantes

Lisez Cycle de vie et sauvegardes avant de créer une expérience de longue durée, puis la liste de contrôle Hébergement et examen avant de nous demander de référencer votre expérience.