Inicio rápido
Prepara una experiencia funcional, ejecútala junto a un pequeño host de prueba y comprueba tu primer guardado. Tardarás unos quince minutos.
Qué necesitas
- Node.js 22 o posterior.
- El paquete
@zenclub/sdk. Escríbenos para recibirlo. - Dos puertos locales libres entre 58000 y 65535: uno para tu experiencia y otro para el host de prueba.
1. Crea un proyecto
Instala el paquete y deja que su herramienta de línea de comandos prepare un proyecto. --port indica dónde se ejecutará tu experiencia y --host es el origen exacto autorizado para incrustarla.
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 devEl proyecto incluye un manifiesto, un pequeño servidor estático, la página y su script, además de una copia del SDK en public/vendor/sdk. No importa nada de la plataforma.
2. Conéctate al host
Abre public/app.js. El núcleo de cada experiencia sigue unos pasos breves: conectar, empezar a cargar, aplicar el tema, leer el guardado y avisar cuando esté lista y en marcha.
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 debe enumerar los orígenes exactos que pueden incrustarte. Usa el host de prueba mientras desarrollas y https://zenclub.app al publicar. Si se abre desde otro lugar, el marco se rechaza con HOST_DENIED.
3. Guarda el progreso
Los guardados usan revisiones. Pasa como expectedRevision la última revisión que leíste y conserva un requestId por cambio para que un reintento nunca escriba dos veces. El guardado automático reúne los cambios frecuentes y los escribe a un ritmo tranquilo.
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. Pruébala en un host de prueba
No necesitas la plataforma ZenClub para desarrollar. La entrada host del SDK monta un complemento igual que la plataforma, así que una página con un almacén de guardados en memoria puede sustituirla. Coloca estos dos archivos en una carpeta test-host dentro de tu proyecto.
<!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'))Inicia ambos servidores y abre el host de prueba. Cambia algo, pulsa Pausa y verás en la consola el guardado que el SDK ha enviado.
npm run dev # your experience on :58301
cd test-host && node serve.mjs # the test host on :58297
# open http://localhost:58297El host de prueba concede todos los permisos del manifiesto y guarda los datos en memoria. Es una ayuda para desarrollar, no una réplica de la revisión ni de los límites de frecuencia de la plataforma.
Siguientes pasos
Lee Ciclo de vida y guardado antes de crear algo que funcione durante mucho tiempo, y consulta la lista de Alojamiento y revisión antes de pedir que incluyamos tu experiencia.