Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.83% |
159 / 177 |
|
72.73% |
8 / 11 |
CRAP | |
0.00% |
0 / 1 |
| StartUpModule | |
89.83% |
159 / 177 |
|
72.73% |
8 / 11 |
52.63 | |
0.00% |
0 / 1 |
| getImplicitDependencies | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
5 | |||
| compileUnresolvedDependencies | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| getModuleRegistrations | |
86.57% |
58 / 67 |
|
0.00% |
0 / 1 |
18.79 | |||
| getGroupId | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| getBaseModules | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getStoreKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getMaxQueryLength | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| getStoreVary | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| getScript | |
87.69% |
57 / 65 |
|
0.00% |
0 / 1 |
12.27 | |||
| supportsURLLoading | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| enableModuleContentVersion | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | * @author Trevor Parscal |
| 6 | * @author Roan Kattouw |
| 7 | */ |
| 8 | namespace MediaWiki\ResourceLoader; |
| 9 | |
| 10 | use DomainException; |
| 11 | use Exception; |
| 12 | use MediaWiki\MainConfigNames; |
| 13 | use Wikimedia\RequestTimeout\TimeoutException; |
| 14 | |
| 15 | /** |
| 16 | * Module for ResourceLoader initialization. |
| 17 | * |
| 18 | * See also <https://www.mediawiki.org/wiki/ResourceLoader/Architecture#Startup_Module> |
| 19 | * |
| 20 | * The startup module, as being called only from ClientHtml, has |
| 21 | * the ability to vary based on extra query parameters, in addition to those |
| 22 | * from Context: |
| 23 | * |
| 24 | * - safemode: Only register modules that have ORIGIN_CORE as their origin. |
| 25 | * This disables ORIGIN_USER modules and mw.loader.store. (T185303, T145498) |
| 26 | * See also: OutputPage::disallowUserJs() |
| 27 | * |
| 28 | * @ingroup ResourceLoader |
| 29 | * @internal |
| 30 | */ |
| 31 | class StartUpModule extends Module { |
| 32 | |
| 33 | /** |
| 34 | * Cache version for client-side ResourceLoader module storage. |
| 35 | * Like ResourceLoaderStorageVersion but not configurable. |
| 36 | */ |
| 37 | private const STORAGE_VERSION = '3'; |
| 38 | |
| 39 | /** @var int[] */ |
| 40 | private array $groupIds = [ |
| 41 | // These reserved numbers MUST start at 0 and not skip any. These are preset |
| 42 | // for forward compatibility so that they can be safely referenced by mediawiki.js, |
| 43 | // even when the code is cached and the order of registrations (and implicit |
| 44 | // group ids) changes between versions of the software. |
| 45 | self::GROUP_USER => 0, |
| 46 | self::GROUP_PRIVATE => 1, |
| 47 | ]; |
| 48 | |
| 49 | /** |
| 50 | * Recursively get all explicit and implicit dependencies for to the given module. |
| 51 | * |
| 52 | * @param array $registryData |
| 53 | * @param string $moduleName |
| 54 | * @param array<string,true> &$handled Internal parameter for recursion. |
| 55 | * @return array |
| 56 | * @throws CircularDependencyError |
| 57 | */ |
| 58 | protected static function getImplicitDependencies( |
| 59 | array $registryData, |
| 60 | string $moduleName, |
| 61 | array &$handled |
| 62 | ): array { |
| 63 | static $dependencyCache = []; |
| 64 | |
| 65 | // No modules will be added or changed server-side after this point, |
| 66 | // so we can safely cache parts of the tree for re-use. |
| 67 | if ( !isset( $dependencyCache[$moduleName] ) ) { |
| 68 | if ( !isset( $registryData[$moduleName] ) ) { |
| 69 | // Unknown module names are allowed here, this is only an optimisation. |
| 70 | // Checks for illegal and unknown dependencies happen as PHPUnit structure tests, |
| 71 | // and also client-side at run-time. |
| 72 | $dependencyCache[$moduleName] = []; |
| 73 | return []; |
| 74 | } |
| 75 | |
| 76 | $data = $registryData[$moduleName]; |
| 77 | $flat = $data['dependencies']; |
| 78 | |
| 79 | // Prevent recursion |
| 80 | $handled[$moduleName] = true; |
| 81 | foreach ( $data['dependencies'] as $dependency ) { |
| 82 | if ( isset( $handled[$dependency] ) ) { |
| 83 | // If we encounter a circular dependency, then stop the optimiser and leave the |
| 84 | // original dependencies array unmodified. Circular dependencies are not |
| 85 | // supported in ResourceLoader. Awareness of them exists here so that we can |
| 86 | // optimise the registry when it isn't broken, and otherwise transport the |
| 87 | // registry unchanged. The client will handle this further. |
| 88 | throw new CircularDependencyError(); |
| 89 | } |
| 90 | // Recursively add the dependencies of the dependencies |
| 91 | $flat = array_merge( |
| 92 | $flat, |
| 93 | self::getImplicitDependencies( $registryData, $dependency, $handled ) |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | $dependencyCache[$moduleName] = $flat; |
| 98 | } |
| 99 | |
| 100 | return $dependencyCache[$moduleName]; |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Optimize the dependency tree in $this->modules. |
| 105 | * |
| 106 | * The optimization basically works like this: |
| 107 | * Given we have module A with the dependencies B and C |
| 108 | * and module B with the dependency C. |
| 109 | * Now we don't have to tell the client to explicitly fetch module |
| 110 | * C as that's already included in module B. |
| 111 | * |
| 112 | * This way we can reasonably reduce the amount of module registration |
| 113 | * data send to the client. |
| 114 | * |
| 115 | * @param array[] &$registryData Modules keyed by name |
| 116 | * @phan-param array<string,array{version:string,dependencies:array,group:?int,source:string}> &$registryData |
| 117 | */ |
| 118 | public static function compileUnresolvedDependencies( array &$registryData ): void { |
| 119 | foreach ( $registryData as &$data ) { |
| 120 | $dependencies = $data['dependencies']; |
| 121 | try { |
| 122 | foreach ( $data['dependencies'] as $dependency ) { |
| 123 | $depCheck = []; |
| 124 | $implicitDependencies = self::getImplicitDependencies( $registryData, $dependency, $depCheck ); |
| 125 | $dependencies = array_diff( $dependencies, $implicitDependencies ); |
| 126 | } |
| 127 | } catch ( CircularDependencyError ) { |
| 128 | // Leave unchanged |
| 129 | $dependencies = $data['dependencies']; |
| 130 | } |
| 131 | |
| 132 | // Rebuild keys |
| 133 | $data['dependencies'] = array_values( $dependencies ); |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * Get registration code for all modules. |
| 139 | * |
| 140 | * @param Context $context |
| 141 | * @return string JavaScript code for registering all modules with the client loader |
| 142 | */ |
| 143 | public function getModuleRegistrations( Context $context ): string { |
| 144 | $resourceLoader = $context->getResourceLoader(); |
| 145 | // Future developers: Use WebRequest::getRawVal() instead getVal(). |
| 146 | // The getVal() method performs slow Language+UTF logic. (f303bb9360) |
| 147 | $safemode = $context->getRequest()->getRawVal( 'safemode' ) === '1'; |
| 148 | $skin = $context->getSkin(); |
| 149 | |
| 150 | $moduleNames = $resourceLoader->getModuleNames(); |
| 151 | |
| 152 | // Preload with a batch so that the below calls to getVersionHash() for each module |
| 153 | // don't require on-demand loading of more information. |
| 154 | try { |
| 155 | $resourceLoader->preloadModuleInfo( $moduleNames, $context ); |
| 156 | } catch ( TimeoutException $e ) { |
| 157 | throw $e; |
| 158 | } catch ( Exception $e ) { |
| 159 | // Don't fail the request (T152266) |
| 160 | // Also print the error in the main output |
| 161 | $resourceLoader->outputErrorAndLog( $e, |
| 162 | 'Preloading module info from startup failed: {exception}', |
| 163 | [ 'exception' => $e ] |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | // Get registry data |
| 168 | $states = []; |
| 169 | $registryData = []; |
| 170 | foreach ( $moduleNames as $name ) { |
| 171 | $module = $resourceLoader->getModule( $name ); |
| 172 | $moduleSkins = $module->getSkins(); |
| 173 | if ( |
| 174 | ( $safemode && $module->getOrigin() > Module::ORIGIN_CORE_INDIVIDUAL ) |
| 175 | || ( $moduleSkins !== null && !in_array( $skin, $moduleSkins ) ) |
| 176 | ) { |
| 177 | continue; |
| 178 | } |
| 179 | |
| 180 | if ( $module instanceof StartUpModule ) { |
| 181 | // Don't register 'startup' to the client because loading it lazily or depending |
| 182 | // on it doesn't make sense, because the startup module *is* the client. |
| 183 | // Registering would be a waste of bandwidth and memory and risks somehow causing |
| 184 | // it to load a second time. |
| 185 | |
| 186 | // ATTENTION: Because of the line below, this is not going to cause infinite recursion. |
| 187 | // Think carefully before making changes to this code! |
| 188 | // The below code is going to call Module::getVersionHash() for every module. |
| 189 | // For StartUpModule (this module) the hash is computed based on the manifest content, |
| 190 | // which is the very thing we are computing right here. As such, this must skip iterating |
| 191 | // over 'startup' itself. |
| 192 | continue; |
| 193 | } |
| 194 | |
| 195 | // Optimization: Exclude modules in the `noscript` group. These are only ever used |
| 196 | // directly by HTML without use of JavaScript (T291735). |
| 197 | if ( $module->getGroup() === self::GROUP_NOSCRIPT ) { |
| 198 | continue; |
| 199 | } |
| 200 | |
| 201 | try { |
| 202 | // The version should be formatted by ResourceLoader::makeHash and be of |
| 203 | // length ResourceLoader::HASH_LENGTH (or empty string). |
| 204 | // The getVersionHash method is final and is covered by tests, as is makeHash(). |
| 205 | $versionHash = $module->getVersionHash( $context ); |
| 206 | } catch ( TimeoutException $e ) { |
| 207 | throw $e; |
| 208 | } catch ( Exception $e ) { |
| 209 | // Don't fail the request (T152266) |
| 210 | // Also print the error in the main output |
| 211 | $resourceLoader->outputErrorAndLog( $e, |
| 212 | 'Calculating version for "{module}" failed: {exception}', |
| 213 | [ |
| 214 | 'module' => $name, |
| 215 | 'exception' => $e, |
| 216 | ] |
| 217 | ); |
| 218 | $versionHash = ''; |
| 219 | $states[$name] = 'error'; |
| 220 | } |
| 221 | |
| 222 | $skipFunction = $module->getSkipFunction(); |
| 223 | if ( $skipFunction !== null && !$context->getDebug() ) { |
| 224 | $skipFunction = ResourceLoader::filter( 'minify-js', $skipFunction ); |
| 225 | } |
| 226 | |
| 227 | $registryData[$name] = [ |
| 228 | 'version' => $versionHash, |
| 229 | 'dependencies' => $module->getDependencies( $context ), |
| 230 | 'group' => $this->getGroupId( $module->getGroup() ), |
| 231 | 'source' => $module->getSource(), |
| 232 | 'skip' => $skipFunction, |
| 233 | ]; |
| 234 | } |
| 235 | |
| 236 | self::compileUnresolvedDependencies( $registryData ); |
| 237 | |
| 238 | // Register sources |
| 239 | $sources = $oldSources = $resourceLoader->getSources(); |
| 240 | $this->getHookRunner()->onResourceLoaderModifyEmbeddedSourceUrls( $sources ); |
| 241 | if ( array_keys( $sources ) !== array_keys( $oldSources ) ) { |
| 242 | throw new DomainException( 'ResourceLoaderModifyEmbeddedSourceUrls hook must not add or remove sources' ); |
| 243 | } |
| 244 | $out = ResourceLoader::makeLoaderSourcesScript( $context, $sources ); |
| 245 | |
| 246 | // Figure out the different call signatures for mw.loader.register |
| 247 | $registrations = []; |
| 248 | foreach ( $registryData as $name => $data ) { |
| 249 | // Call mw.loader.register(name, version, dependencies, group, source, skip) |
| 250 | $registrations[] = [ |
| 251 | $name, |
| 252 | $data['version'], |
| 253 | $data['dependencies'], |
| 254 | $data['group'], |
| 255 | // Swap default (local) for null |
| 256 | $data['source'] === 'local' ? null : $data['source'], |
| 257 | $data['skip'] |
| 258 | ]; |
| 259 | } |
| 260 | |
| 261 | // Register modules |
| 262 | $out .= "\n" . ResourceLoader::makeLoaderRegisterScript( $context, $registrations ); |
| 263 | |
| 264 | if ( $states ) { |
| 265 | $out .= "\n" . ResourceLoader::makeLoaderStateScript( $context, $states ); |
| 266 | } |
| 267 | |
| 268 | return $out; |
| 269 | } |
| 270 | |
| 271 | private function getGroupId( ?string $groupName ): ?int { |
| 272 | if ( $groupName === null ) { |
| 273 | return null; |
| 274 | } |
| 275 | |
| 276 | if ( !array_key_exists( $groupName, $this->groupIds ) ) { |
| 277 | $this->groupIds[$groupName] = count( $this->groupIds ); |
| 278 | } |
| 279 | |
| 280 | return $this->groupIds[$groupName]; |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Base modules implicitly available to all modules. |
| 285 | */ |
| 286 | private function getBaseModules(): array { |
| 287 | return [ 'jquery', 'mediawiki.base' ]; |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * Get the localStorage key for the entire module store. The key references |
| 292 | * $wgDBname to prevent clashes between wikis under the same web domain. |
| 293 | * |
| 294 | * @return string localStorage item key for JavaScript |
| 295 | */ |
| 296 | private function getStoreKey(): string { |
| 297 | return 'MediaWikiModuleStore:' . $this->getConfig()->get( MainConfigNames::DBname ); |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * @see $wgResourceLoaderMaxQueryLength |
| 302 | * @return int |
| 303 | */ |
| 304 | private function getMaxQueryLength(): int { |
| 305 | $len = $this->getConfig()->get( MainConfigNames::ResourceLoaderMaxQueryLength ); |
| 306 | // - Ignore -1, which in MW 1.34 and earlier was used to mean "unlimited". |
| 307 | // - Ignore invalid values, e.g. non-int or other negative values. |
| 308 | if ( $len === false || $len < 0 ) { |
| 309 | // Default |
| 310 | $len = 2000; |
| 311 | } |
| 312 | return $len; |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Get the key on which the JavaScript module cache (mw.loader.store) will vary. |
| 317 | * |
| 318 | * @param Context $context |
| 319 | * @return string String of concatenated vary conditions |
| 320 | */ |
| 321 | private function getStoreVary( Context $context ): string { |
| 322 | return implode( ':', [ |
| 323 | $context->getSkin(), |
| 324 | self::STORAGE_VERSION, |
| 325 | $this->getConfig()->get( MainConfigNames::ResourceLoaderStorageVersion ), |
| 326 | $context->getLanguage(), |
| 327 | ] ); |
| 328 | } |
| 329 | |
| 330 | /** |
| 331 | * @param Context $context |
| 332 | * @return string|array JavaScript code |
| 333 | */ |
| 334 | public function getScript( Context $context ) { |
| 335 | global $IP; |
| 336 | $conf = $this->getConfig(); |
| 337 | |
| 338 | if ( $context->getOnly() !== 'scripts' ) { |
| 339 | return '/* Requires only=scripts */'; |
| 340 | } |
| 341 | |
| 342 | $enableJsProfiler = $conf->get( MainConfigNames::ResourceLoaderEnableJSProfiler ); |
| 343 | |
| 344 | $startupCode = file_get_contents( "$IP/resources/src/startup/startup.js" ); |
| 345 | |
| 346 | $mwLoaderCode = file_get_contents( "$IP/resources/src/startup/mediawiki.js" ) . |
| 347 | file_get_contents( "$IP/resources/src/startup/mediawiki.loader.js" ) . |
| 348 | file_get_contents( "$IP/resources/src/startup/mediawiki.requestIdleCallback.js" ); |
| 349 | if ( $conf->get( MainConfigNames::ResourceLoaderEnableJSProfiler ) ) { |
| 350 | $mwLoaderCode .= file_get_contents( "$IP/resources/src/startup/profiler.js" ); |
| 351 | } |
| 352 | |
| 353 | // Perform replacements for mediawiki.js |
| 354 | $mwLoaderPairs = [ |
| 355 | // This should always be an object, even if the base vars are empty |
| 356 | // (such as when using the default lang/skin). |
| 357 | '$VARS.reqBase' => $context->encodeJson( (object)$context->getReqBase() ), |
| 358 | '$VARS.baseModules' => $context->encodeJson( $this->getBaseModules() ), |
| 359 | '$VARS.maxQueryLength' => $context->encodeJson( |
| 360 | // In debug mode, let the client fetch each module in |
| 361 | // its own dedicated request (T85805). |
| 362 | // This is effectively the equivalent of ClientHtml::makeLoad, |
| 363 | // which does this for stylesheets. |
| 364 | !$context->getDebug() ? $this->getMaxQueryLength() : 0 |
| 365 | ), |
| 366 | '$VARS.storeEnabled' => $context->encodeJson( |
| 367 | $conf->get( MainConfigNames::ResourceLoaderStorageEnabled ) |
| 368 | && !$context->getDebug() |
| 369 | && $context->getRequest()->getRawVal( 'safemode' ) !== '1' |
| 370 | ), |
| 371 | '$VARS.storeKey' => $context->encodeJson( $this->getStoreKey() ), |
| 372 | '$VARS.storeVary' => $context->encodeJson( $this->getStoreVary( $context ) ), |
| 373 | '$VARS.groupUser' => $context->encodeJson( $this->getGroupId( self::GROUP_USER ) ), |
| 374 | '$VARS.groupPrivate' => $context->encodeJson( $this->getGroupId( self::GROUP_PRIVATE ) ), |
| 375 | '$VARS.sourceMapLinks' => $context->encodeJson( |
| 376 | $conf->get( MainConfigNames::ResourceLoaderEnableSourceMapLinks ) |
| 377 | ), |
| 378 | |
| 379 | // When profiling is enabled, insert the calls. |
| 380 | // When disabled (the default), insert nothing. |
| 381 | '$CODE.profileExecuteStart();' => $enableJsProfiler |
| 382 | ? 'mw.loader.profiler.onExecuteStart( module );' |
| 383 | : '', |
| 384 | '$CODE.profileExecuteEnd();' => $enableJsProfiler |
| 385 | ? 'mw.loader.profiler.onExecuteEnd( module );' |
| 386 | : '', |
| 387 | '$CODE.profileScriptStart();' => $enableJsProfiler |
| 388 | ? 'mw.loader.profiler.onScriptStart( module );' |
| 389 | : '', |
| 390 | '$CODE.profileScriptEnd();' => $enableJsProfiler |
| 391 | ? 'mw.loader.profiler.onScriptEnd( module );' |
| 392 | : '', |
| 393 | |
| 394 | // Debug stubs |
| 395 | '$CODE.consoleLog();' => $context->getDebug() |
| 396 | ? 'console.log.apply( console, arguments );' |
| 397 | : '', |
| 398 | |
| 399 | // As a paranoia measure, create a window.QUnit placeholder that shadows any |
| 400 | // DOM global (e.g. for <h2 id="QUnit">), to avoid test code in prod (T356768). |
| 401 | '$CODE.undefineQUnit();' => !$conf->get( MainConfigNames::EnableJavaScriptTest ) |
| 402 | ? 'window.QUnit = undefined;' |
| 403 | : '', |
| 404 | ]; |
| 405 | $mwLoaderCode = strtr( $mwLoaderCode, $mwLoaderPairs ); |
| 406 | |
| 407 | // Perform string replacements for startup.js |
| 408 | $pairs = [ |
| 409 | // Raw JavaScript code (not JSON) |
| 410 | '$CODE.registrations();' => trim( $this->getModuleRegistrations( $context ) ), |
| 411 | '$CODE.defineLoader();' => $mwLoaderCode, |
| 412 | ]; |
| 413 | $startupCode = strtr( $startupCode, $pairs ); |
| 414 | |
| 415 | return [ |
| 416 | 'plainScripts' => [ |
| 417 | [ |
| 418 | 'virtualFilePath' => new FilePath( |
| 419 | 'resources/src/startup/startup.js', |
| 420 | MW_INSTALL_PATH, |
| 421 | $conf->get( MainConfigNames::ResourceBasePath ) |
| 422 | ), |
| 423 | 'content' => $startupCode, |
| 424 | ], |
| 425 | ], |
| 426 | ]; |
| 427 | } |
| 428 | |
| 429 | public function supportsURLLoading(): bool { |
| 430 | return false; |
| 431 | } |
| 432 | |
| 433 | public function enableModuleContentVersion(): bool { |
| 434 | // Enabling this means that ResourceLoader::getVersionHash will simply call getScript() |
| 435 | // and hash it to determine the version (as used by E-Tag HTTP response header). |
| 436 | return true; |
| 437 | } |
| 438 | } |