You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

318 lines
11 KiB

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