diff --git a/rollup.config.ts b/rollup.config.ts index 6105a36f8..b29411b01 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -124,6 +124,7 @@ export default [ external: [ "webpack", "webpack/lib/ModuleTypeConstants", + "webpack/lib/util/makeSerializable", "fs", "path" ], @@ -149,6 +150,7 @@ export default [ external: [ "webpack", "webpack/lib/ModuleTypeConstants", + "webpack/lib/util/makeSerializable", "fs", "path" ], diff --git a/src.compiler/BuilderHelpers.ts b/src.compiler/BuilderHelpers.ts index f3a856928..81b418122 100644 --- a/src.compiler/BuilderHelpers.ts +++ b/src.compiler/BuilderHelpers.ts @@ -1,22 +1,13 @@ import * as ts from 'typescript'; import * as path from 'path' -const ignoredFiles = new Set([ - "alphaTab.webpack.ts", - "rollup.config.ts", - "rollup.config.cjs.ts", - "rollup.config.esm.ts", - "rollup.plugin.resolve.ts", - "rollup.plugin.server.ts", - "alphaTab.main.ts", - "alphaTab.worker.ts", - "alphaTab.worklet.ts", - "WebPack.test.ts" -]) +const ignoredFiles = [ + /rollup.*/ +] export function transpileFilter(file: string): boolean { const fileName = path.basename(file); - return !ignoredFiles.has(fileName); + return !ignoredFiles.find(e => e.exec(fileName)); } export function setMethodBody(m: ts.MethodDeclaration, body: ts.FunctionBody): ts.MethodDeclaration { diff --git a/src.compiler/csharp/CSharpAstTransformer.ts b/src.compiler/csharp/CSharpAstTransformer.ts index 5096627dc..7f0eff918 100644 --- a/src.compiler/csharp/CSharpAstTransformer.ts +++ b/src.compiler/csharp/CSharpAstTransformer.ts @@ -86,6 +86,20 @@ export default class CSharpAstTransformer { const testClasses: ts.CallExpression[] = []; const globalExports: ts.ExportDeclaration[] = []; + switch (this._typeScriptFile.statements[0].kind) { + case ts.SyntaxKind.ClassDeclaration: + case ts.SyntaxKind.InterfaceDeclaration: + case ts.SyntaxKind.FunctionDeclaration: + case ts.SyntaxKind.EnumDeclaration: + case ts.SyntaxKind.TypeAliasDeclaration: + break; + default: + if (this.shouldSkip(this._typeScriptFile.statements[0], false)) { + return; + } + break; + } + this._typeScriptFile.statements.forEach(s => { if (ts.isExportDeclaration(s)) { globalExports.push(s); diff --git a/src/AlphaTabApiBase.ts b/src/AlphaTabApiBase.ts index c754e0b28..c4297354a 100644 --- a/src/AlphaTabApiBase.ts +++ b/src/AlphaTabApiBase.ts @@ -1152,8 +1152,11 @@ export class AlphaTabApiBase { return; } - if (this.settings.player.enableUserInteraction) { - // for the selection ensure start < end + if ( + this.settings.player.enablePlayer && + this.settings.player.enableCursor && + this.settings.player.enableUserInteraction + ) { if (this._selectionEnd) { let startTick: number = this._tickCache?.getBeatStart(this._selectionStart!.beat) ?? this._selectionStart!.beat.absolutePlaybackStart; let endTick: number = this._tickCache?.getBeatStart(this._selectionEnd!.beat) ?? this._selectionEnd!.beat.absolutePlaybackStart; diff --git a/src/alphaTab.main.ts b/src/alphaTab.main.ts index 2dfc09039..876583e9f 100644 --- a/src/alphaTab.main.ts +++ b/src/alphaTab.main.ts @@ -1,3 +1,4 @@ +/**@target web */ export * from './alphaTab.core'; import * as alphaTab from './alphaTab.core'; @@ -45,14 +46,10 @@ alphaTab.Environment.initializeMain( throw new alphaTab.AlphaTabError(alphaTab.AlphaTabErrorType.General, "Audio Worklets not yet supported in Node.js"); } - if (alphaTab.Environment.isWebPackBundled) { - alphaTab.Logger.debug("AlphaTab", "Creating WebPack compatible worklet"); - const alphaTabWorklet = context.audioWorklet; // this name triggers the WebPack Plugin - return alphaTabWorklet.addModule(new URL('./alphaTab.worklet', 'webpack-worklet')); - } - else if (alphaTab.Environment.isWebPackBundled && alphaTab.Environment.webPlatform == alphaTab.WebPlatform.BrowserModule) { + if (alphaTab.Environment.isWebPackBundled ||alphaTab.Environment.webPlatform == alphaTab.WebPlatform.BrowserModule) { alphaTab.Logger.debug("AlphaTab", "Creating Module worklet"); - return context.audioWorklet.addModule(new URL('./alphaTab.worklet', import.meta.url)); + const alphaTabWorklet = context.audioWorklet; // this name triggers the WebPack Plugin + return alphaTabWorklet.addModule(new URL('./alphaTab.worklet', import.meta.url)); } alphaTab.Logger.debug("AlphaTab", "Creating Script worklet"); diff --git a/src/alphaTab.webpack.ts b/src/alphaTab.webpack.ts index ff8cab5c4..5e06edfac 100644 --- a/src/alphaTab.webpack.ts +++ b/src/alphaTab.webpack.ts @@ -1,369 +1,2 @@ -import * as fs from 'fs' -import * as path from 'path' -import webpack from 'webpack' - -import { - JAVASCRIPT_MODULE_TYPE_AUTO, - JAVASCRIPT_MODULE_TYPE_ESM -} from "webpack/lib/ModuleTypeConstants"; - -import { type VariableDeclarator, type Identifier, type Expression, type CallExpression, type NewExpression } from 'estree' - -export interface AlphaTabWebPackPluginChunkOptions { - name?: string; - minSize?: number; - priority?: number; -} - -export interface AlphaTabWebPackPluginOptions { - /** - * The location where alphaTab can be found. - * (default: node_modules/@coderline/alphatab/dist) - */ - alphaTabSourceDir?: string; - /** - * The options related to the chunk into which alphaTab is placed in case - * Webpack is configured with optimization.splitChunks.cacheGroups - * - * (default: { name: "chunk-alphatab", minSize: 0, priority: 10 }) - */ - alphaTabChunk?: AlphaTabWebPackPluginChunkOptions | false; -} - - -const AlphaTabWorkletSpecifierTag = Symbol("alphatab worklet specifier tag"); - - -class AlphaTabWorkletRuntimeModule extends webpack.RuntimeModule { - public static Key: string = "AlphaTabWorkletRuntime"; - - constructor() { - super("alphaTab audio worklet chunk loading", webpack.RuntimeModule.STAGE_BASIC); - } - - override generate(): string | null { - const compilation = this.compilation!; - const runtimeTemplate = compilation.runtimeTemplate; - const globalObject = runtimeTemplate.globalObject; - const chunkLoadingGlobalExpr = `${globalObject}[${JSON.stringify( - compilation.outputOptions.chunkLoadingGlobal - )}]`; - - - const initialChunkIds = new Set(this.chunk!.ids); - for (const c of this.chunk!.getAllInitialChunks()) { - if (webpack.javascript.JavascriptModulesPlugin.chunkHasJs(c, this.chunkGraph!)) { - continue; - } - for (const id of c.ids!) { - initialChunkIds.add(id); - } - } - - return webpack.Template.asString([ - `if ( ! ('AudioWorkletGlobalScope' in ${globalObject}) ) { return; }`, - `const installedChunks = {`, - webpack.Template.indent( - Array.from(initialChunkIds, id => `${JSON.stringify(id)}: 1`).join( - ",\n" - ) - ), - "};", - - "// importScripts chunk loading", - `const installChunk = ${runtimeTemplate.basicFunction("data", [ - runtimeTemplate.destructureArray( - ["chunkIds", "moreModules", "runtime"], - "data" - ), - "for(const moduleId in moreModules) {", - webpack.Template.indent([ - `if(${webpack.RuntimeGlobals.hasOwnProperty}(moreModules, moduleId)) {`, - webpack.Template.indent( - `${webpack.RuntimeGlobals.moduleFactories}[moduleId] = moreModules[moduleId];` - ), - "}" - ]), - "}", - `if(runtime) runtime(${webpack.RuntimeGlobals.require});`, - "while(chunkIds.length)", - webpack.Template.indent("installedChunks[chunkIds.pop()] = 1;"), - "parentChunkLoadingFunction(data);" - ])};`, - `const chunkLoadingGlobal = ${chunkLoadingGlobalExpr} = ${chunkLoadingGlobalExpr} || [];`, - "const parentChunkLoadingFunction = chunkLoadingGlobal.push.bind(chunkLoadingGlobal);", - "chunkLoadingGlobal.forEach(installChunk);", - "chunkLoadingGlobal.push = installChunk;" - ]); - } -} - -class AlphaTabWorkletDependency extends webpack.dependencies.ModuleDependency { - publicPath: string | undefined; - getChunkFileName: (chunk: webpack.Chunk) => string; - - constructor(url: string, range: [number, number], publicPath: string | undefined, getChunkFileName: (chunk: webpack.Chunk) => string) { - super(url); - this.range = range; - this.publicPath = publicPath; - this.getChunkFileName = getChunkFileName; - } -} - -AlphaTabWorkletDependency.Template = class AlphaTabWorkletDependencyTemplate extends webpack.dependencies.ModuleDependency.Template { - override apply(dependency: webpack.Dependency, source: webpack.sources.ReplaceSource, templateContext: { chunkGraph: webpack.ChunkGraph, moduleGraph: webpack.ModuleGraph, runtimeRequirements: Set }): void { - const { chunkGraph, moduleGraph } = templateContext; - const dep = dependency as AlphaTabWorkletDependency; - - const block = moduleGraph.getParentBlock(dependency) as webpack.AsyncDependenciesBlock; - const entrypoint = chunkGraph.getBlockChunkGroup(block) as any; - - const workletImportBaseUrl = dep.publicPath - ? JSON.stringify(dep.publicPath) - : webpack.RuntimeGlobals.publicPath; - - const chunk = entrypoint.getEntrypointChunk(); - - // worklet global scope has no 'self', need to inject it for compatibility with chunks - // some plugins like the auto public path need to right location. we pass this on from the main runtime - // some plugins rely on importScripts to be defined. - const workletInlineBootstrap = ` - globalThis.self = globalThis.self || globalThis; - globalThis.location = \${JSON.stringify(${webpack.RuntimeGlobals.baseURI})}; - globalThis.importScripts = (url) => { throw new Error("importScripts not available, dynamic loading of chunks not supported in this context", url) }; - `; - - chunkGraph.addChunkRuntimeRequirements(chunk, new Set([ - webpack.RuntimeGlobals.moduleFactories, - AlphaTabWorkletRuntimeModule.Key - ])) - - source.replace( - dep.range[0], - dep.range[1] - 1, - webpack.Template.asString([ - "(/* worklet bootstrap */ async function(__webpack_worklet__) {", - webpack.Template.indent([ - `await __webpack_worklet__.addModule(URL.createObjectURL(new Blob([\`${workletInlineBootstrap}\`], { type: "application/javascript; charset=utf-8" })));`, - ...Array.from(chunk.getAllReferencedChunks()).map( - c => - `await __webpack_worklet__.addModule(new URL(${workletImportBaseUrl} + ${JSON.stringify(dep.getChunkFileName(c as webpack.Chunk))}, ${webpack.RuntimeGlobals.baseURI}));` - ) - ]), - `})(alphaTabWorklet)` - ]) - ); - } -} - -export class AlphaTabWebPackPlugin { - options: AlphaTabWebPackPluginOptions; - - constructor(options?: AlphaTabWebPackPluginOptions) { - this.options = options ?? {}; - } - - apply(compiler: webpack.Compiler) { - this.configureChunk(compiler); - this.configure(compiler); - } - - - configure(compiler: webpack.Compiler) { - const pluginName = this.constructor.name; - - compiler.hooks.thisCompilation.tap(pluginName, (compilation, { normalModuleFactory }) => { - this.configureAudioWorklet(pluginName, compiler, compilation, normalModuleFactory); - this.configureAssetCopy(pluginName, compiler, compilation); - }); - } - configureAssetCopy(pluginName: string, compiler: webpack.Compiler, compilation: webpack.Compilation) { - const options = this.options; - compilation.hooks.processAssets.tapAsync( - { - name: pluginName, - stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, - }, - async (_, callback) => { - - let alphaTabSourceDir = options.alphaTabSourceDir; - if (!alphaTabSourceDir) { - alphaTabSourceDir = compilation.getPath('node_modules/@coderline/alphatab/dist/'); - } - - if (!alphaTabSourceDir || !fs.promises.access(path.join(alphaTabSourceDir, 'alphaTab.mjs'), fs.constants.F_OK)) { - compilation.errors.push(new webpack.WebpackError('Could not find alphaTab, please ensure it is installed into node_modules or configure alphaTabSourceDir')); - return; - } - - const outputPath = compiler.options.output.path; - if (!outputPath) { - compilation.errors.push(new webpack.WebpackError('Need output.path configured in application to store asset files.')); - return; - } - - async function copyFiles(subdir: string): Promise { - const fullDir = path.join(alphaTabSourceDir!, subdir); - const files = await fs.promises.readdir(fullDir, { withFileTypes: true }); - - await fs.promises.mkdir(path.join(outputPath!, subdir), { recursive: true }); - - await Promise.all(files.filter(f => f.isFile()).map( - file => fs.promises.copyFile(path.join(file.path, file.name), path.join(outputPath!, subdir, file.name)) - )); - } - - await Promise.all([ - copyFiles("font"), - copyFiles("soundfont") - ]); - - callback(); - } - ); - } - configureAudioWorklet(pluginName: string, compiler: webpack.Compiler, compilation: webpack.Compilation, normalModuleFactory: any) { - compilation.dependencyFactories.set( - AlphaTabWorkletDependency, - normalModuleFactory - ); - compilation.dependencyTemplates.set( - AlphaTabWorkletDependency, - new AlphaTabWorkletDependency.Template() - ); - - compilation.hooks.runtimeRequirementInTree - .for(AlphaTabWorkletRuntimeModule.Key) - .tap(pluginName, (chunk: webpack.Chunk) => { - compilation.addRuntimeModule(chunk, new AlphaTabWorkletRuntimeModule()); - }); - - const parseModuleUrl = (parser: any, expr: Expression) => { - if (expr.type !== "NewExpression" && arguments.length !== 2) { - return; - } - - const newExpr = expr as NewExpression; - const [arg1, arg2] = newExpr.arguments; - const callee = parser.evaluateExpression(newExpr.callee); - - if (!callee.isIdentifier() || callee.identifier !== "URL") { - return; - } - - const arg1Value = parser.evaluateExpression(arg1); - return [ - arg1Value, - [ - (arg1.range!)[0], - (arg2.range!)[1] - ] - ]; - } - - const handleAlphaTabWorklet = (parser: any, expr: CallExpression) => { - const [arg1] = expr.arguments; - const parsedUrl = parseModuleUrl(parser, arg1 as Expression); - if (!parsedUrl) { - return; - } - - const [url] = parsedUrl; - if (!url.isString()) { - return; - } - - const block = new webpack.AsyncDependenciesBlock({ - entryOptions: { - chunkLoading: false, - wasmLoading: false - } - }); - - block.loc = expr.loc; - - const workletBootstrap = new AlphaTabWorkletDependency( - url.string, - [expr.range![0], expr.range![1]], - compiler.options.output.workerPublicPath, - (chunk) => { - return compilation.getPath( - webpack.javascript.JavascriptModulesPlugin.getChunkFilenameTemplate( - chunk, - compilation.outputOptions - ), - { - chunk: chunk, - contentHashType: "javascript" - } - ); - } - ); - workletBootstrap.loc = expr.loc!; - block.addDependency(workletBootstrap); - parser.state.module.addBlock(block); - - return true; - }; - - const parserPlugin = (parser: any) => { - const pattern = "alphaTabWorklet"; - const itemMembers = "addModule"; - - parser.hooks.preDeclarator.tap(pluginName, (decl: VariableDeclarator) => { - if (decl.id.type === "Identifier" && decl.id.name === pattern) { - parser.tagVariable(decl.id.name, AlphaTabWorkletSpecifierTag); - return true; - } - return; - }); - parser.hooks.pattern.for(pattern).tap(pluginName, (pattern: Identifier) => { - parser.tagVariable(pattern.name, AlphaTabWorkletSpecifierTag); - return true; - }); - - parser.hooks.callMemberChain - .for(AlphaTabWorkletSpecifierTag) - .tap(pluginName, (expression: CallExpression, members: string[]) => { - if (itemMembers !== members.join(".")) { - return; - } - return handleAlphaTabWorklet(parser, expression); - }); - }; - - normalModuleFactory.hooks.parser - .for(JAVASCRIPT_MODULE_TYPE_AUTO) - .tap(pluginName, parserPlugin); - normalModuleFactory.hooks.parser - .for(JAVASCRIPT_MODULE_TYPE_ESM) - .tap(pluginName, parserPlugin); - } - - configureChunk(compiler: webpack.Compiler) { - const options = this.options; - let alphaTabChunk: AlphaTabWebPackPluginChunkOptions | undefined; - if (options.alphaTabChunk !== false) { - alphaTabChunk = { - name: "chunk-alphatab", - minSize: 0, - priority: 10, - ...this.options.alphaTabChunk - }; - } - - if (alphaTabChunk && compiler.options.optimization.splitChunks && compiler.options.optimization.splitChunks.cacheGroups) { - const alphaTabSourceDir = options.alphaTabSourceDir ? path.resolve(options.alphaTabSourceDir) : `node_modules${path.sep}@coderline${path.sep}alphatab`; - compiler.options.optimization.splitChunks.cacheGroups["alphatab"] = { - ...alphaTabChunk, - chunks: "all", - test(module: { resource?: string }) { - if (!module.resource) { - return false; - } - return module.resource.includes(alphaTabSourceDir); - } - } - } - } -} \ No newline at end of file +/**@target web */ +export { AlphaTabWebPackPlugin } from './webpack/AlphaTabWebPackPlugin'; \ No newline at end of file diff --git a/src/alphaTab.worker.ts b/src/alphaTab.worker.ts index 7ddf62219..139b73904 100644 --- a/src/alphaTab.worker.ts +++ b/src/alphaTab.worker.ts @@ -1,2 +1,3 @@ +/**@target web */ import * as alphaTab from './alphaTab.core'; alphaTab.Environment.initializeWorker(); \ No newline at end of file diff --git a/src/alphaTab.worklet.ts b/src/alphaTab.worklet.ts index 4336c57fc..81a3c97c8 100644 --- a/src/alphaTab.worklet.ts +++ b/src/alphaTab.worklet.ts @@ -1,2 +1,3 @@ +/**@target web */ import * as alphaTab from './alphaTab.core'; alphaTab.Environment.initializeAudioWorklet(); \ No newline at end of file diff --git a/src/webpack/AlphaTabWebPackPlugin.ts b/src/webpack/AlphaTabWebPackPlugin.ts new file mode 100644 index 000000000..bcc05faa0 --- /dev/null +++ b/src/webpack/AlphaTabWebPackPlugin.ts @@ -0,0 +1,295 @@ +/**@target web */ +import * as fs from 'fs' +import * as path from 'path' +import webpack from 'webpack' +import { contextify } from "webpack/lib/util/identifier" + +import { + JAVASCRIPT_MODULE_TYPE_AUTO, + JAVASCRIPT_MODULE_TYPE_ESM +} from "webpack/lib/ModuleTypeConstants"; + +import { type VariableDeclarator, type Identifier, type Expression, type CallExpression, type NewExpression } from 'estree' +import { AlphaTabWorkletDependency } from './AlphaTabWorkletDependency' +import { AlphaTabWorkletRuntimeModule } from './AlphaTabWorkletRuntimeModule' +import { AlphaTabWorkletStartRuntimeModule } from './AlphaTabWorkletStartRuntimeModule' + +export interface AlphaTabWebPackPluginChunkOptions { + name?: string; + minSize?: number; + priority?: number; +} + +export interface AlphaTabWebPackPluginOptions { + /** + * The location where alphaTab can be found. + * (default: node_modules/@coderline/alphatab/dist) + */ + alphaTabSourceDir?: string; + /** + * The options related to the chunk into which alphaTab is placed in case + * Webpack is configured with optimization.splitChunks.cacheGroups + * + * (default: { name: "chunk-alphatab", minSize: 0, priority: 10 }) + */ + alphaTabChunk?: AlphaTabWebPackPluginChunkOptions | false; + + /** + * The location where assets of alphaTab should be placed. + * (default: compiler.options.output.path) + */ + assetOutputDir?: string; +} + +const AlphaTabWorkletSpecifierTag = Symbol("alphatab worklet specifier tag"); + +const workletIndexMap = new WeakMap(); + +export class AlphaTabWebPackPlugin { + options: AlphaTabWebPackPluginOptions; + + constructor(options?: AlphaTabWebPackPluginOptions) { + this.options = options ?? {}; + } + + apply(compiler: webpack.Compiler) { + this.configureChunk(compiler); + this.configure(compiler); + } + + + configure(compiler: webpack.Compiler) { + const pluginName = this.constructor.name; + + const cachedContextify = contextify.bindContextCache( + compiler.context, + compiler.root + ); + + compiler.hooks.thisCompilation.tap(pluginName, (compilation, { normalModuleFactory }) => { + this.configureAudioWorklet(pluginName, compiler, compilation, normalModuleFactory, cachedContextify); + this.configureAssetCopy(pluginName, compiler, compilation); + }); + } + configureAssetCopy(pluginName: string, compiler: webpack.Compiler, compilation: webpack.Compilation) { + const options = this.options; + compilation.hooks.processAssets.tapAsync( + { + name: pluginName, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + async (_, callback) => { + + let alphaTabSourceDir = options.alphaTabSourceDir; + if (!alphaTabSourceDir) { + alphaTabSourceDir = compilation.getPath('node_modules/@coderline/alphatab/dist/'); + } + + if (!alphaTabSourceDir || !fs.promises.access(path.join(alphaTabSourceDir, 'alphaTab.mjs'), fs.constants.F_OK)) { + compilation.errors.push(new webpack.WebpackError('Could not find alphaTab, please ensure it is installed into node_modules or configure alphaTabSourceDir')); + return; + } + + const outputPath = options.assetOutputDir ?? compiler.options.output.path; + if (!outputPath) { + compilation.errors.push(new webpack.WebpackError('Need output.path configured in application to store asset files.')); + return; + } + + async function copyFiles(subdir: string): Promise { + const fullDir = path.join(alphaTabSourceDir!, subdir); + + compilation.contextDependencies.add(path.normalize(fullDir)); + + const files = await fs.promises.readdir(fullDir, { withFileTypes: true }); + + await fs.promises.mkdir(path.join(outputPath!, subdir), { recursive: true }); + + await Promise.all(files.filter(f => f.isFile()).map( + async file => { + const sourceFilename = path.join(file.path, file.name); + await fs.promises.copyFile(sourceFilename, path.join(outputPath!, subdir, file.name)); + const assetFileName = subdir + '/' + file.name; + const existingAsset = compilation.getAsset(assetFileName); + + const data = await fs.promises.readFile(sourceFilename); + const source = new compiler.webpack.sources.RawSource(data); + + if (existingAsset) { + compilation.updateAsset(assetFileName, source, { + copied: true, + sourceFilename + }); + } else { + compilation.emitAsset(assetFileName, source, { + copied: true, + sourceFilename + }); + } + } + )); + } + + await Promise.all([ + copyFiles("font"), + copyFiles("soundfont") + ]); + + callback(); + } + ); + } + configureAudioWorklet(pluginName: string, compiler: webpack.Compiler, compilation: webpack.Compilation, normalModuleFactory: any, cachedContextify: (s: string) => string) { + compilation.dependencyFactories.set( + AlphaTabWorkletDependency, + normalModuleFactory + ); + compilation.dependencyTemplates.set( + AlphaTabWorkletDependency, + new AlphaTabWorkletDependency.Template() + ); + + compilation.hooks.runtimeRequirementInTree + .for(AlphaTabWorkletRuntimeModule.Key) + .tap(pluginName, (chunk: webpack.Chunk) => { + compilation.addRuntimeModule(chunk, new AlphaTabWorkletRuntimeModule()); + }); + + compilation.hooks.runtimeRequirementInTree + .for(AlphaTabWorkletStartRuntimeModule.RuntimeGlobalWorkletGetStartupChunks) + .tap(pluginName, (chunk: webpack.Chunk) => { + compilation.addRuntimeModule(chunk, new AlphaTabWorkletStartRuntimeModule()); + }); + + const parseModuleUrl = (parser: any, expr: Expression) => { + if (expr.type !== "NewExpression" && arguments.length !== 2) { + return; + } + + const newExpr = expr as NewExpression; + const [arg1, arg2] = newExpr.arguments; + const callee = parser.evaluateExpression(newExpr.callee); + + if (!callee.isIdentifier() || callee.identifier !== "URL") { + return; + } + + const arg1Value = parser.evaluateExpression(arg1); + return [ + arg1Value, + [ + (arg1.range!)[0], + (arg2.range!)[1] + ] + ]; + } + + const handleAlphaTabWorklet = (parser: any, expr: CallExpression) => { + const [arg1] = expr.arguments; + const parsedUrl = parseModuleUrl(parser, arg1 as Expression); + if (!parsedUrl) { + return; + } + + const [url] = parsedUrl; + if (!url.isString()) { + return; + } + + let i = workletIndexMap.get(parser.state) || 0; + workletIndexMap.set(parser.state, i + 1); + let name = `${cachedContextify( + parser.state.module.identifier() + )}|${i}`; + const hash = webpack.util.createHash(compilation.outputOptions.hashFunction); + hash.update(name); + const digest = hash.digest(compilation.outputOptions.hashDigest) as string; + const runtime = digest.slice( + 0, + compilation.outputOptions.hashDigestLength + ); + + const block = new webpack.AsyncDependenciesBlock({ + entryOptions: { + chunkLoading: false, + wasmLoading: false, + runtime: runtime + } + }); + + block.loc = expr.loc; + + const workletBootstrap = new AlphaTabWorkletDependency( + url.string, + [expr.range![0], expr.range![1]], + compiler.options.output.workerPublicPath + + ); + workletBootstrap.loc = expr.loc!; + block.addDependency(workletBootstrap); + parser.state.module.addBlock(block); + + return true; + }; + + const parserPlugin = (parser: any) => { + const pattern = "alphaTabWorklet"; + const itemMembers = "addModule"; + + parser.hooks.preDeclarator.tap(pluginName, (decl: VariableDeclarator) => { + if (decl.id.type === "Identifier" && decl.id.name === pattern) { + parser.tagVariable(decl.id.name, AlphaTabWorkletSpecifierTag); + return true; + } + return; + }); + parser.hooks.pattern.for(pattern).tap(pluginName, (pattern: Identifier) => { + parser.tagVariable(pattern.name, AlphaTabWorkletSpecifierTag); + return true; + }); + + parser.hooks.callMemberChain + .for(AlphaTabWorkletSpecifierTag) + .tap(pluginName, (expression: CallExpression, members: string[]) => { + if (itemMembers !== members.join(".")) { + return; + } + return handleAlphaTabWorklet(parser, expression); + }); + }; + + normalModuleFactory.hooks.parser + .for(JAVASCRIPT_MODULE_TYPE_AUTO) + .tap(pluginName, parserPlugin); + normalModuleFactory.hooks.parser + .for(JAVASCRIPT_MODULE_TYPE_ESM) + .tap(pluginName, parserPlugin); + } + + configureChunk(compiler: webpack.Compiler) { + const options = this.options; + let alphaTabChunk: AlphaTabWebPackPluginChunkOptions | undefined; + if (options.alphaTabChunk !== false) { + alphaTabChunk = { + name: "chunk-alphatab", + minSize: 0, + priority: 10, + ...this.options.alphaTabChunk + }; + } + + if (alphaTabChunk && compiler.options.optimization.splitChunks && compiler.options.optimization.splitChunks.cacheGroups) { + const alphaTabSourceDir = options.alphaTabSourceDir ? path.resolve(options.alphaTabSourceDir) : `node_modules${path.sep}@coderline${path.sep}alphatab`; + compiler.options.optimization.splitChunks.cacheGroups["alphatab"] = { + ...alphaTabChunk, + chunks: "all", + test(module: { resource?: string }) { + if (!module.resource) { + return false; + } + return module.resource.includes(alphaTabSourceDir); + } + } + } + } +} \ No newline at end of file diff --git a/src/webpack/AlphaTabWorkletDependency.ts b/src/webpack/AlphaTabWorkletDependency.ts new file mode 100644 index 000000000..2a336591d --- /dev/null +++ b/src/webpack/AlphaTabWorkletDependency.ts @@ -0,0 +1,115 @@ +/**@target web */ +import webpack from 'webpack' +import makeSerializable from 'webpack/lib/util/makeSerializable' + + +import { AlphaTabWorkletRuntimeModule } from './AlphaTabWorkletRuntimeModule' +import { AlphaTabWorkletStartRuntimeModule } from './AlphaTabWorkletStartRuntimeModule'; + +interface Hash { + update(data: string | Buffer, inputEncoding?: string): Hash; +} + +interface ObjectSerializerContext { + write: (arg0?: any) => void; +} + +interface ObjectDeserializerContext { + read: () => any; +} + +/** + * This module dependency injects the relevant code into a worklet bootstrap script + * to install chunks which have been added to the worklet via addModule before the bootstrap script starts. + */ +export class AlphaTabWorkletDependency extends webpack.dependencies.ModuleDependency { + publicPath: string | undefined; + + private _hashUpdate: string | undefined; + + constructor(url: string, range: [number, number], publicPath: string | undefined) { + super(url); + this.range = range; + this.publicPath = publicPath; + } + + override get type() { + return "alphaTabWorklet"; + } + + override get category() { + return "worker"; + } + + override updateHash(hash: Hash): void { + if (this._hashUpdate === undefined) { + this._hashUpdate = JSON.stringify(this.publicPath); + } + hash.update(this._hashUpdate); + } + + override serialize(context: ObjectSerializerContext): void { + const { write } = context; + write(this.publicPath); + super.serialize(context as any); + } + + override deserialize(context: ObjectDeserializerContext): void { + const { read } = context; + this.publicPath = read(); + super.deserialize(context as any); + } +} + +AlphaTabWorkletDependency.Template = class AlphaTabWorkletDependencyTemplate extends webpack.dependencies.ModuleDependency.Template { + override apply(dependency: webpack.Dependency, source: webpack.sources.ReplaceSource, templateContext: { chunkGraph: webpack.ChunkGraph, moduleGraph: webpack.ModuleGraph, runtimeRequirements: Set }): void { + const { chunkGraph, moduleGraph, runtimeRequirements } = templateContext; + const dep = dependency as AlphaTabWorkletDependency; + + const block = moduleGraph.getParentBlock(dependency) as webpack.AsyncDependenciesBlock; + const entrypoint = chunkGraph.getBlockChunkGroup(block) as any; + + const workletImportBaseUrl = dep.publicPath + ? JSON.stringify(dep.publicPath) + : webpack.RuntimeGlobals.publicPath; + + const chunk = entrypoint.getEntrypointChunk() as webpack.Chunk; + + // worklet global scope has no 'self', need to inject it for compatibility with chunks + // some plugins like the auto public path need to right location. we pass this on from the main runtime + // some plugins rely on importScripts to be defined. + const workletInlineBootstrap = ` + globalThis.self = globalThis.self || globalThis; + globalThis.location = \${JSON.stringify(${webpack.RuntimeGlobals.baseURI})}; + globalThis.importScripts = (url) => { throw new Error("importScripts not available, dynamic loading of chunks not supported in this context", url) }; + `; + + chunkGraph.addChunkRuntimeRequirements(chunk, new Set([ + webpack.RuntimeGlobals.moduleFactories, + AlphaTabWorkletRuntimeModule.Key + ])) + runtimeRequirements.add(AlphaTabWorkletStartRuntimeModule.RuntimeGlobalWorkletGetStartupChunks); + + source.replace( + dep.range[0], + dep.range[1] - 1, + webpack.Template.asString([ + "(/* worklet bootstrap */ async function(__webpack_worklet__) {", + webpack.Template.indent([ + `await __webpack_worklet__.addModule(URL.createObjectURL(new Blob([\`${workletInlineBootstrap}\`], { type: "application/javascript; charset=utf-8" })));`, + `for (const fileName of ${AlphaTabWorkletStartRuntimeModule.RuntimeGlobalWorkletGetStartupChunks}(${chunk.id})) {`, + webpack.Template.indent([ + `await __webpack_worklet__.addModule(new URL(${workletImportBaseUrl} + fileName, ${webpack.RuntimeGlobals.baseURI}));` + ]), + "}" + ]), + `})(alphaTabWorklet)` + ]) + ); + } +} + +makeSerializable( + AlphaTabWorkletDependency, + 'AlphaTabWorkletDependency' +); diff --git a/src/webpack/AlphaTabWorkletRuntimeModule.ts b/src/webpack/AlphaTabWorkletRuntimeModule.ts new file mode 100644 index 000000000..aaa55b279 --- /dev/null +++ b/src/webpack/AlphaTabWorkletRuntimeModule.ts @@ -0,0 +1,66 @@ +/**@target web */ +import webpack from 'webpack' + +export class AlphaTabWorkletRuntimeModule extends webpack.RuntimeModule { + public static Key: string = "AlphaTabWorkletRuntime"; + + constructor() { + super("alphaTab audio worklet chunk loading", webpack.RuntimeModule.STAGE_BASIC); + } + + override generate(): string | null { + const compilation = this.compilation!; + const runtimeTemplate = compilation.runtimeTemplate; + const globalObject = runtimeTemplate.globalObject; + const chunkLoadingGlobalExpr = `${globalObject}[${JSON.stringify( + compilation.outputOptions.chunkLoadingGlobal + )}]`; + + + const initialChunkIds = new Set(this.chunk!.ids); + for (const c of this.chunk!.getAllInitialChunks()) { + if (webpack.javascript.JavascriptModulesPlugin.chunkHasJs(c, this.chunkGraph!)) { + continue; + } + for (const id of c.ids!) { + initialChunkIds.add(id); + } + } + + return webpack.Template.asString([ + `if ( ! ('AudioWorkletGlobalScope' in ${globalObject}) ) { return; }`, + `const installedChunks = {`, + webpack.Template.indent( + Array.from(initialChunkIds, id => `${JSON.stringify(id)}: 1`).join( + ",\n" + ) + ), + "};", + + "// importScripts chunk loading", + `const installChunk = ${runtimeTemplate.basicFunction("data", [ + runtimeTemplate.destructureArray( + ["chunkIds", "moreModules", "runtime"], + "data" + ), + "for(const moduleId in moreModules) {", + webpack.Template.indent([ + `if(${webpack.RuntimeGlobals.hasOwnProperty}(moreModules, moduleId)) {`, + webpack.Template.indent( + `${webpack.RuntimeGlobals.moduleFactories}[moduleId] = moreModules[moduleId];` + ), + "}" + ]), + "}", + `if(runtime) runtime(${webpack.RuntimeGlobals.require});`, + "while(chunkIds.length)", + webpack.Template.indent("installedChunks[chunkIds.pop()] = 1;"), + "parentChunkLoadingFunction(data);" + ])};`, + `const chunkLoadingGlobal = ${chunkLoadingGlobalExpr} = ${chunkLoadingGlobalExpr} || [];`, + "const parentChunkLoadingFunction = chunkLoadingGlobal.push.bind(chunkLoadingGlobal);", + "chunkLoadingGlobal.forEach(installChunk);", + "chunkLoadingGlobal.push = installChunk;" + ]); + } +} \ No newline at end of file diff --git a/src/webpack/AlphaTabWorkletStartRuntimeModule.ts b/src/webpack/AlphaTabWorkletStartRuntimeModule.ts new file mode 100644 index 000000000..91254a11f --- /dev/null +++ b/src/webpack/AlphaTabWorkletStartRuntimeModule.ts @@ -0,0 +1,52 @@ +/**@target web */ +import webpack from 'webpack' +import { AlphaTabWorkletRuntimeModule } from './AlphaTabWorkletRuntimeModule'; + +export class AlphaTabWorkletStartRuntimeModule extends webpack.RuntimeModule { + static readonly RuntimeGlobalWorkletGetStartupChunks = "__webpack_require__.wsc"; + + constructor() { + super("alphaTab audio worklet chunk lookup", webpack.RuntimeModule.STAGE_BASIC); + } + + override generate(): string | null { + const compilation = this.compilation!; + const workletChunkLookup = new Map(); + const chunkGraph = this.chunkGraph!; + + const allChunks = compilation.chunks; + for (const chunk of allChunks) { + const isWorkletEntry = chunkGraph + .getTreeRuntimeRequirements(chunk) + .has(AlphaTabWorkletRuntimeModule.Key); + webpack.Chunk + if (isWorkletEntry) { + const workletChunks = Array.from(chunk.getAllReferencedChunks()).map(c => compilation.getPath( + webpack.javascript.JavascriptModulesPlugin.getChunkFilenameTemplate( + c, + compilation.outputOptions + ), + { + chunk: c, + contentHashType: "javascript" + } + )); + workletChunkLookup.set(String(chunk.id), workletChunks); + } + } + + return webpack.Template.asString([ + `${AlphaTabWorkletStartRuntimeModule.RuntimeGlobalWorkletGetStartupChunks} = (() => {`, + webpack.Template.indent([ + "const lookup = new Map(", + webpack.Template.indent( + JSON.stringify(Array.from(workletChunkLookup.entries())) + ), + ");", + + "return (chunkId) => lookup.get(String(chunkId)) ?? [];" + ]), + "})();" + ]) + } +} \ No newline at end of file diff --git a/test/bundler/WebPack.test.ts b/test/bundler/WebPack.test.ts index 8268d8340..3baa191eb 100644 --- a/test/bundler/WebPack.test.ts +++ b/test/bundler/WebPack.test.ts @@ -1,3 +1,4 @@ +/**@target web */ import { AlphaTabWebPackPlugin } from '../../src/alphaTab.webpack'; import webpack from 'webpack'; import path from 'path' @@ -16,7 +17,7 @@ describe('WebPack', () => { app: './src/app.mjs' }, output: { - filename: "[name].js", + filename: "[name]-[contenthash:8].js", path: path.resolve('./out') }, plugins: [ diff --git a/types/webpack/Identifier.d.ts b/types/webpack/Identifier.d.ts new file mode 100644 index 000000000..578642007 --- /dev/null +++ b/types/webpack/Identifier.d.ts @@ -0,0 +1,5 @@ +declare module 'webpack/lib/util/identifier' { + export const contextify: { + bindContextCache(context: string, associatedObjectForCache: any): (s: string) => string; + } +} \ No newline at end of file diff --git a/types/webpack/MakeSerializable.d.ts b/types/webpack/MakeSerializable.d.ts new file mode 100644 index 000000000..4fe2e32ed --- /dev/null +++ b/types/webpack/MakeSerializable.d.ts @@ -0,0 +1,4 @@ +declare module 'webpack/lib/util/makeSerializable' { + export default function (Constructor: any, request: string): void; + export default function (Constructor: any, request: string, name: string | null): void; +} \ No newline at end of file diff --git a/types/webpack/index.d.ts b/types/webpack/index.d.ts index 21ed7b6f0..5185293dd 100644 --- a/types/webpack/index.d.ts +++ b/types/webpack/index.d.ts @@ -1 +1,3 @@ -import './ModuleTypeConstants' \ No newline at end of file +import './ModuleTypeConstants' +import './MakeSerializable' +import './Identifier' \ No newline at end of file