Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

simplify package path resolution #8186

Draft
wants to merge 16 commits into
base: master
Choose a base branch
from
Draft

simplify package path resolution #8186

wants to merge 16 commits into from

Conversation

turadg
Copy link
Member

@turadg turadg commented Aug 14, 2023

Description

import.meta.resolve returns a string now instead of a promise. https://github.com/wooorm/import-meta-resolve/releases/tag/3.0.0 updates the ponyfill.

This updates to it and goes on to simplify getting paths from packages.

Before

  const autoswapContractUrl = await importMetaResolve(
    '@agoric/zoe/src/contracts/autoswap.js',
    import.meta.url,
  );
  const autoswapContractRoot = new URL(autoswapContractUrl).pathname;

After

  const autoswapContractRoot = pkgAbsPath(
    '@agoric/zoe/src/contracts/autoswap.js',
  );

Where pkgAbsPath is imported from a utility module. It has its own import.meta.url, which is sufficient for resolving packages.

Security Considerations

Scaling Considerations

Documentation Considerations

Testing Considerations

Upgrade Considerations

@turadg turadg changed the title WIP synchronous import.meta.resolve Aug 15, 2023
@turadg turadg changed the title synchronous import.meta.resolve simplify package path resolution Aug 15, 2023
Copy link
Member

@dckc dckc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For resolving paths that are known at design-time, I'm fine with sync access.

But for paths that vary at runtime, let's not dip into sync I/O any more than we must.

* @returns {Promise<string>} the absolute path corresponding to `specPath` if it can be
* @returns {string} the absolute path corresponding to `specPath` if it can be
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a case where the path is dynamic - not determined until runtime. Let's keep it async, please.

Using sync I/O for a sort of linker/loader makes sense, but otherwise, let's not force clients into the world of sync I/O.

@@ -195,31 +194,27 @@ async function resolveSpecFromConfig(referrer, specPath) {
* @param {boolean} expectParameters `true` if the entries should have parameters (for
* example, `true` for `vats` but `false` for bundles).
*/
async function normalizeConfigDescriptor(desc, referrer, expectParameters) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

likewise

* @param {string} specifier
* @returns {string} file system absolute path of the package resource specifier
*/
export const pkgAbsPath = specifier => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear that this is only used with paths that are known at design-time. Let's keep runtime I/O async.

const fullPath = await importMetaResolve(specifier, import.meta.url).then(
u => new URL(u).pathname,
);
const fullPath = pkgAbsPath(specifier);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

presuming the specifier arg is always a design-time constant, sync access is OK here. I'd be OK with copying pkgAbsPath into this module.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference is to inline url.fileURLToPath(importMetaResolve(specifier, import.meta.url)) here with the expectation that this will eventually become url.fileURLToPath(import.meta.resolve(specifier)) when we drop support for Node.js versions that lack import.meta.resolve.

I also encourage a general move toward framing Agoric APIs in terms of URLs instead of paths (as in Compartment Mapper) since they are more portable and obviate any impulse toward making this utility function.

import { resolve } from 'import-meta-resolve';

/**
* @param {string} specifier
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function should not be. Hiding the mechanism of resolution obscures the limitations of its use. I strongly prefer inlining url.filePathToURL(resolve(specifier, import.meta.url)) since this works anywhere without qualification.

If we kept this function, we would need to express the qualifications: it can only be reliably used for absolute module specifiers like @foo/bar and not for relative module specifiers like ./foo.js within this package. The resolution of an absolute specifier is not guaranteed to be consistent between packages, differing version constraints between packages, and varying node_modules layout algorithms.

The reason for these limitations is that it uses its own import.meta.url instead of receiving the referrer specifier as a parameter.

That is to say, if present, this function should not be exported to other packages, which is antithetical to exporting it from tools.js and its usage throughout this change.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolution of an absolute specifier is not guaranteed to be consistent between packages, differing version constraints between packages, and varying node_modules layout algorithms.

This I hadn't considered.

What if this function took parent the way import-meta-resolve does?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this function accepted the import.meta.url from the caller, I would be converted from a “hard no” to a “soft no”, on the grounds that I’d prefer transparency at the call site. url.fileURLToPath(resolve(specifier, import.meta.url)) is very clear and not very long. Just removing the await is an improvement.

@@ -35,7 +35,7 @@
"@endo/far": "^0.2.19",
"@endo/promise-kit": "^0.2.57",
"@endo/stream": "^0.3.26",
"import-meta-resolve": "^2.2.1"
"import-meta-resolve": "^3.0.0"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m ambivalent about upgrading import-meta-resolve to the new synchronous version, even though I’m sure it does degenerate to synchronous I/O (to inspect package.json for imports and exports clauses). The shim is not privy to the information that Node.js collected before evaluating the module in which import.meta.resolve gets expressed.

The native implementations in Node.js and the web of import.meta.resolve do not degenerate to synchronous I/O and I am sure that we can safely virtualize import.meta.resolve for Endo. Although the shim is not able to do this, it does make our code more closely resemble the eventual convergence on the (de-facto but not de-jure standard!) import.meta.resolve.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't parse out the argument against upgrading it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The downside is that the shim will perform synchronous I/O (to read package.json files). The native implementation doesn’t need to read or interpret package.json files because we can be sure Node.js already has them memoized. It had to read them in order to link the surrounding module.

The downside of inducing I/O in these cases is tenuous. It will reduce event loop availability, which is a good reason to avoid them while serving traffic or other operations. If tail latency for a server is not an issue, synchronous I/O tends overall to be faster than concurrent I/O.

I don’t fully grasp an absolute censure for I/O for OCaps. If we’re generally using this mechanism on boot-time or test-time constants, there’s not much risk of an attacker gaining the ability to indirectly tease these programs into reading more package.json files than they strictly need. But this mechanism could induce an exception.

I’d say: be careful with dynamic import, even dynamic resolution. Because it requires care, as a reviewer, I would rather see import.meta.resolve where it’s getting used rather than pkgAbsPath. It raises a flag I need to see.

const fullPath = await importMetaResolve(specifier, import.meta.url).then(
u => new URL(u).pathname,
);
const fullPath = pkgAbsPath(specifier);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference is to inline url.fileURLToPath(importMetaResolve(specifier, import.meta.url)) here with the expectation that this will eventually become url.fileURLToPath(import.meta.resolve(specifier)) when we drop support for Node.js versions that lack import.meta.resolve.

I also encourage a general move toward framing Agoric APIs in terms of URLs instead of paths (as in Compartment Mapper) since they are more portable and obviate any impulse toward making this utility function.

@@ -188,9 +188,7 @@ export const getNodeTestVaultsConfig = async (
bundleDir = 'bundles',
specifier = '@agoric/vm-config/decentral-itest-vaults-config.json',
) => {
const fullPath = await importMetaResolve(specifier, import.meta.url).then(
u => new URL(u).pathname,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m enthusiastic about replacing new URL(u).pathname with url.filePathToURL everywhere it’s found, in order to eventually be portable to Windows! Thank you.

importMetaResolve(`@agoric/vm-config/${configName}`, import.meta.url).then(
u => new URL(u).pathname,
);
pkgAbsPath(`@agoric/vm-config/${configName}`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The correctness of this expression relies on the incidental detail that @agoric/vm-config got installed somewhere accessible to @agoric/swingset-vat. Your pnpm experiment presumably revealed that this is not a reliable incidental detail unless @agoric/vm-config also explicitly depends upon @agoric/swingset-vat, and even then, is not guaranteed unless both of these packages depend on compatible versions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect that peerDependencies will save the day for vm-config.

Copy link
Member

@kriskowal kriskowal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, import.meta.url needs to get threaded to the call site. If there are import specifiers in a config file, import.meta.url needs to be the file URL of the config file. If the config is embedded in a JavaScript file, it should pass its own import.meta.url.

And I would rather see import.meta.url or import.meta.resolve than have them called by a utility function since there are nuances to whether they’re safe to use.

import { resolve } from 'import-meta-resolve';

/**
* @param {string} specifier
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this function accepted the import.meta.url from the caller, I would be converted from a “hard no” to a “soft no”, on the grounds that I’d prefer transparency at the call site. url.fileURLToPath(resolve(specifier, import.meta.url)) is very clear and not very long. Just removing the await is an improvement.

@@ -35,7 +35,7 @@
"@endo/far": "^0.2.19",
"@endo/promise-kit": "^0.2.57",
"@endo/stream": "^0.3.26",
"import-meta-resolve": "^2.2.1"
"import-meta-resolve": "^3.0.0"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The downside is that the shim will perform synchronous I/O (to read package.json files). The native implementation doesn’t need to read or interpret package.json files because we can be sure Node.js already has them memoized. It had to read them in order to link the surrounding module.

The downside of inducing I/O in these cases is tenuous. It will reduce event loop availability, which is a good reason to avoid them while serving traffic or other operations. If tail latency for a server is not an issue, synchronous I/O tends overall to be faster than concurrent I/O.

I don’t fully grasp an absolute censure for I/O for OCaps. If we’re generally using this mechanism on boot-time or test-time constants, there’s not much risk of an attacker gaining the ability to indirectly tease these programs into reading more package.json files than they strictly need. But this mechanism could induce an exception.

I’d say: be careful with dynamic import, even dynamic resolution. Because it requires care, as a reviewer, I would rather see import.meta.resolve where it’s getting used rather than pkgAbsPath. It raises a flag I need to see.

Comment on lines 9 to 16
export const resolvePath = (specifier, base) => {
const href = resolve(specifier, base);
return url.fileURLToPath(href);
};

export const makeResolvePath = base => {
return specifier => resolvePath(specifier, base);
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, this is a good compromise.

@warner I would encourage us to in general migrate as much of our API surface to treating file URLs as first-class, starting with @endo/bundle-source and working our way out. @endo/compartment-mapper APIs are already framed in terms of file URLs.

* @returns {string} file system absolute path of the package resource specifier
*/
export const pkgAbsPath = specifier => {
const href = resolve(specifier, import.meta.url);
export const resolvePath = (specifier, base) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe resolveToPath would be more clear. The arguments are both module specifiers and a path returns.

@kriskowal kriskowal dismissed their stale review August 25, 2023 23:24

Concerns addressed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants