Schnellstart

Setze ein funktionierendes Erlebnis auf, starte es neben einem kleinen Test-Host und beobachte, wie dein erster Speicherstand geschrieben wird. Das dauert etwa fünfzehn Minuten.

Was du brauchst

  • Node.js 22 oder neuer.
  • Das Paket @zenclub/sdk. Schreib uns, um es zu erhalten.
  • Zwei freie lokale Ports zwischen 58000 und 65535: einen für dein Erlebnis und einen für den Test-Host.

1. Projekt erstellen

Installiere das Paket und lass dann sein Kommandozeilenwerkzeug ein Projekt aufsetzen. Unter --port läuft dein Erlebnis, und --host gibt den genauen Origin an, der es einbetten darf.

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

Das Projekt enthält ein Manifest, einen kleinen statischen Server, die Seite samt Skript und eine Kopie des SDK unter public/vendor/sdk. Es importiert nichts von der Plattform.

2. Mit dem Host verbinden

Öffne public/app.js. Jedes Erlebnis folgt im Kern einer kurzen Abfolge: verbinden, Laden starten, Design übernehmen, Speicherstand lesen und dann melden, dass es bereit ist und läuft.

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 muss die genauen Origins aufführen, die dich einbetten dürfen. Verwende beim Entwickeln deinen Test-Host und für die Veröffentlichung https://zenclub.app. Ein Frame von einem anderen Ort wird mit HOST_DENIED abgewiesen.

3. Fortschritt speichern

Speicherstände verwenden Revisionen. Übergib die zuletzt gelesene Revision als expectedRevision und verwende pro Änderung eine eigene requestId, damit eine Wiederholung nie doppelt schreibt. Das automatische Speichern sammelt häufige Änderungen und schreibt sie in ruhigem Tempo.

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. Im Test-Host ausprobieren

Während der Entwicklung brauchst du die ZenClub-Plattform nicht. Der host-Einstieg des SDK bindet ein Plugin genau wie die Plattform ein. Eine Seite mit einem Speicher im Arbeitsspeicher kann sie daher ersetzen. Lege diese beiden Dateien in einem Ordner test-host in deinem Projekt ab.

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'))

Starte beide Server und öffne dann den Test-Host. Ändere etwas, drücke auf Pause und sieh in der Konsole den Speicherstand, den das SDK für dich geschrieben hat.

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

Der Test-Host gewährt alle Berechtigungen aus deinem Manifest und hält Speicherstände im Arbeitsspeicher. Er dient der Entwicklung und bildet weder die Prüfung noch die Ratenbegrenzungen der Plattform nach.

Nächste Schritte

Lies den Leitfaden Lebenszyklus und Speichern, bevor du etwas entwickelst, das lange läuft, und die Checkliste Hosting und Prüfung, bevor du dein Erlebnis in den Katalog aufnehmen lassen möchtest.