Skip to content

Instantly share code, notes, and snippets.

@mgrandy
Created May 24, 2017 02:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mgrandy/a897720f5ab91d7e09d9315ad10666bc to your computer and use it in GitHub Desktop.
Save mgrandy/a897720f5ab91d7e09d9315ad10666bc to your computer and use it in GitHub Desktop.
/** @license
* eventsource.js
* Available under MIT License (MIT)
* https://github.com/Yaffle/EventSource/
*/
/*jslint indent: 2, vars: true, plusplus: true */
/*global setTimeout, clearTimeout */
(function (global) {
"use strict";
var setTimeout = global.setTimeout;
var clearTimeout = global.clearTimeout;
var k = function () {
};
function XHRTransport(xhr, onStartCallback, onProgressCallback, onFinishCallback, thisArg) {
this._internal = new XHRTransportInternal(xhr, onStartCallback, onProgressCallback, onFinishCallback, thisArg);
}
XHRTransport.prototype.open = function (url, withCredentials) {
this._internal.open(url, withCredentials);
};
XHRTransport.prototype.cancel = function () {
this._internal.cancel();
};
function XHRTransportInternal(xhr, onStartCallback, onProgressCallback, onFinishCallback, thisArg) {
this.onStartCallback = onStartCallback;
this.onProgressCallback = onProgressCallback;
this.onFinishCallback = onFinishCallback;
this.thisArg = thisArg;
this.xhr = xhr;
this.state = 0;
this.charOffset = 0;
this.offset = 0;
this.url = "";
this.withCredentials = false;
this.timeout = 0;
}
XHRTransportInternal.prototype.onStart = function () {
if (this.state === 1) {
this.state = 2;
var status = 0;
var statusText = "";
var contentType = undefined;
if (!("contentType" in this.xhr)) {
try {
status = this.xhr.status;
statusText = this.xhr.statusText;
contentType = this.xhr.getResponseHeader("Content-Type");
} catch (error) {
// https://bugs.webkit.org/show_bug.cgi?id=29121
status = 0;
statusText = "";
contentType = undefined;
// FF < 14, WebKit
// https://bugs.webkit.org/show_bug.cgi?id=29658
// https://bugs.webkit.org/show_bug.cgi?id=77854
}
} else {
status = 200;
statusText = "OK";
contentType = this.xhr.contentType;
}
if (contentType == undefined) {
contentType = "";
}
this.onStartCallback.call(this.thisArg, status, statusText, contentType);
}
};
XHRTransportInternal.prototype.onProgress = function () {
this.onStart();
if (this.state === 2 || this.state === 3) {
this.state = 3;
var responseText = "";
try {
responseText = this.xhr.responseText;
} catch (error) {
// IE 8 - 9 with XMLHttpRequest
}
var chunkStart = this.charOffset;
var length = responseText.length;
for (var i = this.offset; i < length; i += 1) {
var c = responseText.charCodeAt(i);
if (c === "\n".charCodeAt(0) || c === "\r".charCodeAt(0)) {
this.charOffset = i + 1;
}
}
this.offset = length;
var chunk = responseText.slice(chunkStart, this.charOffset);
this.onProgressCallback.call(this.thisArg, chunk);
}
};
XHRTransportInternal.prototype.onFinish = function () {
// IE 8 fires "onload" without "onprogress
this.onProgress();
if (this.state === 3) {
this.state = 4;
if (this.timeout !== 0) {
clearTimeout(this.timeout);
this.timeout = 0;
}
this.onFinishCallback.call(this.thisArg);
}
};
XHRTransportInternal.prototype.onReadyStateChange = function () {
if (this.xhr != undefined) { // Opera 12
if (this.xhr.readyState === 4) {
if (this.xhr.status === 0) {
this.onFinish();
} else {
this.onFinish();
}
} else if (this.xhr.readyState === 3) {
this.onProgress();
} else if (this.xhr.readyState === 2) {
// Opera 10.63 throws exception for `this.xhr.status`
// this.onStart();
}
}
};
XHRTransportInternal.prototype.onTimeout2 = function () {
this.timeout = 0;
var tmp = (/^data\:([^,]*?)(base64)?,([\S]*)$/).exec(this.url);
var contentType = tmp[1];
var data = tmp[2] === "base64" ? global.atob(tmp[3]) : decodeURIComponent(tmp[3]);
if (this.state === 1) {
this.state = 2;
this.onStartCallback.call(this.thisArg, 200, "OK", contentType);
}
if (this.state === 2 || this.state === 3) {
this.state = 3;
this.onProgressCallback.call(this.thisArg, data);
}
if (this.state === 3) {
this.state = 4;
this.onFinishCallback.call(this.thisArg);
}
};
XHRTransportInternal.prototype.onTimeout1 = function () {
this.timeout = 0;
this.open(this.url, this.withCredentials);
};
XHRTransportInternal.prototype.onTimeout0 = function () {
var that = this;
this.timeout = setTimeout(function () {
that.onTimeout0();
}, 500);
if (this.xhr.readyState === 3) {
this.onProgress();
}
};
XHRTransportInternal.prototype.handleEvent = function (event) {
if (event.type === "load") {
this.onFinish();
} else if (event.type === "error") {
this.onFinish();
} else if (event.type === "abort") {
// improper fix to match Firefox behaviour, but it is better than just ignore abort
// see https://bugzilla.mozilla.org/show_bug.cgi?id=768596
// https://bugzilla.mozilla.org/show_bug.cgi?id=880200
// https://code.google.com/p/chromium/issues/detail?id=153570
// IE 8 fires "onload" without "onprogress
this.onFinish();
} else if (event.type === "progress") {
this.onProgress();
} else if (event.type === "readystatechange") {
this.onReadyStateChange();
}
};
XHRTransportInternal.prototype.open = function (url, withCredentials) {
if (this.timeout !== 0) {
clearTimeout(this.timeout);
this.timeout = 0;
}
this.url = url;
this.withCredentials = withCredentials;
this.state = 1;
this.charOffset = 0;
this.offset = 0;
var that = this;
var tmp = (/^data\:([^,]*?)(?:;base64)?,[\S]*$/).exec(url);
if (tmp != undefined) {
this.timeout = setTimeout(function () {
that.onTimeout2();
}, 0);
return;
}
// loading indicator in Safari, Chrome < 14
// loading indicator in Firefox
// https://bugzilla.mozilla.org/show_bug.cgi?id=736723
if ((!("ontimeout" in this.xhr) || ("sendAsBinary" in this.xhr) || ("mozAnon" in this.xhr)) && global.document != undefined && global.document.readyState != undefined && global.document.readyState !== "complete") {
this.timeout = setTimeout(function () {
that.onTimeout1();
}, 4);
return;
}
// XDomainRequest#abort removes onprogress, onerror, onload
this.xhr.onload = function (event) {
that.handleEvent({type: "load"});
};
this.xhr.onerror = function () {
that.handleEvent({type: "error"});
};
this.xhr.onabort = function () {
that.handleEvent({type: "abort"});
};
this.xhr.onprogress = function () {
that.handleEvent({type: "progress"});
};
// IE 8-9 (XMLHTTPRequest)
// Firefox 3.5 - 3.6 - ? < 9.0
// onprogress is not fired sometimes or delayed
// see also #64
this.xhr.onreadystatechange = function () {
that.handleEvent({type: "readystatechange"});
};
this.xhr.open("GET", url, true);
// withCredentials should be set after "open" for Safari and Chrome (< 19 ?)
this.xhr.withCredentials = withCredentials;
this.xhr.responseType = "text";
if ("setRequestHeader" in this.xhr) {
// Request header field Cache-Control is not allowed by Access-Control-Allow-Headers.
// "Cache-control: no-cache" are not honored in Chrome and Firefox
// https://bugzilla.mozilla.org/show_bug.cgi?id=428916
//this.xhr.setRequestHeader("Cache-Control", "no-cache");
this.xhr.setRequestHeader("Accept", "text/event-stream");
// Request header field Last-Event-ID is not allowed by Access-Control-Allow-Headers.
//this.xhr.setRequestHeader("Last-Event-ID", this.lastEventId);
}
try {
this.xhr.send(undefined);
} catch (error1) {
// Safari 5.1.7, Opera 12
throw error1;
}
if (("readyState" in this.xhr) && global.opera != undefined) {
// workaround for Opera issue with "progress" events
this.timeout = setTimeout(function () {
that.onTimeout0();
}, 0);
}
};
XHRTransportInternal.prototype.cancel = function () {
if (this.state !== 0 && this.state !== 4) {
this.state = 4;
this.xhr.onload = k;
this.xhr.onerror = k;
this.xhr.onabort = k;
this.xhr.onprogress = k;
this.xhr.onreadystatechange = k;
this.xhr.abort();
if (this.timeout !== 0) {
clearTimeout(this.timeout);
this.timeout = 0;
}
this.onFinishCallback.call(this.thisArg);
}
this.state = 0;
};
function Map() {
this._data = {};
}
Map.prototype.get = function (key) {
return this._data[key + "~"];
};
Map.prototype.set = function (key, value) {
this._data[key + "~"] = value;
};
Map.prototype["delete"] = function (key) {
delete this._data[key + "~"];
};
function EventTarget() {
this._listeners = new Map();
}
function throwError(e) {
setTimeout(function () {
throw e;
}, 0);
}
EventTarget.prototype.dispatchEvent = function (event) {
event.target = this;
var type = event.type.toString();
var listeners = this._listeners;
var typeListeners = listeners.get(type);
if (typeListeners == undefined) {
return;
}
var length = typeListeners.length;
var listener = undefined;
for (var i = 0; i < length; i += 1) {
listener = typeListeners[i];
try {
if (typeof listener.handleEvent === "function") {
listener.handleEvent(event);
} else {
listener.call(this, event);
}
} catch (e) {
throwError(e);
}
}
};
EventTarget.prototype.addEventListener = function (type, callback) {
type = type.toString();
var listeners = this._listeners;
var typeListeners = listeners.get(type);
if (typeListeners == undefined) {
typeListeners = [];
listeners.set(type, typeListeners);
}
for (var i = typeListeners.length; i >= 0; i -= 1) {
if (typeListeners[i] === callback) {
return;
}
}
typeListeners.push(callback);
};
EventTarget.prototype.removeEventListener = function (type, callback) {
type = type.toString();
var listeners = this._listeners;
var typeListeners = listeners.get(type);
if (typeListeners == undefined) {
return;
}
var length = typeListeners.length;
var filtered = [];
for (var i = 0; i < length; i += 1) {
if (typeListeners[i] !== callback) {
filtered.push(typeListeners[i]);
}
}
if (filtered.length === 0) {
listeners["delete"](type);
} else {
listeners.set(type, filtered);
}
};
function Event(type) {
this.type = type;
this.target = undefined;
}
function MessageEvent(type, options) {
Event.call(this, type);
this.data = options.data;
this.lastEventId = options.lastEventId;
}
MessageEvent.prototype = Event.prototype;
var XHR = global.XMLHttpRequest;
var XDR = global.XDomainRequest;
var isCORSSupported = XHR != undefined && (new XHR()).withCredentials != undefined;
var Transport = isCORSSupported || (XHR != undefined && XDR == undefined) ? XHR : XDR;
var WAITING = -1;
var CONNECTING = 0;
var OPEN = 1;
var CLOSED = 2;
var AFTER_CR = 3;
var FIELD_START = 4;
var FIELD = 5;
var VALUE_START = 6;
var VALUE = 7;
var contentTypeRegExp = /^text\/event\-stream;?(\s*charset\=utf\-8)?$/i;
var MINIMUM_DURATION = 1000;
var MAXIMUM_DURATION = 18000000;
var getDuration = function (value, def) {
var n = value;
if (n !== n) {
n = def;
}
return (n < MINIMUM_DURATION ? MINIMUM_DURATION : (n > MAXIMUM_DURATION ? MAXIMUM_DURATION : n));
};
var fire = function (that, f, event) {
try {
if (typeof f === "function") {
f.call(that, event);
}
} catch (e) {
throwError(e);
}
};
function EventSource(url, options) {
EventTarget.call(this);
this.onopen = undefined;
this.onmessage = undefined;
this.onerror = undefined;
this.url = "";
this.readyState = CONNECTING;
this.withCredentials = false;
this._internal = new EventSourceInternal(this, url, options);
}
function EventSourceInternal(es, url, options) {
this.url = url.toString();
this.readyState = CONNECTING;
this.withCredentials = isCORSSupported && options != undefined && Boolean(options.withCredentials);
this.es = es;
this.initialRetry = getDuration(1000, 0);
this.heartbeatTimeout = getDuration(45000, 0);
this.lastEventId = "";
this.retry = this.initialRetry;
this.wasActivity = false;
var CurrentTransport = options != undefined && options.Transport != undefined ? options.Transport : Transport;
var xhr = new CurrentTransport();
this.transport = new XHRTransport(xhr, this.onStart, this.onProgress, this.onFinish, this);
this.timeout = 0;
this.currentState = WAITING;
this.dataBuffer = [];
this.lastEventIdBuffer = "";
this.eventTypeBuffer = "";
this.state = FIELD_START;
this.fieldStart = 0;
this.valueStart = 0;
this.es.url = this.url;
this.es.readyState = this.readyState;
this.es.withCredentials = this.withCredentials;
this.onTimeout();
}
EventSourceInternal.prototype.onStart = function (status, statusText, contentType) {
if (this.currentState === CONNECTING) {
if (contentType == undefined) {
contentType = "";
}
if (status === 200 && contentTypeRegExp.test(contentType)) {
this.currentState = OPEN;
this.wasActivity = true;
this.retry = this.initialRetry;
this.readyState = OPEN;
this.es.readyState = OPEN;
var event = new Event("open");
this.es.dispatchEvent(event);
fire(this.es, this.es.onopen, event);
} else if (status !== 0) {
var message = "";
if (status !== 200) {
message = "EventSource's response has a status " + status + " " + statusText.replace(/\s+/g, " ") + " that is not 200. Aborting the connection.";
} else {
message = "EventSource's response has a Content-Type specifying an unsupported type: " + contentType.replace(/\s+/g, " ") + ". Aborting the connection.";
}
throwError(new Error(message));
this.close();
var event = new Event("error");
this.es.dispatchEvent(event);
fire(this.es, this.es.onerror, event);
}
}
};
EventSourceInternal.prototype.onProgress = function (chunk) {
if (this.currentState === OPEN) {
var length = chunk.length;
if (length !== 0) {
this.wasActivity = true;
}
for (var position = 0; position < length; position += 1) {
var c = chunk.charCodeAt(position);
if (this.state === AFTER_CR && c === "\n".charCodeAt(0)) {
this.state = FIELD_START;
} else {
if (this.state === AFTER_CR) {
this.state = FIELD_START;
}
if (c === "\r".charCodeAt(0) || c === "\n".charCodeAt(0)) {
if (this.state !== FIELD_START) {
if (this.state === FIELD) {
this.valueStart = position + 1;
}
var field = chunk.slice(this.fieldStart, this.valueStart - 1);
var value = chunk.slice(this.valueStart + (this.valueStart < position && chunk.charCodeAt(this.valueStart) === " ".charCodeAt(0) ? 1 : 0), position);
if (field === "data") {
this.dataBuffer.push(value);
} else if (field === "id") {
this.lastEventIdBuffer = value;
} else if (field === "event") {
this.eventTypeBuffer = value;
} else if (field === "retry") {
this.initialRetry = getDuration(Number(value), this.initialRetry);
this.retry = this.initialRetry;
} else if (field === "heartbeatTimeout") {
this.heartbeatTimeout = getDuration(Number(value), this.heartbeatTimeout);
if (this.timeout !== 0) {
clearTimeout(this.timeout);
var that = this;
this.timeout = setTimeout(function () {
that.onTimeout();
}, this.heartbeatTimeout);
}
}
}
if (this.state === FIELD_START) {
if (this.dataBuffer.length !== 0) {
this.lastEventId = this.lastEventIdBuffer;
if (this.eventTypeBuffer === "") {
this.eventTypeBuffer = "message";
}
var event = new MessageEvent(this.eventTypeBuffer, {
data: this.dataBuffer.join("\n"),
lastEventId: this.lastEventIdBuffer
});
this.es.dispatchEvent(event);
if (this.eventTypeBuffer === "message") {
fire(this.es, this.es.onmessage, event);
}
if (this.currentState === CLOSED) {
return;
}
}
this.dataBuffer.length = 0;
this.eventTypeBuffer = "";
}
this.state = c === "\r".charCodeAt(0) ? AFTER_CR : FIELD_START;
} else {
if (this.state === FIELD_START) {
this.fieldStart = position;
this.state = FIELD;
}
if (this.state === FIELD) {
if (c === ":".charCodeAt(0)) {
this.valueStart = position + 1;
this.state = VALUE_START;
}
} else if (this.state === VALUE_START) {
this.state = VALUE;
}
}
}
}
}
};
EventSourceInternal.prototype.onFinish = function () {
if (this.currentState === OPEN || this.currentState === CONNECTING) {
this.currentState = WAITING;
if (this.timeout !== 0) {
clearTimeout(this.timeout);
this.timeout = 0;
}
if (this.retry > this.initialRetry * 16) {
this.retry = this.initialRetry * 16;
}
if (this.retry > MAXIMUM_DURATION) {
this.retry = MAXIMUM_DURATION;
}
var that = this;
this.timeout = setTimeout(function () {
that.onTimeout();
}, this.retry);
this.retry = this.retry * 2 + 1;
this.readyState = CONNECTING;
this.es.readyState = CONNECTING;
var event = new Event("error");
this.es.dispatchEvent(event);
fire(this.es, this.es.onerror, event);
}
};
EventSourceInternal.prototype.onTimeout = function () {
this.timeout = 0;
if (this.currentState !== WAITING) {
if (!this.wasActivity) {
throwError(new Error("No activity within " + this.heartbeatTimeout + " milliseconds. Reconnecting."));
this.transport.cancel();
} else {
this.wasActivity = false;
var that = this;
this.timeout = setTimeout(function () {
that.onTimeout();
}, this.heartbeatTimeout);
}
return;
}
this.wasActivity = false;
var that = this;
this.timeout = setTimeout(function () {
that.onTimeout();
}, this.heartbeatTimeout);
this.currentState = CONNECTING;
this.dataBuffer.length = 0;
this.eventTypeBuffer = "";
this.lastEventIdBuffer = this.lastEventId;
this.fieldStart = 0;
this.valueStart = 0;
this.state = FIELD_START;
var s = this.url.slice(0, 5);
if (s !== "data:" && s !== "blob:") {
s = this.url + ((this.url.indexOf("?", 0) === -1 ? "?" : "&") + "lastEventId=" + encodeURIComponent(this.lastEventId) + "&r=" + (Math.random() + 1).toString().slice(2));
} else {
s = this.url;
}
try {
this.transport.open(s, this.withCredentials);
} catch (error) {
this.close();
throw error;
}
};
EventSourceInternal.prototype.close = function () {
this.currentState = CLOSED;
this.transport.cancel();
if (this.timeout !== 0) {
clearTimeout(this.timeout);
this.timeout = 0;
}
this.readyState = CLOSED;
this.es.readyState = CLOSED;
};
function F() {
this.CONNECTING = CONNECTING;
this.OPEN = OPEN;
this.CLOSED = CLOSED;
}
F.prototype = EventTarget.prototype;
EventSource.prototype = new F();
EventSource.prototype.close = function () {
this._internal.close();
};
F.call(EventSource);
if (isCORSSupported) {
EventSource.prototype.withCredentials = undefined;
}
var isEventSourceSupported = function () {
// Opera 12 fails this test, but this is fine.
return global.EventSource != undefined && ("withCredentials" in global.EventSource.prototype);
};
if (Transport != undefined && (global.EventSource == undefined || (isCORSSupported && !isEventSourceSupported()))) {
// Why replace a native EventSource ?
// https://bugzilla.mozilla.org/show_bug.cgi?id=444328
// https://bugzilla.mozilla.org/show_bug.cgi?id=831392
// https://code.google.com/p/chromium/issues/detail?id=260144
// https://code.google.com/p/chromium/issues/detail?id=225654
// ...
global.NativeEventSource = global.EventSource;
global.EventSource = EventSource;
}
}(typeof window !== 'undefined' ? window : this));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment