jenkins-bot has submitted this change and it was merged. ( https://gerrit.wikimedia.org/r/365888 )
Change subject: resourceloader: Add support for modules sending preload headers ...................................................................... resourceloader: Add support for modules sending preload headers ResourceLoaderModule objects gain a new method getPreloadLinks() which returns an array with the meta data required to build a Link rel=preload header according to the current draft for W3C Preload. <https://w3c.github.io/preload/> Another implementation of this is already in use in OutputPage for preloading the logo image. This array is formatted by the ResourceLoaderModule::getHeaders method, which is implemented as "final" at this time, thus restricting use to the Link rel=preload header. Headers are exposed and process-cached, like all other content (scripts, styles, etc.), through ResourceLoaderModule::getModuleContent, and aggregated by ResoureLoader::makeModuleResponse. I had hoped for the getPreloadLinks to be stateless (not vary on $context). Whether something should be preloaded and what, should not vary on the skin or language. However, while that conceptually holds true, the exact url for any given resource may still vary. Even the main use case for this feature (T164299, preloading base modules request) require $context to pass down skin and lang to the load.php url. Add full test coverage and example documentation. Bug: T164299 Change-Id: I2bfe0796ceaa0c82579c501f5b10e931f2175681 --- M includes/resourceloader/ResourceLoader.php M includes/resourceloader/ResourceLoaderModule.php M tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php M tests/phpunit/includes/resourceloader/ResourceLoaderTest.php 4 files changed, 218 insertions(+), 2 deletions(-) Approvals: jenkins-bot: Verified Gilles: Looks good to me, approved diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php index 2f29200..ad16420 100644 --- a/includes/resourceloader/ResourceLoader.php +++ b/includes/resourceloader/ResourceLoader.php @@ -80,6 +80,15 @@ protected $errors = []; /** + * List of extra HTTP response headers provided by loaded modules. + * + * Populated by makeModuleResponse(). + * + * @var array + */ + protected $extraHeaders = []; + + /** * @var MessageBlobStore */ protected $blobStore; @@ -794,7 +803,7 @@ } } - $this->sendResponseHeaders( $context, $etag, (bool)$this->errors ); + $this->sendResponseHeaders( $context, $etag, (bool)$this->errors, $this->extraHeaders ); // Remove the output buffer and output the response ob_end_clean(); @@ -827,9 +836,12 @@ * @param ResourceLoaderContext $context * @param string $etag ETag header value * @param bool $errors Whether there are errors in the response + * @param string[] $extra Array of extra HTTP response headers * @return void */ - protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) { + protected function sendResponseHeaders( + ResourceLoaderContext $context, $etag, $errors, array $extra = [] + ) { \MediaWiki\HeaderCallback::warnIfHeadersSent(); $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' ); // Use a short cache expiry so that updates propagate to clients quickly, if: @@ -872,6 +884,9 @@ header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" ); $exp = min( $maxage, $smaxage ); header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) ); + } + foreach ( $extra as $header ) { + header( $header ); } } @@ -1008,6 +1023,9 @@ /** * Generate code for a response. * + * Calling this method also populates the `errors` and `headers` members, + * later used by respond(). + * * @param ResourceLoaderContext $context Context in which to generate a response * @param ResourceLoaderModule[] $modules List of module objects keyed by module name * @param string[] $missing List of requested module names that are unregistered (optional) @@ -1052,6 +1070,10 @@ $implementKey = $name . '@' . $module->getVersionHash( $context ); $strContent = ''; + if ( isset( $content['headers'] ) ) { + $this->extraHeaders = array_merge( $this->extraHeaders, $content['headers'] ); + } + // Append output switch ( $context->getOnly() ) { case 'scripts': diff --git a/includes/resourceloader/ResourceLoaderModule.php b/includes/resourceloader/ResourceLoaderModule.php index 1608901..b3c1cd1 100644 --- a/includes/resourceloader/ResourceLoaderModule.php +++ b/includes/resourceloader/ResourceLoaderModule.php @@ -587,6 +587,81 @@ } /** + * Get headers to send as part of a module web response. + * + * It is not supported to send headers through this method that are + * required to be unique or otherwise sent once in an HTTP response + * because clients may make batch requests for multiple modules (as + * is the default behaviour for ResourceLoader clients). + * + * For exclusive or aggregated headers, see ResourceLoader::sendResponseHeaders(). + * + * @since 1.30 + * @param ResourceLoaderContext $context + * @return string[] Array of HTTP response headers + */ + final public function getHeaders( ResourceLoaderContext $context ) { + $headers = []; + + $formattedLinks = []; + foreach ( $this->getPreloadLinks( $context ) as $url => $attribs ) { + $link = "<{$url}>;rel=preload"; + foreach ( $attribs as $key => $val ) { + $link .= ";{$key}={$val}"; + } + $formattedLinks[] = $link; + } + if ( $formattedLinks ) { + $headers[] = 'Link: ' . implode( ',', $formattedLinks ); + } + + return $headers; + } + + /** + * Get a list of resources that web browsers may preload. + * + * Behaviour of rel=preload link is specified at <https://www.w3.org/TR/preload/>. + * + * Use case for ResourceLoader originally part of T164299. + * + * @par Example + * @code + * protected function getPreloadLinks() { + * return [ + * 'https://example.org/script.js' => [ 'as' => 'script' ], + * 'https://example.org/image.png' => [ 'as' => 'image' ], + * ]; + * } + * @encode + * + * @par Example using HiDPI image variants + * @code + * protected function getPreloadLinks() { + * return [ + * 'https://example.org/logo.png' => [ + * 'as' => 'image', + * 'media' => 'not all and (min-resolution: 2dppx)', + * ], + * 'https://example.org/[email protected]' => [ + * 'as' => 'image', + * 'media' => '(min-resolution: 2dppx)', + * ], + * ]; + * } + * @encode + * + * @see ResourceLoaderModule::getHeaders + * @since 1.30 + * @param ResourceLoaderContext $context + * @return array Keyed by url, values must be an array containing + * at least an 'as' key. Optionally a 'media' key as well. + */ + protected function getPreloadLinks( ResourceLoaderContext $context ) { + return []; + } + + /** * Get module-specific LESS variables, if any. * * @since 1.27 @@ -711,6 +786,11 @@ $content['templates'] = $templates; } + $headers = $this->getHeaders( $context ); + if ( $headers ) { + $content['headers'] = $headers; + } + $statTiming = microtime( true ) - $statStart; $statName = strtr( $this->getName(), '.', '_' ); $stats->timing( "resourceloader_build.all", 1000 * $statTiming ); diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php index c861b37..7c7f1cf 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php @@ -180,4 +180,43 @@ 'Substitute placeholders' ); } + + /** + * @covers ResourceLoaderModule::getHeaders + * @covers ResourceLoaderModule::getPreloadLinks + */ + public function testGetHeaders() { + $context = $this->getResourceLoaderContext(); + + $module = new ResourceLoaderTestModule(); + $this->assertSame( [], $module->getHeaders( $context ), 'Default' ); + + $module = $this->getMockBuilder( ResourceLoaderTestModule::class ) + ->setMethods( [ 'getPreloadLinks' ] )->getMock(); + $module->method( 'getPreloadLinks' )->willReturn( [ + 'https://example.org/script.js' => [ 'as' => 'script' ], + ] ); + $this->assertSame( + [ + 'Link: <https://example.org/script.js>;rel=preload;as=script' + ], + $module->getHeaders( $context ), + 'Preload one resource' + ); + + $module = $this->getMockBuilder( ResourceLoaderTestModule::class ) + ->setMethods( [ 'getPreloadLinks' ] )->getMock(); + $module->method( 'getPreloadLinks' )->willReturn( [ + 'https://example.org/script.js' => [ 'as' => 'script' ], + '/example.png' => [ 'as' => 'image' ], + ] ); + $this->assertSame( + [ + 'Link: <https://example.org/script.js>;rel=preload;as=script,' . + '</example.png>;rel=preload;as=image' + ], + $module->getHeaders( $context ), + 'Preload two resources' + ); + } } diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php index e6f709d..e9d022f 100644 --- a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php @@ -794,4 +794,79 @@ 'startup response sets state to error' ); } + + /** + * Integration test for modules sending extra HTTP response headers. + * + * @covers ResourceLoaderModule::getHeaders + * @covers ResourceLoaderModule::buildContent + * @covers ResourceLoader::makeModuleResponse + */ + public function testMakeModuleResponseExtraHeaders() { + $module = $this->getMockBuilder( ResourceLoaderTestModule::class ) + ->setMethods( [ 'getPreloadLinks' ] )->getMock(); + $module->method( 'getPreloadLinks' )->willReturn( [ + 'https://example.org/script.js' => [ 'as' => 'script' ], + ] ); + + $rl = new EmptyResourceLoader(); + $rl->register( [ + 'foo' => $module, + ] ); + $context = $this->getResourceLoaderContext( + [ 'modules' => 'foo', 'only' => 'scripts' ], + $rl + ); + + $modules = [ 'foo' => $rl->getModule( 'foo' ) ]; + $response = $rl->makeModuleResponse( $context, $modules ); + $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders; + + $this->assertEquals( + [ + 'Link: <https://example.org/script.js>;rel=preload;as=script' + ], + $extraHeaders, + 'Extra headers' + ); + } + + /** + * @covers ResourceLoaderModule::getHeaders + * @covers ResourceLoaderModule::buildContent + * @covers ResourceLoader::makeModuleResponse + */ + public function testMakeModuleResponseExtraHeadersMulti() { + $foo = $this->getMockBuilder( ResourceLoaderTestModule::class ) + ->setMethods( [ 'getPreloadLinks' ] )->getMock(); + $foo->method( 'getPreloadLinks' )->willReturn( [ + 'https://example.org/script.js' => [ 'as' => 'script' ], + ] ); + + $bar = $this->getMockBuilder( ResourceLoaderTestModule::class ) + ->setMethods( [ 'getPreloadLinks' ] )->getMock(); + $bar->method( 'getPreloadLinks' )->willReturn( [ + '/example.png' => [ 'as' => 'image' ], + '/example.jpg' => [ 'as' => 'image' ], + ] ); + + $rl = new EmptyResourceLoader(); + $rl->register( [ 'foo' => $foo, 'bar' => $bar ] ); + $context = $this->getResourceLoaderContext( + [ 'modules' => 'foo|bar', 'only' => 'scripts' ], + $rl + ); + + $modules = [ 'foo' => $rl->getModule( 'foo' ), 'bar' => $rl->getModule( 'bar' ) ]; + $response = $rl->makeModuleResponse( $context, $modules ); + $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders; + $this->assertEquals( + [ + 'Link: <https://example.org/script.js>;rel=preload;as=script', + 'Link: </example.png>;rel=preload;as=image,</example.jpg>;rel=preload;as=image' + ], + $extraHeaders, + 'Extra headers' + ); + } } -- To view, visit https://gerrit.wikimedia.org/r/365888 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I2bfe0796ceaa0c82579c501f5b10e931f2175681 Gerrit-PatchSet: 8 Gerrit-Project: mediawiki/core Gerrit-Branch: master Gerrit-Owner: Krinkle <[email protected]> Gerrit-Reviewer: Aaron Schulz <[email protected]> Gerrit-Reviewer: Catrope <[email protected]> Gerrit-Reviewer: Gilles <[email protected]> Gerrit-Reviewer: Krinkle <[email protected]> Gerrit-Reviewer: Legoktm <[email protected]> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list [email protected] https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits
