hyperscript.org
Version:
a small scripting language for the web
232 lines (202 loc) • 7.58 kB
JavaScript
///=========================================================================
/// This module provides the EventSource (SSE) feature for hyperscript
///=========================================================================
/// <reference path="./_hyperscript.js" />
/// <reference path="./eventsource.d.ts" />
(function (self, factory) {
const plugin = factory(self)
if (typeof exports === 'object' && typeof exports['nodeName'] !== 'string') {
module.exports = plugin
} else {
if ('_hyperscript' in self) self._hyperscript.use(plugin)
}
})(typeof self !== 'undefined' ? self : this, self => {
/**
* @param {import("./_hyperscript.js").Hyperscript} _hyperscript
*/
return _hyperscript => {
_hyperscript.addFeature("eventsource", function (parser, runtime, tokens) {
if (tokens.matchToken("eventsource")) {
var urlElement;
var withCredentials = false;
// Get the name we'll assign to this EventSource in the hyperscript context
/** @type {string} */
var name = parser.requireElement("dotOrColonPath", tokens).evaluate();
var nameSpace = name.split(".");
var eventSourceName = nameSpace.pop();
// Get the URL of the EventSource
if (tokens.matchToken("from")) {
urlElement = parser.requireElement("stringLike", tokens);
}
// Get option to connect with/without credentials
if (tokens.matchToken("with")) {
if (tokens.matchToken("credentials")) {
withCredentials = true;
}
}
/** @type {EventSourceStub} */
var stub = {
eventSource: null,
listeners: [],
retryCount: 0,
open: function (url) {
// calculate default values for URL argument.
if (url == undefined) {
if (stub.eventSource != null && stub.eventSource.url != undefined) {
url = stub.eventSource.url;
} else {
throw "no url defined for EventSource.";
}
}
// Guard multiple opens on the same EventSource
if (stub.eventSource != null) {
// If we're opening a new URL, then close the old one first.
if (url != stub.eventSource.url) {
stub.eventSource.close();
} else if (stub.eventSource.readyState != EventSource.CLOSED) {
// Otherwise, we already have the right connection open, so there's nothing left to do.
return;
}
}
// Open the EventSource and get ready to populate event handlers
stub.eventSource = new EventSource(url, {
withCredentials: withCredentials,
});
// On successful connection. Reset retry count.
stub.eventSource.addEventListener("open", function (event) {
stub.retryCount = 0;
});
// On connection error, use exponential backoff to retry (random values from 1 second to 2^7 (128) seconds
stub.eventSource.addEventListener("error", function (event) {
// If the EventSource is closed, then try to reopen
if (stub.eventSource.readyState == EventSource.CLOSED) {
stub.retryCount = Math.min(7, stub.retryCount + 1);
var timeout = Math.random() * (2 ^ stub.retryCount) * 500;
window.setTimeout(stub.open, timeout);
}
});
// Add event listeners
for (var index = 0; index < stub.listeners.length; index++) {
var item = stub.listeners[index];
stub.eventSource.addEventListener(item.type, item.handler, item.options);
}
},
close: function () {
if (stub.eventSource != undefined) {
stub.eventSource.close();
}
stub.retryCount = 0;
},
addEventListener: function (type, handler, options) {
stub.listeners.push({
type: type,
handler: handler,
options: options,
});
if (stub.eventSource != null) {
stub.eventSource.addEventListener(type, handler, options);
}
},
};
// Create the "feature" that will be returned by this function.
/** @type {EventSourceFeature} */
var feature = {
name: eventSourceName,
object: stub,
install: function (target) {
runtime.assignToNamespace(target, nameSpace, eventSourceName, stub);
},
};
// Parse each event listener and add it into the list
while (tokens.matchToken("on")) {
// get event name
var eventName = parser.requireElement("stringLike", tokens, "Expected event name").evaluate(); // OK to evaluate this in real-time?
// default encoding is "" (autodetect)
var encoding = "";
// look for alternate encoding
if (tokens.matchToken("as")) {
encoding = parser.requireElement("stringLike", tokens, "Expected encoding type").evaluate(); // Ok to evaluate this in real time?
}
// get command list for this event handler
var commandList = parser.requireElement("commandList", tokens);
addImplicitReturnToCommandList(commandList);
tokens.requireToken("end");
// Save the event listener into the feature. This lets us
// connect listeners to new EventSources if we have to reconnect.
stub.listeners.push({
type: eventName,
handler: makeHandler(encoding, commandList),
});
}
tokens.requireToken("end");
// If we have a URL element, then connect to the remote server now.
// Otherwise, we can connect later with a call to .open()
if (urlElement != undefined) {
stub.open(urlElement.evaluate());
}
// Success!
return feature;
////////////////////////////////////////////
// ADDITIONAL HELPER FUNCTIONS HERE...
////////////////////////////////////////////
/**
* Makes an eventHandler function that can execute the correct hyperscript commands
* This is outside of the main loop so that closures don't cause us to run the wrong commands.
*
* @param {string} encoding
* @param {*} commandList
* @returns {EventHandlerNonNull}
*/
function makeHandler(encoding, commandList) {
return function (evt) {
var data = decode(evt["data"], encoding);
var context = runtime.makeContext(stub, feature, stub);
context.event = evt;
context.result = data;
commandList.execute(context);
};
}
/**
* Decodes/Unmarshals a string based on the selected encoding. If the
* encoding is not recognized, attempts to auto-detect based on its content
*
* @param {string} data - The original data to be decoded
* @param {string} encoding - The method that the data is currently encoded ("string", "json", or unknown)
* @returns {string} - The decoded data
*/
function decode(data, encoding) {
// Force JSON encoding
if (encoding == "json") {
return JSON.parse(data);
}
// Otherwise, return the data without modification
return data;
}
/**
* Adds a "HALT" command to the commandList.
* TODO: This seems like something that could be optimized:
* maybe the parser could do automatically,
* or could be a public function in the parser available to everyone,
* or the command-executer-thingy could just handle nulls implicitly.
*
* @param {*} commandList
* @returns void
*/
function addImplicitReturnToCommandList(commandList) {
if (commandList.next) {
return addImplicitReturnToCommandList(commandList.next);
}
commandList.next = {
type: "implicitReturn",
op: function (/** @type {Context} */ _context) {
return runtime.HALT;
},
execute: function (/** @type {Context} */ _context) {
// do nothing
},
};
}
}
});
}
})