I’m working on an extension system for my web app. Third-party developers should be able to extend the app by providing named AMD modules exporting constants and functions following a predefined spec and bundled into a single .js JavaScript file.
Example JavaScript bundle:
define('module1', ['exports', 'module3'], (function (exports, module3) { exports.spec = 'http://example.com/spec/extension/v1' exports.onRequest = function (request) { return module3.respond('Hello, World.') } })); define('module2', ['exports', 'module3'], (function (exports, module3) { exports.spec = 'http://example.com/spec/extension/v1' exports.onRequest = function (request) { return module3.respond('Foo. Bar.') } })); define('module3', ['exports'], (function (exports) { exports.respond = function (message) { return { type: 'message', message: message } } }));
In the above example module1
and module2
are extension modules (identified by the spec
export) and module3
is a shared dependency (e.g. coming from an NPM package). Extension bundles will be loaded in a worker within a sandboxed iframe to seal of the untrusted code in the browser.
Example TypeScript source:
// module1.ts import respond from 'module3' export const spec = 'http://example.com/spec/extension/v1' export const onRequest = (request: Request): Response => respond('Hello, World.') // module2.ts import respond from 'module3' export const spec = 'http://example.com/spec/extension/v1' export const onRequest = (request: Request): Response => respond('Foo. Bar.') // module3.ts import dep from 'some-npm-package' export respond = (message: string) => dep.createMessageObject(message)
Here is my list of requirements to bundling:
- All necessary dependencies (e.g. shared module, NPM package logic) must be included in the bundle
- The source code needs to be transpiled to browser compatible code if necessary
- The AMD format is required by the custom extension loader implementation
- The AMD modules must not be anonymous as the module file names are lost while bundling
- No relative paths must be used among dependencies (e.g.
./path/to/module3
instead ofmodule3
) - The result should be one JavaScript bundle, thus ONE JavaScript file and ONE sourcemaps file
What’s the easiest way to do this?
This is the closest solution I found using rollup and the following rollup.config.js
:
import { nodeResolve } from '@rollup/plugin-node-resolve' import { terser } from 'rollup-plugin-terser' import typescript from '@rollup/plugin-typescript' export default { input: [ 'src/module1.ts', 'src/module2.ts' ], output: { dir: 'dist', format: 'amd', sourcemap: true, amd: { autoId: true } }, plugins: [ typescript(), nodeResolve(), terser() ] }
From this I get the desired named AMD modules (one for each entry point and chunk) in separate .js files. Problems:
- Some dependencies are referenced by
./module3
while being namedmodule3
. - The modules appear in separate JavaScript and Sourcemap files instead of being concatenated into a single bundle.
Questions:
Is there an easy fix to the above
rollup.config.js
config to solve the problem?I tried to write a small rollup plugin but I failed to get the final AMD module code within it to concatenate it to a bundle. Only the transpiled code is available to me. In addition I don’t know how to handle sourcemaps during concatenation.
Is there an alternative to rollup better suited to this bundling scenario?
The big picture: Am I completely on the wrong track when it comes to building an extension system? Is AMD the wrong choice?
Advertisement
Answer
I found a way to extend the rollup.config.js
mentioned in the question with a custom concatChunks
rollup plugin to bundle multiple AMD chunks within a single file and having the source maps rendered, too. The only issue I didn’t find an answer to was the relative module names that kept popping up. However, this may be resolved in the AMD loader.
Here’s the full rollup.config.js
that worked for me:
import Concat from 'concat-with-sourcemaps' import glob from 'glob' import typescript from '@rollup/plugin-typescript' import { nodeResolve } from '@rollup/plugin-node-resolve' import { terser } from 'rollup-plugin-terser' const concatChunks = ( fileName = 'bundle.js', sourceMapFileName = 'bundle.js.map' ) => { return { name: 'rollup-plugin-concat-chunks', generateBundle: function (options, bundle, isWrite) { const concat = new Concat(true, fileName, 'n') // Go through each chunk in the bundle let hasSourceMaps = false Object.keys(bundle).forEach(fileId => { const fileInfo = bundle[fileId] if (fileInfo.type === 'chunk') { let hasSourceMap = fileInfo.map !== null hasSourceMaps = hasSourceMaps || hasSourceMap // Concat file content and source maps with bundle concat.add( fileInfo.fileName, fileInfo.code, hasSourceMap ? JSON.stringify(fileInfo.map) : null ) // Prevent single chunks from being emitted delete bundle[fileId] } }) // Emit concatenated chunks this.emitFile({ type: 'asset', name: fileName, fileName: fileName, source: concat.content }) // Emit concatenated source maps, if any if (hasSourceMaps) { this.emitFile({ type: 'asset', name: sourceMapFileName, fileName: sourceMapFileName, source: concat.sourceMap }) } } } } export default { input: glob.sync('./src/*.{ts,js}'), output: { dir: 'dist', format: 'amd', sourcemap: true, amd: { autoId: true } }, plugins: [ typescript(), nodeResolve(), terser(), concatChunks() ] }
Please make sure you npm install
the dependencies referenced in the import statements to make this work.
Considering the big picture, i.e. the extension system itself, I am moving away from a “one AMD module equals one extension/contribution” approach, as current developer tools and JavaScript bundlers are not ready for that (as this question shows). I’ll go with an approach similar to the Visual Studio Code Extension API and will use a single “default” module with an activate
export to register contributions a bundle has to offer. I hope that this will make extension bundling an easy task no matter what tools or languages are being used.