index.js 6.38 KB
/**
 * Copyright 2018 Google Inc. All Rights Reserved.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *     http://www.apache.org/licenses/LICENSE-2.0
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

const { readFileSync } = require("fs");
const { join } = require("path");
const ejs = require("ejs");
const MagicString = require("magic-string");

const defaultOpts = {
  // A string containing the EJS template for the amd loader. If `undefined`,
  // OMT will use `loader.ejs`.
  loader: readFileSync(join(__dirname, "/loader.ejs"), "utf8"),
  // Use `fetch()` + `eval()` to load dependencies instead of `<script>` tags
  // and `importScripts()`. _This is not CSP compliant, but is required if you
  // want to use dynamic imports in ServiceWorker_.
  useEval: false,
  // A RegExp to find `new Workers()` calls. The second capture group _must_
  // capture the provided file name without the quotes.
  workerRegexp: /new Worker\((["'])(.+?)\1(,[^)]+)?\)/g,
  // Function name to use instead of AMD’s `define`.
  amdFunctionName: "define",
  // A function that determines whether the loader code should be prepended to a
  // certain chunk. Should return true if the load is supposed to be prepended.
  prependLoader: (chunk, workerFiles) =>
    chunk.isEntry || workerFiles.includes(chunk.facadeModuleId),
  // The scheme used when importing workers as a URL.
  urlLoaderScheme: "omt",
  // Silence the warning about ESM being badly supported in workers.
  silenceESMWorkerWarning: false,
};

module.exports = function(opts = {}) {
  opts = Object.assign({}, defaultOpts, opts);

  opts.loader = ejs.render(opts.loader, opts);

  const urlLoaderPrefix = opts.urlLoaderScheme + ":";

  let workerFiles;
  let isEsmOutput = false;
  return {
    name: "off-main-thread",

    async buildStart(options) {
      workerFiles = [];
    },

    outputOptions({ format }) {
      if ((format === "esm" || format === "es") && !opts.silenceESMWorkerWarning) {
        this.warn(
          'Very few browsers support ES modules in Workers. If you want to your code to run in all browsers, set `output.format = "amd";`'
        );
        // In ESM, we never prepend a loader.
        isEsmOutput = true;
      } else if (format !== "amd") {
        this.error(
          `\`output.format\` must either be "amd" or "esm", got "${format}"`
        );
      }
    },

    async resolveId(id, importer) {
      if (!id.startsWith(urlLoaderPrefix)) return;

      const path = id.slice(urlLoaderPrefix.length);
      const resolved = await this.resolve(path, importer);
      if (!resolved) throw Error(`Cannot find module '${path}' from '${importer}'`);
      const newId = resolved.id;

      return urlLoaderPrefix + newId;
    },

    load(id) {
      if (!id.startsWith(urlLoaderPrefix)) return;

      const realId = id.slice(urlLoaderPrefix.length);
      const chunkRef = this.emitFile({ id: realId, type: "chunk" });
      return `export default import.meta.ROLLUP_FILE_URL_${chunkRef};`;
    },

    async transform(code, id) {
      // Copy the regexp as they are stateful and this hook is async.
      const workerRegexp = new RegExp(
        opts.workerRegexp.source,
        opts.workerRegexp.flags
      );
      if (!workerRegexp.test(code)) {
        return;
      }

      const ms = new MagicString(code);
      // Reset the regexp
      workerRegexp.lastIndex = 0;
      while (true) {
        const match = workerRegexp.exec(code);
        if (!match) {
          break;
        }

        const workerFile = match[2];
        let optionsObject = {};
        // Parse the optional options object
        if (match[3] && match[3].length > 0) {
          // FIXME: ooooof!
          optionsObject = new Function(`return ${match[3].slice(1)};`)();
        }
        if (!isEsmOutput) {
          delete optionsObject.type;
        }

        if (!new RegExp("^.*/").test(workerFile)) {
          this.warn(
            `Paths passed to the Worker constructor must be relative or absolute, i.e. start with /, ./ or ../ (just like dynamic import!). Ignoring "${workerFile}".`
          );
          continue;
        }

        const resolvedWorkerFile = (await this.resolve(workerFile, id)).id;
        workerFiles.push(resolvedWorkerFile);
        const chunkRefId = this.emitFile({
          id: resolvedWorkerFile,
          type: "chunk"
        });

        const workerParametersStartIndex = match.index + "new Worker(".length;
        const workerParametersEndIndex =
          match.index + match[0].length - ")".length;

        ms.overwrite(
          workerParametersStartIndex,
          workerParametersEndIndex,
          `import.meta.ROLLUP_FILE_URL_${chunkRefId}, ${JSON.stringify(
            optionsObject
          )}`
        );
      }

      return {
        code: ms.toString(),
        map: ms.generateMap({ hires: true })
      };
    },

    resolveFileUrl(chunk) {
      return `"./${chunk.fileName}"`;
    },

    renderChunk(code, chunk, outputOptions) {
      // We don’t need to do any loader processing when targeting ESM format.
      if (isEsmOutput) {
        return;
      }
      if (outputOptions.banner && outputOptions.banner.length > 0) {
        this.error(
          "OMT currently doesn’t work with `banner`. Feel free to submit a PR at https://github.com/surma/rollup-plugin-off-main-thread"
        );
        return;
      }
      const ms = new MagicString(code);

      // Mangle define() call
      const id = `./${chunk.fileName}`;
      ms.remove(0, "define(".length);
      // If the module does not have any dependencies, it’s technically okay
      // to skip the dependency array. But our minimal loader expects it, so
      // we add it back in.
      if (!code.startsWith("define([")) {
        ms.prepend("[],");
      }
      ms.prepend(`${opts.amdFunctionName}("${id}",`);

      // Prepend loader if it’s an entry point or a worker file
      if (opts.prependLoader(chunk, workerFiles)) {
        ms.prepend(opts.loader);
      }

      return {
        code: ms.toString(),
        map: ms.generateMap({ hires: true })
      };
    }
  };
};