index.js
11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
/**
* 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
};
}
};
};