Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Server Sent Events #4609

Closed
wants to merge 14 commits into from
75 changes: 75 additions & 0 deletions core/modules/server/server-sent-events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*\
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core doesn't use JSDoc, just plain text for code comments

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use it to give VS Code the proper intellisense. TiddlyWiki is really hard to work with otherwise.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, for us familiar with type script, interface with typing is very nice.

* 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 = /** @class */ (function () {

function ServerSentEvents(prefix, handler) {
this.handler = handler;
this.prefix = prefix;
}
ServerSentEvents.prototype.getExports = function () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core leaves a blank line between methods

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')) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core doesn't use a space between if and the following parenthesis

response.writeHead(200, {
'Content-Type': 'text/event-stream',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core prefers double quotes for string constants, only using single quotes for strings that need to contain double quotes.

'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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core always wraps the body of an if statement in curly braces

throw new Error("type must be a single-line string");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would normally capitalise an error message

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();
};
return ServerSentEvents;
}());

exports.ServerSentEvents = ServerSentEvents;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The construction used to export the class isn't consistent with the rest of the core, which doesn't use the inner IIFE that returns "ServerSentEvents". It should follow the same approach as, say, the core widget classes.


})();
33 changes: 33 additions & 0 deletions plugins/tiddlywiki/tiddlyweb/sse-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*\
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments should be capitalised, with a space after the //

if($tw.syncadaptor.name !== "tiddlyweb") return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we instead install the event handler when we startup the syncadaptor?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure we could but I don't really know where. I did it here because that is right after the syncer and sync adapter are initialized. But you could just as easily do it there.

//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.endsWith("/")) host += "/";
Arlen22 marked this conversation as resolved.
Show resolved Hide resolved
//setup the event listener
var events = new EventSource(host + "events/plugins/tiddlywiki/tiddlyweb");
events.addEventListener("change", function () {
$tw.syncer.syncFromServer();
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be a good idea to add some debouncing here to reduce the amount of requests going to the server over a small period of time?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another idea - you might want to add an event listener for the error event. Otherwise if you lose the connection to the server, you'll need to reload the entire wiki to reestablish it!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think debouncing is going to be necessary to avoid obvious DDOS vectors. Alternatively, perhaps we should disable SSE by default.

}

})();
62 changes: 62 additions & 0 deletions plugins/tiddlywiki/tiddlyweb/sse-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*\
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 {Record<any, {
* request: import("http").IncomingMessage,
* state: {wiki,pathPrefix},
* emit: (event: string, data: string) => void,
* end: () => void
* }[]>} */
var wikis = {};
/**
* 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) {
// add a new array for this wiki (object references work as keys)
wikis[wiki] = [];
Arlen22 marked this conversation as resolved.
Show resolved Hide resolved
// add the change listener for this wiki
wiki.addEventListener("change", function (changes) {
wikis[wiki].forEach(function (item) {
item.emit("change", JSON.stringify(changes));
});
});
}
/**
*
* @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) {
// setup this particular wiki if we haven't seen it before
if (!wikis[state.wiki]) setupWiki(state.wiki);
// add the connection to the list of connections for this wiki
var item = { request: request, state: state, emit: emit, end: end };
wikis[state.wiki].push(item);
// remove the connection when it closes
request.on("close",function(){
wikis[state.wiki].splice(wikis[state.wiki].indexOf(item),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();

})();