Webpack plugin to statically analyze the usage of an exported function?

Viewed 478

To start, please be aware that I am setting up this hypothetical problem intentionally, because it's a real world problem which I am faced with solving, and am not sure it's even possible.

Let's say I have a JavaScript package, called road-fetcher, with an exported function called find, which I can pass a lat/lng coordinate into, and get back the nearest road to that point.

So:

import { find } from 'road-fetcher'

find(36.585940, -95.304241) // output: 'US Route 66'

Let's suppose that a user consuming my road-fetcher app ends up using this find function 200-300x in their web app (all in predictable, non Math.random ways).

Internally, my road-fetcher package is making network requests to my external API. That works fine, but if we keep doing this at runtime, we incur bandwidth and latency costs (whether in browser or server) on each client, that we needn't necessarily. Moreover, maybe all 200 calls are on the same page. Instead, it'd be ideal to generate initial values at build time, and perhaps set some long TTL to revalidate values later on.

It's commonplace to process JavaScript with Webpack, and I'm wondering if it would be possible to statically analyse a user's usage of the find function to find all the permutations.

Ideally I'd want to compile a total list of function calls with their args and then make a network request based on this, during the build step, but even being able to compile a list of all function call args and store that somewhere in the filesystem (not sure, within node_modules, or cwd), to facilitate a single cache-warm step would be sufficient as well.

Having an initial look at the Webpack docs, it seems like evaluateCallExpressionMember may be a starting point, but I'm pretty out of my depth here.

I appreciate this is a contrived example, but it does represent a very real problem which I am trying to simplify here to clearly isolate the issue at hand.

1 Answers

I managed to write a webpack plugin that accepts a function name, and a callback that will be called with the arguments that were passed to the original function.

Here is the repo if you just want to jump into the code

It works, with some limitations:

  1. It only works with ES modules
  2. It does not work if a named import is renamed (e. g.: import { log as logg } from './something'
  3. It does not work if the arguments passed to the function are variables

Results

This is my source code:

import { log } from './helpers/log';

// these will be logged
log(1);
log(2, 3);
log(2, "foo");
log(2, "foo", 4, "bar");
log(2, "foo", 4, "bar", [1, 2, "asd"]);
log(2, "foo", 4, "bar", [1, 2, "asd"], { foo: "bar" }, [
  { a: "asd", b: 123, c: [] },
]);

// this one will not be logged because it's using a variable
const a = [1,2,3];
log(a);

// this one will also not be logged because it's not using `log` exactly
console.log('asd');

This is the webpack config:

const FunctionCallPlugin = require('./webpack-plugins/FunctionCall');

module.exports = {
  plugins: [ new FunctionCallPlugin({ functionName: 'log', callback: ({ arguments: args }) => {
    console.log('`log` function was found and called with the arguments:', args);
    // you can do whatever here, make a http request, write to db, etc
  }})],
}

This is the terminal outputs when running webpack:

āžœ  webpack-plugin-function-invoke git:(master) āœ— yarn webpack
yarn run v1.16.0
warning ..\package.json: No license field
$ D:\Projects\webpack-plugin-function-invoke\node_modules\.bin\webpack
`log` function was found and called with the arguments: [ 1 ]
`log` function was found and called with the arguments: [ 2, 3 ]
`log` function was found and called with the arguments: [ 2, 'foo' ]
`log` function was found and called with the arguments: [ 2, 'foo', 4, 'bar' ]
`log` function was found and called with the arguments: [ 2, 'foo', 4, 'bar', [ 1, 2, 'asd' ] ]
`log` function was found and called with the arguments: [
  2,
  'foo',
  4,
  { foo: 'bar' },
  [ { a: 'asd', b: 123, c: [] } ]
]

How the plugin works

  1. It loops through all webpack chunks, and all modules inside of each chunks to see if they have any dependencies with the specified name. For example, if you are looking for log, it will find any modules that either does import { log } from './somewhere' or import log from './somewhere'
  2. For every modules that have the specified dependencies, it collect all the file paths to the original source code by looking at its fileDependencies
  3. Each of the files' source code are read as string using fs module
  4. The source code are parsed into an AST using babel parser
  5. The AST is traversed and we look into all CallExpression that uses the same specified function name
  6. We collect all the arguments used in the function call
  7. We run the specified callback, while passing the collected arguments

That's it! It is probably not that performant, but hey it works :)

I have put this in a GitHub repository if you are interested in the code. I know that this is not the complete answer to your question, but hopefully it helps!

Related