跳到內容

ch26-monorepo

取得並執行

這個範例可以獨立 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

說明

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.
Terminal window
pnpm install
pnpm --filter @repo/auth types
pnpm --filter @repo/api types
cd apps/api
npx wrangler d1 execute ch26-links --local --file=migrations/0000_init.sql
npx vite dev --port 9029

One vite dev. Both Workers.

Terminal window
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.

Terminal window
# multi-tenancy is enforced server-side from the RPC'd session, not the request
curl -H "authorization: Bearer t_acme_u1" localhost:9029/api/links # 1 link
curl -H "authorization: Bearer t_other_u9" localhost:9029/api/links # {"links":[]}
# validation comes from the same drizzle-zod schema the client imports
curl -X POST -H "authorization: Bearer t_acme_u1" \
-d '{"slug":"AB","url":"not-a-url"}' localhost:9029/api/links

The 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't

fails, 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.

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 workerd
type 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 nobody
export default { fetch: (req, env, ctx) => routes.fetch(req, env as never, ctx) }
satisfies ExportedHandler<AppEnv>;
"exports": { ".": "./src/index.ts", "./routes": "./src/routes.ts" }
apps/web/src/client.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”
Terminal window
cd apps/api && npx vite build && ls dist
# ch26_api ch26_auth <- worker names, hyphens normalised to underscores

With 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:

Terminal window
npx wrangler deploy -c dist/ch26_auth/wrangler.json
npx wrangler deploy # .wrangler/deploy/config.json points at the entry
auxiliaryWorkers: [{ configPath: "...", devOnly: true }]
unsetdevOnly: true
ls distch26_api ch26_authch26_api
deploy config auxiliaryWorkersone 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, no wrangler.jsonc file 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 from cloudflare.config.ts), experimental.prerenderWorker.
apps/api/worker-configuration.d.ts
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.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/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