J'avais besoin d'un moyen d'utiliser Remix avec Vite et Cloudflare Workers-Pages avec une configuration minimale.
J'ai vu d'autres dépôts, tels que :
Mais ils avaient certaines limites :
Je ne voulais pas le pré-construire, car je ne voulais pas empoisonner les référentiels avec plus de fichiers de configuration.
Cloudflare Workers/Pages a une cible différente. Il est devenu difficile de la cibler avec tsup, car des packages tels que Postgres risquaient de casser les dépendances des nœuds lors de leur importation dans Remix.
J'avais également besoin d'un moyen de consommer différentes cibles (Remix-Cloudflare, Node/Bun)
Néanmoins, je les remercie, car ils ont ouvert la voie pour rendre cela possible !
Assurez-vous de lire la section des pièges en bas !
Je construis une plateforme de tests automatisée en public pour détecter ces 1% d'erreurs en production.
Je partage mes progrès sur :
Vous pouvez accéder à l' implémentation complète ici .
Bien que cela vous guide à travers un nouveau mono-dépôt, il est parfaitement valable de transformer un mono-dépôt existant en un seul.
Cela supposera également que vous avez quelques connaissances en matière de mono repo.
Note:
libs
et packages
.Turborepo fonctionne au-dessus des espaces de travail de votre gestionnaire de paquets pour gérer les scripts et les sorties de votre projet (il peut même mettre en cache votre sortie). Jusqu'à présent, c'est le seul outil mono-repo en dehors de Rush (que je n'ai pas essayé et que je n'aime pas) qui est capable de fonctionner.
NX ne prend pas en charge Vite de Remix (au moment de la rédaction de cet article - 28 août 2024).
pnpm dlx create-turbo@latest
Nous utiliserons les fonctionnalités de l'espace de travail de PNPM pour gérer les dépendances.
Dans votre répertoire Monorepo, créez un pnpm-workspace.yaml
.
À l'intérieur, ajoutez :
packages: - "apps/*" - "libs/*"
Cela indiquera à pnpm que tous les dépôts se trouveront dans apps
et libs
. Notez que l'utilisation libs
ou packages
(comme vous l'avez peut-être vu ailleurs) n'a pas d'importance.
pnpm init
{ "name": "@repo/main", "version": "1.0.0", "scripts": {}, "keywords": [], "author": "", "license": "ISC" }
Notez le name:@repo/main
Cela nous indique qu'il s'agit de l'entrée principale de l'application. Vous n'avez pas besoin de suivre une convention particulière ou d'utiliser le préfixe @
. Les gens l'utilisent pour le différencier des packages locaux/distants ou pour faciliter son regroupement dans une organisation.
turbo.json
à la racine du projet : { "$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": {} } }
Le fichier turbo.json indique au référentiel turbo comment interpréter nos commandes. Tout ce qui se trouve à l'intérieur de la clé tasks
correspondra à ceux trouvés dans le package all.json.
Notez que nous définissons quatre commandes. Celles-ci correspondent à celles de la section script du package.json de chaque dépôt. Cependant, tous les packages.json ne doivent pas implémenter ces commandes.
Par exemple : la commande dev
sera déclenchée par turbo dev
et exécutera tous les packages dont dev
se trouve dans package.json. Si vous ne l'incluez pas dans turbo, elle ne s'exécutera pas.
apps
à la racine du projet mkdir apps
apps
(ou déplacez-en une existante) npx create-remix --template edmundhung/remix-worker-template
Lorsqu'il vous demande d' Install any dependencies with npm
dites non.
name
du package.json en @repo/my-remix-cloudflare-app
(ou votre nom) { - "name": "my-remix-cloudflare-app", + "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "keywords": [], "author": "", "license": "ISC" }
apps/<app>/package.json
vers le package.json
de la racinePar exemple :
<racine>/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" } }
Vérifiez que turbo se trouve dans les devDependencies de package.json. S'il n'est pas répertorié, exécutez la commande suivante :
pnpm add turbo -D -w
L'indicateur -w
indique à pnpm de l'installer à la racine de l'espace de travail.
Ajoutez la commande dev
aux scripts
Ajoutez le packageManager
à l'option
{ "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
pour chacun des packages. 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" } } }
Remarques :
Je commence à mépriser les ORM. J'ai passé plus de 10 ans à en apprendre 6 différents, et ce sont des connaissances qu'on ne peut pas transférer.
Vous rencontrez des problèmes lorsque de nouvelles technologies apparaissent. Prisma ne prend pas en charge les workers Cloudflare par défaut.
Avec les LLM, il est plus facile que jamais d’écrire des requêtes SQL complexes.
Apprendre SQL est un langage universel et ne changera probablement pas.
pnpm add drizzle-orm drizle-kit --filter=@repo/db
Installez Postgres au niveau de l'espace de travail. Voir la section Piège .
pnma add postgres -w
Remarques :
--filter=@repo/db
indique à pnpm d'ajouter le package au référentiel db. pnpm add dotenv -w
Remarques
-w
indique à pnpm de l'installer dans le package.json de la racine pnpm add @repo/config -r --filter=!@repo/config
Remarques :
-r
indique à pnpm d'ajouter le package à tous les référentiels.--filter=!
indique à pnpm d'exclure le référentiel de configuration.!
avant le nom du package Si pnpm extrait les packages du référentiel, nous pouvons créer un fichier .npmrc
à la racine du projet.
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
tsconfig.json
partagé dans Libs/ConfigEn utilisant la puissance des espaces de travail pnpm, vous pouvez créer des fichiers de configuration qui peuvent être partagés entre les projets.
Nous allons créer un tsconfig.lib.json de base que nous utiliserons pour nos bibliothèques.
Dans libs/config
instanciez un tsconfig.lib.json
:
touch "libs/config/tsconfig.base.lib.json"
Ensuite, ajoutez ce qui suit :
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, });
Le fichier de schéma :
// 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(), });
Le dossier client :
// 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!; } }
Le fichier de migration :
// 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();
Ceci devrait être exécuté après les migrations
Et exportez le client et le schéma dans le fichier src/index.ts
. D'autres sont exécutés à des moments précis.
// libs/db/src/index.ts export * from "./drizzle/drizzle-client"; export * from "./drizzle/schema "
Dans votre package.json
, ajoutez le drizzle-kit generate
et le code pour exécuter la commande de migration :
{ "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
partagé pour libs/db
et libs/utils
Créez un tsconfig.json pour libs/db
et libs/utils
touch "libs/db/tsconfig.json" "libs/utils/tsconfig.json"
Ajoutez ensuite à chacun :
{ "extends": "@repo/configs/tsconfig.base.lib.json", "include": ["./src"], }
@repo/configs
est utilisé comme chemin pour faire référence à notre tsconfig.base.lib.json.TypeScript Execute (TSX) est une bibliothèque alternative à ts-node. Nous l'utiliserons pour exécuter les migrations de Drizzle.
pnpm add tsx -D --filter=@repo/db
libs/db
touch "libs/db/.env"
Ajoutez le contenu suivant :
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" NODE_ENV="development" MODE="node"
libs/db
à notre projet de remixDepuis la racine du projet, exécutez :
pnpm add @repo/db --filter=@repo/my-remix-cloudflare-app
Si cela ne fonctionne pas, accédez au package.json de apps/my-remix-cloudflare-app
et ajoutez la dépendance manuellement.
{ "name": "@repo/my-remix-cloudflare-app", "version": "1.0.0", "dependencies": { "@repo/db": "workspace:*" } }
Notez l' workspace:*
dans le champ version. Cela indique à pnpm d'utiliser n'importe quelle version du package dans l'espace de travail.
Si vous l'avez installé via la CLI en utilisant pnpm add,
vous verrez probablement quelque chose comme workspace:^
. Cela ne devrait pas avoir d'importance tant que vous n'augmentez pas les versions des packages locaux.
Si vous l'avez ajouté manuellement, exécutez pnpm install
depuis la racine du projet.
Nous devrions pouvoir utiliser le @repo/db dans notre projet.
Ajoutez ce code au fichier 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 vous n'avez pas d'instance Postgres en cours d'exécution, nous pouvons en lancer une à l'aide de docker-compose. Remarque : je suppose que vous connaissez Docker.
Créez un fichier docker-compose.yml
à la racine du projet.
# 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
Ensuite, vous pouvez exécuter :
docker-compose up -d
L'indicateur -d
indique à docker-compose de s'exécuter séparément afin que vous puissiez à nouveau accéder à votre terminal.
Accédez maintenant au référentiel libs/db et exécutez db:generate
.
cd `./libs/db` && pnpm db:generate
db:generate
est un alias pour : drizzle-kit generate
Nous devons exécuter les migrations pour structurer toutes les tables de notre base de données.
Accédez au référentiel libs/db (si vous n'y êtes pas) et exécutez db:generate
.
cd `./libs/db` && pnpm db:migrate
db:migrate
est un alias pour : 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} />; }
applications/mon-application-cloudflare-remix/.dev.vars
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
Lancer l'instance postgres (si elle n'est pas prête)
docker-compose up -d
Lancer le projet
pnpm turbo dev
Dans mes projets, j'ai tendance à implémenter un modèle CQRS , 2 . Ceci sort du cadre de ce tutoriel.
Néanmoins, dans le contexte de chargement, j’ai tendance à injecter un médiateur (et un message flash de cookie) qui découplera toute mon application Remix de ma logique métier.
Cela ressemble à ceci :
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, }; };
Notez que le code de répartition est omis. Vous pouvez en savoir plus à ce sujet dans mon article sur la façon de décupler votre expérience de développement TypeScript ici .
Je peux supprimer Remix ou utiliser un autre consommateur sans modifier mon code.
Mais….
Il y a un défi supplémentaire lorsque vous travaillez dans une structure monorepo utilisant turborepo.
Si vous importez un fichier TypeScript à partir d'un package dans le contexte de chargement, disons que @repo/db
Vite renverra une erreur indiquant que le fichier avec l'extension .ts
est inconnu et ne saura pas comment le traiter.
Cela se produit parce que le contexte de chargement + les espaces de travail sont en dehors du graphique d'importation principal du site, laissant les fichiers TypeScript hors jeu.
L'astuce consiste à utiliser tsx
et à le charger avant d'appeler Vite, ce qui fonctionnera. Ceci est important car cela permet de surmonter les limitations suivantes :
Dépendances du package Cloudflare.
Dépendances et pré-construction des packages Cloudflare
Tout d’abord, c’était l’étape que j’essayais d’éviter, car cela signifiait que je devais introduire une étape de construction pour chacun des packages, ce qui impliquait plus de configuration.
Heureusement, cela n'a pas fonctionné pour les pages Cloudflare. Des bibliothèques spécifiques, telles que Postgres, détecteront l'exécution et extrairont le package requis.
Il existe une solution de contournement : nous pouvons utiliser tsx pour charger tous les fichiers TypeScript et les transpiler avant de les exécuter.
Vous pouvez affirmer qu'il s'agit d'une étape de pré-construction, mais comme elle se trouve toujours au niveau du référentiel du remix, je ne vois pas de problèmes importants avec cette approche.
Pour résoudre ce problème, nous ajoutons tsx comme dépendance :
pnpm add tsx -D --filter=@repo/my-remix-cloudflare-app
Et puis, nous devons modifier notre package.json
et ajouter le processus tsx à chacun de nos 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
Si vous rencontrez des problèmes lors de l'ajout de vos packages locaux avec la ligne de commande, vous pouvez créer un fichier .npmrc
à la racine du projet.
.npmrc
link-workspace-packages= true prefer-workspace-packages=true
Cela indiquera à pnpm d’utiliser d’abord les packages de l’espace de travail.
Merci à ZoWnx de Reddit qui m'a aidé à créer un fichier .nprmc
Soyez prudent avec les noms de .client
et .server
dans vos fichiers. Même s'ils se trouvent dans une bibliothèque distincte. Remix les utilise pour déterminer s'il s'agit d'un fichier client ou serveur. Le projet n'est pas compilé par référentiel, il génère donc une erreur d'importation !
Si vous rencontrez des problèmes avec des packages multi-plateformes tels que Postgres, il est préférable de l'installer au niveau de l'espace de travail. Il détectera l'importation appropriée. L'installer directement dans le référentiel @repo/db ne fonctionnera pas lors de son importation dans Remix.
C'est tout, les amis !!!
Vous pouvez accéder à l' implémentation complète ici .
Je suis en train de créer un ingénieur de tests automatisé en public pour détecter ces 1% d'erreurs en production.
Je partage mes progrès sur :