Eu precisava de uma maneira de usar o Remix com o Vite e o Cloudflare Workers-Pages com configuração mínima.
Vi outros repositórios, como:
Mas eles tinham algumas limitações:
Eu não queria pré-construí-lo, pois não queria envenenar os repositórios com mais arquivos de configuração.
Cloudflare Workers/Pages tem um alvo diferente. Tornou-se complicado direcioná-lo com tsup, pois pacotes como Postgres puxariam as dependências do nó quebrando quando importados para o Remix.
Eu também precisava de uma maneira de consumir diferentes alvos (Remix-Cloudflare, Node/Bun)
Mesmo assim, agradeço a eles por terem aberto o caminho para tornar isso possível!
Não deixe de ler a seção sobre armadilhas no final!
Estou criando uma plataforma de testes automatizados em público para detectar esses erros de 1% na produção.
Compartilho meu progresso em:
Você pode acessar a implementação completa aqui .
Embora isso o oriente por um novo mono-repositório, é perfeitamente válido transformar um existente em um.
Isso também pressupõe que você tenha algum conhecimento sobre repositórios mono.
Observação:
libs
e packages
.O Turborepo funciona em cima dos espaços de trabalho do seu gerenciador de pacotes para gerenciar os scripts e saídas do seu projeto (ele pode até mesmo armazenar em cache sua saída). Até agora, é a única ferramenta mono-repo além do Rush (que eu não testei e não gosto) que é capaz de funcionar.
O NX não tem suporte ao Vite do Remix (até o momento em que este artigo foi escrito - 28 de agosto de 2024).
pnpm dlx create-turbo@latest
Usaremos os recursos do espaço de trabalho do PNPM para gerenciar dependências.
No seu diretório Monorepo, crie um pnpm-workspace.yaml
.
Dentro dele, adicione:
packages: - "apps/*" - "libs/*"
Isso dirá ao pnpm que todos os repositórios ficarão dentro de apps
e libs
. Note que usar libs
ou packages
(como você pode ter visto em outro lugar) não importa.
pnpm init
{ "name": "@repo/main", "version": "1.0.0", "scripts": {}, "keywords": [], "author": "", "license": "ISC" }
Observe o name:@repo/main
Isso nos diz que esta é a entrada principal do aplicativo. Você não precisa seguir uma convenção específica ou usar o prefixo @
. As pessoas o usam para diferenciá-lo de pacotes locais/remotos ou para facilitar o agrupamento em uma organização.
turbo.json
na raiz do projeto: { "$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": {} } }
O arquivo turbo.json diz ao repositório turbo como interpretar nossos comandos. Tudo o que estiver dentro da chave tasks
corresponderá àqueles encontrados no pacote all.json.
Note que definimos quatro comandos. Eles correspondem aos da seção script do package.json de cada repositório. No entanto, nem todos os package.json devem implementar esses comandos.
Ex.: O comando dev
será acionado pelo turbo dev
, e executará todos os pacotes que dev
for encontrado dentro do package.json. Se você não incluí-lo no turbo, ele não será executado.
apps
na raiz do projeto mkdir apps
apps
(ou mova um existente) npx create-remix --template edmundhung/remix-worker-template
Quando for solicitado que você Install any dependencies with npm
diga não.
name
do package.json para @repo/my-remix-cloudflare-app
(ou seu nome) { - "name": "my-remix-cloudflare-app", + "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "keywords": [], "author": "", "license": "ISC" }
apps/<app>/package.json
para o package.json
do RootPor exemplo:
<root>/pacote.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" } }
Verifique se o turbo está dentro do devDependencies do package.json. Se não estiver listado, execute o seguinte comando:
pnpm add turbo -D -w
O sinalizador -w
informa ao pnpm para instalá-lo na raiz do espaço de trabalho.
Adicione o comando dev
aos scripts
Adicione o packageManager
à opção
{ "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
para cada um dos pacotes. 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" } } }
Notas:
Estou começando a desprezar ORMs. Passei mais de 10 anos aprendendo 6 diferentes, e é um conhecimento que você não pode transferir.
Você tem problemas quando novas tecnologias surgem. O Prisma não suporta os trabalhadores do Cloudflare de fábrica.
Com LLMs, é mais fácil do que nunca escrever consultas SQL complexas.
Aprender SQL é uma linguagem universal e provavelmente não mudará.
pnpm add drizzle-orm drizle-kit --filter=@repo/db
Instale o Postgres no nível do espaço de trabalho. Veja a seção Pitfall .
pnma add postgres -w
Notas:
--filter=@repo/db
informa ao pnpm para adicionar o pacote ao repositório db. pnpm add dotenv -w
Notas
-w
informa ao pnpm para instalá-lo no pacote root.json pnpm add @repo/config -r --filter=!@repo/config
Notas :
-r
informa ao pnpm para adicionar o pacote a todos os repositórios.--filter=!
informa ao pnpm para excluir o repositório de configuração.!
antes do nome do pacote Se o pnpm estiver puxando os pacotes do repositório, podemos criar um arquivo .npmrc
na raiz do projeto.
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
tsconfig.json
compartilhado dentro de Libs/ConfigUsando o poder dos espaços de trabalho do pnpm, você pode criar arquivos de configuração que podem ser compartilhados entre projetos.
Criaremos um tsconfig.lib.json base que usaremos para nossas bibliotecas.
Dentro de libs/config
instancie um tsconfig.lib.json
:
touch "libs/config/tsconfig.base.lib.json"
Em seguida, adicione o seguinte:
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, });
O arquivo de esquema:
// 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(), });
O arquivo do cliente:
// 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!; } }
O arquivo de migração:
// 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();
Isso deve ser executado após as migrações
E exporte o cliente e o esquema no arquivo src/index.ts
. Outros são executados em momentos específicos.
// libs/db/src/index.ts export * from "./drizzle/drizzle-client"; export * from "./drizzle/schema "
No seu package.json
, adicione o drizzle-kit generate
e o código para executar o comando de migração:
{ "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
compartilhado para libs/db
e libs/utils
Crie um tsconfig.json para libs/db
e libs/utils
touch "libs/db/tsconfig.json" "libs/utils/tsconfig.json"
Em seguida, adicione a cada um:
{ "extends": "@repo/configs/tsconfig.base.lib.json", "include": ["./src"], }
@repo/configs
é usado como o caminho para fazer referência ao nosso tsconfig.base.lib.json.TypeScript Execute (TSX) é uma alternativa de biblioteca para ts-node. Usaremos isso para executar as migrações do drizzle.
pnpm add tsx -D --filter=@repo/db
libs/db
touch "libs/db/.env"
Adicione o seguinte conteúdo:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" NODE_ENV="development" MODE="node"
libs/db
ao nosso projeto RemixNa raiz do projeto, execute:
pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app
Se isso não funcionar, vá para o pacote apps/my-remix-cloudflare-app
e adicione a dependência manualmente.
{ "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "dependencies": { "@repo/db": "workspace:*" } }
Observe o workspace:*
no campo version. Isso diz ao pnpm para usar qualquer versão do pacote no workspace.
Se você instalou via CLI usando pnpm add,
provavelmente verá algo como workspace:^
. Não deve importar, desde que você não aumente as versões locais do pacote.
Se você adicionou isso manualmente, execute pnpm install
a partir da raiz do projeto.
Deveríamos conseguir consumir o @repo/db em nosso projeto.
Adicione este código ao arquivo 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
Se você não tiver uma instância do Postgres em execução, podemos iniciar uma usando docker-compose. Observe que estou assumindo que você conhece o Docker.
Crie um arquivo docker-compose.yml
na raiz do projeto.
# 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
Então você pode executar:
docker-compose up -d
O sinalizador -d
informa ao docker-compose para ser executado desanexado para que você possa ter acesso ao seu terminal novamente.
Agora, navegue até o repositório libs/db e execute db:generate
.
cd `./libs/db` && pnpm db:generate
db:generate
é um alias para: drizzle-kit generate
Precisamos executar as migrações para estruturar todas as tabelas em nosso banco de dados.
Navegue até o repositório libs/db (se você não estiver lá) e execute db:generate
.
cd `./libs/db` && pnpm db:migrate
db:migrate
é um alias para: 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} />; }
aplicativos/meu-remix-cloudflare-app/.dev.vars
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
Inicie a instância do postgres (se não estiver pronta)
docker-compose up -d
Lançar o projeto
pnpm turbo dev
Em meus projetos, costumo implementar um padrão CQRS , 2. Isso está fora do escopo deste tutorial.
No entanto, dentro do contexto de carga, costumo injetar um mediador (e uma mensagem flash de cookie) que desacoplará todo o meu aplicativo Remix da minha lógica de negócios.
Isso se parece com algo assim:
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, }; };
Note que o código de despacho é omitido. Você pode encontrar mais sobre isso no meu artigo sobre como 10x sua experiência de desenvolvimento TypeScript aqui .
Posso remover o Remix ou usar outro consumidor sem alterar meu código.
Mas….
Há um desafio adicional quando você trabalha em uma estrutura monorepo usando turborepo.
Se você importar um arquivo TypeScript de um pacote dentro do load-context, digamos que @repo/db
Vite retornará um erro informando que o arquivo com extensão .ts
é desconhecido e não saberá como processá-lo.
Isso acontece porque load-context + workspaces estão fora do gráfico de importação principal do site, deixando os arquivos TypeScript fora do jogo.
O truque é usar tsx
e carregá-lo antes de chamar Vite, o que funcionará. Isso é importante porque supera as seguintes limitações:
Dependências do pacote Cloudflare.
Dependências do pacote Cloudflare e pré-construção
Primeiro, essa era a etapa que eu estava tentando evitar, pois significava que eu teria que introduzir uma etapa de compilação para cada um dos pacotes, o que significava mais configuração.
Felizmente, isso não funcionou para o Cloudflare Pages. Bibliotecas específicas, como Postgres, detectarão o tempo de execução e puxarão o pacote necessário.
Há uma solução alternativa: podemos usar o tsx para carregar todos os arquivos TypeScript e transpilá-los antes de executar.
Você pode argumentar que esta é uma etapa de pré-construção, mas como ainda está no nível do repositório do remix, não vejo problemas significativos com essa abordagem.
Para resolver isso, adicionamos tsx como uma dependência:
pnpm add tsx -D --filter=@repo/my-remix-cloudflare-app
E então, precisamos modificar nosso package.json
e adicionar o processo tsx a cada um dos nossos scripts de 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
Caso esteja tendo problemas ao adicionar seus pacotes locais com a linha de comando, você pode criar um arquivo .npmrc
na raiz do projeto.
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
Isso dirá ao pnpm para usar os pacotes do espaço de trabalho primeiro.
Obrigado ao ZoWnx do Reddit que me ajudou a criar um arquivo .nprmc
Cuidado ao nomear .client
e .server
em seus arquivos. Mesmo se estiver em uma biblioteca separada. O Remix usa esses para determinar se é um arquivo cliente ou servidor. O projeto não é compilado por repositório, então ele lançará um erro de importação!
Se você estiver tendo problemas com pacotes multiplataforma como o Postgres, instalá-lo no nível do espaço de trabalho é melhor. Ele detectará a importação adequada. Instalá-lo diretamente no repositório @repo/db quebrará ao importá-lo para o Remix.
É isso aí, pessoal!!!
Você pode acessar a implementação completa aqui .
Estou criando um engenheiro de testes automatizados em público para capturar esses erros de 1% na produção.
Compartilho meu progresso em: