Skip to content

Commit

Permalink
Merge pull request #18 from voxmedia/ba-vast-wrapper-support
Browse files Browse the repository at this point in the history
Vast Wrapper Support
  • Loading branch information
Brian Anderson authored Mar 10, 2019
2 parents b59bde0 + 84637bc commit b28d054
Show file tree
Hide file tree
Showing 25 changed files with 623 additions and 99 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## v1.1 (March 3, 2019)

- Preparing for open sourcing, adding documentation
- Adds Wrapper/VASTAdTagURI support, `async` in more loading paths
- Moves XML Node Value parsing to it's own library

## v1.0

- Basic Vast support
- Applications for `<video>` element and `videojs`
88 changes: 60 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,76 @@

This is the Concert Vast Parser. It consumes simple inline video VAST tags and provides a nice wrapper to interact with the XML response in a Concert-opinionated way.

### Using this library
## Vast Elements Supported

The Vast Libary offers high and low level interacition with the Vast format. The following example is the simplest case to implement preroll on a `<video>` element.
```
┌─────────────────┐
│ │
┌──────▶│ Video │
│ │ │
│ └─────────────────┘
┌─────────────────┐ │ ┌─────────────────┐
│ Inline Vast │ │ │ │
│ Document │──────┼──────▶│ Clickthrough │
│ │ │ │ │
└─▲───────────────┘ │ └─────────────────┘
: ; │
╲ ╱ │ ┌─────────────────┐
`. ,' │ │ │
`─────' └──────▶│ Impressions │
Wrapper Url │ │
Following └─────────────────┘
Progress, Errors
and Impressions
```

### Concert Vast's Applications for Preroll

The Concert Vast libary offers a high interaction with Vast tags.

As of version 1.0 there are currently two built in Applications:

- **`<video>`** element support via `vast.applyToVideoElementAsPreroll(document.querySelector('video'))`
- **[`videojs`](https://videojs.com/)** support using `applyToVideoJsAsPreroll()`

The following example is the simplest case to implement preroll on a `<video>` element.

```html
<video controls>
<video>
<source src="YOUR GREAT VIDEO URL HERE" type="video/mp4" />
</video>
<script>
const videoElement = document.querySelector('video')
const vast = new ConcertVast()
vast.loadRemoteVast('VAST URL HERE').then(e => {
vast.loadRemoteVast('VAST URL HERE').then({
vast.applyToVideoElementAsPreroll(videoElement)
}).catch(error => {
// handle any errors here
})
</script>
```

It's also possible you don't want the preroll behavior, and you can use:
It's also possible you don't want to load the Vast videos as the main video, in which case the following method is available.

```js
vast.applyToVideoElement(videoElement)
vast.applyToVideoElement(videoElement);
```

#### Doing it on your own
For a full demo of this functionality see the examples found in /test/assets/.

### Full API for Granular Control

Here is a sample of the public API that is exposed in the ConcertVast Library _(more documentation coming)_
Here is a sample of a portion of the public API that is exposed in the ConcertVast Library _(more documentation coming)_

_Note: it is important to remember that all network interactions should anticipate delays and failures. To that end all network-possible methods are `async`-based and maybe throw errors._

```js
// Find the video element on the page
const videoElement = document.querySelector('video')
const videoElement = document.querySelector('video');

// Instantiate a new ConcertVast object
const cv = new ConcertVast()
const vast = new ConcertVast();

// Load the VAST URL, this is async so you can use
// await or .then() to delay execution until the vast
Expand All @@ -43,7 +80,7 @@ const cv = new ConcertVast()
// Optionally a timeout parameter (in ms) can be passed in to specify
// how long to wait for a vast response
try {
await cv.loadRemoteVast(url, { timeout: 10000 })
await vast.loadRemoteVast(url, { timeout: 10000 });
} catch (error) {
// if this raises an error, it is for the following reasons:
// - there was a network error (VastNetworkError)
Expand All @@ -55,30 +92,27 @@ try {
// - browser code support,
// - bitrate (from Vast request) and
// - player size)
const bestVastVideo = cv.bestVideo({
const bestVastVideo = vast.bestVideo({
height: videoElement.clientHeight,
width: videoElement.clientWidth,
})
});

// When using vanilla video element
Array.from(videoElement.querySelectorAll('source')).forEach(s => {
s.remove()
})
const vidSource = document.createElement('source')
vidSource.setAttribute('src', bestVastVideo.url())
vidSource.setAttribute('type', bestVastVideo.mimeType())
videoElement.appendChild(vidSource)
// Need to call load if you change the video source
videoElement.load()
s.remove();
});
const vidSource = document.createElement('source');
vidSource.setAttribute('src', bestVastVideo.url());
vidSource.setAttribute('type', bestVastVideo.mimeType());
videoElement.appendChild(vidSource);
videoElement.load();

// Or if using videojs
const player = videoJs(videoElement)
player.src([{ type: bestVastVideo.mimeType(), src: bestVastVideo.url() }])
const player = videoJs(videoElement);
player.src([{ type: bestVastVideo.mimeType(), src: bestVastVideo.url() }]);
```

### Documented Functionality

### Clone it and Run it
### Developing and Contributing

- Clone this repo
- Run `yarn install`
Expand All @@ -90,8 +124,6 @@ player.src([{ type: bestVastVideo.mimeType(), src: bestVastVideo.url() }])

### Remaining Work

1. Test this with the HymnalAd SDK Video player
1. Design error handling
1. Open source it 🙏

### Contributing
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "concert-vast",
"version": "1.0.0",
"version": "1.1.0",
"description": "Simple Vast Parsing for Concert Video Ads",
"main": "src/index.js",
"author": "Vox Media",
Expand Down Expand Up @@ -31,7 +31,8 @@
"pretty-quick": "^1.10.0",
"webpack": "^4.29.3",
"webpack-cli": "^3.2.3",
"webpack-dev-server": "^3.2.0"
"webpack-dev-server": "^3.2.0",
"xhr-mock": "^2.4.1"
},
"husky": {
"hooks": {
Expand Down
13 changes: 13 additions & 0 deletions src/lib/node_value.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default class NodeValue {
/**
* Returns the first TEXT or CDATA value from an XML element.
*
* @param {DOM Element} el An elemenet with a single CDATA or TEXT entity
*/
static fromElement(el) {
const matchedItem = Array.from(el.childNodes).find(n => {
return (n.nodeType == Node.TEXT_NODE || n.nodeType == Node.CDATA_SECTION_NODE) && !!n.nodeValue.trim();
});
return matchedItem ? matchedItem.nodeValue.trim() : null;
}
}
46 changes: 46 additions & 0 deletions src/lib/remote.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export class VastNetworkError extends Error {}

export default class Remote {
/**
* Fetches a remote XML Vast url. It has no knowledge of XML or the Vast structure
*
* @async
* @param {String} url - Where to download the XML
* @param {Integer} timeout - time in milleseconds to wait until for remote load
* @param {Function} onBandwidthUpdate - Callback when there is a new bandwidth estimate available,
* will be be passed a number representing KB/s
* @returns {Promise<String>} XML Response from the url
*/
static async loadUrl({ url, timeout = 10000, onBandwidthUpdate = () => {} }) {
return new Promise((resolve, reject) => {
this.vastUrl = url;
const request = new XMLHttpRequest();
request.timeout = timeout;
let startTime;

request.addEventListener('load', async e => {
const downloadTime = new Date().getTime() - startTime;
const downloadSize = request.responseText.length;
const bandwidthEstimateInKbs = (downloadSize * 8) / (downloadTime / 1000) / 1024;
onBandwidthUpdate(bandwidthEstimateInKbs);
resolve(request.response);
});

request.addEventListener('error', e => {
reject(new VastNetworkError(`Network Error: Request status: ${request.status}, ${request.responseText}`));
});

request.addEventListener('abort', e => {
reject(new VastNetworkError('Network Aborted'));
});

request.addEventListener('timeout', e => {
reject(new VastNetworkError(`Network Timeout: Request did not complete in ${timeout}ms`));
});

startTime = new Date().getTime();
request.open('GET', this.vastUrl);
request.send();
});
}
}
74 changes: 38 additions & 36 deletions src/lib/vast.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,42 @@ import Clickthrough from './vast_elements/clickthrough';
import Impression from './vast_elements/impression';
import ErrorImpression from './vast_elements/error_impression';
import TrackingEvents from './vast_elements/tracking_events';
import WrapperUrl from './vast_elements/wrapper_url';
import StreamChooser from './stream_chooser';

import Remote, { VastNetworkError } from './remote';

import VideoElementApplication from './applications/video_element';
import VideoJsApplication from './applications/video_js';

export class VastXMLParsingError extends Error {}
export class VastNetworkError extends Error {}

export default class Vast {
constructor({ xml } = {}) {
constructor({ xml, numberWrapperFollowsAllowed } = { numberWrapperFollowsAllowed: 5 }) {
this.vastXml = null;
this.vastUrl = null;
this.vastDocument = null;
this.bandwidthEstimateInKbs = 0;
this.wrapperFollowsRemaining = numberWrapperFollowsAllowed;

this.loadedElements = {
MediaFiles: new MediaFiles(this),
Clickthrough: new Clickthrough(this),
Impression: new Impression(this),
ErrorImpression: new ErrorImpression(this),
TrackingEvents: new TrackingEvents(this),
WrapperUrl: new WrapperUrl(this),
};

if (xml) {
this.useXmlString(xml);
}
}

useXmlString(xml) {
async useXmlString(xml) {
this.vastXml = xml;
this.vastDocument = null;
this.parse();
await this.parse();
}

bandwidth() {
Expand All @@ -57,6 +61,14 @@ export default class Vast {
return this.loadedElements['Clickthrough'].openClickthroughUrl();
}

wrapperUrl() {
return this.loadedElements['WrapperUrl'].wrapperUrl();
}

url() {
return this.vastUrl;
}

impressionUrls() {
return this.loadedElements['Impression'].impressionUrls();
}
Expand Down Expand Up @@ -120,51 +132,41 @@ export default class Vast {
return chooser.bestVideo();
}

parse() {
async parse() {
if (!this.vastDocument) {
const parser = new DOMParser();
this.vastDocument = parser.parseFromString(this.vastXml, 'application/xml');
if (this.vastDocument.documentElement.nodeName == 'parsererror') {
throw new VastXMLParsingError(`Error parsing ${this.vastXml}. Not valid XML`);
} else {
await this.processElements();
}
this.processAllElements();
}
}

async loadRemoteVast(url, { timeout } = { timeout: 10000 }) {
return new Promise((resolve, reject) => {
this.vastUrl = url;
const request = new XMLHttpRequest();
request.timeout = timeout;
let startTime;

request.addEventListener('load', e => {
const downloadTime = new Date().getTime() - startTime;
const downloadSize = request.responseText.length;
this.bandwidthEstimateInKbs = (downloadSize * 8) / (downloadTime / 1000) / 1024;

this.useXmlString(request.response);
resolve();
});

request.addEventListener('error', e => {
reject(new VastNetworkError(`Network Error: Request status: ${request.status}, ${request.responseText}`));
});

request.addEventListener('abort', e => {
reject(new VastNetworkError('Network Aborted'));
});

request.addEventListener('timeout', e => {
reject(new VastNetworkError(`Network Timeout: Request did not complete in ${timeout}ms`));
});
startTime = new Date().getTime();
request.open('GET', this.vastUrl);
request.send();
this.vastUrl = url;
const remoteVastXml = await Remote.loadUrl({
url: url,
timeout: timeout,
onBandwidthUpdate: bw => {
this.bandwidthEstimateInKbs = bw;
},
});

await this.useXmlString(remoteVastXml);
}

processAllElements() {
async processElements() {
Object.values(this.loadedElements).forEach(e => e.process());

if (this.wrapperUrl()) {
if (this.wrapperFollowsRemaining-- > 0) {
await this.loadRemoteVast(this.wrapperUrl());
} else {
this.addErrorImpressionUrls();
throw new VastNetworkError('Network Error: Too Many Vast Wrapper Follows');
}
}
}
}
4 changes: 3 additions & 1 deletion src/lib/vast_elements/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ The idea is that once the Vast XML is loaded, each VastElement will query the XM

### Implementing a new VastElement

There are three major methods that the extending class must support
There are four major methods that the extending class must support

1. `static selector()` – This should return a selector as a string, and is used to determine which elements your element class will consume

1. `static appendElementsOnFollow()` – Returns a boolean (defaults to `true`) and controls, if following Vast's Wrapper VASTAdTagURI url's, should append or reload this Vast Elements' knowledge of the DOM. For most elements this should return true thereby appending and merging all of the visited vast tag's elements. But in a few cases such as finding a new wrapper url to follow would cause redirect loops.

1. `setup()` – this will be called once your element class is loaded, use it like a constructor.

1. `onVastReady()` – The vast file has been onVastReady and your selector has been run, you can find your elements loaded in `this.elements`
Loading

0 comments on commit b28d054

Please sign in to comment.