簡單來說
今天是個充實到幾乎爆炸的一天。 本來以為今天只是「按下發布鍵」的收尾工作,結果從早上一路折騰到下午:先是陷入打包流程的泥淖,接著意外發現一個 Plugin Bug,最後為了真正驗證 SDK 的「開箱即用」體驗,直接在 Monorepo 裡移植了一個全功能的 API 測試應用。 讓我從頭說起。
一、準備發布 v0.1.0:比想像中更多的事
昨天(2/17)已經通過了技術總監級別的最終程式碼審查,四個 CRITICAL 問題全部修復,89/89 單元測試通過,npm pack --dry-run 也確認了 20 個檔案、202.8 kB 的乾淨套件。理論上,今天只需要:
git tag -a v0.1.0 -m "Release v0.1.0"
git push origin main --tags
pnpm publish --filter @yoin/client --access public
然後就可以開慶功宴了。
然而現實並不是這樣的。
二、打包地獄:workspace:* 的背刺
在整個發布流程中,最詭異的問題出現在「嘗試用 npm publish 驗證」的那一刻:
npm error code EUNSUPPORTEDPROTOCOL
npm error Unsupported URL Type "workspace:": workspace:*
這個錯誤一度讓人以為打包設定炸裂了。但追查後才發現,罪魁禍首在於 Monorepo 的 workspace 協議。
packages/client/package.json 的 devDependencies 裡有:
"@yoin/core": "workspace:*"
這個 workspace:* 是 pnpm 的私有語法,用來引用 Monorepo 內的本地套件。當 pnpm 打包時,它會自動將其轉換為實際版本號(0.1.0)。但如果你用 npm 直接發布,或者用 npm pack 預覽,npm 不認識這個協議,直接報錯。
教訓很清晰:在 pnpm Monorepo 裡,所有涉及發布的操作都必須透過 pnpm 執行。
# ✅ 正確:讓 pnpm 介入,自動解析 workspace 協議
pnpm publish --filter @yoin/client --access public
# ❌ 錯誤:npm 不懂 workspace:*
npm publish --access public
這個陷阱相當隱蔽,因為在 dev 開發環境下完全正常,唯有在打包/發布這個特定情境才會爆炸。為此,寫了一份 PUBLISH_CHECKLIST.md 專門記錄這個「血淚教訓」,避免日後自己又踩一次坑。
經過多次 pnpm pack --dry-run 的反覆確認,最終的套件內容令人滿意:
| 關鍵指標 | 結果 |
|---|---|
| 總檔案數 | 20 個 |
| 壓縮後大小 | 202.8 kB |
| WASM 引擎 | dist/core_bg.wasm (308 KB) ✅ |
| workspace:* 殘留 | 無 ✅ |
| 原始碼/測試洩漏 | 無 ✅ |
三、v0.1.0 正式發布:一個里程碑
在確認一切就緒後,v0.1.0 正式推送到 npm:
pnpm publish --filter @yoin/client --access public
這是 Yoin 的第一個公開版本。從 Day 1 的單一 index.html + Rust WASM 原型,到現在:
- 🦀 Rust/WASM CRDT 引擎(基於
yrs,約 300 行精煉的 Rust) - 📦 TypeScript Client SDK(微核心 + Plugin 架構)
- ☁️ Cloudflare Durable Objects WebSocket Relay(含速率限制與 Hibernation API)
- 💾 IndexedDB 持久化 Plugin
- ↩️ Undo/Redo Plugin
- 👥 Awareness 系統(即時游標與在場感知)
- ✨ Proxy 透明寫入(
createMapProxy/createArrayProxy) - ⚛️ React Hooks(
useYoinMap,useYoinArray,useYoinAwareness) - 🔒 Schema 驗證(Zod 整合)
整個架構從單一檔案演化成一個嚴格分層的工業級框架,這個過程本身就是一段值得記錄的歷程。
四、意外的額外測試:YoinUndoPlugin 的隱藏 Bug
v0.1.0 發布後,我決定用「最終使用者的視角」來親自驗證一遍 SDK 的完整體驗。就在測試 createUndoPlugin() 的時候,發現了一個令人頭痛的行為:
安裝了 Plugin,呼叫了 undo(),但什麼事都沒發生。
追查下去,問題根源在於 onInstall() 的實作遺漏了兩個關鍵步驟:
- 安裝 Plugin 後,沒有自動呼叫
doc.enable_undo()(CRDT 層面的 undo 追蹤根本沒開啟)。 - 呼叫
setMap()時,沒有自動呼叫doc.expand_undo_scope(mapName),導致 undo 的作用域為空,無法匡住任何變更。
這就像是裝了一個錄影機,但從來沒按下「錄影」按鈕,事後當然回放不了任何東西。
修復方案如下:
// YoinUndoPlugin.onInstall()
onInstall(client) {
// ✅ 修復:自動啟用 CRDT 層的 undo 追蹤
client.getDoc().enable_undo();
// ✅ 修復:包裝 setMap,首次寫入時自動 expand_undo_scope
const seenMaps = new Set<string>();
const originalSetMap = client.setMap.bind(client);
client.setMap = (mapName, key, value) => {
if (!seenMaps.has(mapName)) {
client.getDoc().expand_undo_scope(mapName);
seenMaps.add(mapName);
}
return originalSetMap(mapName, key, value);
};
}
onDestroy(client) {
// ✅ 完整清理,不留副作用
client.setMap = originalSetMap;
}
修復的核心原則:Plugin 應該對使用者透明。 使用者只要呼叫 client.use(createUndoPlugin()),接下來的一切就應該自動運作,不該要求使用者手動記得去呼叫 enable_undo() 或 expand_undo_scope()。這些是 Plugin 的責任,不是使用者的責任。
這個修復隨即推出為 v0.1.1,就在 v0.1.0 發布後的同一天。
五、api-test 的誕生:第一個「外部消費者」測試場
修完 Bug 之後,我意識到一件事:到目前為止,所有的測試(89 個單元測試、60 個整合測試)都是在 Monorepo 內部進行的,從未真正模擬過「一個陌生的開發者安裝 @yoin/client 後從零開始使用」的情境。
這正是 apps/api-test 的誕生動機:打造一個完整的 API 全功能測試應用,作為 SDK 的第一個真實消費者。
設計哲學
這個應用不追求漂亮的 UI,它的唯一目標是:對每一個公開 API 進行可被人眼確認的測試,並輸出 ✅ / ❌ 的測試結果。
Yoin API 全功能測試
結果:32 通過 / 0 失敗
✅ initYoin() — 首次初始化成功
✅ initYoin() 冪等 — 重複呼叫不報錯
✅ isYoinInitialized() — 回傳 true
✅ new YoinClient() — docId = api-test-1739900712345
✅ getDoc() — typeof = object
✅ setText() — 'Hello Yoin!'
✅ getText() — 取得文字正確
✅ setMap() — age = 30
✅ getMap() — 取得 Map 正確
✅ UndoPlugin undo() — 成功還原
...
技術選型:用 workspace:* 模擬真實消費者
apps/api-test/package.json 最關鍵的一行:
"dependencies": {
"@yoin/client": "workspace:*"
}
在 Monorepo 內使用 workspace:*,意味著 api-test 直接消費本地的 packages/client 原始碼。任何對 SDK 的改動都會立即反映在這裡,沒有任何快取層。這是一種極度緊密的整合測試。
Vite 設定的優雅性
apps/api-test/vite.config.ts 只有四行:
import { defineConfig } from 'vite';
import { yoinViteConfig } from '@yoin/client/vite';
export default defineConfig({
...yoinViteConfig(),
});
這裡用了 SDK 自身匯出的 yoinViteConfig(),它封裝了 vite-plugin-wasm 和 vite-plugin-top-level-await 的配置,讓消費者不需要手動處理 WASM 的跨源隔離標頭(COOP/COEP)等複雜設定。這本身就是一種 API 設計的驗證:SDK 把複雜度吞入自身,外部使用者保持簡單。
涵蓋的測試範圍
1. 初始化 initYoin() 冪等性、isYoinInitialized()
2. YoinClient 建構、底層 getDoc()/getConfig() 存取
3. Text API setText / getText / deleteText
4. Map API setMap / getMap / setMapDeep / getMapJson
5. Array API arrayPush / arrayGet / arrayGetAll
6. Awareness setAwareness / getLocalAwareness / subscribeAwareness
7. 網路狀態 subscribeNetwork / 狀態值驗證
8. Plugin 系統 createLoggerPlugin / createUndoPlugin (+ undo/redo)
9. Proxy API createMapProxy 透明讀寫驗證
10. 生命週期 client.destroy() 後操作的回應
這份測試清單幾乎就是公開 API 的一份活文件——它跑過就代表 API 在這個環境下一定能動。
六、今日工作全覽
| 時間軸 | 工作項目 | 結果 |
|---|---|---|
| 上午 | 準備發布 v0.1.0,排查 workspace:* 打包問題 | ✅ 完整的發布檢查清單 |
| 中午 | 多次 pnpm pack --dry-run,確認套件內容 | ✅ 套件乾淨,20 檔 / 202.8 kB |
| 下午早 | pnpm publish,v0.1.0 正式上線 | ✅ npm 套件首次公開發布 |
| 下午中 | 手動驗測,發現 YoinUndoPlugin 的 undo 失效 Bug | 🐛 發現 & 修復 |
| 下午晚 | v0.1.1 補丁發布 | ✅ Plugin 現自動管理 undo scope |
| 傍晚 | 建立 apps/api-test,移植全功能 API 測試應用 | ✅ 覆蓋全部公開 API |
七、反思:發布是一面鏡子
今天最深的體會是:發布這個動作本身,就是最殘酷的 Code Review。
在開發環境裡,一切都太舒適了。workspace:* 幫你解決依賴,Vite HMR 幫你即時更新,測試只在 Monorepo 的沙盒裡跑。但當你真的要把東西包成 .tgz 推到 npm,讓一個完全陌生的開發者在他的電腦上下載時,很多「假設成立」會突然崩塌。
YoinUndoPlugin 的 Bug 就是一個例子。它在單元測試裡沒有被抓到,因為測試裡有人工地呼叫了 enable_undo()。但真實用戶不會這樣做,他們只會呼叫 client.use(createUndoPlugin()),然後期待 undo 能動。
api-test 的建立,正是為了填補這個「使用者視角的測試空白」。在未來,每次修改公開 API 之前,都應該先看看 api-test 會不會紅。
八、下一步
createDbPlugin/storage.ts的測試覆蓋率:目前是 0%,這是 v0.1.2 的首要目標。- Safari 相容性:WASM + COOP/COEP 在 Safari < 14 的行為仍未完整驗證。
api-test自動化:目前是人工跑瀏覽器確認,未來應整合進 CI 流程(考慮用 Playwright)。- 文件補強:升級指南與 Troubleshooting 章節仍是空缺。
今天就到這裡。從零打包到正式上線,中間踩了無數坑——但每一個坑都讓 Yoin 更健壯一點。
如果你對 Local-First 協作框架、CRDT、或 Rust WASM 開發有興趣,歡迎在 GitHub 追蹤
GitHub - Saisai568/Yoin: A high-performance, developer-friendly Local-First state synchronization framework
A high-performance, developer-friendly Local-First state synchronization framework - Saisai568/Yoin
— Saisai568,2026 年 2 月 18 日晚上