56

I would like to share how to bundle an application that acts as a plugin host and how it can load installed plugins dynamically.

  1. Both the application and the plugins are bundled with Webpack
  2. The application and plugins are compiled and distributed independently.

There are several people on the net who are looking for a solution to this problem:

The solution described here is based on @sokra's Apr 17, 2014 comment on Webpack issue #118 and is slightly adapted in order to work with Webpack 2. https://github.com/webpack/webpack/issues/118

Main points:

  • A plugin needs an ID (or "URI") by which it registers at the backend server, and which is unique to the application.

  • In order to avoid chunk/module ID collisions for every plugin, individual JSONP loader functions will be used for loading the plugin's chunks.

  • Loading a plugin is initiated by dynamically created <script> elements (instead of require()) and let the main application eventually consume the plugin's exports through a JSONP callback.

Note: You may find Webpack's "JSONP" wording misleading as actually no JSON is transferred but the plugin's Javascript wrapped in a "loader function". No padding takes place at server-side.

Building a plugin

A plugin's build configuration uses Webpack's output.library and output.libraryTarget options.

Example plugin configuration:

module.exports = {
  entry: './src/main.js',
  output: {
    path: path.resolve(__dirname, './dist'),
    publicPath: '/' + pluginUri + '/',
    filename: 'js/[name].js',
    library: pluginIdent,
    libraryTarget: 'jsonp'
  },
  ...
}

It's up to the plugin developer to choose an unique ID (or "URI") for the plugin and make it available in the plugin configuration. Here I use the variable pluginURI:

// unique plugin ID (using dots for namespacing)
var pluginUri = 'com.companyX.pluginY'

For the library option you also have to specify an unique name for the plugin. Webpack will use this name when generating the JSONP loader functions. I derive the function name from the plugin URI:

// transform plugin URI into a valid function name
var pluginIdent = "_" + pluginUri.replace(/\./g, '_')

Note that when the library option is set Webpack derives a value for the output.jsonpFunction option automatically.

When building the plugin Webpack generates 3 distribution files:

dist/js/manifest.js
dist/js/vendor.js
dist/js/main.js

Note that vendor.js and main.js are wrapped in JSONP loader functions whose names are taken from output.jsonpFunction and output.library respectively.

Your backend server must serve the distribution files of each installed plugin. For example, my backend server serves the content of a plugin's dist/ directory under the plugin's URI as the 1st path component:

/com.companyX.pluginY/js/manifest.js
/com.companyX.pluginY/js/vendor.js
/com.companyX.pluginY/js/main.js

That's why publicPath is set to '/' + pluginUri + '/' in the example plugin config.

Note: The distribution files can be served as static resources. The backend server is not required to do any padding (the "P" in JSONP). The distribution files are "padded" by Webpack already at build time.

Loading plugins

The main application is supposed to retrieve the list of the installed plugin (URI)s from the backend server.

// retrieved from server
var pluginUris = [
  'com.companyX.pluginX',
  'com.companyX.pluginY',
  'org.organizationX.pluginX',
]

Then load the plugins:

loadPlugins () {
  pluginUris.forEach(pluginUri => loadPlugin(pluginUri, function (exports) {
    // the exports of the plugin's main file are available in `exports`
  }))
}

Now the application has access to the plugin's exports. At this point, the original problem of loading an independently compiled plugin is basically solved :-)

A plugin is loaded by loading its 3 chunks (manifest.js, vendor.js, main.js) in sequence. Once main.js is loaded the callback will be invoked.

function loadPlugin (pluginUri, mainCallback) {
  installMainCallback(pluginUri, mainCallback)
  loadPluginChunk(pluginUri, 'manifest', () =>
    loadPluginChunk(pluginUri, 'vendor', () =>
      loadPluginChunk(pluginUri, 'main')
    )
  )
}

Callback invocation works by defining a global function whose name equals output.library as in the plugin config. The application derives that name from the pluginUri (just like we did in the plugin config already).

function installMainCallback (pluginUri, mainCallback) {
  var _pluginIdent = pluginIdent(pluginUri)
  window[_pluginIdent] = function (exports) {
    delete window[_pluginIdent]
    mainCallback(exports)
  }
}

A chunk is loaded by dynamically creating a <script> element:

function loadPluginChunk (pluginUri, name, callback) {
  return loadScript(pluginChunk(pluginUri, name), callback)
}

function loadScript (url, callback) {
  var script = document.createElement('script')
  script.src = url
  script.onload = function () {
    document.head.removeChild(script)
    callback && callback()
  }
  document.head.appendChild(script)
}

Helper:

function pluginIdent (pluginUri) {
  return '_' + pluginUri.replace(/\./g, '_')
}

function pluginChunk (pluginUri, name) {
  return '/' + pluginUri + '/js/' + name + '.js'
}
λuser
  • 893
  • 8
  • 14
Jörg Richter
  • 667
  • 7
  • 8
  • 2
    Any chance you could make a repo which demoes this concept? – Maciej Gurban May 05 '17 at 21:23
  • At the moment I can't make a repo, sorry. Let me know which part you have problems with. I'll improve the article then. – Jörg Richter May 07 '17 at 09:08
  • Hi, thank you for sharing this! I have just one question: is there any way to inject dependencies from the original webpack bundle? I want to load react components dynamically and without adding it to the bundle that is loaded dynamically. – AlexanderF Jun 05 '17 at 20:15
  • 1
    @AlexanderF A plugin could export a constructor function which expects the needed dependencies as arguments, e.g. the `React` object. When the main application loads the plugins it would call each plugin's constructor function and pass the dependencies. The plugin would not import `react` (and in the plugin's `package.json` would be no `react` dependency). So react would not be bundled with the plugin. The React object is injected via the constructor function. – Jörg Richter Jun 06 '17 at 23:53
  • @JörgRichter Thanks a lot, this is a pretty straightforward solution to the problem. – AlexanderF Jun 07 '17 at 08:35
  • @JörgRichter: Did this work for you? For me, it does not work. I get an error saying that packJsonpplugin is not defined. When I make sure that this function is put into the lazy loaded bundle, I cannot access things from the origin bundle. – Manfred Steyer Jun 11 '17 at 21:03
  • @JörgRichter When building with that output config I only get `main.js`, is that normal? Also, how would you test serving these bundles locally? – Trent Nov 08 '17 at 21:49
  • Can we split bundle module wise during webpack build? – Ankit Sharma Nov 09 '17 at 10:20
  • @Trent `manifest.js` and `vendor.js` are generated by the `CommonsChunkPlugin` (in order to split app and vendor code). It's config is not shown in the article. Without that config you only get `main.js`. That's normal. Sorry for the confusion. For serving you can use any Webserver. – Jörg Richter Nov 09 '17 at 21:36
  • @Trent After installing the callback just load `main.js`. That works for me. – Jörg Richter Nov 09 '17 at 21:45
  • 5
    Is there a question buried in here someplace? While interesting, this is a Q&A site. This post should be removed and hosted on a more appropriate forum for discussion. – Scot Matson Dec 22 '17 at 01:13
  • 3
    To expand on what @ScotMatson wrote ("This post should be removed and hosted on a more appropriate forum for discussion."): Either that or split into a question part and the answer portion provided in an answer. – Agi Hammerthief Jan 24 '18 at 15:19
  • @JörgRichter can you please explain when and where do you load the plugins and how do you access to your imported modules? – Kangcor Mar 12 '18 at 10:34
  • @Kangcor Your frontend loads the plugins as part of its startup procedure. See my frontend's `loadPluginsFromServer()` function: https://github.com/jri/deepamehta/blob/master/modules/dm4-webclient/src/main/js/plugin-manager.js#L66 While plugin initialization the frontend could inject global dependencies (e.g. React) via plugin constructor function. See @AlexanderF's question and my answer above. Let me know if you need more detailed information. – Jörg Richter Mar 15 '18 at 01:54
  • @Kangcor For a demo plugin repo see https://github.com/jri/dm5-plugin-template The plugin main file (the Webpack entry point) is `src/main/js/plugin.js`. Whatever you export from there is available in the plugin loader's main callback (See section "Loading plugins"). Your plugin can export anything relevant to your frontend, e.g. a (constructor) function for dependency injection. See also `webpack.config.js`. Note: *frontend* and *main application* are used synonymously here. The plugin loader is part of your frontend. – Jörg Richter Mar 15 '18 at 02:26
  • The approach described in the article works also with Webpack 3 and Webpack 4. – Jörg Richter Mar 15 '18 at 02:44
  • 1
    this seems to be useful content but is not in the proper format for this site. It should probably be posted as an answer to [the original question](https://stackoverflow.com/questions/40035445/how-to-expose-objects-from-webpack-bundle-and-inject-external-libs-into-compiled), instead. – gMale May 17 '18 at 14:24
  • I'm voting to close this question as off-topic because it belongs as answer [here](https://stackoverflow.com/questions/40035445/how-to-expose-objects-from-webpack-bundle-and-inject-external-libs-into-compiled), it's not a standalone question. – deceze May 22 '18 at 07:39

0 Answers0