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