Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/lockfile-version-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@cloudflare/workers-utils": minor
"wrangler": patch
---

Resolve installed dependency versions from lockfiles instead of node_modules

Dependency version resolution during deploys now reads from the project's lockfile (pnpm-lock.yaml, package-lock.json, yarn.lock, or bun.lock) before falling back to node_modules. This improves the accuracy of the dependency metadata reported in deploys and speeds up version lookups for projects with many dependencies. All four major package managers are supported.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { access, readFile } from "node:fs/promises";
import path from "node:path";
import {
createLockfileCache,
getInstalledPackageVersion,
parsePackageJSON,
} from "@cloudflare/workers-utils";
Expand Down Expand Up @@ -64,6 +65,11 @@ export async function collectPackageDependencies(

const result: PackageDependency[] = [];

// Create a function-scoped cache so the lockfile is parsed once and
// shared across all per-package lookups within this single collection
// pass. The cache is garbage-collected when this function returns.
const lockfileCache = createLockfileCache();

for (const [dependencyName, packageJsonVersion] of Object.entries(
allDependencies
)) {
Expand All @@ -82,7 +88,8 @@ export async function collectPackageDependencies(

const installedVersion = getInstalledPackageVersion(
dependencyName,
projectPath
projectPath,
{ cache: lockfileCache }
);

if (!installedVersion) {
Expand Down
5 changes: 4 additions & 1 deletion packages/workers-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@
"@cloudflare/workflows-shared": "workspace:*",
"@types/command-exists": "^1.2.0",
"@types/node": "catalog:default",
"@types/yarnpkg__lockfile": "^1.1.9",
"@vitest/ui": "catalog:default",
"@yarnpkg/lockfile": "^1.1.0",
"ci-info": "catalog:default",
"cloudflare": "^5.2.0",
"command-exists": "catalog:default",
Expand All @@ -74,7 +76,8 @@
"typescript": "catalog:default",
"update-check": "^1.5.4",
"vitest": "catalog:default",
"xdg-app-paths": "^8.3.0"
"xdg-app-paths": "^8.3.0",
"yaml": "^2.8.1"
},
"peerDependencies": {
"vitest": "^4.1.0"
Expand Down
3 changes: 3 additions & 0 deletions packages/workers-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,14 @@ export * from "./errors";
export { assertNever } from "./assert-never";

export {
createLockfileCache,
getPackagePath,
isPackageInstalled,
getInstalledPackageVersion,
} from "./package-resolution";

export type { LockfileCache } from "./package-resolution";

export * from "./constants";

export { mapWorkerMetadataBindings } from "./map-worker-metadata-bindings";
Expand Down
4 changes: 4 additions & 0 deletions packages/workers-utils/src/package-resolution/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { createLockfileCache } from "./lockfiles-resolution";
export { getPackagePath, isPackageInstalled } from "./node-modules";
export { getInstalledPackageVersion } from "./package-resolution";
export type { LockfileCache } from "./lockfiles-resolution";
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as jsoncParser from "jsonc-parser";

/**
* Parses a bun.lock file (JSONC format) and extracts dependency versions.
*
* Package entries in `bun.lock` are keyed by package name, with the value
* being a tuple where the first element is `"name@version"`.
*
* @param content - Raw JSONC content of the bun.lock file
* @returns A map of package names to resolved versions
*/
export function parseBunLockfile(content: string): Map<string, string> {
const lockfile = jsoncParser.parse(content) as BunLockfile;
const versions = new Map<string, string>();

if (!lockfile?.packages) {
return versions;
}

for (const [key, entry] of Object.entries(lockfile.packages)) {
if (!Array.isArray(entry) || entry.length === 0) {
continue;
}
const resolvedId = entry[0] as string;
if (typeof resolvedId !== "string") {
continue;
}

// resolvedId format: "name@version" or "@scope/name@version"
const resolvedName = extractBunPackageName(resolvedId);
const version = extractBunPackageVersion(resolvedId);

if (!version) {
continue;
}

// Alias detection: the key differs from the resolved package name
if (resolvedName && resolvedName !== key) {
continue;
}

versions.set(key, version);
}

return versions;
}

/**
* Extracts the package name from a bun resolved ID string.
*
* @param resolvedId - A string like `"lodash@4.17.21"` or `"@scope/pkg@1.0.0"`
* @returns The package name portion
*/
function extractBunPackageName(resolvedId: string): string | undefined {
const lastAt = resolvedId.lastIndexOf("@");
if (lastAt <= 0) {
return undefined;
}
return resolvedId.slice(0, lastAt);
}

/**
* Extracts the version from a bun resolved ID string.
*
* @param resolvedId - A string like `"lodash@4.17.21"` or `"@scope/pkg@1.0.0"`
* @returns The version portion
*/
function extractBunPackageVersion(resolvedId: string): string | undefined {
const lastAt = resolvedId.lastIndexOf("@");
if (lastAt <= 0) {
return undefined;
}
return resolvedId.slice(lastAt + 1);
}

interface BunLockfile {
packages?: Record<string, unknown[]>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { statSync } from "node:fs";
import { join } from "node:path";
import * as walk from "empathic/walk";

/**
* Lockfile file names in priority order.
* Used for walking up from the project directory to find the nearest lockfile.
*
* `bun.lockb` is binary and cannot be parsed in JS — omitted intentionally.
*/
export const LOCKFILE_NAMES = [
"pnpm-lock.yaml",
"package-lock.json",
"yarn.lock",
"bun.lock",
] as const;

/**
* The name of a lockfile that can be parsed.
*/
export type LockfileName = (typeof LOCKFILE_NAMES)[number];

/**
* Walks up the directory tree from `startDir` to find the nearest lockfile.
*
* At each directory level, checks all lockfile names in priority order before
* ascending to the parent. This ensures the nearest lockfile always wins,
* and name-based priority only breaks ties within the same directory.
*
* @param startDir - The directory to start searching from
* @param opts - Options
* @param opts.last - If set, stop walking after this directory (inclusive).
* Prevents discovery from reaching ancestor lockfiles.
* @returns The lockfile path and its filename, or `undefined` if none is found
*/
export function findLockfile(
startDir: string,
opts: { last?: string } = {}
): { lockfilePath: string; name: LockfileName } | undefined {
const dirs = walk.up(startDir, {
cwd: startDir,
last: opts.last,
});

for (const dir of dirs) {
for (const name of LOCKFILE_NAMES) {
const candidate = join(dir, name);
try {
if (statSync(candidate).isFile()) {
return { lockfilePath: candidate, name };
}
} catch {
// File doesn't exist or isn't accessible — try the next name
}
}
}
return undefined;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
createLockfileCache,
getInstalledVersionsFromLockfile,
} from "./resolve";
export type { LockfileCache } from "./resolve";
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Parses an npm package-lock.json and extracts top-level dependency versions.
*
* Supports lockfileVersion 1, 2, and 3.
*
* @param content - Raw JSON content of the lockfile
* @returns A map of package names to resolved versions
*/
export function parseNpmLockfile(content: string): Map<string, string> {
const lockfile = JSON.parse(content) as NpmLockfile;
const versions = new Map<string, string>();

if (lockfile.packages) {
// lockfileVersion 2 or 3 — top-level deps live under "node_modules/<name>"
for (const [key, entry] of Object.entries(lockfile.packages)) {
if (!key.startsWith("node_modules/")) {
continue;
}
// Skip nested node_modules (transitive deps stored under a parent)
// e.g. "node_modules/foo/node_modules/bar" — only keep top-level
if (key.indexOf("/node_modules/", "node_modules/".length) !== -1) {
continue;
}
// Skip aliases: when `name` is set and differs from the key's package
// name, this is an npm alias (e.g. "my-react": "npm:react@^18")
const pkgName = key.slice("node_modules/".length);
if (entry.name && entry.name !== pkgName) {
continue;
}
if (entry.version) {
versions.set(pkgName, entry.version);
}
}
} else if (lockfile.dependencies) {
// lockfileVersion 1 fallback
for (const [pkgName, entry] of Object.entries(lockfile.dependencies)) {
// Skip aliases: version starts with "npm:"
if (entry.version?.startsWith("npm:")) {
continue;
}
if (entry.version) {
versions.set(pkgName, entry.version);
}
}
}

return versions;
}

interface NpmLockfile {
lockfileVersion?: number;
packages?: Record<string, { version?: string; name?: string }>;
dependencies?: Record<string, { version?: string }>;
}
Loading
Loading