resl
Version:
A streaming resource loader
427 lines (379 loc) • 10.3 kB
JavaScript
/* global XMLHttpRequest */
var configParameters = [
'manifest',
'onDone',
'onProgress',
'onError'
]
var manifestParameters = [
'type',
'src',
'stream',
'credentials',
'parser'
]
var parserParameters = [
'onData',
'onDone'
]
var STATE_ERROR = -1
var STATE_DATA = 0
var STATE_COMPLETE = 1
function raise (message) {
throw new Error('resl: ' + message)
}
function checkType (object, parameters, name) {
Object.keys(object).forEach(function (param) {
if (parameters.indexOf(param) < 0) {
raise('invalid parameter "' + param + '" in ' + name)
}
})
}
function Loader (name, cancel) {
this.state = STATE_DATA
this.ready = false
this.progress = 0
this.name = name
this.cancel = cancel
}
module.exports = function resl (config) {
if (typeof config !== 'object' || !config) {
raise('invalid or missing configuration')
}
checkType(config, configParameters, 'config')
var manifest = config.manifest
if (typeof manifest !== 'object' || !manifest) {
raise('missing manifest')
}
function getFunction (name, dflt) {
if (name in config) {
var func = config[name]
if (typeof func !== 'function') {
raise('invalid callback "' + name + '"')
}
return func
}
return null
}
var onDone = getFunction('onDone')
if (!onDone) {
raise('missing onDone() callback')
}
var onProgress = getFunction('onProgress')
var onError = getFunction('onError')
var assets = {}
var state = STATE_DATA
function loadXHR (request) {
var name = request.name
var stream = request.stream
var binary = request.type === 'binary'
var parser = request.parser
var xhr = new XMLHttpRequest()
var asset = null
var loader = new Loader(name, cancel)
if (stream) {
xhr.onreadystatechange = onReadyStateChange
} else {
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
onReadyStateChange()
}
}
}
if (binary) {
xhr.responseType = 'arraybuffer'
}
function onReadyStateChange () {
if (xhr.readyState < 2 ||
loader.state === STATE_COMPLETE ||
loader.state === STATE_ERROR) {
return
}
if (xhr.status !== 200) {
return abort('error loading resource "' + request.name + '"')
}
if (xhr.readyState > 2 && loader.state === STATE_DATA) {
var response
if (request.type === 'binary') {
response = xhr.response
} else {
response = xhr.responseText
}
if (parser.data) {
try {
asset = parser.data(response)
} catch (e) {
return abort(e)
}
} else {
asset = response
}
}
if (xhr.readyState > 3 && loader.state === STATE_DATA) {
if (parser.done) {
try {
asset = parser.done()
} catch (e) {
return abort(e)
}
}
loader.state = STATE_COMPLETE
}
assets[name] = asset
loader.progress = 0.75 * loader.progress + 0.25
loader.ready =
(request.stream && !!asset) ||
loader.state === STATE_COMPLETE
notifyProgress()
}
function cancel () {
if (loader.state === STATE_COMPLETE || loader.state === STATE_ERROR) {
return
}
xhr.onreadystatechange = null
xhr.abort()
loader.state = STATE_ERROR
}
// set up request
if (request.credentials) {
xhr.withCredentials = true
}
xhr.open('GET', request.src, true)
xhr.send()
return loader
}
function loadElement (request, element) {
var name = request.name
var parser = request.parser
var loader = new Loader(name, cancel)
var asset = element
function handleProgress () {
if (loader.state === STATE_DATA) {
if (parser.data) {
try {
asset = parser.data(element)
} catch (e) {
return abort(e)
}
} else {
asset = element
}
}
}
function onProgress (e) {
handleProgress()
assets[name] = asset
if (e.lengthComputable) {
loader.progress = Math.max(loader.progress, e.loaded / e.total)
} else {
loader.progress = 0.75 * loader.progress + 0.25
}
notifyProgress(name)
}
function onComplete () {
handleProgress()
if (loader.state === STATE_DATA) {
if (parser.done) {
try {
asset = parser.done()
} catch (e) {
return abort(e)
}
}
loader.state = STATE_COMPLETE
}
loader.progress = 1
loader.ready = true
assets[name] = asset
removeListeners()
notifyProgress('finish ' + name)
}
function onError () {
abort('error loading asset "' + name + '"')
}
if (request.stream) {
element.addEventListener('progress', onProgress)
}
if (request.type === 'image') {
element.addEventListener('load', onComplete)
} else {
var canPlay = false
var loadedMetaData = false
element.addEventListener('loadedmetadata', function () {
loadedMetaData = true
if (canPlay) {
onComplete()
}
})
element.addEventListener('canplay', function () {
canPlay = true
if (loadedMetaData) {
onComplete()
}
})
}
element.addEventListener('error', onError)
function removeListeners () {
if (request.stream) {
element.removeEventListener('progress', onProgress)
}
if (request.type === 'image') {
element.addEventListener('load', onComplete)
} else {
element.addEventListener('canplay', onComplete)
}
element.removeEventListener('error', onError)
}
function cancel () {
if (loader.state === STATE_COMPLETE || loader.state === STATE_ERROR) {
return
}
loader.state = STATE_ERROR
removeListeners()
element.src = ''
}
// set up request
if (request.credentials) {
element.crossOrigin = 'use-credentials'
} else {
element.crossOrigin = 'anonymous'
}
element.src = request.src
return loader
}
var loaders = {
text: loadXHR,
binary: function (request) {
// TODO use fetch API for streaming if supported
return loadXHR(request)
},
image: function (request) {
return loadElement(request, document.createElement('img'))
},
video: function (request) {
return loadElement(request, document.createElement('video'))
},
audio: function (request) {
return loadElement(request, document.createElement('audio'))
}
}
// First we parse all objects in order to verify that all type information
// is correct
var pending = Object.keys(manifest).map(function (name) {
var request = manifest[name]
if (typeof request === 'string') {
request = {
src: request
}
} else if (typeof request !== 'object' || !request) {
raise('invalid asset definition "' + name + '"')
}
checkType(request, manifestParameters, 'asset "' + name + '"')
function getParameter (prop, accepted, init) {
var value = init
if (prop in request) {
value = request[prop]
}
if (accepted.indexOf(value) < 0) {
raise('invalid ' + prop + ' "' + value + '" for asset "' + name + '", possible values: ' + accepted)
}
return value
}
function getString (prop, required, init) {
var value = init
if (prop in request) {
value = request[prop]
} else if (required) {
raise('missing ' + prop + ' for asset "' + name + '"')
}
if (typeof value !== 'string') {
raise('invalid ' + prop + ' for asset "' + name + '", must be a string')
}
return value
}
function getParseFunc (name, dflt) {
if (name in request.parser) {
var result = request.parser[name]
if (typeof result !== 'function') {
raise('invalid parser callback ' + name + ' for asset "' + name + '"')
}
return result
} else {
return dflt
}
}
var parser = {}
if ('parser' in request) {
if (typeof request.parser === 'function') {
parser = {
data: request.parser
}
} else if (typeof request.parser === 'object' && request.parser) {
checkType(parser, parserParameters, 'parser for asset "' + name + '"')
if (!('onData' in parser)) {
raise('missing onData callback for parser in asset "' + name + '"')
}
parser = {
data: getParseFunc('onData'),
done: getParseFunc('onDone')
}
} else {
raise('invalid parser for asset "' + name + '"')
}
}
return {
name: name,
type: getParameter('type', Object.keys(loaders), 'text'),
stream: !!request.stream,
credentials: !!request.credentials,
src: getString('src', true, ''),
parser: parser
}
}).map(function (request) {
return (loaders[request.type])(request)
})
function abort (message) {
if (state === STATE_ERROR || state === STATE_COMPLETE) {
return
}
state = STATE_ERROR
pending.forEach(function (loader) {
loader.cancel()
})
if (onError) {
if (typeof message === 'string') {
onError(new Error('resl: ' + message))
} else {
onError(message)
}
} else {
console.error('resl error:', message)
}
}
function notifyProgress (message) {
if (state === STATE_ERROR || state === STATE_COMPLETE) {
return
}
var progress = 0
var numReady = 0
pending.forEach(function (loader) {
if (loader.ready) {
numReady += 1
}
progress += loader.progress
})
if (numReady === pending.length) {
state = STATE_COMPLETE
onDone(assets)
} else {
if (onProgress) {
onProgress(progress / pending.length, message)
}
}
}
if (pending.length === 0) {
setTimeout(function () {
notifyProgress('done')
}, 1)
}
}