ch26-monorepo
在 GitHub 上檢視·22 個檔案·12.1 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch26-monorepo
cd ch26-monorepo
npm install可用指令
npm run dev # pnpm --filter @repo/api dev
npm run typecheck # pnpm -r typecheck
npm run build # pnpm --filter @repo/api build說明
ch26 — monorepo and type sharing
Section titled “ch26 — monorepo and type sharing”A pnpm workspace where one type flows from a Drizzle table all the way to a React component, and where one command runs two Workers with working RPC between them.
Verified with 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.
packages/schema/ @repo/schema — Drizzle table + drizzle-zod. Single source of truth.workers/auth/ @repo/auth — WorkerEntrypoint, called over RPC.apps/api/ @repo/api — Hono Worker. Exports AppType from ./routes.apps/web/ @repo/web — hc<AppType> + TanStack Query.pnpm installpnpm --filter @repo/auth typespnpm --filter @repo/api typescd apps/apinpx wrangler d1 execute ch26-links --local --file=migrations/0000_init.sqlnpx vite dev --port 9029One vite dev. Both Workers.
curl -H "authorization: Bearer t_acme_u1" localhost:9029/api/whoami{ "session": { "tenantId": "acme", "userId": "u1", "scopes": ["links:read","links:write"] }, "upstream": { "worker": "ch26-auth", "instanceFresh": true }}session comes from env.AUTH.verify(token) and upstream from
env.AUTH.whoami() — two real cross-Worker RPC calls, no second terminal, no
--remote.
# multi-tenancy is enforced server-side from the RPC'd session, not the requestcurl -H "authorization: Bearer t_acme_u1" localhost:9029/api/links # 1 linkcurl -H "authorization: Bearer t_other_u9" localhost:9029/api/links # {"links":[]}
# validation comes from the same drizzle-zod schema the client importscurl -X POST -H "authorization: Bearer t_acme_u1" \ -d '{"slug":"AB","url":"not-a-url"}' localhost:9029/api/linksThe trap this example exists to demonstrate
Section titled “The trap this example exists to demonstrate”Importing the API package’s types from the browser package like this:
import type { AppType } from "@repo/api"; // ← don'tfails, because internal packages export raw .ts and TypeScript therefore
pulls the Worker’s entire type graph into the browser package:
../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'.../../workers/auth/src/index.ts(32,13): error TS2304: Cannot find name 'ExportedHandler'.ExportedHandler, Service<T>, ApiBindings, AuthBindings are ambient
globals declared in each package’s own worker-configuration.d.ts. Ambient
declarations do not cross package boundaries — and Workers’ entire type system
is built on them.
The rule: any file that another package imports must not depend on ambient types.
The fix used here
Section titled “The fix used here”Split the thin Worker entry from the thick route definitions, and describe bindings structurally in the portable half:
// apps/api/src/routes.ts — type graph never touches workerdtype Structural = { DB: Parameters<typeof drizzle>[0]; AUTH: { verify(token: string): Promise<Session | null>; whoami(): Promise<...> };};export const routes = new Hono<{ Bindings: Structural; Variables: Vars }>()...;export type AppType = typeof routes;// apps/api/src/index.ts — the only file touching Workers globals, imported by nobodyexport default { fetch: (req, env, ctx) => routes.fetch(req, env as never, ctx) } satisfies ExportedHandler<AppEnv>;"exports": { ".": "./src/index.ts", "./routes": "./src/routes.ts" }import type { AppType } from "@repo/api/routes";apps/web now typechecks with zero Workers types in its tsconfig.
The alternative — adding "types": ["../api/worker-configuration.d.ts"] to the
browser tsconfig — also compiles, but puts DurableObjectState, R2Bucket and
a workerd fetch into the browser package’s global namespace. Not recommended.
Build output is named after the Vite environment
Section titled “Build output is named after the Vite environment”cd apps/api && npx vite build && ls dist# ch26_api ch26_auth <- worker names, hyphens normalised to underscoresWith explicit names:
cloudflare({ viteEnvironment: { name: "gateway" }, auxiliaryWorkers: [{ configPath: "...", viteEnvironment: { name: "identity" } }],})dist/gateway/ dist/identity/CI that hardcodes dist/<worker-name> breaks on any hyphenated Worker name.
Deploy the dependency first, then the entry:
npx wrangler deploy -c dist/ch26_auth/wrangler.jsonnpx wrangler deploy # .wrangler/deploy/config.json points at the entrydevOnly
Section titled “devOnly”auxiliaryWorkers: [{ configPath: "...", devOnly: true }]| unset | devOnly: true | |
|---|---|---|
ls dist | ch26_api ch26_auth | ch26_api |
deploy config auxiliaryWorkers | one entry | [] |
Use it for a fake auth/payment/email Worker locally while production points at the real one — service bindings resolve by Worker name, so no code changes.
Plugin options not covered by Cloudflare’s docs
Section titled “Plugin options not covered by Cloudflare’s docs”From @cloudflare/vite-plugin@1.50.0’s index.d.mts:
AuxiliaryWorkerInlineConfig— an auxiliary Worker defined entirely inline, nowrangler.jsoncfile needed.devOnly?: boolean | (() => boolean)assetsOnly?: boolean | (() => boolean)on the entry Worker — server code in dev, a purely static deploy in production.tunnel,remoteBindings,experimental.newConfig(load config fromcloudflare.config.ts),experimental.prerenderWorker.
Types still do not flow across Workers
Section titled “Types still do not flow across Workers”AUTH: Service /* entrypoint AuthService from ch26-auth */;AuthService is in a comment. Re-declare it by hand in src/env.ts — that
file may use ambient types, because only index.ts imports it.
原始碼
package.jsonworkers/auth/package.jsonworkers/auth/src/index.tsworkers/auth/tsconfig.jsonworkers/auth/wrangler.jsoncapps/api/migrations/0000_init.sqlapps/api/package.jsonapps/api/src/env.tsapps/api/src/index.tsapps/api/src/routes.tsapps/api/tsconfig.jsonapps/api/vite.config.tsapps/api/wrangler.jsoncapps/web/package.jsonapps/web/src/client.tsapps/web/src/links.tsxapps/web/tsconfig.jsonpackages/schema/package.jsonpackages/schema/src/index.tspackages/schema/tsconfig.jsonpnpm-workspace.yaml.gitignore
package.json
{
"name": "ch26-monorepo",
"private": true,
"scripts": {
"dev": "pnpm --filter @repo/api dev",
"build": "pnpm --filter @repo/api build",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": { "typescript": "^5.9.2" }
}workers/auth/package.json
{
"name": "@repo/auth",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": { "typecheck": "tsc --noEmit", "types": "wrangler types --env-interface AuthBindings" },
"dependencies": { "@repo/schema": "workspace:*" },
"devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2" }
}workers/auth/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";
import type { Session } from "@repo/schema";
/**
* Do NOT reference the ambient `AuthBindings` global here. `wrangler types`
* writes it into THIS package's worker-configuration.d.ts, which the API
* package's tsconfig does not include -- so importing this file from another
* package fails with `TS2304: Cannot find name 'AuthBindings'`.
* Export the shape instead. See the chapter for the full explanation.
*/
export type AuthEnv = Record<string, never>;
// A WorkerEntrypoint (chapter 18). The API Worker calls this over RPC, and
// because both Workers run in one `vite dev` process the call works locally.
export class AuthService extends WorkerEntrypoint<AuthEnv> {
async verify(token: string): Promise<Session | null> {
if (!token.startsWith("t_")) return null;
const [, tenantId, userId] = token.split("_");
if (!tenantId || !userId) return null;
return { tenantId, userId, scopes: ["links:read", "links:write"] };
}
async whoami(): Promise<{ worker: string; instanceFresh: boolean }> {
return { worker: "ch26-auth", instanceFresh: !seen.has(this) && (seen.add(this), true) };
}
}
const seen = new WeakSet<object>();
// A default fetch handler is still required for the Worker to be deployable.
export default {
fetch: () => new Response("ch26-auth: RPC only\n", { status: 404 }),
} satisfies ExportedHandler<AuthEnv>;workers/auth/tsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
}workers/auth/wrangler.jsonc
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "ch26-auth",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true }
}apps/api/migrations/0000_init.sql
create table if not exists links (
slug text primary key,
tenant_id text not null,
url text not null,
clicks integer not null default 0,
created_at integer not null
);
create index if not exists links_tenant_created on links (tenant_id, created_at);apps/api/package.json
{
"name": "@repo/api",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./routes": "./src/routes.ts"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"typecheck": "tsc --noEmit",
"types": "wrangler types --env-interface ApiBindings"
},
"dependencies": {
"@repo/schema": "workspace:*",
"@repo/auth": "workspace:*",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.32",
"zod": "^4.1.12"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.50.0",
"vite": "^8.2.0",
"wrangler": "^4.118.0",
"typescript": "^5.9.2"
}
}apps/api/src/env.ts
import type { AuthService } from "@repo/auth";
/**
* `wrangler types` emits:
* AUTH: Service /* entrypoint AuthService from ch26-auth *\/;
* -- a comment, not a type. Types do NOT flow across Workers (chapter 18).
* Re-declare the binding here so the RPC surface is actually typed.
*/
export interface AppEnv extends Omit<ApiBindings, "AUTH"> {
AUTH: Service<AuthService>;
}apps/api/src/index.ts
import { routes } from "./routes";
import type { AppEnv } from "./env";
// The Worker entry is thin: it only supplies the runtime env. All route
// definitions -- and therefore AppType -- live in ./routes.ts, which is
// import-safe from the browser package.
export default {
fetch: (request, env, ctx) => routes.fetch(request, env as unknown as never, ctx),
} satisfies ExportedHandler<AppEnv>;
export type { AppType } from "./routes";apps/api/src/routes.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { eq, desc, and } from "drizzle-orm";
import { links, insertLinkSchema, type Session } from "@repo/schema";
/**
* IMPORTANT: this file must NOT import anything that pulls in Workers ambient
* globals (`cloudflare:workers`, `ExportedHandler`, `Service<T>`, the generated
* `*Bindings` interfaces). It is the file the browser package type-imports, so
* its ENTIRE type graph has to be portable.
*
* That is why the D1 binding and the AUTH stub are declared structurally here
* instead of being pulled from worker-configuration.d.ts.
*/
type Structural = {
DB: Parameters<typeof drizzle>[0];
AUTH: { verify(token: string): Promise<Session | null>; whoami(): Promise<{ worker: string; instanceFresh: boolean }> };
};
type Vars = { session: Session; db: ReturnType<typeof drizzle> };
export const app = new Hono<{ Bindings: Structural; Variables: Vars }>();
const auth = app.use(async (c, next) => {
const token = c.req.header("authorization")?.replace(/^Bearer /, "") ?? "";
const session = await c.env.AUTH.verify(token);
if (!session) return c.json({ error: "unauthorized" }, 401);
c.set("session", session);
c.set("db", drizzle(c.env.DB));
await next();
});
void auth;
export const routes = app
.get("/api/health", (c) => c.json({ ok: true, worker: "ch26-api" }))
.get("/api/whoami", async (c) => {
return c.json({ session: c.get("session"), upstream: await c.env.AUTH.whoami() });
})
.get("/api/links", async (c) => {
const rows = await c
.get("db")
.select()
.from(links)
.where(eq(links.tenantId, c.get("session").tenantId))
.orderBy(desc(links.createdAt))
.limit(50);
return c.json({ links: rows });
})
.post("/api/links", async (c) => {
const parsed = insertLinkSchema.safeParse(await c.req.json());
if (!parsed.success) return c.json({ error: "invalid", issues: parsed.error.issues }, 400);
const row = { ...parsed.data, tenantId: c.get("session").tenantId, clicks: 0, createdAt: new Date() };
await c.get("db").insert(links).values(row);
return c.json({ link: row }, 201);
})
.delete("/api/links/:slug", async (c) => {
await c
.get("db")
.delete(links)
.where(and(eq(links.slug, c.req.param("slug")), eq(links.tenantId, c.get("session").tenantId)));
return c.body(null, 204);
});
/** The contract the browser imports. Its type graph never touches workerd. */
export type AppType = typeof routes;apps/api/tsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler",
"types": ["./worker-configuration.d.ts", "vite/client"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts", "vite.config.ts"]
}apps/api/vite.config.ts
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
cloudflare({
// One `vite dev` runs BOTH Workers, so the service binding and RPC
// above actually work locally -- no second terminal, no --remote.
auxiliaryWorkers: [{ configPath: "../../workers/auth/wrangler.jsonc" }],
inspectorPort: false,
}),
],
});apps/api/wrangler.jsonc
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "ch26-api",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true },
"d1_databases": [
{ "binding": "DB", "database_name": "ch26-links", "database_id": "ch26-local" }
],
"services": [
// Points at the auxiliary Worker by NAME. The Vite plugin wires this up
// locally; in production it resolves to the deployed Worker.
{ "binding": "AUTH", "service": "ch26-auth", "entrypoint": "AuthService" }
]
}apps/web/package.json
{
"name": "@repo/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": { "typecheck": "tsc --noEmit" },
"dependencies": {
"@repo/api": "workspace:*",
"@repo/schema": "workspace:*",
"@tanstack/react-query": "^5.90.0",
"hono": "^4.12.32",
"react": "^19.2.8"
},
"devDependencies": { "typescript": "^5.9.2", "@types/react": "^19.2.0" }
}apps/web/src/client.ts
import { hc } from "hono/client";
// A TYPE-ONLY import. No API runtime code reaches the browser bundle.
import type { AppType } from "@repo/api/routes";
export const api = hc<AppType>("/", {
headers: () => ({ authorization: `Bearer ${localStorage.getItem("token") ?? ""}` }),
});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));
// Inferred end to end: { links: { 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({
// NewLink comes from the same drizzle-zod schema the Worker validates with.
mutationFn: async (body: NewLink) => {
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"] }),
});
}
export function LinkList() {
const { data, isPending } = useLinks();
const create = useCreateLink();
if (isPending) return <p>loading…</p>;
return (
<>
<ul>
{data?.map((l) => (
<li key={l.slug}>
{l.slug} → {l.url} ({l.clicks})
</li>
))}
</ul>
<button onClick={() => create.mutate({ slug: "demo", url: "https://example.com" })}>
add
</button>
</>
);
}apps/web/tsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext", "dom"], "module": "esnext",
"moduleResolution": "bundler", "jsx": "react-jsx",
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}packages/schema/package.json
{
"name": "@repo/schema",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": { "typecheck": "tsc --noEmit" },
"dependencies": { "drizzle-orm": "^0.45.2", "zod": "^4.1.12", "drizzle-zod": "^0.8.3" },
"devDependencies": { "typescript": "^5.9.2" }
}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";
// ONE source of truth. Everything downstream is derived from this table.
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)],
);
// Derived, not hand-written.
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>;
// Shared across the RPC boundary too (see workers/auth).
export type Session = { tenantId: string; userId: string; scopes: string[] };packages/schema/tsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "strict": true, "skipLibCheck": true,
"noEmit": true, "isolatedModules": true, "verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}pnpm-workspace.yaml
packages:
- "packages/*"
- "workers/*"
- "apps/*".gitignore
node_modules/
dist/
.wrangler/
worker-configuration.d.ts