daily-soup-widget 從寫到上架(下):tsc、npm 2FA、dist/、cron 四個 publish 踩雷
上篇講三個設計選型,這篇講把它真的送上 npm 的時候撞到的四個雷——TypeScript 不肯吐 .d.ts、npm 的 2FA 把 CI 擋在外面、dist/ 沒清漏 ship 了過期檔、GitHub Actions 的 cron 該設多頻繁。每一個都是「上線後才發現」的那種錯。
上篇講三個設計選型——雙通道 distribution、Shadow DOM、build-time schedule。設計選完之後,就是把它真的送上架——push 到 GitHub、publish 到 npm、設好每天的排程。
這四個雷的共通點是「光看 README 寫不出來」:
- TypeScript 編不出
.d.ts給 npm 用——noEmit: true跟declaration: true兩個設定打架。 npm publish在我本機需要二次驗證(2FA),但 CI 上沒人按手機——要嘛把 token 升級成 granular access token,要嘛改用 GitHub Actions OIDC。- 從 0.1.x 升到 0.2.0 時
dist/沒清,舊版的檔案被一起 publish 上去,npm 上的 package 比 git tag 還舊。 - 「每天的排程要每天跑一次 cron 嗎」——直覺答是,正確答是「看 schedule 多久過期一次」。
文章寫給沒 publish 過 npm 套件、沒 ship 過 CI cron 的人看。
本文導覽
TypeScript 編不出 .d.ts
.d.ts 是 TypeScript 的型別宣告檔,npm 套件用它告訴下載者「我這個函式吃什麼參數、回傳什麼」。沒有 .d.ts,TypeScript 使用者下載你的套件就會看到一片紅底「could not find a declaration file for module」1。
$ npm publish
# 上去之後別人裝來:
$ npm install daily-soup-widget
$ import { DailySoup } from 'daily-soup-widget'
# error TS7016: Could not find a declaration file for module 'daily-soup-widget'.
第一次 publish 完就吃這個錯。回去翻 tsconfig.json,declaration: true 明明開了,為什麼沒生出 .d.ts?
兩個設定打架
整個專案的 tsconfig.json 是給 Next.js 開發用的,裡面有一行:
{
"compilerOptions": {
"noEmit": true
}
}
noEmit: true 的意思是「tsc 只做型別檢查,不要產出任何檔案」——Next.js 自己會把 TypeScript 編譯掉,不需要 tsc 再產一份。
問題是 noEmit: true 比 declaration: true 大2。tsc 看到 noEmit: true 就完全不寫檔案——連 .d.ts 也不寫。所以「tsc --noEmit 做型別檢查」跟「tsc 產 .d.ts 給 npm 用」這兩件事,沒辦法用同一份 tsconfig.json 同時做。
解法:另開一份 build tsconfig
開一份 tsconfig.build.json 專門給「產 .d.ts」用:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"outDir": "./dist/types",
"incremental": false,
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
四個關鍵設定:
noEmit: false把母設定的noEmit: true翻過來。emitDeclarationOnly: true告訴tsc只產.d.ts,不產.js(.js我用 esbuild 另外打包)。outDir: "./dist/types"把.d.ts都集中放在一個資料夾,package.json的"types"欄位直接指過去。include: ["src/**/*"]限縮範圍,不要把app/、scripts/、tests/也一起編成.d.ts——只有src/才是 npm 套件要對外的程式碼。
package.json 加一個 build:types script:
{
"scripts": {
"build:types": "tsc -p tsconfig.build.json"
}
}
再執行 npm run build:types,dist/types/index.d.ts 就出來了。
為什麼不用一份 tsconfig 解決
最直覺的反應是「那把 noEmit: true 拿掉不就好了?」拿掉之後 npx tsc --noEmit 還是可以照常做型別檢查(指令參數會覆蓋設定檔),但 Next.js 在某些版本會跟「tsc 預設會 emit」的設定打架,VS Code 也會偶爾跳出莫名其妙的 ghost 檔案3。
把「型別檢查」跟「產對外宣告檔」拆成兩份設定,是 monorepo 跟「同一個 repo 既是 app 又發 npm」的標準做法。
npm publish 撞 2FA 牆
.d.ts 解決之後,下一步是 npm publish。在本機跑:
$ npm publish --access public
npm notice
npm notice 📦 daily-soup-widget@0.1.0
npm notice === Tarball Contents ===
...
npm ERR! code EOTP
npm ERR! This operation requires a one-time password from your authenticator.
EOTP 是 "one-time password required"——npm 要我從 Authenticator app 輸入 6 位數驗證碼。本機輸完就過了。
問題是接下來想把這件事搬到 CI 上自動跑(push 一個 git tag → GitHub Actions 自動 publish)。CI 上沒人按手機,怎麼辦?
兩條路:granular token 或 GitHub OIDC
第一條路是 granular access token4。npm 在 2024 年推出「細粒度個人存取權杖」——你可以發一個專屬於某個 package 的 token,設定它「跳過 2FA」、「只能 publish 不能讀私人資源」、「30 天後過期」。流程:
- npm 網站 → Access Tokens → Generate New Token (Granular)
- 勾「Packages and scopes:daily-soup-widget」
- 勾「Permissions:Read and write」
- 勾「Bypass 2FA for publish」
- 把 token 存到 GitHub Secrets,叫
NPM_TOKEN
GitHub Actions 用:
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
registry-url 那行是必要的——setup-node 看到它才會把 NODE_AUTH_TOKEN 寫進 .npmrc。
第二條路是 OIDC(Trusted Publishing)。npm 在 2024 年底加入 GitHub Actions OIDC 支援,CI 完全不需要存 long-lived token——每次 publish 都是 GitHub 即時發一個 short-lived token 給 npm。安全度高一截,但需要在 npm 網站把 package 設成「trust this GitHub workflow」。
我這次選 granular token,因為設定步驟少,後面要換 OIDC 不用改 publish script。token 過期前的提醒比較不可靠,記得在 calendar 上設一個。
「token 設了還是要 2FA」的隱藏雷
設好 token 之後 CI 第一次跑還是噴 EOTP。原因:npm 帳號層級有一個「Require 2FA for write actions」設定——這個比 token 設定優先。要在 npm 帳號 Settings → Two-Factor Authentication → 改成「Only for login」,granular token 的「Bypass 2FA」才有效。
這個關係寫在 npm 的 2FA 設定頁,不在 publish 教學頁裡——Authorization and writes 跟 Authorization only 兩個 2FA 等級對應的就是「token 能不能 bypass」5。
dist/ 沒清,0.2.0 漏 ship
0.1.5 publish 完之後改了一輪內容(從 60 句縮成 30 句),bump 版本到 0.2.0:
$ npm version 0.2.0
$ git push --tags
# CI 跑完,npm 上出現 0.2.0
$ npm install daily-soup-widget@0.2.0
# 拿下來解開……
$ cat dist/schedule-zh.json | jq '. | length'
60
60。我明明 build 的時候是 30 句,怎麼裝下來變回 60 句?
dist/ 沒清,build 是 merge
dist/ 是 build 輸出資料夾。我的 build:lib script 長這樣:
{
"scripts": {
"build:schedule": "tsx scripts/build-schedule.ts",
"build:bundle": "tsx scripts/build-bundle.ts",
"build:types": "tsc -p tsconfig.build.json",
"build:lib": "npm run build:schedule && npm run build:bundle && npm run build:types"
}
}
三個 build step 都會寫 dist/,但沒有任何一個 step 會先把 dist/ 清乾淨。所以:
- 0.1.5 build 時產
dist/schedule-zh.json60 句。 - 0.2.0 build 時
build-schedule.ts用新內容覆蓋dist/schedule-zh.json30 句——這檔對。 - 但 0.1.5 還有別的舊檔案(例如某個被砍掉的英文版
dist/schedule-en.json)留在那裡。 npm publish看到dist/整包都 publish 上去。
我這次的具體狀況是 build-schedule.ts 改動之後輸出檔名變了(dist/schedule.json → dist/schedule-zh.json),舊的 dist/schedule.json 還在 dist/ 裡,被 publish 上去當作有效檔。
解法:每次 build 之前 rm -rf dist
加一個 clean script,串在 build:lib 最前面:
{
"scripts": {
"clean": "rm -rf dist",
"build:lib": "npm run clean && npm run build:schedule && npm run build:bundle && npm run build:types"
}
}
再加一個 prepublishOnly hook——它會在 npm publish 真的開始打包之前自動執行:
{
"scripts": {
"prepublishOnly": "npm run build:lib"
}
}
prepublishOnly 跟 prepublish 不一樣:prepublish 在 npm install 時也會跑(行為很怪),prepublishOnly 只在真的要 publish 時跑6。所以這個 hook 等於是「保證每次 publish 上 npm 的 dist/ 都是當下重新 build 的」。
bump 到 0.2.1 之後重新 publish,30 句出現了。
為什麼不靠 files 欄位限制
package.json 的 files 欄位可以限定「只有這些路徑會被 publish」:
{
"files": [
"dist/embed.*",
"dist/schedule-*.json",
"dist/types/**/*.d.ts"
]
}
我有設這個,但它擋不住「dist/schedule.json 跟 dist/schedule-*.json 都符合 glob」這種情況——schedule.json 自己就 match schedule-*.json 的 pattern。files 是 allowlist,不是「強迫每次重 build」。
對策就是雙保險:prepublishOnly 保證 dist/ 是新的,files 保證沒有 source map / .tsbuildinfo 之類的東西漏 ship。
GitHub Actions cron:每天跑還是每月跑
build-schedule.ts 會把「未來 90 天每天該顯示哪句話」算好,存進 dist/schedule-zh.json。第 91 天怎麼辦?需要 CI 定期重新跑一次,往後再延 90 天。
GitHub Actions 的 cron 設定長這樣7:
on:
schedule:
- cron: '0 16 1 * *' # 每月 1 號 UTC 16:00(= 台北時間 2 號 00:00)
workflow_dispatch:
workflow_dispatch 加上去是為了手動觸發測試——上線前一定要先按一次手動跑,確認 CI 環境真的能跑通,不要等月初 cron 才發現 secret 沒設好。
直覺答 vs 實際答
最直覺的反應是「每天跑一次」——畢竟我寫的是 daily widget。
但**「daily widget」跟「daily regenerate」是兩件事**。schedule 一次算 90 天,每天跑代表「每天重算未來 90 天」——90 天的內容根本沒變化,只是右邊多一天、左邊少一天。如果 cron 改成每月跑一次,每月重算未來 90 天,讀者最久也是看 60 天前算的內容,沒有任何顯示落差。
換句話說,cron 頻率該對齊資源週轉週期,不是對齊「使用者體感名稱」:
| 看起來該對齊的 | 實際該對齊的 | 結論 |
|---|---|---|
| 「daily widget」這個名字 | schedule 涵蓋天數 | schedule 涵蓋 90 天 → 月 cron 夠用 |
| 「使用者每天看到的」 | schedule 是否過期 | 不會過期(90 天 buffer) |
| 內容是否更新 | 內容 push 觸發 publish | 改 quote 用 PR、不用 cron |
每天跑的成本不只是 GitHub Actions 額度——每跑一次 cron 都會 commit 一個「regen schedule」commit 進 git log,連續 30 次就 30 個雜訊 commit。
我的 cron 最後設成 0 16 1 * *——每月 1 號跑。
還有一個雷:concurrency group
cron 工作要加 concurrency:
concurrency:
group: regen-schedule
cancel-in-progress: false
cancel-in-progress: false 的意思是「如果上一輪還在跑就排隊等,不要殺掉」。schedule 重新 build 中途被殺,會留下 dist/schedule-zh.json 寫到一半的狀態。cancel-in-progress: true 適合 PR build(新 push 就殺舊的),不適合 cron。
七個決策完整版
把上下兩篇的七個決策列在一起:
| # | 決策 | 選什麼 | 為什麼 |
|---|---|---|---|
| 1 | Distribution channel | script tag + npm 兩條都走 | 涵蓋兩種使用者,bundle / dependency 各自的優勢分到對的人 |
| 2 | 樣式隔離 | Shadow DOM | iframe 撐不出 RWD、scoped CSS 防不住宿主全域選擇器 |
| 3 | 內容排程 | build 時算 90 天,存 JSON | 跳過 runtime API、跳過 CORS、過期日期還是不變 |
| 4 | .d.ts 產生 | 另開 tsconfig.build.json | 母 tsconfig 的 noEmit: true 比 declaration: true 大 |
| 5 | npm publish 認證 | granular token + bypass 2FA | OIDC 設定多,granular token 對 side project 夠用 |
| 6 | dist/ 髒問題 | clean script + prepublishOnly | files allowlist 擋不住舊檔名 match 新 glob |
| 7 | cron 頻率 | 每月 1 號 | schedule 涵蓋 90 天,月 cron 留 60 天 buffer 已經夠 |
這七個決策的共通點是——指令本身很短,但每個指令的「為什麼這樣設」要靠撞才會懂。tsc -p tsconfig.build.json 是一行,但要先理解 noEmit: true 跟 declaration: true 哪個比較大;npm publish --access public 是一行,但要先設好 token、設好帳號 2FA 等級;cron: '0 16 1 * *' 是一行,但要先想清楚 schedule 涵蓋多久。
寫下來的目的是給「第二次做」用——第二次做的時候,看自己第一次踩雷的紀錄會省半天時間。
參考資料
Footnotes
-
TypeScript Handbook — Declaration Files Introduction. https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html ↩
-
TypeScript TSConfig Reference —
noEmit. https://www.typescriptlang.org/tsconfig/#noEmit(明確說明noEmit: true會阻止任何 emit,包含 declaration) ↩ -
TypeScript TSConfig Reference —
emitDeclarationOnly. https://www.typescriptlang.org/tsconfig/#emitDeclarationOnly ↩ -
npm Docs — About access tokens (Granular access tokens). https://docs.npmjs.com/about-access-tokens ↩
-
npm Docs — Configuring two-factor authentication. https://docs.npmjs.com/configuring-two-factor-authentication("Authorization and writes" vs "Authorization only" 兩個 2FA 等級的差別) ↩
-
npm Docs — Scripts (
prepublishOnlyvsprepublishlifecycle). https://docs.npmjs.com/cli/v10/using-npm/scripts#prepare-and-prepublish ↩ -
GitHub Docs — Events that trigger workflows (
scheduleevent, POSIX cron syntax). https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule ↩