Resolving a Prisma Generation Issue with PNPM workspaces
The Project
I recently started work on a project which uses PNPM as its primary package manager, and is structured as a monorepo.
PNPM makes this really easy to do, sharing of dependencies and local packages is seamless. And adding Turborepo to the mix makes things like build and test parallelisation really easy as well.
Following the Turborepo guide for adding Prisma, I setup a package called database
which re-exports the PrismaClient from prisma/client
. This package could then be consumed in any of the other packages within the monorepo.
The app itself is a Remix app called ui
. Remix uses Vite for builds, so any bundling of application code is handled there.
Deploying with Docker
When it comes to deployments for the project, I’m using a custom Docker image which is pushed to Github and eventually deployed to Railway.
The PNPM documentation has details on how to setup a custom Docker image, but if you use corepack
you can skip the requirement for a specific tailored pnpm
flavoured Docker image.
Here’s the Dockerfile I’m using (located at the root of the monorepo)
FROM node:22-slim AS base
ARG DATABASE_URL
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN apt-get update -y && apt-get install -y openssl
FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN pnpm install --frozen-lockfile
RUN pnpm run --filter=database generate
RUN pnpm run --filter=database deploy:prod
RUN pnpm run --filter=ui build
EXPOSE 8000
CMD [ "pnpm", "--filter=ui", "start" ]
Some notable additions to the Dockerfile from the PNPM documentation
apt-get install -y openssl
- Prisma requires openssl to be installedRUN pnpm run --filter=database generate
- Generate the Prisma Client package for the database packageRUN pnpm run --filter=database deploy:prod
- Deploys any migrations to the databaseRUN pnpm run --filter=ui build
- Builds the Remix app
Prisma Client Generation
Prisma is slightly unique in that there is a code generation step which needs to be performed before the application can be run. Your local schema.prisma
file is read by Prisma, and a Typescript package is generated and placed within the node_modules/@prisma/client
folder.
The Prisma documenation does a good job of explaining all of this, but in essence you have to run the following command after node modules has been installed, and after any schema changes have been made.
prisma migrate
Explicit Output Location
There is also a way of explicitly stating where the generated client should be placed which can be useful, for instance when working with multiple Prisma schemas in a single monorepo.
generator client {
provider = "prisma-client-js"
output = "./src/generated/prisma"
}
The Issue
Ok so onto the actual issue. The UI builds fine locally and in Docker. The Prisma Client is generated, and the database is deployed. When starting the application container however, a painful Prisma Client error is thrown.
Error: Cannot find module '.prisma/client/default'
Following the stack trace, I can see in the node modules folder for the Prisma client located at packages/database/node_modules/@prisma/client
that the .prisma
folder is indeed missing. Same with the apps/ui/node_modules/@prisma/client
. But yet running the app locally outside of Docker works just fine.
.
├── apps
│ └── ui
│ └── node_modules
│ └── @prisma
│ └── client
| └── .prisma ❌ Not found
├── packages
│ └── database
│ └── node_modules
│ └── @prisma
│ └── client
| └── .prisma ❌ Also not found 👿
Attempts to resolve
When debugging I did come across other people[1][2] with a similar issue, but no clear steps for resolution.
Bundling Server Side code
One dead-end I explored was to bundle all server side code together, and marking non of the dependencies as external. But to cut a long story short, this was not a viable solution for me.
Prisma needs to be external, as it wraps a Rust binary which needs to be loaded by the process at runtime.
The Resolution
In the end, PNPM workspaces were the issue.
When running the prisma generate
command, the Prisma Client location is actually not the node_modules/@prisma/client
folder, but the root of the workspace, inside PNPM’s special .pnpm
cache folder.
.
├── node_modules
│ └── .pnpm
│ └── @[email protected][email protected]
│ └── node_modules
| └── .prisma ✅ Found
Bingo. So it exists somewhere, just not where the running container expects it to be.
Update Vite’s Build Config
Vite has a resolve.alias
option which can be used to redirect imports to a different location. This is what I needed to do to get the build to pick up the Prisma Client from the cache location.
I started by hardcoding the following
alias: {
".prisma/client/default":
"../../node_modules/.pnpm/@[email protected][email protected]/node_modules/@prisma/client/default.js",
},
…and it worked!
Full credit to Jolg42’s issue comment for the idea.
Later in the same issue thread, leifermendez left a suggestion which generalised the Vite build config, leaving me with the following:
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { createRequire } from "module";
import path from "path";
// Create a require function for ES modules
const require = createRequire(import.meta.url);
/**
* Resolves the path to the Prisma client file.
* @param {string} replacement - The part of the path to replace.
* @param {string} toReplace - The new path to replace with.
* @returns {string} - The resolved path.
*/
const resolvePrismaClientPath = (
replacement: string,
toReplace: string
): string => {
const regex = new RegExp(`@prisma[\\\\/]client[\\\\/]${replacement}\\.js`);
const resolvePath =
replacement !== "default"
? `@prisma/client/${replacement}`
: "@prisma/client";
const resolvedPath = require.resolve(resolvePath).replace(regex, toReplace);
return resolvedPath;
};
const prismaIndexPaths = Object.fromEntries(
["index-browser", "edge", "default"].map((key) => [
key,
path.normalize(
path.relative(
process.cwd(),
resolvePrismaClientPath(
key,
path.join(".prisma", "client", `${key}.js`)
)
)
),
])
);
export default defineConfig({
plugins: [
tsconfigPaths(),
remix({
basename: "/",
buildDirectory: "build",
future: {
/* any enabled future flags */
},
appDirectory: "src",
}),
],
ssr: {
external: ["bcrypt"],
},
resolve: {
alias: {
".prisma/client/default": prismaIndexPaths.default,
".prisma/client/index-browser": prismaIndexPaths.indexBrowser,
".prisma/client/edge": prismaIndexPaths.edge,
},
},
});
Problem solved!