Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.83% covered (warning)
89.83%
159 / 177
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
StartUpModule
89.83% covered (warning)
89.83%
159 / 177
72.73% covered (warning)
72.73%
8 / 11
52.63
0.00% covered (danger)
0.00%
0 / 1
 getImplicitDependencies
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 compileUnresolvedDependencies
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getModuleRegistrations
86.57% covered (warning)
86.57%
58 / 67
0.00% covered (danger)
0.00%
0 / 1
18.79
 getGroupId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getBaseModules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStoreKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMaxQueryLength
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getStoreVary
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getScript
87.69% covered (warning)
87.69%
57 / 65
0.00% covered (danger)
0.00%
0 / 1
12.27
 supportsURLLoading
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 enableModuleContentVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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 */
8namespace MediaWiki\ResourceLoader;
9
10use DomainException;
11use Exception;
12use MediaWiki\MainConfigNames;
13use 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 */
31class 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}