快速开始
搭建一个可运行的体验,在本地测试宿主旁启动它,看看第一份存档成功写入。整个过程约需十五分钟。
准备事项
- 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. 在测试宿主中试用
开发时不需要启动觉城平台。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测试宿主会授予清单中的所有权限,并将存档保存在内存中。它是开发辅助工具,不代表平台的审核和速率限制。