Publishing Your Deno Project as a Monorepo using dnt
Before providing theoretical guidance, let’s look at how to achieve this in practice. After completion, I will explain the advantages of this project management solution.
Tools
Preparation
-
Create your project:
deno init dnt-mono # cd dnt-mono # code . # open in ide -
Initialize a git repository
git init echo "npm\nnode_modules" > .gitignore # ignore the npm folder -
Initialize package.json, and other files typically required by npm/pnpm
npm init --yes --private # create a package.json file echo "MIT" > LICENSE echo "# Hello Dnt ❤️ Monorepo" > README.md echo "packages:\n - \"npm/*\"" > pnpm-workspace.yaml -
Prepare the dnt script
deno add @deno/dntRefer to Setup, as we need to build multiple npm packages, create the
scripts/npmBuilder.tsfile:import { build, BuildOptions, emptyDir } from "@deno/dnt"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const rootDir = import.meta.resolve("../"); const rootResolve = (path: string) => fileURLToPath(new URL(path, rootDir)); export const npmBuilder = async (config: { packageDir: string; version?: string; importMap?: string; options?: Partial<BuildOptions>; }) => { const { packageDir, version, importMap, options } = config; const packageResolve = (path: string) => fileURLToPath(new URL(path, packageDir)); const packageJson = JSON.parse( fs.readFileSync(packageResolve("./package.json"), "utf-8"), ); // remove some field which dnt will create. if you known how dnt work, you can keep them. delete packageJson.main; delete packageJson.module; delete packageJson.exports; console.log(`\nstart dnt: ${packageJson.name}`); const npmDir = rootResolve(`./npm/${packageJson.name.split("/").pop()}`); const npmResolve = (p: string) => path.resolve(npmDir, p); await emptyDir(npmDir); if (version) { Object.assign(packageJson, { version: version }); } await build({ entryPoints: [{ name: ".", path: packageResolve("./index.ts") }], outDir: npmDir, packageManager: "pnpm", shims: { deno: true, }, // you should open it in actual test: false, importMap: importMap, package: packageJson, // custom by yourself compilerOptions: { lib: ["DOM", "ES2022"], target: "ES2022", emitDecoratorMetadata: true, }, postBuild() { // steps to run after building and before running the tests Deno.copyFileSync(rootResolve("./LICENSE"), npmResolve("./LICENSE")); Deno.copyFileSync( packageResolve("./README.md"), npmResolve("./README.md"), ); }, ...options, }); };
Main Steps
-
Create two subfolders and add some project files
# start from root mkdir packages/module-a cd packages/module-a echo "export const a = 1;" > index.ts echo "# @dnt-mono/module-a" > README.md npm init --scope @dnt-mono --yes # name: @dnt-mono/module-aRepeat the steps to create a
module-bfolder# start from root mkdir packages/module-b cd packages/module-b echo "import { a } from \"@dnt-mono/module-a\";\nexport const b = a + 1;" > index.ts echo "# @dnt-mono/module-b" > README.md npm init --scope @dnt-mono --yes # name: @dnt-mono/module-b pnpm add @dnt-mono/module-a --workspace # add module-a as a dependency -
In this example,
module-bdepends onmodule-a, and we used the specifier@dnt-mono/module-ain the code, so we need some configurations to make the deno language server work correctly. In theimportsfield ofdeno.json, add these configurations:"@dnt-mono/module-a": "./packages/module-a/index.ts", // in imports "@dnt-mono/module-b": "./packages/module-b/index.ts" // in imports -
Next, create the build script and configuration files
-
scripts/build_npm.tsimport { npmBuilder } from "./npmBuilder.ts"; const version = Deno.args[0]; await npmBuilder({ packageDir: import.meta.resolve("../packages/module-a/"), importMap: import.meta.resolve("./import_map.npm.json"), version, }); await npmBuilder({ packageDir: import.meta.resolve("../packages/module-b/"), importMap: import.meta.resolve("./import_map.npm.json"), version, }); -
scripts/import_map.npm.json{ "imports": { "@dnt-mono/module-a": "npm:@dnt-mono/module-a", "@dnt-mono/module-b": "npm:@dnt-mono/module-b" } }
-
-
Then, in your
deno.json, configure the build command:"build": "deno run -A ./scripts/build_npm.ts" // in tasks -
Finally, try executing the build command to create the npm directory
deno task buildNow, you should see the npm directory has been populated with the module-a and module-b folders ready for npm publishing. You can try to publish these npm packages:
pnpm publish -r --no-git-checks --dry-run # you should remove --dry-run for an actual run
How It Works
- We use deno as the language server, which is quite powerful, vastly improved from tsc itself through customized development.
- So here, the package.json is just a “template file” and not a configuration file. The only configuration file that goes into effect during development is deno.json.
- Hence, pnpm is just a tool for the final output built by dnt, meaning it only serves the
npm/*directory. This is also whypnpm-workspaces.yamlis configured as it is. - The
import_map.npm.jsonused in dnt is essential. We can’t usedeno.jsondirectly asimportMapbecausedeno.jsonis configured for the deno language server, whileimport_map.npm.jsonis for dnt/pnpm use. In complex projects, it’s advisable to manage it automatically with a script.
Advanced Tips
In deno development, our philosophy is file-oriented rather than module-oriented. Therefore, if needed, you may want to add this kind of configuration in deno.json:
{
// ...
"imports": {
// ...
"@dnt-mono/module-a": "./packages/module-a/index.ts",
"@dnt-mono/module-a/": "./packages/module-a/src/",
"@dnt-mono/module-b": "./packages/module-b/index.ts",
"@dnt-mono/module-b/": "./packages/module-b/src/",
// ...
},
}
I prefer to put files other than index.ts into a src directory, which aligns more with the style of node projects.
However, remember not to move the
index.tsfile to thesrcdirectory as well, as it could cause exceptions #249.
Then, it’s about the dnt configuration, where you need to iterate over all your files and configure them in the entryPoints:
build({
entryPoints: [
// default entry
{ name: ".", path: packageResolve("./index.ts") },
// src files
ALL_SRC_TS_FILES.map((name) => ({
name: `./${name}`,
path: `./src/${name}`,
})),
],
// ...
});
Now, you can write code like this:
import { xxx } from "@dnt-mono/module-a/xxx.ts";
Points to Note
- Plan your project structure well to avoid cyclic dependencies. If needed, you should configure peerDependencies yourself.
- Don’t self-import within a module.
The language server doesn’t understand that you intend to publish to npm, so even if deno works correctly, your goal is to make it work with node as well.
It is advisable to write lint rules to avoid these mistakes in actual projects.import { a } from "@dnt-mono/module-a"; // don't import module-a in module-a