Necesitaba una forma de usar Remix con Vite y Cloudflare Workers-Pages con una configuración mínima.
Vi otros repositorios, como:
Pero tenían algunas limitaciones:
No quería compilarlo previamente, ya que no quería envenenar los repositorios con más archivos de configuración.
Cloudflare Workers/Pages tiene un objetivo diferente. Se volvió complicado apuntarlo con tsup, ya que paquetes como Postgres extraían las dependencias de los nodos y se rompían al importarlos a Remix.
También necesitaba una forma de consumir diferentes objetivos (Remix-Cloudflare, Node/Bun)
¡Aun así, les agradezco porque allanaron el camino para que esto fuera posible!
¡Asegúrate de leer la sección de peligros al final!
Estoy construyendo una plataforma de pruebas automatizadas en público para detectar esos errores del 1% en producción.
Comparto mis avances en:
Puedes acceder a la implementación completa aquí .
Si bien esto lo guía a través de un nuevo mono-repositorio, es perfectamente válido transformar uno existente en uno.
Esto también supondrá que tienes algún conocimiento sobre repositorios mono.
Nota:
libs
y packages
.Turborepo funciona sobre los espacios de trabajo de tu administrador de paquetes para administrar los scripts y los resultados de tu proyecto (incluso puede almacenar en caché los resultados). Hasta el momento, es la única herramienta mono-repositorio además de Rush (que no he probado y no me gusta) que es capaz de funcionar.
NX no tiene soporte para Vite de Remix (al momento de escribir este artículo, 28 de agosto de 2024).
pnpm dlx create-turbo@latest
Utilizaremos las capacidades del espacio de trabajo de PNPM para administrar las dependencias.
En su directorio Monorepo, cree un pnpm-workspace.yaml
.
Dentro de ella, añade:
packages: - "apps/*" - "libs/*"
Esto le indicará a pnpm que todos los repositorios se encontrarán dentro de apps
y libs
. Tenga en cuenta que no importa si se utilizan libs
o packages
(como puede haber visto en otro lugar).
pnpm init
{ "name": "@repo/main", "version": "1.0.0", "scripts": {}, "keywords": [], "author": "", "license": "ISC" }
Tenga en cuenta el name:@repo/main
Esto nos indica que esta es la entrada principal de la aplicación. No es necesario seguir una convención en particular ni utilizar el prefijo @
. La gente lo utiliza para diferenciarlo de los paquetes locales o remotos o para facilitar su agrupación en una organización.
turbo.json
en la raíz del proyecto: { "$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": {} } }
El archivo turbo.json le indica al repositorio de Turbo cómo interpretar nuestros comandos. Todo lo que se encuentre dentro de la clave tasks
coincidirá con lo que se encuentre en el archivo package.json.
Tenga en cuenta que definimos cuatro comandos. Estos coinciden con los que se encuentran en la sección de script del package.json de cada repositorio. Sin embargo, no todos los package.json deben implementar estos comandos.
Por ejemplo: el comando dev
se activará con turbo dev
y ejecutará todos los paquetes que dev
encuentren en package.json. Si no lo incluye en turbo, no se ejecutará.
apps
en la raíz del proyecto mkdir apps
apps
(o mueve una existente) npx create-remix --template edmundhung/remix-worker-template
Cuando le pida que Install any dependencies with npm
responda no.
name
del paquete.json a @repo/my-remix-cloudflare-app
(o su nombre) { - "name": "my-remix-cloudflare-app", + "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "keywords": [], "author": "", "license": "ISC" }
apps/<app>/package.json
al package.json
de la raízP.ej:
<root>/paquete.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 que Turbo esté dentro de devDependencies de package.json. Si no está en la lista, ejecute el siguiente comando:
pnpm add turbo -D -w
El indicador -w
le indica a pnpm que lo instale en la raíz del espacio de trabajo.
Agregar el comando dev
a scripts
Agregue el packageManager
a la opción
{ "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 uno de los paquetes. 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:
Estoy empezando a despreciar los ORM. He pasado más de 10 años aprendiendo 6 diferentes y es un conocimiento que no se puede transferir.
Tienes problemas cuando aparecen nuevas tecnologías. Prisma no es compatible con los trabajadores de Cloudflare de manera predeterminada.
Con LLM, es más fácil que nunca escribir consultas SQL complejas.
Aprender SQL es un lenguaje universal y probablemente no cambiará.
pnpm add drizzle-orm drizle-kit --filter=@repo/db
Instalar Postgres en el nivel del espacio de trabajo. Consulta la sección Problemas .
pnma add postgres -w
Notas:
--filter=@repo/db
le indica a pnpm que agregue el paquete al repositorio de base de datos. pnpm add dotenv -w
Notas
-w
le dice a pnpm que lo instale en el paquete package.json de la raíz pnpm add @repo/config -r --filter=!@repo/config
Notas :
-r
le dice a pnpm que agregue el paquete a todos los repositorios.--filter=!
le indica a pnpm que excluya el repositorio de configuración.!
antes del nombre del paquete Si pnpm extrae los paquetes del repositorio, podemos crear un archivo .npmrc
en la raíz del proyecto.
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
tsconfig.json
compartido dentro de Libs/ConfigAl utilizar el poder de los espacios de trabajo de pnpm, puede crear archivos de configuración que se pueden compartir entre proyectos.
Crearemos una base tsconfig.lib.json que usaremos para nuestras bibliotecas.
Dentro de libs/config
crea una instancia de tsconfig.lib.json
:
touch "libs/config/tsconfig.base.lib.json"
Luego agrega lo siguiente:
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, });
El archivo 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(), });
El archivo del 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!; } }
El archivo de migración:
// 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();
Esto debe ejecutarse después de las migraciones.
Y exporta el cliente y el esquema en el archivo src/index.ts
. Otros se ejecutan en momentos específicos.
// libs/db/src/index.ts export * from "./drizzle/drizzle-client"; export * from "./drizzle/schema "
En su package.json
, agregue drizzle-kit generate
y el código para ejecutar el comando de migración:
{ "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
compartido para libs/db
y libs/utils
Cree un tsconfig.json para libs/db
y libs/utils
touch "libs/db/tsconfig.json" "libs/utils/tsconfig.json"
Luego añade a cada uno:
{ "extends": "@repo/configs/tsconfig.base.lib.json", "include": ["./src"], }
@repo/configs
se utiliza como ruta para hacer referencia a nuestro tsconfig.base.lib.json.TypeScript Execute (TSX) es una biblioteca alternativa a ts-node. La utilizaremos para ejecutar las migraciones de Drizzle.
pnpm add tsx -D --filter=@repo/db
libs/db
touch "libs/db/.env"
Añade el siguiente contenido:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" NODE_ENV="development" MODE="node"
libs/db
a nuestro proyecto RemixDesde la raíz del proyecto, ejecute:
pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app
Si esto no funciona, vaya al paquete json de apps/my-remix-cloudflare-app
y agregue la dependencia manualmente.
{ "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "dependencies": { "@repo/db": "workspace:*" } }
Tenga en cuenta el workspace:*
en el campo de versión. Esto le indica a pnpm que use cualquier versión del paquete en el espacio de trabajo.
Si lo instalaste a través de la CLI usando pnpm add,
probablemente verás algo como workspace:^
. No debería importar siempre y cuando no aumentes las versiones de los paquetes locales.
Si lo agregó manualmente, ejecute pnpm install
desde la raíz del proyecto.
Deberíamos poder consumir @repo/db en nuestro proyecto.
Agregue este código al archivo 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
Si no tienes una instancia de Postgres en ejecución, podemos iniciar una usando docker-compose. Ten en cuenta que supongo que conoces Docker.
Cree un archivo docker-compose.yml
en la raíz del proyecto.
# 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
Luego puedes ejecutar:
docker-compose up -d
El indicador -d
le indica a docker-compose que se ejecute de forma separada para que pueda tener acceso a su terminal nuevamente.
Ahora, navegue al repositorio libs/db y ejecute db:generate
.
cd `./libs/db` && pnpm db:generate
db:generate
es un alias para: drizzle-kit generate
Necesitamos ejecutar las migraciones para estructurar todas las tablas dentro de nuestra base de datos.
Navega al repositorio libs/db (si no estás allí) y ejecuta db:generate
.
cd `./libs/db` && pnpm db:migrate
db:migrate
es un 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} />; }
aplicaciones/mi-remix-cloudflare-app/.dev.vars
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
Iniciar la instancia de postgres (si no está lista)
docker-compose up -d
Lanzar el proyecto
pnpm turbo dev
En mis proyectos, suelo implementar un patrón CQRS , 2. Esto queda fuera del alcance de este tutorial.
Sin embargo, dentro del contexto de carga, tiendo a inyectar un mediador (y un mensaje flash de cookie) que desacoplará toda mi aplicación Remix de mi lógica comercial.
Esto se parece a esto:
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, }; };
Tenga en cuenta que se omite el código de envío. Puede encontrar más información al respecto en mi artículo sobre cómo multiplicar por diez su experiencia de desarrollo con TypeScript aquí .
Puedo eliminar Remix o utilizar otro consumidor sin alterar mi código.
Pero….
Existe un desafío adicional cuando se trabaja en una estructura monorepositorio utilizando turborepo.
Si importa un archivo TypeScript desde un paquete dentro del contexto de carga, digamos @repo/db
Vite devolverá un error indicando que el archivo con extensión .ts
es desconocido y no sabrá cómo procesarlo.
Esto sucede porque load-context + workspaces están fuera del gráfico de importación principal del sitio, lo que deja los archivos TypeScript fuera de juego.
El truco es usar tsx
y cargarlo antes de llamar a Vite, lo que funcionará. Esto es importante porque supera las siguientes limitaciones:
Dependencias del paquete Cloudflare.
Dependencias de paquetes de Cloudflare y compilación previa
En primer lugar, ese era el paso que estaba tratando de evitar, ya que significaba que tenía que introducir un paso de compilación para cada uno de los paquetes, lo que implicaba más configuración.
Afortunadamente, esto no funcionó con Cloudflare Pages. Bibliotecas específicas, como Postgres, detectarán el entorno de ejecución y extraerán el paquete requerido.
Hay una solución alternativa: podemos usar tsx para cargar todos los archivos TypeScript y transpilarlos antes de ejecutarlos.
Se podría argumentar que este es un paso previo a la compilación, pero como todavía está en el nivel del repositorio del remix, no veo problemas significativos con este enfoque.
Para solucionar esto, agregamos tsx como dependencia:
pnpm add tsx -D --filter=@repo/my-remix-cloudflare-app
Y luego, necesitamos modificar nuestro package.json
y agregar el proceso tsx a cada uno de nuestros 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
En caso de que tenga problemas al agregar sus paquetes locales con la línea de comando, puede crear un archivo .npmrc
en la raíz del proyecto.
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
Esto le indicará a pnpm que utilice primero los paquetes del espacio de trabajo.
Gracias a ZoWnx de Reddit que me ayudó a crear un archivo .nprmc
Tenga cuidado al nombrar .client
y .server
en sus archivos, incluso si están en una biblioteca separada. Remix los usa para determinar si es un archivo de cliente o de servidor. El proyecto no se compila por repositorio, por lo que generará un error de importación.
Si tienes problemas con paquetes multiplataforma como Postgres, es mejor instalarlo en el nivel del espacio de trabajo. Detectará la importación adecuada. Instalarlo directamente en el repositorio @repo/db dejará de funcionar al importarlo a Remix.
¡¡¡Eso es todo, amigos!!!
Puedes acceder a la implementación completa aquí .
Estoy construyendo un ingeniero de pruebas automatizado en público para detectar esos errores del 1% en producción.
Comparto mis avances en: