2023-10-03 11:14:36 +08:00
/ * *
* 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 = / ( n e w \ s + W o r k e r \ ( ) \ s * ( ( ' . * ? ' | " . * ? " ) | i m p o r t \ . m e t a \ . u r l | n e w \ s + U R L \ ( ( ' . * ? ' | " . * ? " ) , \ s * i m p o r t \ . m e t a \ . u r l \ ) ) / g s ;
// 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 = / n e w \ s + W o r k e r \ ( n e w \ s + U R L \ ( ( ? : ' . * ? ' | " . * ? " ) , \ s * m o d u l e \ . u r i \ ) \ s * ( , ( [ ^ ) ] + ) ) / g s ;
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
} ;
}
} ;
} ;