Spaces:
Sleeping
Sleeping
/** | |
* 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. | |
*/ | |
; | |
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 | |
}; | |
} | |
}; | |
}; | |