*/
protected $errors = [];
+ /**
+ * List of extra HTTP response headers provided by loaded modules.
+ *
+ * Populated by makeModuleResponse().
+ *
+ * @var array
+ */
+ protected $extraHeaders = [];
+
/**
* @var MessageBlobStore
*/
}
}
- $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();
* @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:
$exp = min( $maxage, $smaxage );
header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
}
+ foreach ( $extra as $header ) {
+ header( $header );
+ }
}
/**
/**
* 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)
$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':
$this->msgBlobs[$lang] = $blob;
}
+ /**
+ * 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/logo@2x.png' => [
+ * '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.
*
$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 );
'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'
+ );
+ }
}
'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'
+ );
+ }
}