diff --git a/core/modules/server/server-sent-events.js b/core/modules/server/server-sent-events.js new file mode 100644 index 00000000000..061f1182d27 --- /dev/null +++ b/core/modules/server/server-sent-events.js @@ -0,0 +1,76 @@ +/*\ +title: $:/core/modules/server/server-sent-events.js +type: application/javascript +module-type: library +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +/** + * @param {string} prefix + * Usually the plugin path, such as `plugins/tiddlywiki/tiddlyweb`. + * The route will match `/events/${prefix}` exactly. + * @param {( + * request: import("http").IncomingMessage, + * state, + * emit: (event: string, data: string) => void, + * end: () => void + * ) => void} handler + * A function that will be called each time a request + * comes in with the request and state from the + * route and an emit function to call. + */ +var ServerSentEvents = function ServerSentEvents(prefix, handler) { + this.handler = handler; + this.prefix = prefix; +} + +ServerSentEvents.prototype.getExports = function() { + return { + bodyFormat: "stream", + method: "GET", + path: new RegExp("^/events/" + this.prefix + "$"), + handler: this.handleEventRequest.bind(this) + }; +}; + +/** + * + * @param {import("http").IncomingMessage} request + * @param {import("http").ServerResponse} response + * @param {*} state + */ +ServerSentEvents.prototype.handleEventRequest = function(request, response, state) { + if(request.headers.accept && request.headers.accept.startsWith("text/event-stream")) { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive" + }); + this.handler(request, state, this.emit.bind(this, response), this.end.bind(this, response)); + } else { + response.writeHead(406, "Not Acceptable", {}); + response.end(); + } +}; + +ServerSentEvents.prototype.emit = function(response, event, data) { + if(typeof event !== "string" || event.indexOf("\n") !== -1) { + throw new Error("Type must be a single-line string"); + } + if(typeof data !== "string" || data.indexOf("\n") !== -1) { + throw new Error("Data must be a single-line string"); + } + response.write("event: " + event + "\ndata: " + data + "\n\n", "utf8"); +}; + +ServerSentEvents.prototype.end = function(response) { + response.end(); +}; + +exports.ServerSentEvents = ServerSentEvents; + +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/tiddlyweb/sse-client.js b/plugins/tiddlywiki/tiddlyweb/sse-client.js new file mode 100644 index 00000000000..a8639f8e3d3 --- /dev/null +++ b/plugins/tiddlywiki/tiddlyweb/sse-client.js @@ -0,0 +1,46 @@ +/*\ +title: $:/plugins/tiddlywiki/tiddlyweb/sse-client.js +type: application/javascript +module-type: startup + +GET /recipes/default/tiddlers/:title + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +exports.name = "/events/plugins/tiddlywiki/tiddlyweb"; +exports.after = ["startup"]; +exports.synchronous = true; +exports.platforms = ["browser"]; +exports.startup = function() { + // Make sure we're actually being used + if($tw.syncadaptor.name !== "tiddlyweb") return; + // Get the mount point in case a path prefix is used + var host = $tw.syncadaptor.getHost(); + // Make sure it ends with a slash (it usually does) + if(host[host.length - 1] !== "/") host += "/"; + // Setup the event listener + setupEvents(host); +} + +function setupEvents(host){ + var events = new EventSource(host + "events/plugins/tiddlywiki/tiddlyweb"); + var timeout = null; + events.addEventListener("change", function() { + if(timeout) clearTimeout(timeout); + timeout = setTimeout(function(){ + $tw.syncer.syncFromServer(); + }, $tw.syncer.throttleInterval); + }); + events.onerror = function() { + events.close(); + setTimeout(function() { + setupEvents(host); + }, $tw.syncer.errorRetryInterval) + }; +} +})(); \ No newline at end of file diff --git a/plugins/tiddlywiki/tiddlyweb/sse-server.js b/plugins/tiddlywiki/tiddlyweb/sse-server.js new file mode 100644 index 00000000000..129b19f6936 --- /dev/null +++ b/plugins/tiddlywiki/tiddlyweb/sse-server.js @@ -0,0 +1,72 @@ +/*\ +title: $:/plugins/tiddlywiki/tiddlyweb/sse-server.js +type: application/javascript +module-type: route + +GET /events/plugins/tiddlywiki/tiddlyweb + +\*/ +(function(){ + +/*jslint node: true, browser: true */ +/*global $tw: false */ +"use strict"; + +/** @type {any[]} */ +var wikis = []; +/** @type {{ + * request: import("http").IncomingMessage, + * state: {wiki,pathPrefix}, + * emit: (event: string, data: string) => void, + * end: () => void + * }[][]} */ +var conns = []; +/** + * Setups up the array for this wiki and adds the change listener + * + * @param {$tw.Wiki} wiki The wiki object to listen to changes on + */ +function setupWiki(wiki) { + var index = wikis.length; + var connections = []; + // Add a new array for this wiki (object references work as keys) + wikis.push(wiki); + conns.push(connections); + // Add the change listener for this wiki + wiki.addEventListener("change", function(changes) { + connections.forEach(function(item) { + item.emit("change", JSON.stringify(changes)); + }); + }); + return index; +} + +/** + * + * @param {import("http").IncomingMessage} request + * @param {{wiki,pathPrefix}} state + * @param {(event: string, data: string) => void} emit + * @param {() => void} end + */ +function handleConnection(request, state, emit, end) { + var index = wikis.indexOf(state.wiki); + // Setup this particular wiki if we haven't seen it before + if (index === -1) index = setupWiki(state.wiki); + // Add the connection to the list of connections for this wiki + var item = { request: request, state: state, emit: emit, end: end }; + conns[index].push(item); + // Remove the connection when it closes + request.on("close",function(){ + var remIndex = conns[index].indexOf(item); + if(remIndex > -1) conns[index].splice(remIndex,1); + }); +} + +// Import the ServerSentEvents class +var ServerSentEvents = require("$:/core/modules/server/server-sent-events.js").ServerSentEvents; +// Instantiate the class +var events = new ServerSentEvents("plugins/tiddlywiki/tiddlyweb", handleConnection); +// Export the route definition for this server sent events instance +module.exports = events.getExports(); + +})(); \ No newline at end of file