快速入門

建立可運作的體驗,與精簡的測試主機並行執行,並確認第一筆儲存成功。約需 15 分鐘。

準備項目

  • 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

測試主機會授予資訊清單中列出的所有權限,並將資料保存在記憶體中。它是開發輔助工具,不等同於平台的審查與速率限制。

接下來

開始製作長時間執行的體驗前,請先閱讀生命週期與儲存;申請將體驗上架前,請閱讀託管與審查檢查清單。