| 1 | /**
|
|---|
| 2 | * Copyright 2018 Google Inc. All Rights Reserved.
|
|---|
| 3 | * Licensed under the Apache License, Version 2.0 (the "License");
|
|---|
| 4 | * you may not use this file except in compliance with the License.
|
|---|
| 5 | * You may obtain a copy of the License at
|
|---|
| 6 | * http://www.apache.org/licenses/LICENSE-2.0
|
|---|
| 7 | * Unless required by applicable law or agreed to in writing, software
|
|---|
| 8 | * distributed under the License is distributed on an "AS IS" BASIS,
|
|---|
| 9 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|---|
| 10 | * See the License for the specific language governing permissions and
|
|---|
| 11 | * limitations under the License.
|
|---|
| 12 | */
|
|---|
| 13 |
|
|---|
| 14 | "use strict";
|
|---|
| 15 |
|
|---|
| 16 | const { readFileSync } = require("fs");
|
|---|
| 17 | const { join } = require("path");
|
|---|
| 18 | const ejs = require("ejs");
|
|---|
| 19 | const MagicString = require("magic-string");
|
|---|
| 20 | const json5 = require("json5");
|
|---|
| 21 | // See https://github.com/surma/rollup-plugin-off-main-thread/issues/49
|
|---|
| 22 | const matchAll = require("string.prototype.matchall");
|
|---|
| 23 |
|
|---|
| 24 | const defaultOpts = {
|
|---|
| 25 | // A string containing the EJS template for the amd loader. If `undefined`,
|
|---|
| 26 | // OMT will use `loader.ejs`.
|
|---|
| 27 | loader: readFileSync(join(__dirname, "/loader.ejs"), "utf8"),
|
|---|
| 28 | // Use `fetch()` + `eval()` to load dependencies instead of `<script>` tags
|
|---|
| 29 | // and `importScripts()`. _This is not CSP compliant, but is required if you
|
|---|
| 30 | // want to use dynamic imports in ServiceWorker_.
|
|---|
| 31 | useEval: false,
|
|---|
| 32 | // Function name to use instead of AMD’s `define`.
|
|---|
| 33 | amdFunctionName: "define",
|
|---|
| 34 | // A function that determines whether the loader code should be prepended to a
|
|---|
| 35 | // certain chunk. Should return true if the load is supposed to be prepended.
|
|---|
| 36 | prependLoader: (chunk, workerFiles) =>
|
|---|
| 37 | chunk.isEntry || workerFiles.includes(chunk.facadeModuleId),
|
|---|
| 38 | // The scheme used when importing workers as a URL.
|
|---|
| 39 | urlLoaderScheme: "omt",
|
|---|
| 40 | // Silence the warning about ESM being badly supported in workers.
|
|---|
| 41 | silenceESMWorkerWarning: false
|
|---|
| 42 | };
|
|---|
| 43 |
|
|---|
| 44 | // A regexp to find static `new Worker` invocations.
|
|---|
| 45 | // Matches `new Worker(...file part...`
|
|---|
| 46 | // File part matches one of:
|
|---|
| 47 | // - '...'
|
|---|
| 48 | // - "..."
|
|---|
| 49 | // - `import.meta.url`
|
|---|
| 50 | // - new URL('...', import.meta.url)
|
|---|
| 51 | // - new URL("...", import.meta.url)
|
|---|
| 52 | const workerRegexpForTransform = /(new\s+Worker\()\s*(('.*?'|".*?")|import\.meta\.url|new\s+URL\(('.*?'|".*?"),\s*import\.meta\.url\))/gs;
|
|---|
| 53 |
|
|---|
| 54 | // A regexp to find static `new Worker` invocations we've rewritten during the transform phase.
|
|---|
| 55 | // Matches `new Worker(...file part..., ...options...`.
|
|---|
| 56 | // File part matches one of:
|
|---|
| 57 | // - new URL('...', module.uri)
|
|---|
| 58 | // - new URL("...", module.uri)
|
|---|
| 59 | const workerRegexpForOutput = /new\s+Worker\(new\s+URL\((?:'.*?'|".*?"),\s*module\.uri\)\s*(,([^)]+))/gs;
|
|---|
| 60 |
|
|---|
| 61 | let longWarningAlreadyShown = false;
|
|---|
| 62 |
|
|---|
| 63 | module.exports = function(opts = {}) {
|
|---|
| 64 | opts = Object.assign({}, defaultOpts, opts);
|
|---|
| 65 |
|
|---|
| 66 | opts.loader = ejs.render(opts.loader, opts);
|
|---|
| 67 |
|
|---|
| 68 | const urlLoaderPrefix = opts.urlLoaderScheme + ":";
|
|---|
| 69 |
|
|---|
| 70 | let workerFiles;
|
|---|
| 71 | let isEsmOutput = () => { throw new Error("outputOptions hasn't been called yet") };
|
|---|
| 72 | return {
|
|---|
| 73 | name: "off-main-thread",
|
|---|
| 74 |
|
|---|
| 75 | async buildStart(options) {
|
|---|
| 76 | workerFiles = [];
|
|---|
| 77 | },
|
|---|
| 78 |
|
|---|
| 79 | async resolveId(id, importer) {
|
|---|
| 80 | if (!id.startsWith(urlLoaderPrefix)) return;
|
|---|
| 81 |
|
|---|
| 82 | const path = id.slice(urlLoaderPrefix.length);
|
|---|
| 83 | const resolved = await this.resolve(path, importer);
|
|---|
| 84 | if (!resolved)
|
|---|
| 85 | throw Error(`Cannot find module '${path}' from '${importer}'`);
|
|---|
| 86 | const newId = resolved.id;
|
|---|
| 87 |
|
|---|
| 88 | return urlLoaderPrefix + newId;
|
|---|
| 89 | },
|
|---|
| 90 |
|
|---|
| 91 | load(id) {
|
|---|
| 92 | if (!id.startsWith(urlLoaderPrefix)) return;
|
|---|
| 93 |
|
|---|
| 94 | const realId = id.slice(urlLoaderPrefix.length);
|
|---|
| 95 | const chunkRef = this.emitFile({ id: realId, type: "chunk" });
|
|---|
| 96 | return `export default import.meta.ROLLUP_FILE_URL_${chunkRef};`;
|
|---|
| 97 | },
|
|---|
| 98 |
|
|---|
| 99 | async transform(code, id) {
|
|---|
| 100 | const ms = new MagicString(code);
|
|---|
| 101 |
|
|---|
| 102 | const replacementPromises = [];
|
|---|
| 103 |
|
|---|
| 104 | for (const match of matchAll(code, workerRegexpForTransform)) {
|
|---|
| 105 | let [
|
|---|
| 106 | fullMatch,
|
|---|
| 107 | partBeforeArgs,
|
|---|
| 108 | workerSource,
|
|---|
| 109 | directWorkerFile,
|
|---|
| 110 | workerFile,
|
|---|
| 111 | ] = match;
|
|---|
| 112 |
|
|---|
| 113 | const workerParametersEndIndex = match.index + fullMatch.length;
|
|---|
| 114 | const matchIndex = match.index;
|
|---|
| 115 | const workerParametersStartIndex = matchIndex + partBeforeArgs.length;
|
|---|
| 116 |
|
|---|
| 117 | let workerIdPromise;
|
|---|
| 118 | if (workerSource === "import.meta.url") {
|
|---|
| 119 | // Turn the current file into a chunk
|
|---|
| 120 | workerIdPromise = Promise.resolve(id);
|
|---|
| 121 | } else {
|
|---|
| 122 | // Otherwise it's a string literal either directly or in the `new URL(...)`.
|
|---|
| 123 | if (directWorkerFile) {
|
|---|
| 124 | const fullMatchWithOpts = `${fullMatch}, …)`;
|
|---|
| 125 | const fullReplacement = `new Worker(new URL(${directWorkerFile}, import.meta.url), …)`;
|
|---|
| 126 |
|
|---|
| 127 | if (!longWarningAlreadyShown) {
|
|---|
| 128 | this.warn(
|
|---|
| 129 | `rollup-plugin-off-main-thread:
|
|---|
| 130 | \`${fullMatchWithOpts}\` suggests that the Worker should be relative to the document, not the script.
|
|---|
| 131 | 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.
|
|---|
| 132 | This might lead to incorrect behaviour during runtime.
|
|---|
| 133 | If you did mean to use a URL relative to the current module, please change your code to the following form:
|
|---|
| 134 | \`${fullReplacement}\`
|
|---|
| 135 | This will become a hard error in the future.`,
|
|---|
| 136 | matchIndex
|
|---|
| 137 | );
|
|---|
| 138 | longWarningAlreadyShown = true;
|
|---|
| 139 | } else {
|
|---|
| 140 | this.warn(
|
|---|
| 141 | `rollup-plugin-off-main-thread: Treating \`${fullMatchWithOpts}\` as \`${fullReplacement}\``,
|
|---|
| 142 | matchIndex
|
|---|
| 143 | );
|
|---|
| 144 | }
|
|---|
| 145 | workerFile = directWorkerFile;
|
|---|
| 146 | }
|
|---|
| 147 |
|
|---|
| 148 | // Cut off surrounding quotes.
|
|---|
| 149 | workerFile = workerFile.slice(1, -1);
|
|---|
| 150 |
|
|---|
| 151 | if (!/^\.{1,2}\//.test(workerFile)) {
|
|---|
| 152 | let isError = false;
|
|---|
| 153 | if (directWorkerFile) {
|
|---|
| 154 | // If direct worker file, it must be in `./something` form.
|
|---|
| 155 | isError = true;
|
|---|
| 156 | } else {
|
|---|
| 157 | // If `new URL(...)` it can be in `new URL('something', import.meta.url)` form too,
|
|---|
| 158 | // so just check it's not absolute.
|
|---|
| 159 | if (/^(\/|https?:)/.test(workerFile)) {
|
|---|
| 160 | isError = true;
|
|---|
| 161 | } else {
|
|---|
| 162 | // If it does turn out to be `new URL('something', import.meta.url)` form,
|
|---|
| 163 | // prepend `./` so that it becomes valid module specifier.
|
|---|
| 164 | workerFile = `./${workerFile}`;
|
|---|
| 165 | }
|
|---|
| 166 | }
|
|---|
| 167 | if (isError) {
|
|---|
| 168 | this.warn(
|
|---|
| 169 | `Paths passed to the Worker constructor must be relative to the current file, i.e. start with ./ or ../ (just like dynamic import!). Ignoring "${workerFile}".`,
|
|---|
| 170 | matchIndex
|
|---|
| 171 | );
|
|---|
| 172 | continue;
|
|---|
| 173 | }
|
|---|
| 174 | }
|
|---|
| 175 |
|
|---|
| 176 | workerIdPromise = this.resolve(workerFile, id).then(res => res.id);
|
|---|
| 177 | }
|
|---|
| 178 |
|
|---|
| 179 | replacementPromises.push(
|
|---|
| 180 | (async () => {
|
|---|
| 181 | const resolvedWorkerFile = await workerIdPromise;
|
|---|
| 182 | workerFiles.push(resolvedWorkerFile);
|
|---|
| 183 | const chunkRefId = this.emitFile({
|
|---|
| 184 | id: resolvedWorkerFile,
|
|---|
| 185 | type: "chunk"
|
|---|
| 186 | });
|
|---|
| 187 |
|
|---|
| 188 | ms.overwrite(
|
|---|
| 189 | workerParametersStartIndex,
|
|---|
| 190 | workerParametersEndIndex,
|
|---|
| 191 | `new URL(import.meta.ROLLUP_FILE_URL_${chunkRefId}, import.meta.url)`
|
|---|
| 192 | );
|
|---|
| 193 | })()
|
|---|
| 194 | );
|
|---|
| 195 | }
|
|---|
| 196 |
|
|---|
| 197 | // No matches found.
|
|---|
| 198 | if (!replacementPromises.length) {
|
|---|
| 199 | return;
|
|---|
| 200 | }
|
|---|
| 201 |
|
|---|
| 202 | // Wait for all the scheduled replacements to finish.
|
|---|
| 203 | await Promise.all(replacementPromises);
|
|---|
| 204 |
|
|---|
| 205 | return {
|
|---|
| 206 | code: ms.toString(),
|
|---|
| 207 | map: ms.generateMap({ hires: true })
|
|---|
| 208 | };
|
|---|
| 209 | },
|
|---|
| 210 |
|
|---|
| 211 | resolveFileUrl(chunk) {
|
|---|
| 212 | return JSON.stringify(chunk.relativePath);
|
|---|
| 213 | },
|
|---|
| 214 |
|
|---|
| 215 | outputOptions({ format }) {
|
|---|
| 216 | if (format === "esm" || format === "es") {
|
|---|
| 217 | if (!opts.silenceESMWorkerWarning) {
|
|---|
| 218 | this.warn(
|
|---|
| 219 | 'Very few browsers support ES modules in Workers. If you want to your code to run in all browsers, set `output.format = "amd";`'
|
|---|
| 220 | );
|
|---|
| 221 | }
|
|---|
| 222 | // In ESM, we never prepend a loader.
|
|---|
| 223 | isEsmOutput = () => true;
|
|---|
| 224 | } else if (format !== "amd") {
|
|---|
| 225 | this.error(
|
|---|
| 226 | `\`output.format\` must either be "amd" or "esm", got "${format}"`
|
|---|
| 227 | );
|
|---|
| 228 | } else {
|
|---|
| 229 | isEsmOutput = () => false;
|
|---|
| 230 | }
|
|---|
| 231 | },
|
|---|
| 232 |
|
|---|
| 233 | renderDynamicImport() {
|
|---|
| 234 | if (isEsmOutput()) return;
|
|---|
| 235 |
|
|---|
| 236 | // In our loader, `require` simply return a promise directly.
|
|---|
| 237 | // This is tinier and simpler output than the Rollup's default.
|
|---|
| 238 | return {
|
|---|
| 239 | left: 'require(',
|
|---|
| 240 | right: ')'
|
|---|
| 241 | };
|
|---|
| 242 | },
|
|---|
| 243 |
|
|---|
| 244 | resolveImportMeta(property) {
|
|---|
| 245 | if (isEsmOutput()) return;
|
|---|
| 246 |
|
|---|
| 247 | if (property === 'url') {
|
|---|
| 248 | // In our loader, `module.uri` is already fully resolved
|
|---|
| 249 | // so we can emit something shorter than the Rollup's default.
|
|---|
| 250 | return `module.uri`;
|
|---|
| 251 | }
|
|---|
| 252 | },
|
|---|
| 253 |
|
|---|
| 254 | renderChunk(code, chunk, outputOptions) {
|
|---|
| 255 | // We don’t need to do any loader processing when targeting ESM format.
|
|---|
| 256 | if (isEsmOutput()) return;
|
|---|
| 257 |
|
|---|
| 258 | if (outputOptions.banner && outputOptions.banner.length > 0) {
|
|---|
| 259 | this.error(
|
|---|
| 260 | "OMT currently doesn’t work with `banner`. Feel free to submit a PR at https://github.com/surma/rollup-plugin-off-main-thread"
|
|---|
| 261 | );
|
|---|
| 262 | return;
|
|---|
| 263 | }
|
|---|
| 264 | const ms = new MagicString(code);
|
|---|
| 265 |
|
|---|
| 266 | for (const match of matchAll(code, workerRegexpForOutput)) {
|
|---|
| 267 | let [fullMatch, optionsWithCommaStr, optionsStr] = match;
|
|---|
| 268 | let options;
|
|---|
| 269 | try {
|
|---|
| 270 | options = json5.parse(optionsStr);
|
|---|
| 271 | } catch (e) {
|
|---|
| 272 | // If we couldn't parse the options object, maybe it's something dynamic or has nested
|
|---|
| 273 | // parentheses or something like that. In that case, treat it as a warning
|
|---|
| 274 | // and not a hard error, just like we wouldn't break on unmatched regex.
|
|---|
| 275 | console.warn("Couldn't match options object", fullMatch, ": ", e);
|
|---|
| 276 | continue;
|
|---|
| 277 | }
|
|---|
| 278 | if (!("type" in options)) {
|
|---|
| 279 | // Nothing to do.
|
|---|
| 280 | continue;
|
|---|
| 281 | }
|
|---|
| 282 | delete options.type;
|
|---|
| 283 | const replacementEnd = match.index + fullMatch.length;
|
|---|
| 284 | const replacementStart = replacementEnd - optionsWithCommaStr.length;
|
|---|
| 285 | optionsStr = json5.stringify(options);
|
|---|
| 286 | optionsWithCommaStr = optionsStr === "{}" ? "" : `, ${optionsStr}`;
|
|---|
| 287 | ms.overwrite(
|
|---|
| 288 | replacementStart,
|
|---|
| 289 | replacementEnd,
|
|---|
| 290 | optionsWithCommaStr
|
|---|
| 291 | );
|
|---|
| 292 | }
|
|---|
| 293 |
|
|---|
| 294 | // Mangle define() call
|
|---|
| 295 | ms.remove(0, "define(".length);
|
|---|
| 296 | // If the module does not have any dependencies, it’s technically okay
|
|---|
| 297 | // to skip the dependency array. But our minimal loader expects it, so
|
|---|
| 298 | // we add it back in.
|
|---|
| 299 | if (!code.startsWith("define([")) {
|
|---|
| 300 | ms.prepend("[],");
|
|---|
| 301 | }
|
|---|
| 302 | ms.prepend(`${opts.amdFunctionName}(`);
|
|---|
| 303 |
|
|---|
| 304 | // Prepend loader if it’s an entry point or a worker file
|
|---|
| 305 | if (opts.prependLoader(chunk, workerFiles)) {
|
|---|
| 306 | ms.prepend(opts.loader);
|
|---|
| 307 | }
|
|---|
| 308 |
|
|---|
| 309 | const newCode = ms.toString();
|
|---|
| 310 | const hasCodeChanged = code !== newCode;
|
|---|
| 311 | return {
|
|---|
| 312 | code: newCode,
|
|---|
| 313 | // Avoid generating sourcemaps if possible as it can be a very expensive operation
|
|---|
| 314 | map: hasCodeChanged ? ms.generateMap({ hires: true }) : null
|
|---|
| 315 | };
|
|---|
| 316 | }
|
|---|
| 317 | };
|
|---|
| 318 | };
|
|---|