快速开始

搭建一个可运行的体验,在本地测试宿主旁启动它,看看第一份存档成功写入。整个过程约需十五分钟。

准备事项

  • Node.js 22 或更高版本。
  • @zenclub/sdk 包。联系我们获取。
  • 两个未占用的本地端口,范围为 58000 到 65535:一个用于你的体验,一个用于测试宿主。

1. 创建项目

安装软件包,然后使用它的命令行工具搭建项目。--port 指定体验运行的端口,--host 指定获准嵌入体验的确切源站。

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

项目包含清单、简易静态服务器、页面及其脚本,以及位于 public/vendor/sdk 的 SDK 副本。它不会导入平台中的任何内容。

2. 连接宿主

打开 public/app.js。每个体验的核心步骤都很简短:连接、开始加载、跟随主题、读取存档,然后报告已就绪并开始运行。

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,确保重试不会重复写入。自动保存会收集频繁的更改,并按平稳节奏写入。

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. 在测试宿主中试用

开发时不需要启动觉城平台。SDK 的 host 入口会像平台一样挂载插件,因此一个带内存存档库的页面就能代替平台。将这两个文件放入项目内的 test-host 文件夹。

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

启动两台服务器,然后打开测试宿主。修改内容并按下“暂停”,控制台会显示 SDK 替你刷新的存档。

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

测试宿主会授予清单中的所有权限,并将存档保存在内存中。它是开发辅助工具,不代表平台的审核和速率限制。

后续步骤

开始构建长时间运行的体验前,请阅读生命周期与存档;申请将体验列入目录前,请查看托管与审核清单。