빠른 시작
작동하는 경험의 기본 구조를 만들고 작은 테스트 호스트와 함께 실행해 첫 저장이 처리되는 모습을 확인해요. 약 15분이면 돼요.
준비물
- Node.js 22 이상
@zenclub/sdk패키지. 받으려면 문의해 주세요.- 58000~65535 사이의 사용 가능한 로컬 포트 두 개. 하나는 경험용이고 다른 하나는 테스트 호스트용이에요.
1. 프로젝트 만들기
패키지를 설치한 다음 명령줄 도구로 프로젝트 기본 구조를 만들어요. --port는 경험을 실행할 포트이고, --host는 경험을 포함할 수 있는 정확한 출처예요.
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프로젝트에는 매니페스트, 작은 정적 서버, 페이지와 스크립트, public/vendor/sdk의 SDK 사본이 들어 있어요. 플랫폼에서는 아무것도 가져오지 않아요.
2. 호스트에 연결하기
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에는 경험을 포함할 수 있는 정확한 출처를 적어야 해요. 개발 중에는 테스트 호스트를, 배포할 때는 https://zenclub.app을 사용해요. 다른 곳에서 열린 프레임은 HOST_DENIED 오류를 반환해요.
3. 진행 상황 저장하기
저장에는 리비전이 사용돼요. 마지막으로 읽은 리비전을 expectedRevision으로 전달하고, 변경마다 requestId 하나를 유지해 재시도해도 중복 기록되지 않게 해요. 자동 저장은 자주 발생하는 변경을 모아 적절한 속도로 기록해요.
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. 테스트 호스트에서 실행하기
개발 중에는 ZenClub 플랫폼이 없어도 돼요. SDK의 host 항목은 플랫폼과 똑같이 플러그인을 탑재하므로, 메모리 내 저장소가 있는 페이지 하나로 대신할 수 있어요. 프로젝트 안에 test-host 폴더를 만들고 이 두 파일을 넣어요.
<!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'))두 서버를 시작하고 테스트 호스트를 열어요. 내용을 바꾸고 일시 정지를 누르면 SDK가 저장소에 반영한 내용이 콘솔에 표시돼요.
npm run dev # your experience on :58301
cd test-host && node serve.mjs # the test host on :58297
# open http://localhost:58297테스트 호스트는 매니페스트에 선언된 모든 권한을 허용하고 메모리에 저장해요. 개발 보조 도구이며 플랫폼의 검토 및 요청 제한을 그대로 재현하지는 않아요.
다음 단계
오래 실행되는 기능을 만들기 전에 수명 주기와 저장을 읽고, 경험 등록을 요청하기 전에 호스팅과 검토 체크리스트를 확인해요.