クイックスタート
動作する体験をひな形から作り、小さなテストホストと並べて実行し、最初の保存を確認します。所要時間は約15分です。
必要なもの
- Node.js 22以降。
@zenclub/sdkパッケージ。受け取り方法はお問い合わせください。- 58000から65535の範囲で空いているローカルポートを2つ。体験用とテストホスト用に1つずつ使います。
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として渡し、変更ごとに1つの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エントリーはプラットフォームと同じようにプラグインを読み込むため、メモリ内保存を使う1ページで代用できます。次の2つのファイルをプロジェクト内の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テストホストはマニフェスト内のすべての権限を付与し、保存データをメモリに保持します。これは開発補助用であり、プラットフォームの審査やリクエスト上限を再現するものではありません。
次のステップ
長時間動く機能を作る前にライフサイクルと保存を読み、体験の掲載を依頼する前にホスティングと審査のチェックリストを確認してください。