<?php
/**
- * Base class for resource loading system.
- *
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
use Wikimedia\WrappedString;
/**
- * Dynamic JavaScript and CSS resource loading system.
+ * @defgroup ResourceLoader ResourceLoader
+ *
+ * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
+ */
+
+/**
+ * ResourceLoader is a loading system for JavaScript and CSS resources.
+ *
+ * For higher level documentation, see <https://www.mediawiki.org/wiki/ResourceLoader/Architecture>.
*
- * Most of the documentation is on the MediaWiki documentation wiki starting at:
- * https://www.mediawiki.org/wiki/ResourceLoader
+ * @ingroup ResourceLoader
+ * @since 1.17
*/
class ResourceLoader implements LoggerAwareInterface {
- /** @var int */
- const CACHE_VERSION = 8;
+ /** @var Config */
+ protected $config;
+ /** @var MessageBlobStore */
+ protected $blobStore;
- /** @var bool */
- protected static $debugMode = null;
+ /** @var LoggerInterface */
+ private $logger;
- /**
- * Module name/ResourceLoaderModule object pairs
- * @var array
- */
+ /** @var ResourceLoaderModule[] Map of (module name => ResourceLoaderModule) */
protected $modules = [];
-
- /**
- * Associative array mapping module name to info associative array
- * @var array
- */
+ /** @var array[] Map of (module name => associative info array) */
protected $moduleInfos = [];
-
- /** @var Config $config */
- protected $config;
-
/**
* Associative array mapping framework ids to a list of names of test suite modules
* like [ 'qunit' => [ 'mediawiki.tests.qunit.suites', 'ext.foo.tests', ... ], ... ]
* @var array
*/
protected $testModuleNames = [];
+ /** @var string[] List of module names that contain QUnit test suites */
+ protected $testSuiteModuleNames = [];
- /**
- * E.g. [ 'source-id' => 'http://.../load.php' ]
- * @var array
- */
+ /** @var array Map of (source => path); E.g. [ 'source-id' => 'http://.../load.php' ] */
protected $sources = [];
-
- /**
- * Errors accumulated during current respond() call.
- * @var array
- */
+ /** @var array Errors accumulated during current respond() call */
protected $errors = [];
-
- /**
- * List of extra HTTP response headers provided by loaded modules.
- *
- * Populated by makeModuleResponse().
- *
- * @var array
- */
+ /** @var string[] Extra HTTP response headers from modules loaded in makeModuleResponse() */
protected $extraHeaders = [];
- /**
- * @var MessageBlobStore
- */
- protected $blobStore;
+ /** @var bool */
+ protected static $debugMode = null;
- /**
- * @var LoggerInterface
- */
- private $logger;
+ /** @var int */
+ const CACHE_VERSION = 8;
- /** @var string JavaScript / CSS pragma to disable minification. **/
+ /** @var string JavaScript / CSS pragma to disable minification. * */
const FILTER_NOMIN = '/*@nomin*/';
/**
/**
* @internal For use by ServiceWiring only
+ * @codeCoverageIgnore
*/
public function registerTestModules() {
global $IP;
. 'Edit your <code>LocalSettings.php</code> to enable it.' );
}
- $testModules = [
- 'qunit' => [],
- ];
+ // This has a 'qunit' key for compat with the below hook.
+ $testModulesMeta = [ 'qunit' => [] ];
// Get test suites from extensions
// Avoid PHP 7.1 warning from passing $this by reference
$rl = $this;
- Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
+ Hooks::run( 'ResourceLoaderTestModules', [ &$testModulesMeta, &$rl ] );
$extRegistry = ExtensionRegistry::getInstance();
// In case of conflict, the deprecated hook has precedence.
- $testModules['qunit'] += $extRegistry->getAttribute( 'QUnitTestModules' );
+ $testModules = $testModulesMeta['qunit'] + $extRegistry->getAttribute( 'QUnitTestModules' );
- // Add the QUnit testrunner as implicit dependency to extension test suites.
- foreach ( $testModules['qunit'] as &$module ) {
- // Shuck any single-module dependency as an array
+ $testSuiteModuleNames = [];
+ foreach ( $testModules as $name => &$module ) {
+ // Turn any single-module dependency into an array
if ( isset( $module['dependencies'] ) && is_string( $module['dependencies'] ) ) {
$module['dependencies'] = [ $module['dependencies'] ];
}
+ // Ensure the testrunner loads before any test suites
$module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
- }
- // Get core test suites
- $testModules['qunit'] =
- ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
+ // Keep track of the test suites to load on SpecialJavaScriptTest
+ $testSuiteModuleNames[] = $name;
+ }
- foreach ( $testModules as $id => $names ) {
- // Register test modules
- $this->register( $testModules[$id] );
+ // Core test suites (their names have further precedence).
+ $testModules = ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules;
+ $testSuiteModuleNames[] = 'test.mediawiki.qunit.suites';
- // Keep track of their names so that they can be loaded together
- $this->testModuleNames[$id] = array_keys( $testModules[$id] );
- }
+ $this->register( $testModules );
+ $this->testSuiteModuleNames = $testSuiteModuleNames;
}
/**
}
/**
- * Get a list of test module names for one (or all) frameworks.
+ * Get a list of module names with QUnit test suites.
*
- * If the given framework id is unknkown, or if the in-object variable is not an array,
- * then it will return an empty array.
- *
- * @param string $framework Get only the test module names for one
- * particular framework (optional)
+ * @internal For use by SpecialJavaScriptTest only
* @return array
+ * @codeCoverageIgnore
*/
- public function getTestModuleNames( $framework = 'all' ) {
- if ( $framework == 'all' ) {
- return $this->testModuleNames;
- } elseif ( isset( $this->testModuleNames[$framework] )
- && is_array( $this->testModuleNames[$framework] )
- ) {
- return $this->testModuleNames[$framework];
- } else {
- return [];
- }
+ public function getTestSuiteModuleNames() {
+ return $this->testSuiteModuleNames;
}
/**
}
/**
+ * @internal For use by ResourceLoaderStartUpModule only.
+ */
+ const HASH_LENGTH = 5;
+
+ /**
+ * Create a hash for module versioning purposes.
+ *
+ * This hash is used in three ways:
+ *
+ * - To differentiate between the current version and a past version
+ * of a module by the same name.
+ *
+ * In the cache key of localStorage in the browser (mw.loader.store).
+ * This store keeps only one version of any given module. As long as the
+ * next version the client encounters has a different hash from the last
+ * version it saw, it will correctly discard it in favour of a network fetch.
+ *
+ * A browser may evict a site's storage container for any reason (e.g. when
+ * the user hasn't visited a site for some time, and/or when the device is
+ * low on storage space). Anecdotally it seems devices rarely keep unused
+ * storage beyond 2 weeks on mobile devices and 4 weeks on desktop.
+ * But, there is no hard limit or expiration on localStorage.
+ * ResourceLoader's Client also clears localStorage when the user changes
+ * their language preference or when they (temporarily) use Debug Mode.
+ *
+ * The only hard factors that reduce the range of possible versions are
+ * 1) the name and existence of a given module, and
+ * 2) the TTL for mw.loader.store, and
+ * 3) the `$wgResourceLoaderStorageVersion` configuration variable.
+ *
+ * - To identify a batch response of modules from load.php in an HTTP cache.
+ *
+ * When fetching modules in a batch from load.php, a combined hash
+ * is created by the JS code, and appended as query parameter.
+ *
+ * In cache proxies (e.g. Varnish, Nginx) and in the browser's HTTP cache,
+ * these urls are used to identify other previously cached responses.
+ * The range of possible versions a given version has to be unique amongst
+ * is determined by the maximum duration each response is stored for, which
+ * is controlled by `$wgResourceLoaderMaxage['versioned']`.
+ *
+ * - To detect race conditions between multiple web servers in a MediaWiki
+ * deployment of which some have the newer version and some still the older
+ * version.
+ *
+ * An HTTP request from a browser for the Startup manifest may be responded
+ * to by a server with the newer version. The browser may then use that to
+ * request a given module, which may then be responded to by a server with
+ * the older version. To avoid caching this for too long (which would pollute
+ * all other users without repairing itself), the combined hash that the JS
+ * client adds to the url is verified by the server (in ::sendResponseHeaders).
+ * If they don't match, we instruct cache proxies and clients to not cache
+ * this response as long as they normally would. This is also the reason
+ * that the algorithm used here in PHP must match the one used in JS.
+ *
+ * The fnv132 digest creates a 32-bit integer, which goes upto 4 Giga and
+ * needs up to 7 chars in base 36.
+ * Within 7 characters, base 36 can count up to 78,364,164,096 (78 Giga),
+ * (but with fnv132 we'd use very little of this range, mostly padding).
+ * Within 6 characters, base 36 can count up to 2,176,782,336 (2 Giga).
+ * Within 5 characters, base 36 can count up to 60,466,176 (60 Mega).
+ *
* @since 1.26
* @param string $value
* @return string Hash
*/
public static function makeHash( $value ) {
$hash = hash( 'fnv132', $value );
- return Wikimedia\base_convert( $hash, 16, 36, 7 );
+ // The base_convert will pad it (if too short),
+ // then substr() will trim it (if too long).
+ return substr(
+ Wikimedia\base_convert( $hash, 16, 36, self::HASH_LENGTH ),
+ 0,
+ self::HASH_LENGTH
+ );
}
/**
*
* @since 1.28
* @param ResourceLoaderContext $context
+ * @param string[]|null $modules
* @return string Hash
*/
- public function makeVersionQuery( ResourceLoaderContext $context ) {
+ public function makeVersionQuery( ResourceLoaderContext $context, array $modules = null ) {
+ if ( $modules === null ) {
+ wfDeprecated( __METHOD__ . ' without $modules', '1.34' );
+ $modules = $context->getModules();
+ }
// As of MediaWiki 1.28, the server and client use the same algorithm for combining
// version hashes. There is no technical reason for this to be same, and for years the
// implementations differed. If getCombinedVersion in PHP (used for StartupModule and
// E-Tag headers) differs in the future from getCombinedVersion in JS (used for 'version'
// query parameter), then this method must continue to match the JS one.
- $moduleNames = [];
- foreach ( $context->getModules() as $name ) {
+ $filtered = [];
+ foreach ( $modules as $name ) {
if ( !$this->getModule( $name ) ) {
// If a versioned request contains a missing module, the version is a mismatch
// as the client considered a module (and version) we don't have.
return '';
}
- $moduleNames[] = $name;
+ $filtered[] = $name;
}
- return $this->getCombinedVersion( $context, $moduleNames );
+ return $this->getCombinedVersion( $context, $filtered );
}
/**
// Do not allow private modules to be loaded from the web.
// This is a security issue, see T36907.
if ( $module->getGroup() === 'private' ) {
+ // Not a serious error, just means something is trying to access it (T101806)
$this->logger->debug( "Request for private module '$name' denied" );
- $this->errors[] = "Cannot show private module \"$name\"";
+ $this->errors[] = "Cannot build private module \"$name\"";
continue;
}
$modules[$name] = $module;
if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
return; // output handled
}
+ } else {
+ $fileCache = null;
}
// Generate a response
}
}
- // Save response to file cache unless there are errors
- if ( isset( $fileCache ) && !$this->errors && $missing === [] ) {
- // Cache single modules and images...and other requests if there are enough hits
- if ( ResourceFileCache::useFileCache( $context ) ) {
- if ( $fileCache->isCacheWorthy() ) {
- $fileCache->saveText( $response );
- } else {
- $fileCache->incrMissesRecent( $context->getRequest() );
- }
+ // Consider saving the response to file cache (unless there are errors).
+ if ( $fileCache &&
+ !$this->errors &&
+ $missing === [] &&
+ ResourceFileCache::useFileCache( $context )
+ ) {
+ if ( $fileCache->isCacheWorthy() ) {
+ // There were enough hits, save the response to the cache
+ $fileCache->saveText( $response );
+ } else {
+ $fileCache->incrMissesRecent( $context->getRequest() );
}
}
$errorText = implode( "\n\n", $this->errors );
$errorResponse = self::makeComment( $errorText );
if ( $context->shouldIncludeScripts() ) {
- $errorResponse .= 'if (window.console && console.error) {'
- . Xml::encodeJsCall( 'console.error', [ $errorText ] )
- . "}\n";
+ $errorResponse .= 'if (window.console && console.error) { console.error('
+ . $context->encodeJson( $errorText )
+ . "); }\n";
}
// Prepend error info to the response
// - Version mismatch (T117587, T47877)
if ( is_null( $context->getVersion() )
|| $errors
- || $context->getVersion() !== $this->makeVersionQuery( $context )
+ || $context->getVersion() !== $this->makeVersionQuery( $context, $context->getModules() )
) {
$maxage = $rlMaxage['unversioned']['client'];
$smaxage = $rlMaxage['unversioned']['server'];
$strContent = $scripts;
} elseif ( is_array( $scripts ) ) {
// ...except when $scripts is an array of URLs or an associative array
- $strContent = self::makeLoaderImplementScript( $implementKey, $scripts, [], [], [] );
+ $strContent = self::makeLoaderImplementScript(
+ $context,
+ $implementKey,
+ $scripts,
+ [],
+ [],
+ []
+ );
}
break;
case 'styles':
}
}
$strContent = self::makeLoaderImplementScript(
+ $context,
$implementKey,
$scripts,
$content['styles'] ?? [],
// Set the state of modules we didn't respond to with mw.loader.implement
if ( $states ) {
- $stateScript = self::makeLoaderStateScript( $states );
+ $stateScript = self::makeLoaderStateScript( $context, $states );
if ( !$context->getDebug() ) {
$stateScript = self::filter( 'minify-js', $stateScript );
}
}
} elseif ( $states ) {
$this->errors[] = 'Problematic modules: '
- . self::encodeJsonForScript( $states );
+ . $context->encodeJson( $states );
}
return $out;
/**
* Return JS code that calls mw.loader.implement with given module properties.
*
+ * @param ResourceLoaderContext $context
* @param string $name Module name or implement key (format "`[name]@[version]`")
* @param XmlJsCode|array|string $scripts Code as XmlJsCode (to be wrapped in a closure),
* list of URLs to JavaScript files, string of JavaScript for `$.globalEval`, or array with
* @throws MWException
* @return string JavaScript code
*/
- protected static function makeLoaderImplementScript(
- $name, $scripts, $styles, $messages, $templates
+ private static function makeLoaderImplementScript(
+ ResourceLoaderContext $context, $name, $scripts, $styles, $messages, $templates
) {
if ( $scripts instanceof XmlJsCode ) {
if ( $scripts->value === '' ) {
$scripts = null;
- } elseif ( self::inDebugMode() ) {
+ } elseif ( $context->getDebug() ) {
$scripts = new XmlJsCode( "function ( $, jQuery, require, module ) {\n{$scripts->value}\n}" );
} else {
$scripts = new XmlJsCode( 'function($,jQuery,require,module){' . $scripts->value . '}' );
// All of these essentially do $file = $file['content'];, some just have wrapping around it
if ( $file['type'] === 'script' ) {
// Multi-file modules only get two parameters ($ and jQuery are being phased out)
- if ( self::inDebugMode() ) {
+ if ( $context->getDebug() ) {
$file = new XmlJsCode( "function ( require, module ) {\n{$file['content']}\n}" );
} else {
$file = new XmlJsCode( 'function(require,module){' . $file['content'] . '}' );
}
$scripts = XmlJsCode::encodeObject( [
'main' => $scripts['main'],
- 'files' => XmlJsCode::encodeObject( $files, self::inDebugMode() )
- ], self::inDebugMode() );
+ 'files' => XmlJsCode::encodeObject( $files, $context->getDebug() )
+ ], $context->getDebug() );
} elseif ( !is_string( $scripts ) && !is_array( $scripts ) ) {
throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
}
];
self::trimArray( $module );
- return Xml::encodeJsCall( 'mw.loader.implement', $module, self::inDebugMode() );
+ return Xml::encodeJsCall( 'mw.loader.implement', $module, $context->getDebug() );
}
/**
* Returns JS code which, when called, will register a given list of messages.
*
- * @param mixed $messages Either an associative array mapping message key to value, or a
- * JSON-encoded message blob containing the same data, wrapped in an XmlJsCode object.
+ * @param mixed $messages Associative array mapping message key to value.
* @return string JavaScript code
*/
public static function makeMessageSetScript( $messages ) {
*
* @internal
* @since 1.32
- * @param bool|string|array $data
- * @return string JSON
+ * @param mixed $data
+ * @return string|false JSON string, false on error
*/
public static function encodeJsonForScript( $data ) {
// Keep output as small as possible by disabling needless escape modes
}
/**
- * Returns a JS call to mw.loader.state, which sets the state of one
- * ore more modules to a given value. Has two calling conventions:
- *
- * - ResourceLoader::makeLoaderStateScript( $name, $state ):
- * Set the state of a single module called $name to $state
+ * Returns a JS call to mw.loader.state, which sets the state of modules
+ * to a given value:
*
- * - ResourceLoader::makeLoaderStateScript( [ $name => $state, ... ] ):
+ * - ResourceLoader::makeLoaderStateScript( $context, [ $name => $state, ... ] ):
* Set the state of modules with the given names to the given states
*
- * @param array|string $states
- * @param string|null $state
+ * @internal
+ * @param ResourceLoaderContext $context
+ * @param array $states
* @return string JavaScript code
*/
- public static function makeLoaderStateScript( $states, $state = null ) {
- if ( !is_array( $states ) ) {
- $states = [ $states => $state ];
- }
+ public static function makeLoaderStateScript(
+ ResourceLoaderContext $context, array $states
+ ) {
return 'mw.loader.state('
- . self::encodeJsonForScript( $states )
+ . $context->encodeJson( $states )
. ');';
}
* @par Example
* @code
*
- * ResourceLoader::makeLoaderRegisterScript( [
+ * ResourceLoader::makeLoaderRegisterScript( $context, [
* [ $name1, $version1, $dependencies1, $group1, $source1, $skip1 ],
* [ $name2, $version2, $dependencies1, $group2, $source2, $skip2 ],
* ...
* ] ):
* @endcode
*
- * @internal
- * @since 1.32
+ * @internal For use by ResourceLoaderStartUpModule only
+ * @param ResourceLoaderContext $context
* @param array $modules Array of module registration arrays, each containing
* - string: module name
* - string: module version
* - string|null: Script body of a skip function (optional)
* @return string JavaScript code
*/
- public static function makeLoaderRegisterScript( array $modules ) {
+ public static function makeLoaderRegisterScript(
+ ResourceLoaderContext $context, array $modules
+ ) {
// Optimisation: Transform dependency names into indexes when possible
// to produce smaller output. They are expanded by mw.loader.register on
// the other end using resolveIndexedDependencies().
array_walk( $modules, [ self::class, 'trimArray' ] );
return 'mw.loader.register('
- . self::encodeJsonForScript( $modules )
+ . $context->encodeJson( $modules )
. ');';
}
/**
* Returns JS code which calls mw.loader.addSource() with the given
- * parameters. Has two calling conventions:
+ * parameters.
*
- * - ResourceLoader::makeLoaderSourcesScript( $id, $properties ):
- * Register a single source
- *
- * - ResourceLoader::makeLoaderSourcesScript( [ $id1 => $loadUrl, $id2 => $loadUrl, ... ] );
+ * - ResourceLoader::makeLoaderSourcesScript( $context,
+ * [ $id1 => $loadUrl, $id2 => $loadUrl, ... ]
+ * );
* Register sources with the given IDs and properties.
*
- * @param string|array $sources Source ID
- * @param string|null $loadUrl load.php url
+ * @internal For use by ResourceLoaderStartUpModule only
+ * @param ResourceLoaderContext $context
+ * @param array $sources
* @return string JavaScript code
*/
- public static function makeLoaderSourcesScript( $sources, $loadUrl = null ) {
- if ( !is_array( $sources ) ) {
- $sources = [ $sources => $loadUrl ];
- }
+ public static function makeLoaderSourcesScript(
+ ResourceLoaderContext $context, array $sources
+ ) {
return 'mw.loader.addSource('
- . self::encodeJsonForScript( $sources )
+ . $context->encodeJson( $sources )
. ');';
}
* @throws Exception
*/
public static function makeConfigSetScript( array $configuration ) {
- $js = Xml::encodeJsCall(
- 'mw.config.set',
- [ $configuration ],
- self::inDebugMode()
- );
- if ( $js === false ) {
+ $json = self::encodeJsonForScript( $configuration );
+ if ( $json === false ) {
$e = new Exception(
'JSON serialization of config data failed. ' .
'This usually means the config data is not valid UTF-8.'
);
MWExceptionHandler::logException( $e );
- $js = Xml::encodeJsCall( 'mw.log.error', [ $e->__toString() ] );
+ return 'mw.log.error(' . self::encodeJsonForScript( $e->__toString() ) . ');';
}
- return $js;
+ return "mw.config.set($json);";
}
/**
* Get global LESS variables.
*
* @since 1.27
- * @deprecated since 1.32 Use ResourceLoderModule::getLessVars() instead.
+ * @deprecated since 1.32 Use ResourceLoaderModule::getLessVars() instead.
* @return array Map of variable names to string CSS values.
*/
public function getLessVars() {
+ wfDeprecated( __METHOD__, '1.32' );
return [];
}
}