idea: cross-repo development with a custom external-pnpm: protocol and .pnpmfile.cjs #11442
shrinktofit
started this conversation in
Ideas
Replies: 2 comments
-
|
Here's my /* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-require-imports */
const externalPnpmPackagePlugin = createExternalPnpmPackagePlugin();
module.exports = {
resolvers: [externalPnpmPackagePlugin.resolver],
fetchers: [externalPnpmPackagePlugin.fetcher],
};
function createExternalPnpmPackagePlugin() {
const fs = require('node:fs');
const { join, resolve: resolvePath } = require('node:path');
const cp = require('node:child_process');
const assert = require('node:assert');
async function* readDirRecursive(root, currentR) {
const current = join(root, currentR);
const entries = await fs.promises.readdir(current, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (entry.name === 'node_modules') {
continue;
}
yield* readDirRecursive(root, join(currentR, entry.name));
} else {
yield join(currentR, entry.name);
}
}
}
async function resolveManifest(pkgDir, manifest) {
const packageVersions = await queryWorkspacePackageVersions(pkgDir);
const resolveDependencies = async (dependencies) => {
const resolved = {};
for (const [dep, specifier] of Object.entries(dependencies)) {
if (!specifier.startsWith('workspace:')) {
continue;
}
if (!packageVersions[dep]) {
throw new Error(`Package ${dep} not found in workspace`);
}
resolved[dep] = packageVersions[dep];
}
return resolved;
};
if (manifest.dependencies) {
manifest.dependencies = await resolveDependencies(manifest.dependencies);
}
if (manifest.peerDependencies) {
manifest.peerDependencies = await resolveDependencies(
manifest.peerDependencies,
);
}
return manifest;
}
async function queryWorkspacePackageVersions(pkgDir) {
const stdout = await cp.execSync('pnpm ls -r --depth=0 --json', {
cwd: pkgDir,
shell: true,
encoding: 'utf-8',
});
const listResult = JSON.parse(stdout);
assert.ok(Array.isArray(listResult));
const packageVersions = {};
for (const item of listResult) {
packageVersions[item.name] = item.version;
}
return packageVersions;
}
const specifierPrefix = 'external-pnpm:';
return {
resolver: {
canResolve: (dep) => {
return dep.bareSpecifier.startsWith(specifierPrefix);
},
async resolve(dep, opts) {
const path = dep.bareSpecifier.slice(specifierPrefix.length);
if (!path) {
throw new Error(`Path is required for ${dep.alias}, received '${dep.bareSpecifier}'`);
}
const resolvedPath = resolvePath(path);
return {
id: `${dep.alias}@external-pnpm_${resolvedPath}`,
resolution: {
type: 'custom:external-pnpm',
path: resolvedPath,
},
};
},
},
fetcher: {
canFetch: (pkgId, resolution) => {
return resolution.type === 'custom:external-pnpm';
},
fetch: async (cafs, resolution, opts, fetchers) => {
const filesMap = new Map();
const root = fs.realpathSync(resolution.path);
for await (const file of await readDirRecursive(root, '')) {
filesMap.set(file, join(root, file));
}
const manifest = JSON.parse(
await fs.promises.readFile(join(root, 'package.json')),
);
const resolvedManifest = await resolveManifest(root, manifest);
return {
filesMap,
local: true,
manifest: resolvedManifest,
packageImportMethod: 'hardlink',
};
},
},
};
} |
Beta Was this translation helpful? Give feedback.
0 replies
-
|
@shrinktofit Resolving Feels like this should be a first-class protocol (something like external-workspace:) rather than a hack. Curious any noticeable perf hit from running |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Hey everyone,
I keep running into this workflow pain: I have two separate pnpm monorepos — one “app”, one “lib”. While developing the app, I often need to tweak a package in the lib repo and test the change immediately, without going through the whole publish-and-reinstall dance.
pnpm already has
file:andlink:protocols for local packages, but they don’t quite cut it:link:symlinks the entire package folder, which also drags in its local workspace dependencies. If both repos happen to depend on the same internal package (say@lib/c), the app ends up with two different instances of it. That’s a real problem when that package has side effects (e.g. registering globals).file:would be great because it hardlinks, but pnpm rejectsworkspace:*orcatalog:*protocols inside afile:dependency.So I often resorted to manually copying files — not fun.
With pnpm v11 we can hook into resolution/fetching via
.pnpmfile.cjs, so I hacked together a small plugin to make this smoother:package.json, when I need to test a lib package from the other repo, I temporarily change its version:A
.pnpmfile.cjsplugin intercepts this customexternal-pnpm:protocol.The plugin does two things:
dependenciesandpeerDependencies. For anyworkspace:*(orcatalog:*) it runspnpm ls -r --depth=0inside the lib repo to resolve them to real versions, then replaces them in the manifest before handing it back to pnpm.The result: the external package gets installed cleanly, with all workspace deps resolved and properly deduplicated. No duplicate instances, no protocol errors, and no manual copying.
It’s been a big time saver for me. Curious if others have struggled with this and if there’s a better way — or maybe this could inspire a built-in feature. Happy to share the plugin code if there’s interest.
Cheers.
Beta Was this translation helpful? Give feedback.
All reactions