我需要一种以最少的配置将 Remix 与 Vite 和 Cloudflare Workers-Pages 结合使用的方法。
我看到了其他存储库,例如:
但它们也有一些局限性:
我不想预先构建它,因为我不想用更多的配置文件毒害存储库。
Cloudflare Workers/Pages 具有不同的目标。使用 tsup 将其作为目标变得很棘手,因为 Postgres 等软件包在导入 Remix 时会破坏节点依赖关系。
我还需要一种方法来使用不同的目标(Remix-Cloudflare、Node/Bun)
尽管如此,我还是要感谢他们,因为他们为实现这一目标铺平了道路!
请务必阅读底部的陷阱部分!
我正在公开构建一个自动化测试平台,以捕捉生产中出现的那 1% 的错误。
我分享我的进展:
您可以在此处访问完整的实施。
尽管这将引导您完成一个新的单一存储库,但将现有的存储库转换为一个单一存储库也是完全有效的。
这也假设您具有一些 Mono Repo 知识。
笔记:
libs
和packages
目录之外。Turborepo 在您的包管理器工作区之上工作,以管理项目的脚本和输出(它甚至可以缓存您的输出)。到目前为止,它是除了 Rush(我没有尝试过并且不喜欢)之外唯一能够工作的 mono-repo 工具。
NX 不支持 Remix 的 Vite(截至撰写本文时 - 2024 年 8 月 28 日)。
pnpm dlx create-turbo@latest
我们将使用 PNPM 的工作区功能来管理依赖关系。
在您的 Monorepo 目录中,创建一个pnpm-workspace.yaml
。
在其中添加:
packages: - "apps/*" - "libs/*"
这将告诉 pnpm 所有存储库都位于apps
和libs
中。请注意,使用libs
或packages
(您可能在其他地方看到过)并不重要。
pnpm init
{ "name": "@repo/main", "version": "1.0.0", "scripts": {}, "keywords": [], "author": "", "license": "ISC" }
注意name:@repo/main
这告诉我们这是应用程序的主入口。您不需要遵循特定的约定或使用@
前缀。人们使用它来将其与本地/远程包区分开来,或者使其易于将其分组到组织中。
turbo.json
文件: { "$schema": "https://turbo.build/schema.json", "tasks": { "build": {}, "dev": { "cache": false, "persistent": true }, "start": { "dependsOn": ["^build"], "persistent": true }, "preview": { "cache": false, "persistent": true }, "db:migrate": {} } }
turbo.json 文件告诉 turbo repo 如何解释tasks
的命令。tasks 键内的所有内容都将与 all package.json 中找到的内容匹配。
请注意,我们定义了四个命令。这些命令与每个存储库的 package.json 的脚本部分中的命令相匹配。但是,并非所有 package.json 都必须实现这些命令。
例如: dev
命令将由turbo dev
触发,它将执行 package.json 中找到的所有dev
包。如果你没有在 turbo 中包含它,它将不会执行。
apps
文件夹mkdir apps
apps
文件夹中创建 Remix 应用程序(或移动现有应用程序) npx create-remix --template edmundhung/remix-worker-template
当它要求您Install any dependencies with npm
时,请说“不”。
name
重命名为@repo/my-remix-cloudflare-app
(或您的姓名) { - "name": "my-remix-cloudflare-app", + "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "keywords": [], "author": "", "license": "ISC" }
apps/<app>/package.json
复制到 Root 的package.json
例如:
<根目录>/package.json
{ "name": "@repo/main", "version": "1.0.0", "scripts": {}, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@markdoc/markdoc": "^0.4.0", "@remix-run/cloudflare": "^2.8.1", "@remix-run/cloudflare-pages": "^2.8.1", "@remix-run/react": "^2.8.1", "isbot": "^3.6.5", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240222.0", "@octokit/types": "^12.6.0", "@playwright/test": "^1.42.1", "@remix-run/dev": "^2.8.1", "@remix-run/eslint-config": "^2.8.1", "@tailwindcss/typography": "^0.5.10", "@types/react": "^18.2.64", "@types/react-dom": "^18.2.21", "autoprefixer": "^10.4.18", "concurrently": "^8.2.2", "cross-env": "^7.0.3", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "lint-staged": "^15.2.2", "msw": "^2.2.3", "postcss": "^8.4.35", "prettier": "^3.2.5", "prettier-plugin-tailwindcss": "^0.5.12", "rimraf": "^5.0.5", "tailwindcss": "^3.4.1", "typescript": "^5.4.2", "vite": "^5.1.5", "vite-tsconfig-paths": "^4.3.1", "wrangler": "^3.32.0" } }
验证 turbo 是否在 package.json 的 devDependencies 中。如果未列出,请执行以下命令:
pnpm add turbo -D -w
-w
标志告诉 pnpm 将其安装在工作区根目录。
将dev
命令添加到scripts
将packageManager
添加到选项
{ "name": "@repo/main", "version": "1.0.0", "scripts": { "dev": "turbo dev" }, "keywords": [], "author": "", "license": "ISC", "packageManager": "[email protected]", "dependencies": { // omitted for brevity }, "devDependencies": { // omitted for brevity } }
pnpm dev
验证一切正常pnpm dev
mkdir -p libs/config libs/db libs/utils
src/index.ts
。 touch libs/config/src/index.ts libs/db/src/index.ts libs/utils/src/index.ts
libs/config/package.json
文件: { "name": "@repo/config", "version": "1.0.0", "type": "module", "exports": { ".": { "import": "./src/index.ts", "default": "./src/index.ts" } } }
libs/db/package.json
执行相同操作: { "name": "@repo/db", "version": "1.0.0", "exports": { ".": { "import": "./src/index.ts", "default": "./src/index.ts" } } }
libs/utils/package.json
: { "name": "@repo/utils", "version": "1.0.0", "exports": { ".": { "import": "./src/index.ts", "default": "./src/index.ts" } } }
笔记:
我开始鄙视 ORM。我花了 10 多年时间学习了 6 种不同的 ORM,而且这些知识是无法传授的。
当新技术出现时,你会遇到问题。Prisma 不支持开箱即用的 Cloudflare Worker。
有了 LLM,编写复杂的 SQL 查询比以往更加容易。
学习 SQL 是一种通用语言,并且不太可能改变。
pnpm add drizzle-orm drizle-kit --filter=@repo/db
在工作区级别安装 Postgres。请参阅陷阱部分。
pnma add postgres -w
笔记:
--filter=@repo/db
标志告诉 pnpm 将包添加到 db 存储库。pnpm add dotenv -w
笔记
-w
标志告诉 pnpm 将其安装在根目录的 package.json 中 pnpm add @repo/config -r --filter=!@repo/config
注意:
-r
标志告诉 pnpm 将包添加到所有存储库。--filter=!
标志告诉 pnpm 排除配置存储库。!
如果 pnpm 正在从存储库中提取包,我们可以在项目的根目录创建一个.npmrc
文件。
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
tsconfig.json
利用 pnpm 工作区的强大功能,您可以创建可跨项目共享的配置文件。
我们将创建一个用于我们的库的基本 tsconfig.lib.json。
在libs/config
中实例化一个tsconfig.lib.json
:
touch "libs/config/tsconfig.base.lib.json"
然后添加以下内容:
tsconfig.base.lib.json
{ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "lib": ["ES2022"], "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "target": "ES2022", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "allowImportingTsExtensions": true, "allowJs": true, "noUncheckedIndexedAccess": true, "noEmit": true, "incremental": true, "composite": false, "declaration": true, "declarationMap": true, "inlineSources": false, "isolatedModules": true, "noUnusedLocals": false, "noUnusedParameters": false, "preserveWatchOutput": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "sourceMap": true, } }
// libs/db/drizzle.config.ts (Yes, this one is at root of the db package, outside the src folder) // We don't want to export this file as this is ran at setup. import "dotenv/config"; // make sure to install dotenv package import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "postgresql", out: "./src/generated", schema: "./src/drizzle/schema.ts", dbCredentials: { url: process.env.DATABASE_URL!, }, // Print all statements verbose: true, // Always ask for confirmation strict: true, });
架构文件:
// libs/db/src/drizzle/schema.ts export const User = pgTable("User", { userId: char("userId", { length: 26 }).primaryKey().notNull(), subId: char("subId", { length: 36 }).notNull(), // We are not making this unique to support merging accounts in later // iterations email: text("email"), loginProvider: loginProviderEnum("loginProvider").array().notNull(), createdAt: timestamp("createdAt", { precision: 3, mode: "date" }).notNull(), updatedAt: timestamp("updatedAt", { precision: 3, mode: "date" }).notNull(), });
客户端文件:
// libs/db/src/drizzle-client.ts import { drizzle, PostgresJsDatabase } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./schema"; export type DrizzleClient = PostgresJsDatabase<typeof schema>; let drizzleClient: DrizzleClient | undefined; type GetClientInput = { databaseUrl: string; env: string; mode?: "cloudflare" | "node"; }; declare var window: typeof globalThis; declare var self: typeof globalThis; export function getDrizzleClient(input: GetClientInput) { const { mode, env } = input; if (mode === "cloudflare") { return generateClient(input); } const globalObject = typeof globalThis !== "undefined" ? globalThis : typeof global !== "undefined" ? global : typeof window !== "undefined" ? window : self; if (env === "production") { drizzleClient = generateClient(input); } else if (globalObject) { if (!(globalObject as any).__db__) { (globalObject as any).__db__ = generateClient(input); } drizzleClient = (globalObject as any).__db__; } else { drizzleClient = generateClient(input); } return drizzleClient; } type GenerateClientInput = { databaseUrl: string; env: string; }; function generateClient(input: GenerateClientInput) { const { databaseUrl, env } = input; const isLoggingEnabled = env === "development"; // prepare: false for serverless try { const client = postgres(databaseUrl, { prepare: false }); const db = drizzle(client, { schema, logger: isLoggingEnabled }); return db; } catch (e) { console.log("ERROR", e); return undefined!; } }
迁移文件:
// libs/db/src/drizzle/migrate.ts import { config } from "dotenv"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import "dotenv/config"; import path from "path"; config({ path: "../../../../apps/my-remix-cloudflare-app/.dev.vars" }); const ssl = process.env.ENVIRONMENT === "development" ? undefined : "require"; const databaseUrl = drizzle( postgres(`${process.env.DATABASE_URL}`, { ssl, max: 1 }) ); // Somehow the current starting path is /libs/db // Remember to have the DB running before running this script const migration = path.resolve("./src/generated"); const main = async () => { try { await migrate(databaseUrl, { migrationsFolder: migration, }); console.log("Migration complete"); } catch (error) { console.log(error); } process.exit(0); }; main();
这应该在迁移之后执行
并在src/index.ts
文件中导出客户端和模式。其他的都在特定的时间运行。
// libs/db/src/index.ts export * from "./drizzle/drizzle-client"; export * from "./drizzle/schema "
在您的package.json
中,添加drizzle-kit generate
和运行迁移命令的代码:
{ "name": "@repo/db", "version": "1.0.0", "main": "./src/index.ts", "module": "./src/index.ts", "types": "./src/index.ts", "scripts": { "db:generate": "drizzle-kit generate", "db:migrate": "dotenv tsx ./drizzle/migrate", }, "exports": { ".": { "import": "./src/index.ts", "default": "./src/index.ts" } }, "dependencies": { "@repo/configs": "workspace:^", "drizzle-kit": "^0.24.1", "drizzle-orm": "^0.33.0", }, "devDependencies": { "@types/node": "^22.5.0" } }
tsconfig.json
作为libs/db
和libs/utils
为libs/db
和libs/utils
创建 tsconfig.json
touch "libs/db/tsconfig.json" "libs/utils/tsconfig.json"
然后向每个添加:
{ "extends": "@repo/configs/tsconfig.base.lib.json", "include": ["./src"], }
@repo/configs
被用作引用我们的tsconfig.base.lib.json的路径。TypeScript Execute (TSX) 是 ts-node 的一个替代库。我们将使用它来执行 drizzle 的迁移。
pnpm add tsx -D --filter=@repo/db
libs/db
目录中添加一个空的 .env touch "libs/db/.env"
添加以下内容:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" NODE_ENV="development" MODE="node"
libs/db
存储库添加到我们的 Remix 项目从项目的根目录运行:
pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app
如果这不起作用,请转到apps/my-remix-cloudflare-app
的 package.json,并手动添加依赖项。
{ "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "dependencies": { "@repo/db": "workspace:*" } }
注意版本字段中的workspace:*
。这告诉 pnpm 使用工作区中包的任意版本。
如果你使用pnpm add,
你可能会看到类似workspace:^
内容。只要你不增加本地软件包版本,这应该没关系。
如果您确实手动添加了它,那么请从项目的根目录运行pnpm install
。
我们应该能够在我们的项目中使用@repo/db。
将此代码添加到libs/utils/src/index.ts
文件中:
// libs/utils/src/index.ts export function hellowWorld() { return "Hello World!"; }
pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app
如果您没有运行 Postgres 实例,我们可以使用 docker-compose 启动一个。请注意,我假设您了解 Docker。
在项目根目录创建一个docker-compose.yml
文件。
# Auto-generated docker-compose.yml file. version: '3.8' # Define services. services: postgres: image: postgres:latest restart: always environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - POSTGRES_DB=postgres ports: - "5432:5432" volumes: - ./postgres-data:/var/lib/postgresql/data pgadmin: # To connect PG Admin, navigate to http://localhost:8500 use: # host.docker.internal # postgres # (username) postgres # (password) postgres image: dpage/pgadmin4 ports: - "8500:80" environment: PGADMIN_DEFAULT_EMAIL: [email protected] PGADMIN_DEFAULT_PASSWORD: admin
然后你可以运行:
docker-compose up -d
-d
标志告诉 docker-compose 以分离方式运行,以便您可以再次访问您的终端。
现在,导航到 libs/db 存储库并运行db:generate
。
cd `./libs/db` && pnpm db:generate
db:generate
是drizzle-kit generate
的别名我们需要运行迁移来构建数据库中的所有表。
导航到 libs/db 存储库(如果您不在那里)并运行db:generate
。
cd `./libs/db` && pnpm db:migrate
db:migrate
是以下命令的别名: dotenv tsx ./drizzle/migrate
// apps/my-remix-cloudflare-app/app/routes/_index.tsx import type { LoaderFunctionArgs } from '@remix-run/cloudflare'; import { json, useLoaderData } from '@remix-run/react'; import { getDrizzleClient } from '@repo/db'; import { Markdown } from '~/components'; import { getFileContentWithCache } from '~/services/github.server'; import { parse } from '~/services/markdoc.server'; export async function loader({ context }: LoaderFunctionArgs) { const client = await getDrizzleClient({ databaseUrl: context.env.DATABASE_URL, env: 'development', mode: 'cloudflare', }); if (client) { const res = await client.query.User.findFirst(); console.log('res', res); } const content = await getFileContentWithCache(context, 'README.md'); return json( { content: parse(content), // user: firstUser, }, { headers: { 'Cache-Control': 'public, max-age=3600', }, }, ); } export default function Index() { const { content } = useLoaderData<typeof loader>(); return <Markdown content={content} />; }
应用程序/my-remix-cloudflare-app/.dev.vars
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
启动 postgres 实例(如果尚未准备好)
docker-compose up -d
启动项目
pnpm turbo dev
在我的项目中,我倾向于实现CQRS 模式2 。这超出了本教程的范围。
尽管如此,在加载上下文中,我倾向于注入一个中介(和一个 cookie 闪存消息),将我的整个 Remix 应用程序与我的业务逻辑分离。
这看起来像这样:
export const getLoadContext: GetLoadContext = async ({ context, request }) => { const isEnvEmpty = Object.keys(context.cloudflare.env).length === 0; const env = isEnvEmpty ? process.env : context.cloudflare.env; const sessionFlashSecret = env.SESSION_FLASH_SECRET; const flashStorage = createCookieSessionStorage({ cookie: { name: "__flash", httpOnly: true, maxAge: 60, path: "/", sameSite: "lax", secrets: [sessionFlashSecret], secure: true, }, }); return { ...context, cloudflare: { ...context.cloudflare, env, }, dispatch: (await dispatchWithContext({ env: env as unknown as Record<string, string>, request, })) as Dispatch, flashStorage, }; };
请注意,省略了调度代码。你可以在我的文章《如何将你的 TypeScript 开发体验提升 10 倍》中找到更多相关信息。
我可以删除 Remix 或使用其他消费者,而无需更改我的代码。
但…。
当您使用 turborepo 在 monorepo 结构中工作时,还会面临额外的挑战。
如果你从加载上下文中的包中导入 TypeScript 文件,比如说@repo/db
Vite 将返回一个错误,提示“扩展名为.ts
文件未知,并且不知道如何处理它”。
发生这种情况的原因是,load-context + workingspaces 位于站点的主要导入图之外,从而导致 TypeScript 文件无法发挥作用。
诀窍是在调用 Vite之前使用tsx
并加载它,这样就可以了。这很重要,因为它克服了以下限制:
Cloudflare 包依赖项。
Cloudflare 软件包依赖项和预构建
首先,这是我试图避免的步骤,因为这意味着我必须为每个包引入一个构建步骤,这意味着更多的配置。
幸运的是,这对 Cloudflare Pages 不起作用。特定库(例如 Postgres)将检测运行时并提取所需的包。
有一个解决方法:我们可以使用 tsx 加载所有 TypeScript 文件并在执行之前对其进行转换。
您可以争辩说这是一个预构建步骤,但由于它仍处于 remix 的存储库级别,因此我认为这种方法不会存在重大问题。
为了解决这个问题,我们添加 tsx 作为依赖项:
pnpm add tsx -D --filter=@repo/my-remix-cloudflare-app
然后,我们需要修改package.json
并将 tsx 进程添加到我们的每个 remix 脚本中:
{ "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "scripts": { // Other scripts omitted "build": "NODE_OPTIONS=\"--import tsx/esm\" remix vite:build", "dev": "NODE_OPTIONS=\"--import tsx/esm\" remix vite:dev", "start": "NODE_OPTIONS=\"--import tsx/esm\" wrangler pages dev ./build/client" } }
.npmrc
文件如果您在使用命令行添加本地包时遇到问题,您可以在项目的根目录中创建一个.npmrc
文件。
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
这将告诉 pnpm 首先使用工作区包。
感谢 Reddit 的ZoWnx帮助我制作了 .nprmc 文件
小心命名文件中的.client
和.server
。即使它位于单独的库中。Remix 使用这些来确定它是客户端文件还是服务器文件。该项目不是按存储库编译的,因此它会抛出导入错误!
如果您在使用 Postgres 等多平台软件包时遇到问题,最好在工作区级别安装它。它将检测正确的导入。直接在 @repo/db 存储库中安装它会在将其导入 Remix 时中断。
就是这样了,伙计们!!!
您可以在此处访问完整的实施。
我正在公开构建一个自动化测试工程师来捕捉生产中出现的那 1% 的错误。
我分享我的进展: