diff --git a/README.md b/README.md index 3f260a904..d581d4958 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Video.js Compatibility: 7.x, 8.x - [vhs.stats](#vhsstats) - [Events](#events) - [loadedmetadata](#loadedmetadata) + - [xhr-hooks-ready](#xhr-hooks-ready) - [VHS Usage Events](#vhs-usage-events) - [Presence Stats](#presence-stats) - [Use Stats](#use-stats) @@ -639,36 +640,63 @@ Type: `function` The xhr function that is used by VHS internally is exposed on the per- player `vhs` object. While it is possible, we do not recommend replacing the function with your own implementation. Instead, `xhr` provides -the ability to specify `onRequest` and `onResponse` hooks which take a -callback function as a parameter as well as `offRequest` and `offResponse` -functions which will remove a callback function from the `onRequest` or -`onResponse` set if it exists. +the ability to specify `onRequest` and `onResponse` hooks which each take a +callback function as a parameter, as well as `offRequest` and `offResponse` +functions which can remove a callback function from the `onRequest` or +`onResponse` Set. An `xhr-hooks-ready` event is fired from a player when per-player +hooks are ready to be added or removed. This will ensure player specific hooks are +set prior to any manifest or segment requests. -The `onRequest(callback)` function takes a `callback` function that will pass the xhr `request` -Object to that callback. These callbacks are called synchronously, in the order registered -and act as pre-request hooks for modifying the xhr `request` Object prior to making a request. +The `onRequest(callback)` function takes a `callback` function that will pass an xhr `options` +Object to that callback. These callbacks are called synchronously, in the order registered +and act as pre-request hooks for modifying the xhr `options` Object prior to making a request. + +Note: This callback *MUST* return an `options` Object as the `xhr` wrapper and each `onRequest` +hook receives the returned `options` as a parameter. Example: ```javascript -const playerRequestHook = (request) => { - const requestUrl = new URL(request.uri); - requestUrl.searchParams.set('foo', 'bar'); - request.uri = requestUrl.href; -}; -player.tech().vhs.xhr.onRequest(playerRequestHook); +player.on('xhr-hooks-ready', () => { + const playerRequestHook = (options) => { + return { + uri: 'https://new.options.uri' + }; + }; + player.tech().vhs.xhr.onRequest(playerRequestHook); +}); +``` + +If access to the `xhr` Object is required prior to the `xhr.send` call, an `options.beforeSend` +callback can be set within an `onRequest` callback function that will pass the `xhr` Object +as a parameter and will be called immediately prior to `xhr.send`. + +Example: +```javascript +player.on('xhr-hooks-ready', () => { + const playerXhrRequestHook = (options) => { + options.beforeSend = (xhr) => { + xhr.setRequestHeader('foo', 'bar'); + }; + return options; + }; + player.tech().vhs.xhr.onRequest(playerXhrRequestHook); +}); ``` The `onResponse(callback)` function takes a `callback` function that will pass the xhr `request`, `error`, and `response` Objects to that callback. These callbacks are called in the order registered and act as post-request hooks for gathering data from the -xhr `request`, `error` and `response` Objects. +xhr `request`, `error` and `response` Objects. `onResponse` callbacks do not require a +return value, the parameters are passed to each subsequent callback by reference. Example: ```javascript -const playerResponseHook = (request, error, response) => { - const bar = response.headers.foo -}; -player.tech().vhs.xhr.onResponse(playerResponseHook); +player.on('xhr-hooks-ready', () => { + const playerResponseHook = (request, error, response) => { + const bar = response.headers.foo; + }; + player.tech().vhs.xhr.onResponse(playerResponseHook); +}); ``` The `offRequest` function takes a `callback` function, and will remove that function from @@ -676,7 +704,9 @@ the collection of `onRequest` hooks if it exists. Example: ```javascript -player.tech().vhs.xhr.offRequest(playerRequestHook); +player.on('xhr-hooks-ready', () => { + player.tech().vhs.xhr.offRequest(playerRequestHook); +}); ``` The `offResponse` function takes a `callback` function, and will remove that function from @@ -684,40 +714,37 @@ the collection of `offResponse` hooks if it exists. Example: ```javascript -player.tech().vhs.xhr.offResponse(playerResponseHook); +player.on('xhr-hooks-ready', () => { + player.tech().vhs.xhr.offResponse(playerResponseHook); +}); ``` -Additionally a `beforeRequest` function can be defined, -that will be called with an object containing the options that will be used -to create the xhr request. -Note: any registered `onRequest` hooks, are called _after_ the `beforeRequest` function, so xhr -options modified by this function may be further modified by these hooks. +The global `videojs.Vhs` also exposes an `xhr` property. Adding `onRequest` +and/or `onResponse` hooks will allow you to intercept the request options and xhr +Object as well as request, error, and response data for *all* requests in *every* +player on a page. For consistency across browsers the video source should be set +at runtime once the video player is ready. Example: ```javascript -player.tech().vhs.xhr.beforeRequest = function(options) { - options.uri = options.uri.replace('example.com', 'foo.com'); - - return options; +// Global request callback, will affect every player. +const globalRequestHook = (options) => { + return { + uri: 'https://new.options.global.uri' + }; }; +videojs.Vhs.xhr.onRequest(globalRequestHook); ``` -The global `videojs.Vhs` also exposes an `xhr` property. Adding -`onRequest`, `onResponse` hooks and/or specifying a `beforeRequest` -function that will allow you to intercept the request Object, response -data and options for *all* requests in every player on a page. For -consistency across browsers the video source should be set at runtime -once the video player is ready. - -Example: ```javascript -// Global request callback, will affect every player. -const globalRequestHook = (request) => { - const requestUrl = new URL(request.uri); - requestUrl.searchParams.set('foo', 'bar'); - request.uri = requestUrl.href; +// Global request callback defining beforeSend function, will affect every player. +const globalXhrRequestHook = (options) => { + options.beforeSend = (xhr) => { + xhr.setRequestHeader('foo', 'bar'); + }; + return options; }; -videojs.Vhs.xhr.onRequest(globalRequestHook); +videojs.Vhs.xhr.onRequest(globalXhrRequestHook); ``` ```javascript @@ -739,24 +766,6 @@ videojs.Vhs.xhr.offRequest(globalRequestHook); videojs.Vhs.xhr.offResponse(globalResponseHook); ``` -```javascript -videojs.Vhs.xhr.beforeRequest = function(options) { - /* - * Modifications to requests that will affect every player. - */ - - return options; -}; - -var player = videojs('video-player-id'); -player.ready(function() { - this.src({ - src: 'https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8', - type: 'application/x-mpegURL', - }); -}); -``` - For information on the type of options that you can modify see the documentation at [https://github.com/Raynos/xhr](https://github.com/Raynos/xhr). @@ -796,6 +805,11 @@ are triggered on the player object. Fired after the first segment is downloaded for a playlist. This will not happen until playback if video.js's `metadata` setting is `none` +#### xhr-hooks-ready + +Fired when the player `xhr` object is ready to set `onRequest` and `onResponse` hooks, as well +as remove hooks with `offRequest` and `offResponse`. + ### VHS Usage Events Usage tracking events are fired when we detect a certain HLS feature, encoding setting, diff --git a/src/videojs-http-streaming.js b/src/videojs-http-streaming.js index fc0e60993..38cb53c0c 100644 --- a/src/videojs-http-streaming.js +++ b/src/videojs-http-streaming.js @@ -1332,6 +1332,10 @@ class VhsHandler extends Component { this.xhr.offResponse = (callback) => { removeOnResponseHook(this.xhr, callback); }; + + // Trigger an event on the player to notify the user that vhs is ready to set xhr hooks. + // This allows hooks to be set before the source is set to vhs when handleSource is called. + this.player_.trigger('xhr-hooks-ready'); } } @@ -1356,7 +1360,6 @@ const VhsSourceHandler = { tech.vhs = new VhsHandler(source, tech, localOptions); tech.vhs.xhr = xhrFactory(); tech.vhs.setupXhrHooks_(); - tech.vhs.src(source.src, source.type); return tech.vhs; }, diff --git a/src/xhr.js b/src/xhr.js index 4cb45fde6..7d3c213d8 100644 --- a/src/xhr.js +++ b/src/xhr.js @@ -57,19 +57,38 @@ const callbackWrapper = function(request, error, response, callback) { }; /** - * Iterates over a Set of callback hooks and calls them in order + * Iterates over the request hooks Set and calls them in order * - * @param {Set} hooks the hook set to iterate over + * @param {Set} hooks the hook Set to iterate over + * @param {Object} options the request options to pass to the xhr wrapper + * @return the callback hook function return value, the modified or new options Object. + */ +const callAllRequestHooks = (requestSet, options) => { + if (!requestSet || !requestSet.size) { + return; + } + let newOptions = options; + + requestSet.forEach((requestCallback) => { + newOptions = requestCallback(newOptions); + }); + return newOptions; +}; + +/** + * Iterates over the response hooks Set and calls them in order. + * + * @param {Set} hooks the hook Set to iterate over * @param {Object} request the xhr request object * @param {Object} error the xhr error object * @param {Object} response the xhr response object */ -const callAllHooks = (hooks, request, error, response) => { - if (!hooks) { +const callAllResponseHooks = (responseSet, request, error, response) => { + if (!responseSet || !responseSet.size) { return; } - hooks.forEach((hookCallback) => { - hookCallback(request, error, response); + responseSet.forEach((responseCallback) => { + responseCallback(request, error, response); }); }; @@ -82,26 +101,32 @@ const xhrFactory = function() { // Allow an optional user-specified function to modify the option // object before we construct the xhr request + // TODO: Remove beforeRequest in the next major release. const beforeRequest = XhrFunction.beforeRequest || videojs.Vhs.xhr.beforeRequest; // onRequest and onResponse hooks as a Set, at either the player or global level. - const _requestCallbackSet = XhrFunction._requestCallbackSet || videojs.Vhs.xhr._requestCallbackSet; + // TODO: new Set added here for beforeRequest alias. Remove this when beforeRequest is removed. + const _requestCallbackSet = XhrFunction._requestCallbackSet || videojs.Vhs.xhr._requestCallbackSet || new Set(); const _responseCallbackSet = XhrFunction._responseCallbackSet || videojs.Vhs.xhr._responseCallbackSet; if (beforeRequest && typeof beforeRequest === 'function') { - const newOptions = beforeRequest(options); - - if (newOptions) { - options = newOptions; - } + videojs.log.warn('beforeRequest is deprecated, use onRequest instead.'); + _requestCallbackSet.add(beforeRequest); } // Use the standard videojs.xhr() method unless `videojs.Vhs.xhr` has been overriden // TODO: switch back to videojs.Vhs.xhr.name === 'XhrFunction' when we drop IE11 const xhrMethod = videojs.Vhs.xhr.original === true ? videojsXHR : videojs.Vhs.xhr; - const request = xhrMethod(options, function(error, response) { + // call all registered onRequest hooks, assign new options. + const beforeRequestOptions = callAllRequestHooks(_requestCallbackSet, options); + + // Remove the beforeRequest function from the hooks set so stale beforeRequest functions are not called. + _requestCallbackSet.delete(beforeRequest); + + // xhrMethod will call XMLHttpRequest.open and XMLHttpRequest.send + const request = xhrMethod(beforeRequestOptions || options, function(error, response) { // call all registered onResponse hooks - callAllHooks(_responseCallbackSet, request, error, response); + callAllResponseHooks(_responseCallbackSet, request, error, response); return callbackWrapper(request, error, response, callback); }); const originalAbort = request.abort; @@ -112,9 +137,6 @@ const xhrFactory = function() { }; request.uri = options.uri; request.requestTime = Date.now(); - // call all registered onRequest hooks - callAllHooks(_requestCallbackSet, request); - return request; }; diff --git a/test/videojs-http-streaming.test.js b/test/videojs-http-streaming.test.js index e943ab2eb..7be0ad38e 100644 --- a/test/videojs-http-streaming.test.js +++ b/test/videojs-http-streaming.test.js @@ -4005,6 +4005,7 @@ QUnit.test( this.standardXHRResponse(this.requests.shift()); assert.ok(beforeRequestCalled, 'beforeRequest was called'); + assert.equal(this.env.log.warn.calls, 2, 'warning logged for deprecation'); // verify stats assert.equal(this.player.tech_.vhs.stats.bandwidth, 4194304, 'default'); @@ -4030,6 +4031,7 @@ QUnit.test('Allows specifying the beforeRequest function globally', function(ass this.standardXHRResponse(this.requests.shift()); assert.ok(beforeRequestCalled, 'beforeRequest was called'); + assert.equal(this.env.log.warn.calls, 2, 'warning logged for deprecation'); delete videojs.Vhs.xhr.beforeRequest; @@ -4098,6 +4100,7 @@ QUnit.test('Allows overriding the global beforeRequest function', function(asser 'for the media playlist and media'); assert.equal(beforeGlobalRequestCalled, 1, 'global beforeRequest was called once ' + 'for the main playlist'); + assert.equal(this.env.log.warn.calls, 3, 'warning logged for deprecation'); delete videojs.Vhs.xhr.beforeRequest; }); @@ -4114,16 +4117,17 @@ QUnit.test('Allows setting onRequest hooks globally', function(assert) { this.clock.tick(1); openMediaSource(this.player, this.clock); - const globalRequestHook1 = (request) => { - const requestUrl = new URL(request.url); + const globalRequestHook1 = (options) => { + const requestUrl = new URL(options.uri); requestUrl.searchParams.set('foo', 'bar'); - request.url = decodeURIComponent(requestUrl.href); - actualRequestUrl = request.url; + actualRequestUrl = options.uri = requestUrl.href; onRequestHookCallCount++; + return options; }; - const globalRequestHook2 = () => { + const globalRequestHook2 = (options) => { onRequestHookCallCount++; + return options; }; videojs.Vhs.xhr.onRequest(globalRequestHook1); @@ -4151,16 +4155,17 @@ QUnit.test('Allows setting onRequest hooks on the player', function(assert) { this.clock.tick(1); openMediaSource(this.player, this.clock); - const playerRequestHook1 = (request) => { - const requestUrl = new URL(request.url); + const playerRequestHook1 = (options) => { + const requestUrl = new URL(options.uri); requestUrl.searchParams.set('foo', 'bar'); - request.url = decodeURIComponent(requestUrl.href); - actualRequestUrl = request.url; + actualRequestUrl = options.uri = requestUrl.href; onRequestHookCallCount++; + return options; }; - const playerRequestHook2 = () => { + const playerRequestHook2 = (options) => { onRequestHookCallCount++; + return options; }; // Setup player level xhr hooks. @@ -4193,31 +4198,33 @@ QUnit.test('Allows setting onRequest hooks globally and overriding with player h this.clock.tick(1); openMediaSource(this.player, this.clock); - const globalRequestHook1 = (request) => { - const requestUrl = new URL(request.url); + const globalRequestHook1 = (options) => { + const requestUrl = new URL(options.uri); requestUrl.searchParams.set('foo', 'bar'); - request.url = decodeURIComponent(requestUrl.href); - actualRequestUrlGlobal = request.url; + actualRequestUrlGlobal = options.uri = requestUrl.href; onRequestHookCallCountGlobal++; + return options; }; - const globalRequestHook2 = () => { + const globalRequestHook2 = (options) => { onRequestHookCallCountGlobal++; + return options; }; videojs.Vhs.xhr.onRequest(globalRequestHook1); videojs.Vhs.xhr.onRequest(globalRequestHook2); - const playerRequestHook1 = (request) => { - const requestUrl = new URL(request.url); + const playerRequestHook1 = (options) => { + const requestUrl = new URL(options.uri); requestUrl.searchParams.set('bar', 'foo'); - request.url = decodeURIComponent(requestUrl.href); - actualRequestUrlPlayer = request.url; + actualRequestUrlPlayer = options.uri = requestUrl.href; onRequestHookCallCountPlayer++; + return options; }; - const playerRequestHook2 = () => { + const playerRequestHook2 = (options) => { onRequestHookCallCountPlayer++; + return options; }; // Setup player level xhr hooks. @@ -4259,16 +4266,17 @@ QUnit.test('Allows removing onRequest hooks globally with offRequest', function( this.clock.tick(1); openMediaSource(this.player, this.clock); - const globalRequestHook1 = (request) => { - const requestUrl = new URL(request.url); + const globalRequestHook1 = (options) => { + const requestUrl = new URL(options.uri); requestUrl.searchParams.set('foo', 'bar'); - request.url = decodeURIComponent(requestUrl.href); - actualRequestUrl = request.url; + actualRequestUrl = options.uri = requestUrl.href; onRequestHookCallCount++; + return options; }; - const globalRequestHook2 = () => { + const globalRequestHook2 = (options) => { onRequestHookCallCount++; + return options; }; videojs.Vhs.xhr.onRequest(globalRequestHook1); @@ -4447,6 +4455,111 @@ QUnit.test('Allows removing onResponse hooks globally with offResponse', functio videojs.Vhs.xhr.offResponse(globalResponseHook2); }); +QUnit.test('Allows xhr object access in global onRequest hooks', function(assert) { + let onRequestHookCallCount = 0; + let expectedUrl; + + this.player.src({ + src: 'main.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + this.clock.tick(1); + + openMediaSource(this.player, this.clock); + const globalXhrRequestHook1 = (options) => { + options.beforeSend = (xhr) => { + xhr.open('GET', 'https://foo.bar'); + }; + onRequestHookCallCount++; + return options; + }; + const globalXhrRequestHook2 = (options) => { + options.beforeSend = (xhr) => { + xhr.open('GET', 'https://new.url'); + expectedUrl = xhr.url; + }; + onRequestHookCallCount++; + return options; + }; + + videojs.Vhs.xhr.onRequest(globalXhrRequestHook1); + videojs.Vhs.xhr.onRequest(globalXhrRequestHook2); + + // main + this.standardXHRResponse(this.requests.shift()); + + assert.equal(onRequestHookCallCount, 2, '2 onRequest hooks called'); + assert.equal(expectedUrl, 'https://new.url', 'request url modified by onRequest hook calling open on xhr'); + // remove global hooks for other tests + videojs.Vhs.xhr.offRequest(globalXhrRequestHook1); + videojs.Vhs.xhr.offRequest(globalXhrRequestHook2); +}); + +QUnit.test('Allows xhr object access in player onRequest hooks', function(assert) { + let onRequestHookCallCount = 0; + let expectedUrl; + + this.player.src({ + src: 'main.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + this.clock.tick(1); + + openMediaSource(this.player, this.clock); + const playerXhrRequestHook1 = (options) => { + options.beforeSend = (xhr) => { + xhr.open('GET', 'https://new.url'); + }; + onRequestHookCallCount++; + return options; + }; + const playerXhrRequestHook2 = (options) => { + options.beforeSend = (xhr) => { + xhr.open('GET', 'https://foo.bar'); + expectedUrl = xhr.url; + }; + onRequestHookCallCount++; + return options; + }; + + // Setup player level xhr hooks. + this.player.tech_.vhs.setupXhrHooks_(); + + this.player.tech_.vhs.xhr.onRequest(playerXhrRequestHook1); + this.player.tech_.vhs.xhr.onRequest(playerXhrRequestHook2); + + // main + this.standardXHRResponse(this.requests.shift()); + + assert.equal(onRequestHookCallCount, 2, '2 onRequest hooks called'); + assert.equal(expectedUrl, 'https://foo.bar', 'request url modified by onRequest hook calling open on xhr'); + // remove player hooks for other tests + this.player.tech_.vhs.xhr.offRequest(playerXhrRequestHook1); + this.player.tech_.vhs.xhr.offRequest(playerXhrRequestHook2); +}); + +QUnit.test('xhr-hooks-ready event fires as expected', function(assert) { + const done = assert.async(); + + this.player.on('xhr-hooks-ready', (event) => { + assert.equal(event.type, 'xhr-hooks-ready', 'event type is xhr-hooks-ready'); + assert.ok(this.player.tech(true).vhs.xhr.onRequest); + assert.ok(this.player.tech(true).vhs.xhr.onResponse); + assert.ok(this.player.tech(true).vhs.xhr.offRequest); + assert.ok(this.player.tech(true).vhs.xhr.offResponse); + done(); + }); + + this.player.src({ + src: 'main.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + this.clock.tick(1); +}); + QUnit.test( 'passes useCueTags vhs option to main playlist controller', function(assert) { diff --git a/test/xhr.test.js b/test/xhr.test.js index 7ab09f271..83cace694 100644 --- a/test/xhr.test.js +++ b/test/xhr.test.js @@ -33,6 +33,7 @@ QUnit.test('xhr respects beforeRequest', function(assert) { this.xhr(defaultOptions); assert.equal(this.requests.shift().url, 'player', 'url changed with player override'); + assert.equal(this.env.log.warn.calls, 1, 'warning logged for deprecation'); videojs.Vhs.xhr.beforeRequest = (options) => { options.url = 'global'; @@ -41,11 +42,46 @@ QUnit.test('xhr respects beforeRequest', function(assert) { this.xhr(defaultOptions); assert.equal(this.requests.shift().url, 'player', 'prioritizes player override'); + assert.equal(this.env.log.warn.calls, 1, 'warning logged for deprecation'); delete this.xhr.beforeRequest; this.xhr(defaultOptions); assert.equal(this.requests.shift().url, 'global', 'url changed with global override'); + assert.equal(this.env.log.warn.calls, 1, 'warning logged for deprecation'); + + delete videojs.Vhs.xhr.beforeRequest; +}); + +QUnit.test('beforeRequest can return a new options object', function(assert) { + const defaultOptions = { + url: 'default' + }; + + this.xhr(defaultOptions); + assert.equal(this.requests.shift().url, 'default', 'url the same without override'); + + videojs.Vhs.xhr.beforeRequest = () => { + return { uri: 'global-newOptions'}; + }; + + this.xhr(defaultOptions); + assert.equal(this.requests.shift().url, 'global-newOptions', 'url changed with global override'); + assert.equal(this.env.log.warn.calls, 1, 'warning logged for deprecation'); + + this.xhr.beforeRequest = () => { + return { uri: 'player-newOptions'}; + }; + + this.xhr(defaultOptions); + assert.equal(this.requests.shift().url, 'player-newOptions', 'url changed with player override'); + assert.equal(this.env.log.warn.calls, 1, 'warning logged for deprecation'); + + delete this.xhr.beforeRequest; + delete videojs.Vhs.xhr.beforeRequest; + + this.xhr(defaultOptions); + assert.equal(this.requests.shift().url, 'default', 'url the same without override'); }); QUnit.test('calls global and player onRequest hooks respectively', function(assert) { @@ -58,13 +94,15 @@ QUnit.test('calls global and player onRequest hooks respectively', function(asse // create the global onRequest set and 2 hooks videojs.Vhs.xhr._requestCallbackSet = new Set(); - const globalRequestHook1 = (request) => { - request.url = 'global'; + const globalRequestHook1 = (options) => { + options.url = 'global'; + return options; }; - const globalRequestHook2 = (request) => { - request.headers = { + const globalRequestHook2 = (options) => { + options.headers = { foo: 'bar' }; + return options; }; // add them to the set @@ -79,13 +117,15 @@ QUnit.test('calls global and player onRequest hooks respectively', function(asse // create the player onRequest set and 2 hooks this.xhr._requestCallbackSet = new Set(); - const playerRequestHook1 = (request) => { - request.url = 'player'; + const playerRequestHook1 = (options) => { + options.url = 'player'; + return options; }; - const playerRequestHook2 = (request) => { - request.headers = { + const playerRequestHook2 = (options) => { + options.headers = { bar: 'foo' }; + return options; }; // add them to the set