Skip to content
Advertisement

Bundle multiple named AMD modules with dependencies into one JS file (building a web app extension system)

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 of module3)
  • 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 named module3.
  • 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.

Advertisement