index.js 11.2 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.
 */

"use strict";

const { readFileSync } = require("fs");
const { join } = require("path");
const ejs = require("ejs");
const MagicString = require("magic-string");
const json5 = require("json5");
// See https://github.com/surma/rollup-plugin-off-main-thread/issues/49
const matchAll = require("string.prototype.matchall");

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,
  // 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
};

// A regexp to find static `new Worker` invocations.
// Matches `new Worker(...file part...`
// File part matches one of:
// - '...'
// - "..."
// - `import.meta.url`
// - new URL('...', import.meta.url)
// - new URL("...", import.meta.url)
const workerRegexpForTransform = /(new\s+Worker\()\s*(('.*?'|".*?")|import\.meta\.url|new\s+URL\(('.*?'|".*?"),\s*import\.meta\.url\))/gs;

// A regexp to find static `new Worker` invocations we've rewritten during the transform phase.
// Matches `new Worker(...file part..., ...options...`.
// File part matches one of:
// - new URL('...', module.uri)
// - new URL("...", module.uri)
const workerRegexpForOutput = /new\s+Worker\(new\s+URL\((?:'.*?'|".*?"),\s*module\.uri\)\s*(,([^)]+))/gs;

let longWarningAlreadyShown = 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 = () => { throw new Error("outputOptions hasn't been called yet") };
  return {
    name: "off-main-thread",

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

    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) {
      const ms = new MagicString(code);

      const replacementPromises = [];

      for (const match of matchAll(code, workerRegexpForTransform)) {
        let [
          fullMatch,
          partBeforeArgs,
          workerSource,
          directWorkerFile,
          workerFile,
        ] = match;

        const workerParametersEndIndex = match.index + fullMatch.length;
        const matchIndex = match.index;
        const workerParametersStartIndex = matchIndex + partBeforeArgs.length;

        let workerIdPromise;
        if (workerSource === "import.meta.url") {
          // Turn the current file into a chunk
          workerIdPromise = Promise.resolve(id);
        } else {
          // Otherwise it's a string literal either directly or in the `new URL(...)`.
          if (directWorkerFile) {
            const fullMatchWithOpts = `${fullMatch}, …)`;
            const fullReplacement = `new Worker(new URL(${directWorkerFile}, import.meta.url), …)`;

            if (!longWarningAlreadyShown) {
              this.warn(
                `rollup-plugin-off-main-thread:
\`${fullMatchWithOpts}\` suggests that the Worker should be relative to the document, not the script.
In the bundler, we don't know what the final document's URL will be, and instead assume it's a URL relative to the current module.
This might lead to incorrect behaviour during runtime.
If you did mean to use a URL relative to the current module, please change your code to the following form:
\`${fullReplacement}\`
This will become a hard error in the future.`,
                matchIndex
              );
              longWarningAlreadyShown = true;
            } else {
              this.warn(
                `rollup-plugin-off-main-thread: Treating \`${fullMatchWithOpts}\` as \`${fullReplacement}\``,
                matchIndex
              );
            }
            workerFile = directWorkerFile;
          }

          // Cut off surrounding quotes.
          workerFile = workerFile.slice(1, -1);

          if (!/^\.{1,2}\//.test(workerFile)) {
            let isError = false;
            if (directWorkerFile) {
              // If direct worker file, it must be in `./something` form.
              isError = true;
            } else {
              // If `new URL(...)` it can be in `new URL('something', import.meta.url)` form too,
              // so just check it's not absolute.
              if (/^(\/|https?:)/.test(workerFile)) {
                isError = true;
              } else {
                // If it does turn out to be `new URL('something', import.meta.url)` form,
                // prepend `./` so that it becomes valid module specifier.
                workerFile = `./${workerFile}`;
              }
            }
            if (isError) {
              this.warn(
                `Paths passed to the Worker constructor must be relative to the current file, i.e. start with ./ or ../ (just like dynamic import!). Ignoring "${workerFile}".`,
                matchIndex
              );
              continue;
            }
          }

          workerIdPromise = this.resolve(workerFile, id).then(res => res.id);
        }

        replacementPromises.push(
          (async () => {
            const resolvedWorkerFile = await workerIdPromise;
            workerFiles.push(resolvedWorkerFile);
            const chunkRefId = this.emitFile({
              id: resolvedWorkerFile,
              type: "chunk"
            });

            ms.overwrite(
              workerParametersStartIndex,
              workerParametersEndIndex,
              `new URL(import.meta.ROLLUP_FILE_URL_${chunkRefId}, import.meta.url)`
            );
          })()
        );
      }

      // No matches found.
      if (!replacementPromises.length) {
        return;
      }

      // Wait for all the scheduled replacements to finish.
      await Promise.all(replacementPromises);

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

    resolveFileUrl(chunk) {
      return JSON.stringify(chunk.relativePath);
    },

    outputOptions({ format }) {
      if (format === "esm" || format === "es") {
        if (!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}"`
        );
      } else {
        isEsmOutput = () => false;
      }
    },

    renderDynamicImport() {
      if (isEsmOutput()) return;

      // In our loader, `require` simply return a promise directly.
      // This is tinier and simpler output than the Rollup's default.
      return {
        left: 'require(',
        right: ')'
      };
    },

    resolveImportMeta(property) {
      if (isEsmOutput()) return;

      if (property === 'url') {
        // In our loader, `module.uri` is already fully resolved
        // so we can emit something shorter than the Rollup's default.
        return `module.uri`;
      }
    },

    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);

      for (const match of matchAll(code, workerRegexpForOutput)) {
        let [fullMatch, optionsWithCommaStr, optionsStr] = match;
        let options;
        try {
          options = json5.parse(optionsStr);
        } catch (e) {
          // If we couldn't parse the options object, maybe it's something dynamic or has nested
          // parentheses or something like that. In that case, treat it as a warning
          // and not a hard error, just like we wouldn't break on unmatched regex.
          console.warn("Couldn't match options object", fullMatch, ": ", e);
          continue;
        }
        if (!("type" in options)) {
          // Nothing to do.
          continue;
        }
        delete options.type;
        const replacementEnd = match.index + fullMatch.length;
        const replacementStart = replacementEnd - optionsWithCommaStr.length;
        optionsStr = json5.stringify(options);
        optionsWithCommaStr = optionsStr === "{}" ? "" : `, ${optionsStr}`;
        ms.overwrite(
          replacementStart,
          replacementEnd,
          optionsWithCommaStr
        );
      }

      // Mangle define() call
      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}(`);

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

      const newCode = ms.toString();
      const hasCodeChanged = code !== newCode;
      return {
        code: newCode,
        // Avoid generating sourcemaps if possible as it can be a very expensive operation
        map: hasCodeChanged ? ms.generateMap({ hires: true }) : null
      };
    }
  };
};