跳到內容

前後端整合:型別共享與 monorepo

查證日期
驗證環境pnpm@10.28.0·@cloudflare/vite-plugin@1.50.0·vite@8.2.0·hono@4.12.32·drizzle-orm@0.45.2·zod@4.1.12·wrangler@4.118.0·node@22.22.2

前面二十五章累積出一堆分散的東西:D1 schema(第 9、10 章)、Hono API(第 5 章)、跨 Worker 的 RPC(第 18 章)、兩個前端(第 24、25 章)。這一章把它們放進同一個 repo,並且回答兩個具體問題:

  1. 一份型別能不能從 D1 schema 一路流到 React component?
  2. 多個 Worker 之間的 service binding,能不能在本機用一個指令就跑起來?

第二個問題的答案很乾脆:能,而且是本章最實用的一段。第一個問題的答案是「能,但有一個沒人講的陷阱會擋住你」—— 我在建這個範例時就撞上了,那個錯誤訊息會在 26.4 原樣呈現。


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


packages/schema/src/index.ts
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() 明確列出使用者能提供的欄位。 tenantIdclickscreatedAt 全部由伺服器決定。這不只是型別問題,是安全問題:如果 insert schema 包含 tenantId,使用者就能偽造租戶。

(三)Session 也放在這裡。 它同時被 auth Worker(RPC 回傳型別)、API Worker(middleware 變數)、前端(顯示登入者)使用。跨 RPC 邊界的型別必須有一個共同的家。

實測驗證這條鏈確實接通了 —— 送一個壞的 body 進 API:

Terminal window
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-pluginauxiliaryWorkers 解決了這件事。

apps/api/vite.config.ts
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
cloudflare({
auxiliaryWorkers: [{ configPath: "../../workers/auth/wrangler.jsonc" }],
inspectorPort: false,
}),
],
});
apps/api/wrangler.jsonc
"services": [
{ "binding": "AUTH", "service": "ch26-auth", "entrypoint": "AuthService" }
]

service 欄位比對的是 auxiliary Worker 的 name。實測 —— 一個 vite dev,跨 Worker 的 RPC 直接可用:

Terminal window
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) 得到,upstreamenv.AUTH.whoami() 得到 —— 兩次都是真的跨 Worker RPC 呼叫,在同一個 dev process 裡完成。

實測 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 未設devOnly: true
ls distch26_api ch26_authch26_api
.wrangler/deploy/config.jsonauxiliaryWorkers[{ 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/
.wrangler/deploy/config.json
{ "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 要各自部署

Terminal window
npx vite build
npx wrangler deploy -c dist/ch26_auth/wrangler.json # 先部署被依賴的
npx wrangler deploy # 再部署 entry

順序有意義:service binding 指向一個還不存在的 Worker,部署會失敗。


到目前為止一切順利。接著我在 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'.

在 monorepo 裡,內部套件通常直接匯出 TypeScript 原始碼"exports": { ".": "./src/index.ts" })—— 這是 pnpm workspace 最常見也最方便的做法,不需要 build step。

但這代表:@repo/web 型別檢查時,TypeScript 會把 @repo/api 的整個型別依賴圖拉進來。 而那張圖包含:

  • ExportedHandlerService<T> 這些 workerd 的 ambient 全域型別
  • ApiBindingsAuthBindings 這些 wrangler types 產生的 ambient interface
  • cloudflare:workers 虛擬模組

這三樣東西的宣告都在各自 package 的 worker-configuration.d.ts,而那個檔案只被那個 package 的 tsconfig.jsontypes 欄位引入。瀏覽器 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 型別塞進消費端。

apps/web/tsconfig.json
"types": ["../api/worker-configuration.d.ts"]

實測可行,tsc 通過。但這很難看 —— 一個瀏覽器 package 現在的全域命名空間裡有 DurableObjectStateExecutionContextR2Bucket。你會在寫前端時得到 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 全域,而它不被任何人 import
import { 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>;
apps/api/package.json
"exports": { ".": "./src/index.ts", "./routes": "./src/routes.ts" }
apps/web/src/client.ts
import type { AppType } from "@repo/api/routes"; // 不是 "@repo/api"

實測結果:apps/webtsc --noEmit 通過,而且它的 tsconfig 裡完全沒有任何 Workers 型別

注意 Structural 那個型別的寫法 —— AUTH 用的是 RPC 方法的結構型別而不是 Service<AuthService>。這一方面讓型別圖可攜,另一方面也是更好的設計:路由邏輯只依賴它實際用到的方法,而不是整個 Worker 類別。這正是依賴反轉。

這個 pattern 值得記住:Worker 進入點是薄的,路由定義是厚的,而型別契約從厚的那一層匯出。 除了讓型別可攜之外,它也讓路由在測試裡可以直接 routes.request(...) 而不需要 Workers runtime(第 38 章會用到)。

順帶重申第 18 章的結論,實測 apps/api/worker-configuration.d.ts

DB: D1Database;
AUTH: Service /* entrypoint AuthService from ch26-auth */;

AuthService 出現在註解裡。wrangler types 知道你綁的是哪個 entrypoint,但不會去讀那個 Worker 的原始碼。所以 Worker 進入點仍然需要手寫一層:

apps/api/src/env.ts
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”
apps/web/src/client.ts
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

apps/web/src/links.tsx
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"] }),
});
}

三個型別安全的證據,全部由編譯器保證:

  1. 路徑打錯會編譯失敗。 api.api.linkz.$get()Property 'linkz' does not exist
  2. 方法打錯會編譯失敗。 api.api.links.$put() → 該路由沒有 PUT。
  3. 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。


{ "dependencies": { "@repo/schema": "workspace:*" } }

pnpm 會把它連結到本地 package 而不是去 registry 找。* 表示「任何版本」,適合永遠不發布的內部套件。

"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。

每個 Worker package 都要自己跑 wrangler types,各自產出各自的檔案。不要試圖共用一份 —— 不同 Worker 的 binding 不同,合併只會製造「型別上存在但執行期不存在」的 binding。

.gitignore 掉它們,在 postinstall 或 CI 裡重新產生。

實測 dist/ch26_api/wrangler.json 裡:

"d1_databases": [{ ..., "migrations_dir": "../../migrations" }]

Vite plugin 會把 migrations_dir 改寫成相對於輸出目錄的路徑。所以 wrangler d1 migrations apply 要在專案目錄執行(用你手寫的 wrangler.jsonc),不是對建置產物執行。


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"
}
}

#結論影響
1auxiliaryWorkers 讓一個 vite dev 同時跑多個 Worker,service binding 與 RPC 本機直接可用不再需要多終端機 + 多 wrangler dev
2service 欄位比對 auxiliary Worker 的 name名稱一致即可替換實作
3建置輸出目錄是 Vite environment 名稱,未指定時由 Worker 名稱衍生並把 - 換成 _CI 硬編碼 dist/<worker-name> 會找不到路徑
4devOnly: true 讓 auxiliary Worker 不出現在 dist/,且 deploy config 的 auxiliaryWorkers 變成 []本機假服務、production 真服務
5plugin 支援 AuxiliaryWorkerInlineConfig(不需要 wrangler 檔案)與 assetsOnly兩者都未見於 Cloudflare 文件
6auxiliary 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 下測試
10WorkerEntrypoint<AuthBindings> 這種 ambient 依賴,光是被別的 package import 就會炸會被跨 package import 的檔案不可依賴 ambient 型別
11wrangler types 仍然只產出 AUTH: Service /* entrypoint AuthService from ch26-auth */型別不跨 Worker 流動(同第 18 章)
12hc<AppType> 把伺服器的 Date 正確推導成客戶端的 string手寫 interface 幾乎必錯的地方
13Vite plugin 把 migrations_dir 改寫成相對於輸出目錄migrations 指令要在專案目錄執行

  1. packages/schemalinks 加一個 expiresAt 欄位,不要改動任何其他檔案,跑 pnpm -r typecheck,看編譯器在哪幾處要求你處理它。
  2. apps/web/src/client.ts 的 import 改回 @repo/api(而不是 @repo/api/routes),重現 26.4 的四個錯誤,然後試著只用修法 A 修好,比較兩種修法在 IDE 自動完成上的差異。
  3. workers/auth 寫一個 devOnly 的假實作(永遠回傳固定 session),確認 vite build 之後它不在 dist/ 裡。
  4. 加上第三個 auxiliary Worker(Queue consumer,第 19 章),確認 producer/consumer 在同一個 vite dev 裡能通。
  5. apps/apiviteEnvironment.name 改成含連字號的字串,看 Vite 會不會拒絕 —— 這能解釋為什麼預設值要把 - 換成 _

下一章(第 27 章):Auth on the Edge。把本章那個假的 AuthService 換成真的 —— session、JWT、OAuth,以及在沒有中央 session store 的環境裡撤銷登入這件事到底該怎麼做。