From 209992fa30a21acbe9761e31ec325c199d2755d8 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 14 Jul 2022 02:38:21 +0200 Subject: [PATCH 1/5] nodejs builder: process package.json in-place no need for package-json.bak, just put the original data aside in the JSON. --- src/subsystems/nodejs/builders/granular/default.nix | 1 - .../nodejs/builders/granular/fix-package.py | 13 +++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/subsystems/nodejs/builders/granular/default.nix b/src/subsystems/nodejs/builders/granular/default.nix index 7e8c976087..035fea09eb 100644 --- a/src/subsystems/nodejs/builders/granular/default.nix +++ b/src/subsystems/nodejs/builders/granular/default.nix @@ -368,7 +368,6 @@ rm $nodeModules/$packageName/package.json.old # run python script (see comment above): - cp package.json package.json.bak python $fixPackage \ || \ # exit code 3 -> the package is incompatible to the current platform diff --git a/src/subsystems/nodejs/builders/granular/fix-package.py b/src/subsystems/nodejs/builders/granular/fix-package.py index 9c60b4b06f..d67c5e4c1c 100644 --- a/src/subsystems/nodejs/builders/granular/fix-package.py +++ b/src/subsystems/nodejs/builders/granular/fix-package.py @@ -14,7 +14,7 @@ changed = False -# fail if platform incompatible +# fail if platform incompatible - should not happen due to filters if 'os' in package_json: platform = sys.platform if platform not in package_json['os']\ @@ -39,7 +39,7 @@ f"{package_json.get('version')} -> {version}", file=sys.stderr ) - changed = True + package_json['origVersion'] = package_json['version'] package_json['version'] = version @@ -48,6 +48,7 @@ # as NPM install will otherwise re-fetch these if 'dependencies' in package_json: dependencies = package_json['dependencies'] + depsChanged = False # dependencies can be a list or dict for pname in dependencies: if 'bundledDependencies' in package_json\ @@ -58,17 +59,21 @@ f"WARNING: Dependency {pname} wanted but not available. Ignoring.", file=sys.stderr ) + depsChanged = True continue version =\ 'unknown' if isinstance(dependencies, list) else dependencies[pname] if available_deps[pname] != version: - version = available_deps[pname] - changed = True + depsChanged = True print( f"package.json: Pinning version '{version}' to '{available_deps[pname]}'" f" for dependency '{pname}'", file=sys.stderr ) + if depsChanged: + changed = True + package_json['dependencies'] = available_deps + package_json['origDependencies'] = dependencies # write changes to package.json if changed: From a97263fdf3e5536afcde42ad5f3f3f673a016557 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 14 Jul 2022 19:30:27 +0200 Subject: [PATCH 2/5] dream-lock: change cyclic deps api make it easier to implement cycle management --- src/default.nix | 2 +- .../haskell/builders/default/default.nix | 21 ++++-- .../builders/build-rust-package/default.nix | 2 +- .../rust/builders/crane/default.nix | 5 +- src/templates/builders/default.nix | 21 ++++-- src/utils/dream-lock.nix | 65 ++++++++++++++++++- 6 files changed, 95 insertions(+), 21 deletions(-) diff --git a/src/default.nix b/src/default.nix index 50457e277f..11a365107c 100644 --- a/src/default.nix +++ b/src/default.nix @@ -284,7 +284,7 @@ in let getSourceSpec getRoot getDependencies - getCyclicDependencies + getCyclicHelpers defaultPackageName defaultPackageVersion packages diff --git a/src/subsystems/haskell/builders/default/default.nix b/src/subsystems/haskell/builders/default/default.nix index ef09ed63c2..a843f8b778 100644 --- a/src/subsystems/haskell/builders/default/default.nix +++ b/src/subsystems/haskell/builders/default/default.nix @@ -14,15 +14,22 @@ in { }: { ### FUNCTIONS # AttrSet -> Bool) -> AttrSet -> [x] - getCyclicDependencies, # name: version: -> [ {name=; version=; } ] - getDependencies, # name: version: -> [ {name=; version=; } ] - getSource, # name: version: -> store-path + # name: version: -> helpers + getCyclicHelpers, + # name: version: -> [ {name=; version=; } ] + getDependencies, + # name: version: -> store-path + getSource, # to get information about the original source spec - getSourceSpec, # name: version: -> {type="git"; url=""; hash="";} + # name: version: -> {type="git"; url=""; hash="";} + getSourceSpec, ### ATTRIBUTES - subsystemAttrs, # attrset - defaultPackageName, # string - defaultPackageVersion, # string + # attrset + subsystemAttrs, + # string + defaultPackageName, + # string + defaultPackageVersion, # all exported (top-level) package names and versions # attrset of pname -> version, packages, diff --git a/src/subsystems/rust/builders/build-rust-package/default.nix b/src/subsystems/rust/builders/build-rust-package/default.nix index 7ad90c891d..6b2a6ff278 100644 --- a/src/subsystems/rust/builders/build-rust-package/default.nix +++ b/src/subsystems/rust/builders/build-rust-package/default.nix @@ -9,7 +9,7 @@ subsystemAttrs, defaultPackageName, defaultPackageVersion, - getCyclicDependencies, + getCyclicHelpers, getDependencies, getSource, getSourceSpec, diff --git a/src/subsystems/rust/builders/crane/default.nix b/src/subsystems/rust/builders/crane/default.nix index a708c84c0c..cfd577dd99 100644 --- a/src/subsystems/rust/builders/crane/default.nix +++ b/src/subsystems/rust/builders/crane/default.nix @@ -9,7 +9,7 @@ subsystemAttrs, defaultPackageName, defaultPackageVersion, - getCyclicDependencies, + getCyclicHelpers, getDependencies, getSource, getSourceSpec, @@ -27,7 +27,8 @@ then externals.crane toolchain else if toolchain ? cargo then - externals.crane { + externals.crane + { cargoHostTarget = toolchain.cargo; cargoBuildBuild = toolchain.cargo; } diff --git a/src/templates/builders/default.nix b/src/templates/builders/default.nix index cadb984f86..befd3c01e1 100644 --- a/src/templates/builders/default.nix +++ b/src/templates/builders/default.nix @@ -11,15 +11,22 @@ }: { ### FUNCTIONS # AttrSet -> Bool) -> AttrSet -> [x] - getCyclicDependencies, # name: version: -> [ {name=; version=; } ] - getDependencies, # name: version: -> [ {name=; version=; } ] - getSource, # name: version: -> store-path + # name: version: -> helpers + getCyclicHelpers, + # name: version: -> [ {name=; version=; } ] + getDependencies, + # name: version: -> store-path + getSource, # to get information about the original source spec - getSourceSpec, # name: version: -> {type="git"; url=""; hash="";} + # name: version: -> {type="git"; url=""; hash="";} + getSourceSpec, ### ATTRIBUTES - subsystemAttrs, # attrset - defaultPackageName, # string - defaultPackageVersion, # string + # attrset + subsystemAttrs, + # string + defaultPackageName, + # string + defaultPackageVersion, # all exported (top-level) package names and versions # attrset of pname -> version, packages, diff --git a/src/utils/dream-lock.nix b/src/utils/dream-lock.nix index e766527fb7..f94b431a85 100644 --- a/src/utils/dream-lock.nix +++ b/src/utils/dream-lock.nix @@ -136,9 +136,68 @@ (dep: ! b.elem dep cyclicDependencies."${pname}"."${version}" or []) dependencyGraph."${pname}"."${version}" or []; - getCyclicDependencies = pname: version: - cyclicDependencies."${pname}"."${version}" or []; + # inverted cyclicDependencies { name.version = parent } + cyclicParents = with l; + foldAttrs (c: acc: acc // (listToAttrs [(nameValuePair c.version c.cyclic)])) {} (flatten (mapAttrsToList (cyclicName: cyclicVersions: + mapAttrsToList (cyclicVersion: cycleeDeps: + map (cycleeDep: (listToAttrs [ + ( + nameValuePair cycleeDep.name + { + version = cycleeDep.version; + cyclic = { + name = cyclicName; + version = cyclicVersion; + }; + } + ) + ])) + cycleeDeps) + cyclicVersions) + cyclicDependencies)); + + getCyclicHelpers = name: version: let + # [ {name; version} ] + cycleeDeps = cyclicDependencies."${name}"."${version}" or []; + + # {name: {version: true}} + cycleeMap = lib.foldAttrs (depVersion: acc: + acc + // (lib.listToAttrs [ + { + name = depVersion; + value = true; + } + ])) {} + cycleeDeps; + + cyclicParent = cyclicParents."${name}"."${version}" or null; + isCyclee = depName: depVersion: cycleeMap."${depName}"."${depVersion}" or false; + isThisCycleeFor = depName: depVersion: + cyclicParent + == { + name = depName; + version = depVersion; + }; + replaceCyclees = deps: + with l; + filter (d: d != null) + (map (d: let + parent = cyclicParents."${d.name}"."${d.version}" or null; + in + if parent != null + then + if parent == cyclicParent + # These packages will be part of their parent package + then null + else parent // {replaces = d;} + else d) + deps); + # TODO better name + in { + inherit cycleeDeps cyclicParent isCyclee isThisCycleeFor replaceCyclees; + }; getRoot = pname: version: let spec = getSourceSpec pname version; in @@ -157,7 +216,7 @@ defaultPackageName defaultPackageVersion subsystemAttrs - getCyclicDependencies + getCyclicHelpers getDependencies getSourceSpec getRoot From 16d63e6a488f1628f6cab5c91fd303056d818cfb Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 11 Aug 2022 14:36:02 +0200 Subject: [PATCH 3/5] nodejs builder: implement tree-of-symlinks - works out-of-the-box, no node|tsc settings necessary - optimal use of storage, composable - handles cyclic dependencies by co-locating cycles Changes: - remove now-unnecessary code - store binaries in bin/ NixOS standard location, and .bin should only be used for dependencies, not the main package - in a package, .bin is now a symlink to bin - in bin name, strip .js ending for string case --- .../nodejs/builders/granular/default.nix | 338 ++++++++++++------ .../nodejs/builders/granular/devShell.nix | 62 +--- .../nodejs/builders/granular/install-deps.py | 212 ----------- .../nodejs/builders/granular/link-bins.py | 5 +- .../builders/granular/tsconfig-to-json.js | 19 - 5 files changed, 255 insertions(+), 381 deletions(-) delete mode 100644 src/subsystems/nodejs/builders/granular/install-deps.py delete mode 100644 src/subsystems/nodejs/builders/granular/tsconfig-to-json.js diff --git a/src/subsystems/nodejs/builders/granular/default.nix b/src/subsystems/nodejs/builders/granular/default.nix index 035fea09eb..b52c906b91 100644 --- a/src/subsystems/nodejs/builders/granular/default.nix +++ b/src/subsystems/nodejs/builders/granular/default.nix @@ -1,3 +1,11 @@ +# TODO if build script in main, run with devModules +# TODO split build in multiple derivations +# - first derivation: +# - unpack without modules +# - if main, optional build step with devModules (discover with runcmd) +# - optional npm install w/ all deps + source-only cyclic +# - only inject deps when needed to prevent rebuilds. Remove deps after build. +# - second derivation: link with modules. Cyclic deps are joined. {...}: { type = "pure"; @@ -14,15 +22,23 @@ ... }: { # Funcs - # AttrSet -> Bool) -> AttrSet -> [x] - getCyclicDependencies, # name: version: -> [ {name=; version=; } ] - getDependencies, # name: version: -> [ {name=; version=; } ] - getSource, # name: version: -> store-path + # name: version: -> helpers + getCyclicHelpers, + # name: version: -> [ {name=; version=; } ] + getDependencies, + # name: version: -> store-path + getSource, + # name: version: -> {type="git"; url=""; hash="";} + extra values from npm packages + getSourceSpec, # Attributes - subsystemAttrs, # attrset - defaultPackageName, # string - defaultPackageVersion, # string - packages, # list + # attrset + subsystemAttrs, + # string + defaultPackageName, + # string + defaultPackageVersion, + # list + packages, # attrset of pname -> versions, # where versions is a list of version strings packageVersions, @@ -43,11 +59,11 @@ (args.packages."${name}" or null) == version; nodejs = - if args ? nodejs - then args.nodejs - else + args.nodejs + or ( pkgs."nodejs-${builtins.toString nodejsVersion}_x" - or (throw "Could not find nodejs version '${nodejsVersion}' in pkgs"); + or (throw "Could not find nodejs version '${nodejsVersion}' in pkgs") + ); nodeSources = runCommandLocal "node-sources" {} '' tar --no-same-owner --no-same-permissions -xf ${nodejs.src} @@ -104,7 +120,7 @@ local ver ver="v$(cat $electronDist/version | tr -d '\n')" mkdir $TMP/$ver - cp $electronHeaders $TMP/$ver/node-$ver-headers.tar.gz + cp --no-preserve=mode $electronHeaders $TMP/$ver/node-$ver-headers.tar.gz # calc checksums cd $TMP/$ver @@ -115,8 +131,7 @@ python -m http.server 45034 --directory $TMP & # copy electron distribution - cp -r $electronDist $TMP/electron - chmod -R +w $TMP/electron + cp -r --no-preserve=mode $electronDist $TMP/electron # configure electron toolchain ${pkgs.jq}/bin/jq ".build.electronDist = \"$TMP/electron\"" package.json \ @@ -159,15 +174,141 @@ # Generates a derivation for a specific package name + version makePackage = name: version: let - pname = lib.replaceStrings ["@" "/"] ["__at__" "__slash__"] name; - - deps = getDependencies name version; - - nodeDeps = - lib.forEach - deps - (dep: allPackages."${dep.name}"."${dep.version}"); - + pname = name; + + rawDeps = getDependencies name version; + inherit (getCyclicHelpers name version) cycleeDeps cyclicParent isCyclee isThisCycleeFor replaceCyclees; + + # cycles + # for nodejs, we need to copy any cycles into a single package together + # getCyclicHelpers already cut the cycles for us, into one cyclic (e.g. eslint) and many cyclee (e.g. eslint-util) + # when a package is cyclic: + # - the cyclee deps should not be in the cyclic/node_modules folder + # - the cyclee deps need to be copied into the package next to cyclic + # so node can find them all together + # when a package is cyclee: + # - the cyclic dep should not be in the cyclee/node_modules folder + # when a dep is cyclee: + # - the dep path should point into the cyclic parent + + # Keep only the deps we can install, assume it all works out + deps = let + myOS = with stdenv.targetPlatform; + if isLinux + then "linux" + else if isDarwin + then "darwin" + else ""; + in + replaceCyclees (lib.filter + ( + dep: let + p = allPackages."${dep.name}"."${dep.version}"; + s = p.sourceInfo; + in + # this dep is a cyclee + !(isCyclee dep.name dep.version) + # this dep is not for this os + && ((s.os or null == null) || lib.any (o: o == myOS) s.os) + # this package is a cyclee + && !(isThisCycleeFor dep.name dep.version) + ) + rawDeps); + + nodePkgs = + l.map + (dep: let + pkg = allPackages."${dep.name}"."${dep.version}"; + in + if dep ? replaces + then pkg // {packageName = dep.replaces.name;} + else pkg) + deps; + cycleePkgs = + l.map + (dep: allPackages."${dep.name}"."${dep.version}") + cycleeDeps; + + # Derivation building the ./node_modules directory in isolation. + makeModules = { + withDev ? false, + withOptionals ? true, + }: let + isMain = isMainPackage name version; + # These flags will only be present if true. Also, dev deps are required for non-main packages + myDeps = + lib.filter + (dep: let + s = dep.sourceInfo; + in + (withOptionals || !(s.optional or false)) + && (!isMain || (withDev || !(s.dev or false)))) + nodePkgs; + in + if lib.length myDeps == 0 + then null + else + pkgs.runCommandLocal "node_modules-${pname}" {} '' + shopt -s nullglob + set -e + + mkdir $out + + function doLink() { + local name=$(basename $1) + local target="$2/$name" + if [ -e "$target" ]; then + local link=$(readlink $target) + if [ "$link" = $1 ]; then + # cyclic dep, all ok + return + fi + echo "Cannot overwrite $target (-> $link) with $1 - incorrect cycle! Versions issue?" >&2 + exit 1 + fi + ln -s $1 $target + } + + function linkDep() { + local pkg=$1 + local name=$2 + # special case for namespaced modules + if [[ $name == @* ]]; then + local namespace=$(dirname $name) + mkdir -p $out/$namespace + doLink $pkg/lib/node_modules/$name $out/$namespace + else + doLink $pkg/lib/node_modules/$name $out + fi + } + + ${l.toString (l.map + (d: "linkDep ${l.toString d} ${d.packageName}\n") + myDeps)} + + # symlink module executables to ./node_modules/.bin + mkdir $out/.bin + for dep in ${l.toString myDeps}; do + # We assume dotfiles are not public binaries + for b in $dep/bin/*; do + if [ -L "$b" ]; then + # when these relative symlinks, make absolute + # last one wins (-sf) + ln -sf $(readlink -f $b) $out/.bin/$(basename $b) + else + # e.g. wrapped binary + ln -sf $b $out/.bin/$(basename $b) + fi + done + done + # remove empty .bin + rmdir $out/.bin || true + ''; + prodModules = makeModules {withDev = false;}; + # if noDev was used, these are just the prod modules + devModules = makeModules {withDev = true;}; + + # TODO why is this needed? Seems to work without passthruDeps = l.listToAttrs (l.forEach deps @@ -207,7 +348,6 @@ inherit dependenciesJson electronHeaders - nodeDeps nodeSources version ; @@ -216,15 +356,11 @@ inherit pname; - passthru.dependencies = passthruDeps; + # TODO why is this needed? It works without it? + # passthru.dependencies = passthruDeps; passthru.devShell = import ./devShell.nix { - inherit - mkShell - nodejs - packageName - pkg - ; + inherit mkShell nodejs devModules; }; /* @@ -232,10 +368,11 @@ reduces errors with build tooling that doesn't cope well with symlinking. */ - installMethod = - if isMainPackage name version - then "copy" - else "symlink"; + # TODO implement copy and make configurable + # installMethod = + # if isMainPackage name version + # then "copy" + # else "symlink"; electronAppDir = "."; @@ -249,7 +386,7 @@ buildInputs = [jq nodejs python3]; # prevents running into ulimits - passAsFile = ["dependenciesJson" "nodeDeps"]; + passAsFile = ["dependenciesJson"]; preConfigurePhases = ["d2nLoadFuncsPhase" "d2nPatchPhase"]; @@ -261,12 +398,6 @@ # (see comments below on d2nPatchPhase) fixPackage = "${./fix-package.py}"; - # script to install (symlink or copy) dependencies. - installDeps = "${./install-deps.py}"; - - # python script to link bin entries from package.json - linkBins = "${./link-bins.py}"; - # costs performance and doesn't seem beneficial in most scenarios dontStrip = true; @@ -282,14 +413,9 @@ continue fi echo "copying $f" - chmod +wx $(dirname "$f") - mv "$f" "$f.bak" - mkdir "$f" - if [ -n "$(ls -A "$f.bak/")" ]; then - cp -r "$f.bak"/* "$f/" - chmod -R +w $f - fi - rm "$f.bak" + l=$(readlink -f $f) + rm -f "$f" + cp -r --no-preserve=mode "$l" "$f" done } ''; @@ -328,8 +454,9 @@ # Figure out what directory has been unpacked export packageDir="$(find . -maxdepth 1 -type d | tail -1)" + # TODO why is this needed? + # find "$packageDir" -type d -exec chmod u+x {} \; # Restore write permissions - find "$packageDir" -type d -exec chmod u+x {} \; chmod -R u+w -- "$packageDir" # Move the extracted tarball into the output folder @@ -345,28 +472,30 @@ mv -- "$strippedName" "$sourceRoot" fi + # provide bin, we'll remove it if unused + mkdir $out/bin + # We keep the binaries in /bin but node uses .bin + # Symlink so that wrapper scripts etc work + ln -s ../../bin $nodeModules/.bin + runHook postUnpack ''; # The python script wich is executed in this phase: # - ensures that the package is compatible to the current system + # (if not already filtered above with os prop from translator) # - ensures the main version in package.json matches the expected # - pins dependency versions in package.json # (some npm commands might otherwise trigger networking) # - creates symlinks for executables declared in package.json + # - Any usage of 'link:' in deps will be replaced with the exact version # Apart from that: - # - Any usage of 'link:' in package.json is replaced with 'file:' # - If package-lock.json exists, it is deleted, as it might conflict # with the parent package-lock.json. d2nPatchPhase = '' # delete package-lock.json as it can lead to conflicts rm -f package-lock.json - # repair 'link:' -> 'file:' - mv $nodeModules/$packageName/package.json $nodeModules/$packageName/package.json.old - cat $nodeModules/$packageName/package.json.old | sed 's!link:!file\:!g' > $nodeModules/$packageName/package.json - rm $nodeModules/$packageName/package.json.old - # run python script (see comment above): python $fixPackage \ || \ @@ -379,48 +508,48 @@ else exit 1 fi - - # configure typescript - if [ -f ./tsconfig.json ] \ - && node -e 'require("typescript")' &>/dev/null; then - node ${./tsconfig-to-json.js} - ${pkgs.jq}/bin/jq ".compilerOptions.preserveSymlinks = true" tsconfig.json \ - | ${pkgs.moreutils}/bin/sponge tsconfig.json - fi ''; - # - installs dependencies into the node_modules directory - # - adds executables of direct node module dependencies to PATH - # - adds the current node module to NODE_PATH + # - links dependencies into the node_modules directory + adds bin to PATH # - sets HOME=$TMPDIR, as this is required by some npm scripts - # TODO: don't install dev dependencies. Load into NODE_PATH instead configurePhase = '' runHook preConfigure - # symlink sub dependencies as well as this imitates npm better - python $installDeps - - echo "Symlinking transitive executables to $nodeModules/.bin" - for dep in ${l.toString nodeDeps}; do - binDir=$dep/lib/node_modules/.bin - if [ -e $binDir ]; then - for bin in $(ls $binDir/); do - mkdir -p $nodeModules/.bin - - # symlink might have been already created by install-deps.py - # if installMethod=copy was selected - if [ ! -e $nodeModules/.bin/$bin ]; then - ln -s $binDir/$bin $nodeModules/.bin/$bin + ${ + if prodModules != null + then '' + if [ -L $sourceRoot/node_modules ] || [ -e $sourceRoot/node_modules ]; then + echo Warning: The source $sourceRoot includes a node_modules directory. Replacing. >&2 + rm -rf $sourceRoot/node_modules + fi + ln -s ${prodModules} $sourceRoot/node_modules + if [ -d ${prodModules}/.bin ]; then + export PATH="$PATH:$sourceRoot/node_modules/.bin" + fi + '' + else "" + } + ${ + # Here we copy cyclee deps into the cyclehead node_modules + # so the cyclic deps can find each other + if cycleePkgs != [] + then '' + for dep in ${l.toString cycleePkgs}; do + # We must copy everything so Node finds it + # Let's hope that clashing names are just duplicates + # keep write perms with no-preserve + cp -rf --no-preserve=mode $dep/lib/node_modules/* $nodeModules + if [ -d $dep/bin ]; then + # this copies symlinks as-is, so they will point to the + # local target when relative, and module-local links + # are made relative by nixpkgs post-build + # last one wins (-f) + cp -af --no-preserve=mode $dep/bin/. $out/bin/. fi done - fi - done - - # add bin path entries collected by python script - export PATH="$PATH:$nodeModules/.bin" - - # add dependencies to NODE_PATH - export NODE_PATH="$NODE_PATH:$nodeModules/$packageName/node_modules" + '' + else "" + } export HOME=$TMPDIR @@ -429,6 +558,7 @@ # Runs the install command which defaults to 'npm run postinstall'. # Allows using custom install command by overriding 'buildScript'. + # TODO this logic supposes a build script, which is not documented buildPhase = '' runHook preBuild @@ -449,15 +579,9 @@ elif [ -n "$runBuild" ] && [ "$(jq '.scripts.build' ./package.json)" != "null" ]; then npm run build else - if [ "$(jq '.scripts.preinstall' ./package.json)" != "null" ]; then - npm --production --offline --nodedir=$nodeSources run preinstall - fi - if [ "$(jq '.scripts.install' ./package.json)" != "null" ]; then - npm --production --offline --nodedir=$nodeSources run install - fi - if [ "$(jq '.scripts.postinstall' ./package.json)" != "null" ]; then - npm --production --offline --nodedir=$nodeSources run postinstall - fi + npm --omit=dev --offline --nodedir=$nodeSources run --if-present preinstall + npm --omit=dev --offline --nodedir=$nodeSources run --if-present install + npm --omit=dev --offline --nodedir=$nodeSources run --if-present postinstall fi runHook postBuild @@ -470,6 +594,15 @@ echo "Symlinking bin entries from package.json" python $linkBins + if rmdir $out/bin 2>/dev/null; then + # we didn't install any binaries + rm $nodeModules/.bin + else + # make sure binaries are executable, following symlinks + # ignore failures from symlinks pointing to other pkgs + chmod a+x $out/bin/* 2>/dev/null || true + fi + echo "Symlinking manual pages" if [ -d "$nodeModules/$packageName/man" ] then @@ -494,7 +627,8 @@ ''; }); in - pkg; + pkg + // {sourceInfo = getSourceSpec name version;}; in outputs; } diff --git a/src/subsystems/nodejs/builders/granular/devShell.nix b/src/subsystems/nodejs/builders/granular/devShell.nix index 82b69c556b..b1b92e509d 100644 --- a/src/subsystems/nodejs/builders/granular/devShell.nix +++ b/src/subsystems/nodejs/builders/granular/devShell.nix @@ -17,51 +17,23 @@ with a fully reproducible copy again. { mkShell, nodejs, - packageName, - pkg, + devModules, }: mkShell { - buildInputs = [ - nodejs - ]; - shellHook = let - /* - This uses the existig package derivation, and modifies it, to - disable all phases but the one which creates the ./node_modules. - - The result is a derivation only generating the node_modules and - .bin directories. - - TODO: This is be a bit hacky and could be abstracted better - TODO: Don't always delete all of ./node_modules. Only overwrite - missing or changed modules. - */ - nodeModulesDrv = pkg.overrideAttrs (old: { - buildPhase = ":"; - installMethod = "copy"; - dontPatch = true; - dontBuild = true; - dontInstall = true; - dontFixup = true; - # the configurePhase fails if these variables are not set - d2nPatchPhase = '' - nodeModules=$out/lib/node_modules - mkdir -p $nodeModules/$packageName - cd $nodeModules/$packageName - ''; - }); - nodeModulesDir = "${nodeModulesDrv}/lib/node_modules/${packageName}/node_modules"; - binDir = "${nodeModulesDrv}/lib/node_modules/.bin"; - in '' - # create the ./node_modules directory - rm -rf ./node_modules - mkdir -p ./node_modules/.bin - cp -r ${nodeModulesDir}/* ./node_modules/ - for link in $(ls ${binDir}); do - target=$(readlink ${binDir}/$link | cut -d'/' -f4-) - ln -s ../$target ./node_modules/.bin/$link - done - chmod -R +w ./node_modules - export PATH="$PATH:$(realpath ./node_modules)/.bin" - ''; + buildInputs = [nodejs]; + # TODO implement copy, maybe + shellHook = + if devModules != null + then '' + # create the ./node_modules directory + if [ -e ./node_modules ] && [ ! -L ./node_modules ]; then + echo -e "\nFailed creating the ./node_modules symlink to '${devModules}'" + echo -e "\n./node_modules already exists and is a directory, which means it is managed by another program. Please delete ./node_modules first and re-enter the dev shell." + else + rm -f ./node_modules + ln -s ${devModules} ./node_modules + export PATH="$PATH:$(realpath ./node_modules)/.bin" + fi + '' + else ""; } diff --git a/src/subsystems/nodejs/builders/granular/install-deps.py b/src/subsystems/nodejs/builders/granular/install-deps.py deleted file mode 100644 index 9d74e09924..0000000000 --- a/src/subsystems/nodejs/builders/granular/install-deps.py +++ /dev/null @@ -1,212 +0,0 @@ -import json -import os -import pathlib -import shutil -import subprocess as sp -import sys - - -pname = os.environ.get('packageName') -version = os.environ.get('version') -bin_dir = f"{os.path.abspath('..')}/.bin" -root = f"{os.path.abspath('.')}/node_modules" -package_json_cache = {} - - -with open(os.environ.get("nodeDepsPath")) as f: - nodeDeps = f.read().split() - -def get_package_json(path): - if path not in package_json_cache: - if not os.path.isfile(f"{path}/package.json"): - return None - with open(f"{path}/package.json") as f: - package_json_cache[path] = json.load(f) - return package_json_cache[path] - -def install_direct_dependencies(): - if not os.path.isdir(root): - os.mkdir(root) - with open(os.environ.get('nodeDepsPath')) as f: - deps = f.read().split() - for dep in deps: - if os.path.isdir(f"{dep}/lib/node_modules"): - for module in os.listdir(f"{dep}/lib/node_modules"): - # ignore hidden directories - if module[0] == ".": - continue - if module[0] == '@': - for submodule in os.listdir(f"{dep}/lib/node_modules/{module}"): - pathlib.Path(f"{root}/{module}").mkdir(exist_ok=True) - print(f"installing: {module}/{submodule}") - origin =\ - os.path.realpath(f"{dep}/lib/node_modules/{module}/{submodule}") - if not os.path.exists(f"{root}/{module}/{submodule}"): - os.symlink(origin, f"{root}/{module}/{submodule}") - else: - print(f"installing: {module}") - origin = os.path.realpath(f"{dep}/lib/node_modules/{module}") - if not os.path.isdir(f"{root}/{module}"): - os.symlink(origin, f"{root}/{module}") - else: - print(f"already exists: {root}/{module}") - - -def collect_dependencies(root, depth): - if not os.path.isdir(root): - return [] - dirs = os.listdir(root) - - currentDeps = [] - for d in dirs: - if d.rpartition('/')[-1].startswith('@'): - subdirs = os.listdir(f"{root}/{d}") - for sd in subdirs: - cur_dir = f"{root}/{d}/{sd}" - currentDeps.append(f"{cur_dir}") - else: - cur_dir = f"{root}/{d}" - currentDeps.append(cur_dir) - - if depth == 0: - return currentDeps - else: - depsOfDeps =\ - map(lambda dep: collect_dependencies(f"{dep}/node_modules", depth - 1), currentDeps) - result = [] - for deps in depsOfDeps: - result += deps - return result - - -def symlink_sub_dependencies(): - for dep in collect_dependencies(root, 1): - # compute module path - d1, d2 = dep.split('/')[-2:] - if d1.startswith('@'): - path = f"{root}/{d1}/{d2}" - else: - path = f"{root}/{d2}" - - # check for collision - if os.path.isdir(path): - continue - - # create parent dir - pathlib.Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True) - - # symlink dependency - os.symlink(os.path.realpath(dep), path) - - -# create symlinks for executables (bin entries from package.json) -def symlink_bin(bin_dir, package_location, package_json, force=False): - if package_json and 'bin' in package_json and package_json['bin']: - bin = package_json['bin'] - - def link(name, relpath): - source = f'{bin_dir}/{name}' - sourceDir = os.path.dirname(source) - # create parent dir - pathlib.Path(sourceDir).mkdir(parents=True, exist_ok=True) - dest = os.path.relpath(f'{package_location}/{relpath}', sourceDir) - print(f"symlinking executable. dest: {dest}; source: {source}") - if force and os.path.exists(source): - os.remove(source) - if not os.path.exists(source): - os.symlink(dest, source) - - if isinstance(bin, str): - name = package_json['name'].split('/')[-1] - link(name, bin) - - else: - for name, relpath in bin.items(): - link(name, relpath) - - -# checks if dependency is already installed in the current or parent dir. -def dependency_satisfied(root, pname, version): - if root == "/nix/store": - return False - - parent = os.path.dirname(root) - - if os.path.isdir(f"{root}/{pname}"): - package_json_file = f"{root}/{pname}/package.json" - if os.path.isfile(package_json_file): - if version == get_package_json(f"{root}/{pname}").get('version'): - return True - - return dependency_satisfied(parent, pname, version) - - -# transforms symlinked dependencies into real copies -def symlinks_to_copies(node_modules): - sp.run(f"chmod +wx {node_modules}".split()) - for dep in collect_dependencies(node_modules, 0): - - # only handle symlinks to directories - if not os.path.islink(dep) or os.path.isfile(dep): - continue - - d1, d2 = dep.split('/')[-2:] - if d1[0] == '@': - pname = f"{d1}/{d2}" - sp.run(f"chmod +wx {node_modules}/{d1}".split()) - else: - pname = d2 - - package_json = get_package_json(dep) - if package_json is not None: - version = package_json['version'] - if dependency_satisfied(os.path.dirname(node_modules), pname, version): - os.remove(dep) - continue - - print(f"copying {dep}") - os.rename(dep, f"{dep}.bac") - os.mkdir(dep) - contents = os.listdir(f"{dep}.bac") - if contents != []: - for node in contents: - if os.path.isdir(f"{dep}.bac/{node}"): - shutil.copytree(f"{dep}.bac/{node}", f"{dep}/{node}", symlinks=True) - if os.path.isdir(f"{dep}/node_modules"): - symlinks_to_copies(f"{dep}/node_modules") - else: - shutil.copy(f"{dep}.bac/{node}", f"{dep}/{node}") - os.remove(f"{dep}.bac") - symlink_bin(f"{bin_dir}", dep, package_json) - - -def symlink_direct_bins(): - deps = [] - package_json_file = get_package_json(f"{os.path.abspath('.')}") - - if package_json_file: - if 'devDependencies' in package_json_file and package_json_file['devDependencies']: - for dep,_ in package_json_file['devDependencies'].items(): - deps.append(dep) - if 'dependencies' in package_json_file and package_json_file['dependencies']: - for dep,_ in package_json_file['dependencies'].items(): - deps.append(dep) - - for name in deps: - package_location = f"{root}/{name}" - package_json = get_package_json(package_location) - symlink_bin(f"{bin_dir}", package_location, package_json, force=True) - - -# install direct deps -install_direct_dependencies() - -# symlink non-colliding deps -symlink_sub_dependencies() - -# symlinks to copies -if os.environ.get('installMethod') == 'copy': - symlinks_to_copies(root) - -# symlink direct deps bins -symlink_direct_bins() diff --git a/src/subsystems/nodejs/builders/granular/link-bins.py b/src/subsystems/nodejs/builders/granular/link-bins.py index 26c28629d9..8d45b8a0bc 100644 --- a/src/subsystems/nodejs/builders/granular/link-bins.py +++ b/src/subsystems/nodejs/builders/granular/link-bins.py @@ -28,14 +28,13 @@ def link(name, relpath): os.symlink(dest, source) if isinstance(bin, str): - name = package_json['name'].split('/')[-1] + # take name and remove .js extension from scripts + name = (package_json['name'].split('/')[-1]).rsplit('.js', 1)[0] link(name, bin) else: for name, relpath in bin.items(): link(name, relpath) -# symlink current packages executables to $nodeModules/.bin -symlink_bin(f'{out}/lib/node_modules/.bin/', package_json) # symlink current packages executables to $out/bin symlink_bin(f'{out}/bin/', package_json) diff --git a/src/subsystems/nodejs/builders/granular/tsconfig-to-json.js b/src/subsystems/nodejs/builders/granular/tsconfig-to-json.js deleted file mode 100644 index 1ccd3db042..0000000000 --- a/src/subsystems/nodejs/builders/granular/tsconfig-to-json.js +++ /dev/null @@ -1,19 +0,0 @@ -try { - console.log(require.resolve("typescript")); -} catch(e) { - console.error("typescript is not found"); - process.exit(e.code); -} - -const ts = require("typescript") -const fs = require('fs') - -try { - const data = fs.readFileSync('tsconfig.json', 'utf8') -} catch (err) { - console.error(err) -} - -config = ts.parseConfigFileTextToJson(data) -newdata = JSON.stringify(config) -fs.writeFileSync('tsconfig.json', newdata); From 2afcbec163689783dbeecd9ec51db505a5f4bbcd Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Wed, 10 Aug 2022 22:14:51 +0200 Subject: [PATCH 4/5] nodejs builder: only build when needed This provides a mild speedup. If we know that there are no install scripts (thanks to npm), don't run all the setup. Also removed the link: -> file: sed invocation since that should only matter in dependencies, and they are replaced with exact versions by the python script. --- .../nodejs/builders/granular/default.nix | 60 ++++++++---- .../nodejs/builders/granular/fix-package.py | 96 ++++++++++--------- 2 files changed, 88 insertions(+), 68 deletions(-) diff --git a/src/subsystems/nodejs/builders/granular/default.nix b/src/subsystems/nodejs/builders/granular/default.nix index b52c906b91..84f4a0b407 100644 --- a/src/subsystems/nodejs/builders/granular/default.nix +++ b/src/subsystems/nodejs/builders/granular/default.nix @@ -173,6 +173,7 @@ ''; # Generates a derivation for a specific package name + version + # TODO if noInstall return derivation with minimal build deps, less rebuilds makePackage = name: version: let pname = name; @@ -344,6 +345,16 @@ then null else pkgs."electron_${electronVersionMajor}".headers; + sourceInfo = let + e = getSourceSpec name version; + try = builtins.tryEval (builtins.deepSeq e e); + in + if try.success + then try.value + else {}; + hasInstall = !(sourceInfo.noInstall or false); + isMain = isMainPackage name version; + pkg = produceDerivation name (stdenv.mkDerivation rec { inherit dependenciesJson @@ -370,14 +381,14 @@ */ # TODO implement copy and make configurable # installMethod = - # if isMainPackage name version + # if isMain # then "copy" # else "symlink"; electronAppDir = "."; # only run build on the main package - runBuild = isMainPackage name version; + runBuild = isMain; src = getSource name version; @@ -391,8 +402,9 @@ preConfigurePhases = ["d2nLoadFuncsPhase" "d2nPatchPhase"]; # can be overridden to define alternative install command - # (defaults to 'npm run postinstall') + # (defaults to npm install steps) buildScript = null; + shouldBuild = hasInstall || buildScript != null || electronHeaders != null; # python script to modify some metadata to support installation # (see comments below on d2nPatchPhase) @@ -493,6 +505,10 @@ # - If package-lock.json exists, it is deleted, as it might conflict # with the parent package-lock.json. d2nPatchPhase = '' + if [ -z "$shouldBuild" ] && [ -n "$runBuild" ] && [ "$(jq '.scripts.build' ./package.json)" != "null" ]; then + shouldBuild=1 + fi + # delete package-lock.json as it can lead to conflicts rm -f package-lock.json @@ -562,26 +578,28 @@ buildPhase = '' runHook preBuild - # execute electron-rebuild - if [ -n "$electronHeaders" ]; then - echo "executing electron-rebuild" - ${electron-rebuild} - fi + if [ -n "$shouldBuild" ]; then + # execute electron-rebuild + if [ -n "$electronHeaders" ]; then + echo "executing electron-rebuild" + ${electron-rebuild} + fi - # execute install command - if [ -n "$buildScript" ]; then - if [ -f "$buildScript" ]; then - $buildScript + # execute install command + if [ -n "$buildScript" ]; then + if [ -f "$buildScript" ]; then + $buildScript + else + eval "$buildScript" + fi + elif [ -n "$runBuild" ]; then + # by default, only for top level packages, `npm run build` is executed + npm run --if-present build else - eval "$buildScript" + npm --omit=dev --offline --nodedir=$nodeSources run --if-present preinstall + npm --omit=dev --offline --nodedir=$nodeSources run --if-present install + npm --omit=dev --offline --nodedir=$nodeSources run --if-present postinstall fi - # by default, only for top level packages, `npm run build` is executed - elif [ -n "$runBuild" ] && [ "$(jq '.scripts.build' ./package.json)" != "null" ]; then - npm run build - else - npm --omit=dev --offline --nodedir=$nodeSources run --if-present preinstall - npm --omit=dev --offline --nodedir=$nodeSources run --if-present install - npm --omit=dev --offline --nodedir=$nodeSources run --if-present postinstall fi runHook postBuild @@ -628,7 +646,7 @@ }); in pkg - // {sourceInfo = getSourceSpec name version;}; + // {inherit sourceInfo;}; in outputs; } diff --git a/src/subsystems/nodejs/builders/granular/fix-package.py b/src/subsystems/nodejs/builders/granular/fix-package.py index d67c5e4c1c..76905c6aab 100644 --- a/src/subsystems/nodejs/builders/granular/fix-package.py +++ b/src/subsystems/nodejs/builders/granular/fix-package.py @@ -11,6 +11,7 @@ package_json = json.load(f) out = os.environ.get('out') +shouldBuild = os.environ.get('shouldBuild') changed = False @@ -25,55 +26,56 @@ ) exit(3) -# replace version -# If it is a github dependency referred by revision, -# we can not rely on the version inside the package.json. -# In case of an 'unknown' version coming from the dream lock, -# do not override the version from package.json -version = os.environ.get("version") -if version not in ["unknown", package_json.get('version')]: - print( - "WARNING: The version of this package defined by its package.json " - "doesn't match the version expected by dream2nix." - "\n -> Replacing version in package.json: " - f"{package_json.get('version')} -> {version}", - file=sys.stderr - ) - package_json['origVersion'] = package_json['version'] - package_json['version'] = version +if shouldBuild != '': + # replace version + # If it is a github dependency referred by revision, + # we can not rely on the version inside the package.json. + # In case of an 'unknown' version coming from the dream lock, + # do not override the version from package.json + version = os.environ.get("version") + if version not in ["unknown", package_json.get('version')]: + print( + "WARNING: The version of this package defined by its package.json " + "doesn't match the version expected by dream2nix." + "\n -> Replacing version in package.json: " + f"{package_json.get('version')} -> {version}", + file=sys.stderr + ) + package_json['origVersion'] = package_json['version'] + package_json['version'] = version -# pinpoint exact versions -# This is mostly needed to replace git references with exact versions, -# as NPM install will otherwise re-fetch these -if 'dependencies' in package_json: - dependencies = package_json['dependencies'] - depsChanged = False - # dependencies can be a list or dict - for pname in dependencies: - if 'bundledDependencies' in package_json\ - and pname in package_json['bundledDependencies']: - continue - if pname not in available_deps: - print( - f"WARNING: Dependency {pname} wanted but not available. Ignoring.", - file=sys.stderr - ) - depsChanged = True - continue - version =\ - 'unknown' if isinstance(dependencies, list) else dependencies[pname] - if available_deps[pname] != version: - depsChanged = True - print( - f"package.json: Pinning version '{version}' to '{available_deps[pname]}'" - f" for dependency '{pname}'", - file=sys.stderr - ) - if depsChanged: - changed = True - package_json['dependencies'] = available_deps - package_json['origDependencies'] = dependencies + # pinpoint exact versions + # This is mostly needed to replace git references with exact versions, + # as NPM install will otherwise re-fetch these + if 'dependencies' in package_json: + dependencies = package_json['dependencies'] + depsChanged = False + # dependencies can be a list or dict + for pname in dependencies: + if 'bundledDependencies' in package_json\ + and pname in package_json['bundledDependencies']: + continue + if pname not in available_deps: + print( + f"WARNING: Dependency {pname} wanted but not available. Ignoring.", + file=sys.stderr + ) + depsChanged = True + continue + version =\ + 'unknown' if isinstance(dependencies, list) else dependencies[pname] + if available_deps[pname] != version: + depsChanged = True + print( + f"package.json: Pinning version '{version}' to '{available_deps[pname]}'" + f" for dependency '{pname}'", + file=sys.stderr + ) + if depsChanged: + changed = True + package_json['dependencies'] = available_deps + package_json['origDependencies'] = dependencies # write changes to package.json if changed: From b8d816a0b67130ebbd40f100118b51e2c8274414 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Wed, 10 Aug 2022 21:05:34 +0200 Subject: [PATCH 5/5] nodejs builder: provide binaries from deep deps npm does this too --- src/subsystems/nodejs/builders/granular/default.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/subsystems/nodejs/builders/granular/default.nix b/src/subsystems/nodejs/builders/granular/default.nix index 84f4a0b407..344b400c91 100644 --- a/src/subsystems/nodejs/builders/granular/default.nix +++ b/src/subsystems/nodejs/builders/granular/default.nix @@ -541,6 +541,9 @@ ln -s ${prodModules} $sourceRoot/node_modules if [ -d ${prodModules}/.bin ]; then export PATH="$PATH:$sourceRoot/node_modules/.bin" + # pass down transitive binaries, like npm does + # all links are absolute so we can just copy + cp -af --no-preserve=mode ${prodModules}/.bin/. $out/bin/. fi '' else ""