319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
|
/**
|
|||
|
* 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
|
|||
|
};
|
|||
|
}
|
|||
|
};
|
|||
|
};
|