前後端整合:型別共享與 monorepo
前面二十五章累積出一堆分散的東西:D1 schema(第 9、10 章)、Hono API(第 5 章)、跨 Worker 的 RPC(第 18 章)、兩個前端(第 24、25 章)。這一章把它們放進同一個 repo,並且回答兩個具體問題:
- 一份型別能不能從 D1 schema 一路流到 React component?
- 多個 Worker 之間的 service binding,能不能在本機用一個指令就跑起來?
第二個問題的答案很乾脆:能,而且是本章最實用的一段。第一個問題的答案是「能,但有一個沒人講的陷阱會擋住你」—— 我在建這個範例時就撞上了,那個錯誤訊息會在 26.4 原樣呈現。
26.1 目標架構
Section titled “26.1 目標架構”ch26-monorepo/ pnpm-workspace.yaml packages/ schema/ @repo/schema — Drizzle schema + drizzle-zod,唯一真相來源 workers/ auth/ @repo/auth — WorkerEntrypoint,透過 RPC 被呼叫 apps/ api/ @repo/api — Hono Worker,匯出 AppType web/ @repo/web — 前端,用 hc<AppType> + TanStack Query型別的流向是一條線:
Drizzle table → drizzle-zod (createInsertSchema / createSelectSchema) → Hono route (safeParse + c.json) → typeof routes = AppType → hc<AppType> on the client → TanStack Query 的 data 型別中間沒有一次手寫的 interface,沒有一次 as any。
26.2 唯一真相來源:@repo/schema
Section titled “26.2 唯一真相來源:@repo/schema”import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";import { createInsertSchema, createSelectSchema } from "drizzle-zod";import { z } from "zod";
export const links = sqliteTable( "links", { slug: text("slug").primaryKey(), tenantId: text("tenant_id").notNull(), url: text("url").notNull(), clicks: integer("clicks").notNull().default(0), createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), }, (t) => [index("links_tenant_created").on(t.tenantId, t.createdAt)],);
// 衍生,不是手寫export const selectLinkSchema = createSelectSchema(links);export const insertLinkSchema = createInsertSchema(links, { url: z.url(), slug: z.string().min(3).max(64).regex(/^[a-z0-9-]+$/),}).pick({ slug: true, url: true });
export type Link = z.infer<typeof selectLinkSchema>;export type NewLink = z.infer<typeof insertLinkSchema>;
export type Session = { tenantId: string; userId: string; scopes: string[] };三個設計決定值得說明:
(一)createInsertSchema 的第二個參數用來收緊,不是用來重寫。 url 在資料庫裡只是 text not null,drizzle-zod 會產生 z.string();我們把它收緊成 z.url()。這個覆寫是加法,欄位的必填/可選仍然由 schema 決定 —— 你不會因為改了 DB 欄位卻忘了改驗證而產生不一致。
(二).pick() 明確列出使用者能提供的欄位。 tenantId、clicks、createdAt 全部由伺服器決定。這不只是型別問題,是安全問題:如果 insert schema 包含 tenantId,使用者就能偽造租戶。
(三)Session 也放在這裡。 它同時被 auth Worker(RPC 回傳型別)、API Worker(middleware 變數)、前端(顯示登入者)使用。跨 RPC 邊界的型別必須有一個共同的家。
實測驗證這條鏈確實接通了 —— 送一個壞的 body 進 API:
curl -X POST -H "authorization: Bearer t_acme_u1" \ -d '{"slug":"AB","url":"not-a-url"}' http://localhost:9029/api/links{ "error": "invalid", "issues": [ { "code": "too_small", "minimum": 3, "path": ["slug"], "message": "Too small: expected string to have >=3 characters" }, { "code": "invalid_format", "pattern": "/^[a-z0-9-]+$/", "path": ["slug"], ... }, ... ]}這些規則寫在 packages/schema,執行在 Worker,而前端 form 可以 import 同一個 schema 做即時驗證 —— 一份定義,三處生效。
26.3 auxiliaryWorkers:一個指令跑起整個系統
Section titled “26.3 auxiliaryWorkers:一個指令跑起整個系統”第 18 章講 service binding 時,本地開發的痛點是:你得開兩個終端機、跑兩個 wrangler dev,還要處理 port。@cloudflare/vite-plugin 的 auxiliaryWorkers 解決了這件事。
import { cloudflare } from "@cloudflare/vite-plugin";import { defineConfig } from "vite";
export default defineConfig({ plugins: [ cloudflare({ auxiliaryWorkers: [{ configPath: "../../workers/auth/wrangler.jsonc" }], inspectorPort: false, }), ],});"services": [ { "binding": "AUTH", "service": "ch26-auth", "entrypoint": "AuthService" }]service 欄位比對的是 auxiliary Worker 的 name。實測 —— 一個 vite dev,跨 Worker 的 RPC 直接可用:
curl -H "authorization: Bearer t_acme_u1" http://localhost:9029/api/whoami{ "session": { "tenantId": "acme", "userId": "u1", "scopes": ["links:read", "links:write"] }, "upstream": { "worker": "ch26-auth", "instanceFresh": true }}session 由 API Worker 呼叫 env.AUTH.verify(token) 得到,upstream 由 env.AUTH.whoami() 得到 —— 兩次都是真的跨 Worker RPC 呼叫,在同一個 dev process 裡完成。
完整的 plugin 設定型別
Section titled “完整的 plugin 設定型別”實測 node_modules/@cloudflare/vite-plugin/dist/index.d.mts:
interface PluginConfig extends EntryWorkerConfig { auxiliaryWorkers?: AuxiliaryWorkerConfig[]; persistState?: PersistState; inspectorPort?: number | false; remoteBindings?: boolean; tunnel?: boolean | TunnelConfig; experimental?: Experimental;}
interface BaseWorkerConfig { viteEnvironment?: { name?: string; childEnvironments?: string[] };}
interface AuxiliaryWorkerFileConfig extends BaseWorkerConfig { configPath: string; devOnly?: DevOnly; // boolean | (() => boolean)}
interface AuxiliaryWorkerInlineConfig extends BaseWorkerConfig { configPath?: string; config: WorkerConfigCustomizer<false>; // 直接寫設定,不需要檔案 devOnly?: DevOnly;}
interface EntryWorkerConfig extends BaseWorkerConfig { configPath?: string; config?: WorkerConfigCustomizer<true>; assetsOnly?: DevOnly; // 開發時有 server code,部署時純靜態}三個沒有出現在 Cloudflare 文件裡、但很有用的東西:
AuxiliaryWorkerInlineConfig—— auxiliary Worker 可以完全用 inline 設定,不需要wrangler.jsonc檔案。適合那種只在開發時存在的假服務。devOnly: boolean | (() => boolean)—— 見下一段。assetsOnly—— 開發時跑 Worker、部署時只出靜態資源。適合「本機有 mock API、production 是純靜態站」的情境。
devOnly:只在開發時存在的 Worker
Section titled “devOnly:只在開發時存在的 Worker”實測差異:
devOnly 未設 | devOnly: true | |
|---|---|---|
ls dist | ch26_api ch26_auth | ch26_api |
.wrangler/deploy/config.json 的 auxiliaryWorkers | [{ configPath: ".../ch26_auth/wrangler.json" }] | [] |
用途很具體:本機用一個假的 auth / payment / email Worker,production 指向真的服務。 因為 service binding 是按 Worker 名稱解析的,只要假 Worker 的 name 和真 Worker 一致,兩邊的程式碼完全不用改。
輸出目錄是 Vite environment 名稱,不是 Worker 名稱
Section titled “輸出目錄是 Vite environment 名稱,不是 Worker 名稱”這一點很容易誤會,實測驗證:
預設(沒有設 viteEnvironment.name):
dist/ch26_api/ <- Worker 名稱 "ch26-api",連字號被換成底線dist/ch26_auth/ <- Worker 名稱 "ch26-auth"顯式指定之後:
cloudflare({ viteEnvironment: { name: "gateway" }, auxiliaryWorkers: [{ configPath: "...", viteEnvironment: { name: "identity" } }],})dist/gateway/dist/identity/{ "configPath": "../../dist/gateway/wrangler.json", "auxiliaryWorkers": [{ "configPath": "../../dist/identity/wrangler.json" }] }目錄名跟著 Vite environment 名稱走。 未指定時它由 Worker 名稱衍生(並把連字號正規化成底線,因為 Vite environment 名稱必須是合法識別字)。你的 CI 腳本如果硬編碼 dist/<worker-name> 路徑,遇到有連字號的 Worker 名稱就會找不到。
每個目錄裡都有一份完整的 wrangler.json。實測 dist/ch26_api/wrangler.json:
{ "name": "ch26-api", "main": "index.js", "no_bundle": true, "services": [{ "binding": "AUTH", "service": "ch26-auth", "entrypoint": "AuthService" }], "d1_databases": [{ "binding": "DB", "database_name": "ch26-links", ... }]}部署時,entry Worker 用裸 wrangler deploy(.wrangler/deploy/config.json 會指路),auxiliary Worker 要各自部署:
npx vite buildnpx wrangler deploy -c dist/ch26_auth/wrangler.json # 先部署被依賴的npx wrangler deploy # 再部署 entry順序有意義:service binding 指向一個還不存在的 Worker,部署會失敗。
26.4 那個沒人講的型別陷阱
Section titled “26.4 那個沒人講的型別陷阱”到目前為止一切順利。接著我在 apps/web 裡寫下這一行:
import type { AppType } from "@repo/api";然後 tsc --noEmit:
../api/src/env.ts(9,38): error TS2304: Cannot find name 'ApiBindings'.../api/src/env.ts(10,9): error TS2304: Cannot find name 'Service'.../../workers/auth/src/index.ts(1,34): error TS2307: Cannot find module 'cloudflare:workers' or its corresponding type declarations.../../workers/auth/src/index.ts(32,13): error TS2304: Cannot find name 'ExportedHandler'.為什麼會這樣
Section titled “為什麼會這樣”在 monorepo 裡,內部套件通常直接匯出 TypeScript 原始碼("exports": { ".": "./src/index.ts" })—— 這是 pnpm workspace 最常見也最方便的做法,不需要 build step。
但這代表:當 @repo/web 型別檢查時,TypeScript 會把 @repo/api 的整個型別依賴圖拉進來。 而那張圖包含:
ExportedHandler、Service<T>這些 workerd 的 ambient 全域型別ApiBindings、AuthBindings這些wrangler types產生的 ambient interfacecloudflare:workers虛擬模組
這三樣東西的宣告都在各自 package 的 worker-configuration.d.ts 裡,而那個檔案只被那個 package 的 tsconfig.json 的 types 欄位引入。瀏覽器 package 當然沒有引入 —— 它憑什麼要有 Workers runtime 的型別?
一個相關的小坑,我在同一次除錯中也撞到了:workers/auth/src/index.ts 原本寫 WorkerEntrypoint<AuthBindings>,光是被 apps/api import 就會炸 TS2304: Cannot find name 'AuthBindings' —— 因為 api package 的 tsconfig 引入的是自己的 worker-configuration.d.ts,裡面沒有 AuthBindings。
一般化的規則:任何會被別的 package import 的檔案,都不可以依賴 ambient 全域型別。 ambient 宣告不跨 package 邊界。這對 Workers 特別致命,因為 Workers 的整個型別系統就是建立在 ambient 全域之上的。
修法 A:把 Workers 型別塞進消費端。
"types": ["../api/worker-configuration.d.ts"]實測可行,tsc 通過。但這很難看 —— 一個瀏覽器 package 現在的全域命名空間裡有 DurableObjectState、ExecutionContext、R2Bucket。你會在寫前端時得到 Workers 的自動完成,而且 fetch 的型別可能被 workerd 的版本覆蓋。能動,但不建議。
修法 B(本章採用):把路由定義抽到一個「可攜」的模組。
關鍵洞察是:AppType = typeof routes 只需要路由的型別,不需要 Worker 進入點的型別。所以把兩者拆開:
// apps/api/src/routes.ts —— 這個檔案的型別依賴圖必須是可攜的import { Hono } from "hono";import { drizzle } from "drizzle-orm/d1";import { links, insertLinkSchema, type Session } from "@repo/schema";
// 用結構型別描述 binding,而不是從 worker-configuration.d.ts 拉。type Structural = { DB: Parameters<typeof drizzle>[0]; AUTH: { verify(token: string): Promise<Session | null>; whoami(): Promise<{ worker: string; instanceFresh: boolean }>; };};
export const app = new Hono<{ Bindings: Structural; Variables: Vars }>();export const routes = app.get("/api/health", ...).get("/api/links", ...) /* ... */;
/** 瀏覽器 import 的契約。它的型別依賴圖永遠不會碰到 workerd。 */export type AppType = typeof routes;// apps/api/src/index.ts —— 只有這個檔案碰 Workers 全域,而它不被任何人 importimport { routes } from "./routes";import type { AppEnv } from "./env";
export default { fetch: (request, env, ctx) => routes.fetch(request, env as unknown as never, ctx),} satisfies ExportedHandler<AppEnv>;"exports": { ".": "./src/index.ts", "./routes": "./src/routes.ts" }import type { AppType } from "@repo/api/routes"; // 不是 "@repo/api"實測結果:apps/web 的 tsc --noEmit 通過,而且它的 tsconfig 裡完全沒有任何 Workers 型別。
注意 Structural 那個型別的寫法 —— AUTH 用的是 RPC 方法的結構型別而不是 Service<AuthService>。這一方面讓型別圖可攜,另一方面也是更好的設計:路由邏輯只依賴它實際用到的方法,而不是整個 Worker 類別。這正是依賴反轉。
這個 pattern 值得記住:Worker 進入點是薄的,路由定義是厚的,而型別契約從厚的那一層匯出。 除了讓型別可攜之外,它也讓路由在測試裡可以直接
routes.request(...)而不需要 Workers runtime(第 38 章會用到)。
型別跨 Worker 依然不流動
Section titled “型別跨 Worker 依然不流動”順帶重申第 18 章的結論,實測 apps/api/worker-configuration.d.ts:
DB: D1Database;AUTH: Service /* entrypoint AuthService from ch26-auth */;AuthService 出現在註解裡。wrangler types 知道你綁的是哪個 entrypoint,但不會去讀那個 Worker 的原始碼。所以 Worker 進入點仍然需要手寫一層:
import type { AuthService } from "@repo/auth";
export interface AppEnv extends Omit<ApiBindings, "AUTH"> { AUTH: Service<AuthService>;}這個檔案可以依賴 ambient 型別,因為它只被 index.ts import,而 index.ts 不被任何跨 package 的東西 import。
26.5 前端:hc<AppType> + TanStack Query
Section titled “26.5 前端:hc<AppType> + TanStack Query”import { hc } from "hono/client";import type { AppType } from "@repo/api/routes";
export const api = hc<AppType>("/", { headers: () => ({ authorization: `Bearer ${localStorage.getItem("token") ?? ""}` }),});import type 是重點 —— 只有型別跨過邊界,沒有任何 API 的執行期程式碼進入瀏覽器 bundle。
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import type { NewLink } from "@repo/schema";import { api } from "./client";
export function useLinks() { return useQuery({ queryKey: ["links"], queryFn: async () => { const res = await api.api.links.$get(); if (!res.ok) throw new Error(String(res.status)); // 推導出來的型別:{ slug: string; tenantId: string; url: string; // clicks: number; createdAt: string }[] return (await res.json()).links; }, staleTime: 30_000, });}
export function useCreateLink() { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: NewLink) => { // 同一份 drizzle-zod schema const res = await api.api.links.$post({ json: body }); if (res.status === 400) throw new Error("invalid"); return await res.json(); }, onSuccess: () => qc.invalidateQueries({ queryKey: ["links"] }), });}三個型別安全的證據,全部由編譯器保證:
- 路徑打錯會編譯失敗。
api.api.linkz.$get()→Property 'linkz' does not exist。 - 方法打錯會編譯失敗。
api.api.links.$put()→ 該路由沒有 PUT。 createdAt在伺服器是Date,在客戶端是string。 因為hc知道回應經過 JSON 序列化,型別會自動變成string。這一點手寫 interface 幾乎一定會寫錯。
延續第 24 章的建議,這裡再明確一次:
| 層 | 負責 |
|---|---|
| Hono route | 驗證、授權、資料存取 |
hc<AppType> | 型別安全的傳輸,不做快取 |
| TanStack Query | 客戶端快取、失效、樂觀更新、重試 |
| framework loader(第 24 章) | 首屏資料,initialData 餵給 Query |
hc 不是 data fetching library,它只是一個型別化的 fetch 包裝。快取交給 TanStack Query。
26.6 workspace 的實務細節
Section titled “26.6 workspace 的實務細節”workspace:* 協定
Section titled “workspace:* 協定”{ "dependencies": { "@repo/schema": "workspace:*" } }pnpm 會把它連結到本地 package 而不是去 registry 找。* 表示「任何版本」,適合永遠不發布的內部套件。
原始碼匯出 vs 建置匯出
Section titled “原始碼匯出 vs 建置匯出”"exports": { ".": "./src/index.ts", "./routes": "./src/routes.ts" }直接匯出 .ts 的好處是沒有 build step、改了立刻生效、type navigation 直接跳到原始碼。代價就是 26.4 那個陷阱 —— 消費端要能編譯這些原始碼。
替代方案是每個 package 都 tsc --emitDeclarationOnly 產出 .d.ts 再匯出。那樣型別邊界乾淨,但多了建置步驟與 watch 模式的複雜度。對 Cloudflare 專案,我建議走原始碼匯出 + 可攜契約模組(26.4 修法 B),因為 Vite 本來就會處理 TS,不需要額外的 build pipeline。
各 package 的 worker-configuration.d.ts
Section titled “各 package 的 worker-configuration.d.ts”每個 Worker package 都要自己跑 wrangler types,各自產出各自的檔案。不要試圖共用一份 —— 不同 Worker 的 binding 不同,合併只會製造「型別上存在但執行期不存在」的 binding。
.gitignore 掉它們,在 postinstall 或 CI 裡重新產生。
遷移檔的路徑
Section titled “遷移檔的路徑”實測 dist/ch26_api/wrangler.json 裡:
"d1_databases": [{ ..., "migrations_dir": "../../migrations" }]Vite plugin 會把 migrations_dir 改寫成相對於輸出目錄的路徑。所以 wrangler d1 migrations apply 要在專案目錄執行(用你手寫的 wrangler.jsonc),不是對建置產物執行。
26.7 LinkForge 的完整開發體驗
Section titled “26.7 LinkForge 的完整開發體驗”linkforge/ packages/ schema/ Drizzle + zod(本章) ui/ 共用 React 元件 workers/ auth/ AuthService(第 18、27 章) ingest/ Queue consumer(第 19 章) apps/ api/ Hono API + 短網址重導向 dashboard/ React Router v8(第 24 章) site/ Astro(第 25 章)apps/api/vite.config.ts:
export default defineConfig({ plugins: [ cloudflare({ viteEnvironment: { name: "api" }, auxiliaryWorkers: [ { configPath: "../../workers/auth/wrangler.jsonc", viteEnvironment: { name: "auth" } }, { configPath: "../../workers/ingest/wrangler.jsonc", viteEnvironment: { name: "ingest" } }, ], }), ],});一個 pnpm --filter @repo/api dev,三個 Worker 全部起來,service binding、RPC、Queue producer/consumer 全部在本機連通。
dashboard 和 site 是各自獨立的 Vite 專案(它們有自己的框架 plugin),開發時各跑各的,透過 proxy 打到 api 的 port。這是刻意的:把三個框架塞進一個 Vite config 只會讓 plugin 順序變成噩夢。
package.json 的頂層腳本:
{ "scripts": { "dev": "pnpm --parallel --filter \"./apps/*\" dev", "build": "pnpm -r build", "typecheck": "pnpm -r typecheck", "types": "pnpm -r --filter \"./workers/*\" --filter \"./apps/api\" types" }}26.8 本章實測結論彙整
Section titled “26.8 本章實測結論彙整”| # | 結論 | 影響 |
|---|---|---|
| 1 | auxiliaryWorkers 讓一個 vite dev 同時跑多個 Worker,service binding 與 RPC 本機直接可用 | 不再需要多終端機 + 多 wrangler dev |
| 2 | service 欄位比對 auxiliary Worker 的 name | 名稱一致即可替換實作 |
| 3 | 建置輸出目錄是 Vite environment 名稱,未指定時由 Worker 名稱衍生並把 - 換成 _ | CI 硬編碼 dist/<worker-name> 會找不到路徑 |
| 4 | devOnly: true 讓 auxiliary Worker 不出現在 dist/,且 deploy config 的 auxiliaryWorkers 變成 [] | 本機假服務、production 真服務 |
| 5 | plugin 支援 AuxiliaryWorkerInlineConfig(不需要 wrangler 檔案)與 assetsOnly | 兩者都未見於 Cloudflare 文件 |
| 6 | auxiliary Worker 各自產出完整的 dist/<env>/wrangler.json,需個別部署 | 先部署被依賴者,再部署 entry |
| 7 | 跨 package import Worker 原始碼會拉進 ambient 全域型別而編譯失敗 | TS2304: Cannot find name 'ApiBindings' / 'Service' / 'ExportedHandler'、TS2307: cloudflare:workers |
| 8 | 修法 A(把 Workers 型別塞進前端 tsconfig)可行但污染瀏覽器 package 的全域命名空間 | 不建議 |
| 9 | 修法 B(把路由抽到型別可攜的模組、binding 用結構型別)實測讓前端在零 Workers 型別下通過檢查 | 本章採用;同時讓路由可在無 runtime 下測試 |
| 10 | WorkerEntrypoint<AuthBindings> 這種 ambient 依賴,光是被別的 package import 就會炸 | 會被跨 package import 的檔案不可依賴 ambient 型別 |
| 11 | wrangler types 仍然只產出 AUTH: Service /* entrypoint AuthService from ch26-auth */ | 型別不跨 Worker 流動(同第 18 章) |
| 12 | hc<AppType> 把伺服器的 Date 正確推導成客戶端的 string | 手寫 interface 幾乎必錯的地方 |
| 13 | Vite plugin 把 migrations_dir 改寫成相對於輸出目錄 | migrations 指令要在專案目錄執行 |
26.9 動手練習
Section titled “26.9 動手練習”- 在
packages/schema給links加一個expiresAt欄位,不要改動任何其他檔案,跑pnpm -r typecheck,看編譯器在哪幾處要求你處理它。 - 把
apps/web/src/client.ts的 import 改回@repo/api(而不是@repo/api/routes),重現 26.4 的四個錯誤,然後試著只用修法 A 修好,比較兩種修法在 IDE 自動完成上的差異。 - 為
workers/auth寫一個devOnly的假實作(永遠回傳固定 session),確認vite build之後它不在dist/裡。 - 加上第三個 auxiliary Worker(Queue consumer,第 19 章),確認 producer/consumer 在同一個
vite dev裡能通。 - 把
apps/api的viteEnvironment.name改成含連字號的字串,看 Vite 會不會拒絕 —— 這能解釋為什麼預設值要把-換成_。
- Cloudflare Vite plugin — API · Vite Environments
- Hono RPC /
hc - drizzle-zod
- pnpm workspaces
- plugin 設定型別來源:
node_modules/@cloudflare/vite-plugin/dist/index.d.mts(1.50.0)
下一章(第 27 章):Auth on the Edge。把本章那個假的
AuthService換成真的 —— session、JWT、OAuth,以及在沒有中央 session store 的環境裡撤銷登入這件事到底該怎麼做。