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.
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 devLe 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.
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é.
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.
<!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>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.
npm run dev # your experience on :58301
cd test-host && node serve.mjs # the test host on :58297
# open http://localhost:58297L’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.