Skip to content

Instantly share code, notes, and snippets.

@BenoitZugmeyer
Last active August 28, 2019 10:34
Show Gist options
  • Save BenoitZugmeyer/5f86ce6f4775deda0d77dffdeb781d03 to your computer and use it in GitHub Desktop.
Save BenoitZugmeyer/5f86ce6f4775deda0d77dffdeb781d03 to your computer and use it in GitHub Desktop.
Quick and dirty experiment to render a dynamic Web UI mostly from the server side.

Quick and dirty experiment to render a dynamic Web UI mostly from the server side.

  1. The server serves static files (a minimal HTML boilerplate and a client JS script client.mjs) and starts a WebSocket server.

  2. The client connects to the WebSocket server as soon as possible.

  3. For each new connection, the server renders the component and sends the full "virtual DOM" to the client through the WebSocket.

  4. When the user interacts with the generated DOM, the client invoke the corresponding callback of the component by sending a message through the WebSocket.

  5. The component changes its state accordingly.

  6. The server compares the new "virtual DOM" generated by the component with the previous one, and sends a patch to the client.

Thoughts

  • Very few JS code is sent to the browser (only a minimal framework to handle the server communication and DOM patching).

  • As the JS client side code is generic (= can be made into a separate dependency), the whole app can be written in any programming language.

  • All of the memory and CPU intensive tasks are run on the server, so the client can be very light.

  • Instead of doing the initial rendering when the client is loaded, it could be trivially done on the server (instead of serving index.html).

  • With a bit of engineering, deployment of a new codebase could be done with a simple WebSocket reconnection and the interface can be refreshed with a "virtual DOM" patch. Since there is no logic in the client side, there is no need to download any new JS file or doing a full page refresh.

Try it now!

git clone https://gist.github.com/BenoitZugmeyer/5f86ce6f4775deda0d77dffdeb781d03 ws-framework
cd ws-framework
npm ci
npm start
const ws = new WebSocket(`ws://${location.host}`);
ws.addEventListener("message", event => {
const message = JSON.parse(event.data);
switch (message.kind) {
case "init":
init(message.root);
break;
case "patch":
patch(message.delta);
break;
}
});
function init(node) {
document.querySelector("main").appendChild(createElement(node));
}
function patch(delta) {
patchElement(document.querySelector("main *"), delta);
}
function wrapCallback(id) {
return () => {
ws.send(JSON.stringify({ kind: "call", id }));
};
}
function setAttribute(element, name, value) {
if (name.startsWith("on")) {
if (value) {
element.addEventListener(name.slice(2), wrapCallback(value));
}
} else {
element.setAttribute(name, value);
}
}
function createElement(node) {
if (typeof node === "string") {
return document.createTextNode(node);
}
if (!Array.isArray(node)) {
return document.createComment('(placeholder)')
}
const [tag, attributes, ...children] = node;
const element = document.createElement(tag);
if (attributes) {
for (const name in attributes) {
setAttribute(element, name, attributes[name]);
}
}
for (const child of children) {
element.appendChild(createElement(child));
}
return element;
}
function patchElement(element, [attributesDiff, childrenDiff]) {
for (const diff of attributesDiff) {
switch (diff.kind) {
case "remove":
element.removeAttribute(diff.name);
break;
case "set":
setAttribute(element, diff.name, diff.value);
break;
}
}
for (const diff of childrenDiff) {
switch (diff.kind) {
case "pop":
element.removeChild(element.lastSibling)
break;
case "push":
element.appendChild(createElement(diff.node));
break;
case "replace":
element.childNodes[diff.i].replaceWith(createElement(diff.node))
break;
case "patch":
patchElement(element.childNodes[diff.i], diff.delta);
break;
}
}
}
// This (kind of ugly) syntax emulates a JSX output
module.exports = function Counter(state, setState) {
return [
"div",
{},
[
"button",
{
disabled: state.count > 0 ? false : "disabled",
onclick() {
setState({ count: state.count - 1 });
}
},
"-"
],
[
"button",
{
onclick() {
setState({ count: state.count + 1 });
}
},
"+"
],
['div', {}, `Count: ${state.count}`],
state.count > 10 && [
'div',
{},
'wow this is a lot!',
],
];
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title></title>
</head>
<body>
<main></main>
<script src="./client.mjs" type="module"></script>
</body>
</html>
const http = require("http");
const serveHandler = require("serve-handler");
const ws = require("ws");
const Counter = require('./Counter')
const server = http.createServer(serveHandler);
server.listen(3000, () => {
console.log("Running at http://localhost:3000");
});
const wss = new ws.Server({ server });
wss.on("connection", ws => {
render(ws, Counter, { count: 0 });
});
function collectCallbacks([, attributes, ...children], callbacks) {
if (attributes) {
for (const key in attributes) {
const attribute = attributes[key]
if (typeof attribute === 'function') {
attributes[key] = `cb-${callbacks.length}`
callbacks.push(attribute)
}
}
}
for (const child of children) {
if (Array.isArray(child)) collectCallbacks(child, callbacks)
}
}
function render(client, component, initialState) {
function setState(newState) {
callbacks.length = 0
const newRoot = component(newState, setState)
collectCallbacks(newRoot, callbacks)
client.send(JSON.stringify({ kind: 'patch', delta: computeDelta(root, newRoot) }))
root = newRoot
}
let root = Counter(initialState, setState)
const callbacks = []
collectCallbacks(root, callbacks)
client.send(JSON.stringify({ kind: 'init', root }))
client.on('message', data => {
const message = JSON.parse(data)
switch (message.kind) {
case 'call':
callbacks[message.id.slice(3)]()
break
}
})
}
function computeDelta([tagA, attributesA, ...childrenA], [tagB, attributesB, ...childrenB]) {
if (tagA !== tagB) throw new Error('Not implemented yet')
const attributesDiff = []
if (attributesB) {
for (const name in attributesB) {
if (!attributesA || attributesA[name] !== attributesB[name]) {
if (!attributesB[name]) {
attributesDiff.push({ kind: 'remove', name })
} else {
attributesDiff.push({ kind: 'set', name, value: attributesB[name] })
}
}
}
}
if (attributesA) {
for (const name in attributesA) {
if (!attributesB || !(name in attributesB)) {
attributesDiff.push({ kind: 'remove', name })
}
}
}
const childrenDiff = []
for (let i = 0; i < childrenB.length; i += 1) {
if (i < childrenA.length) {
if (Array.isArray(childrenA[i]) && Array.isArray(childrenB[i])) {
const delta = computeDelta(childrenA[i], childrenB[i])
if (delta[0].length || delta[1].length) {
childrenDiff.push({ kind: 'patch', delta, i })
}
} else if (childrenA[i] !== childrenB[i]) {
childrenDiff.push({ kind: 'replace', node: childrenB[i], i })
}
} else {
childrenDiff.push({ kind: 'push', node: childrenB[i] })
}
}
for (let i = childrenB.length; i < childrenA.length; i += 1) {
childrenDiff.push({ kind: 'pop' })
}
return [attributesDiff, childrenDiff]
}
{
"name": "ws-framework",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"async-limiter": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
"integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg="
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"content-disposition": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
"integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ="
},
"fast-url-parser": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz",
"integrity": "sha1-9K8+qfNNiicc9YrSs3WfQx8LMY0=",
"requires": {
"punycode": "^1.3.2"
}
},
"mime-db": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
"integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="
},
"mime-types": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz",
"integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
"requires": {
"mime-db": "~1.33.0"
}
},
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"requires": {
"brace-expansion": "^1.1.7"
}
},
"path-is-inside": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
"integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM="
},
"path-to-regexp": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz",
"integrity": "sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ=="
},
"punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
},
"range-parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
"integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4="
},
"serve-handler": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.1.tgz",
"integrity": "sha512-LQPvxGia2TYqyMkHKH4jW9jx6jlQUMcWz6gJavZ3+4vsnB+SaWbYTncb9YsK5YBR6SlvyumREZJAzLw8VaFAUQ==",
"requires": {
"bytes": "3.0.0",
"content-disposition": "0.5.2",
"fast-url-parser": "1.1.3",
"mime-types": "2.1.18",
"minimatch": "3.0.4",
"path-is-inside": "1.0.2",
"path-to-regexp": "2.2.1",
"range-parser": "1.2.0"
}
},
"ws": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.1.2.tgz",
"integrity": "sha512-gftXq3XI81cJCgkUiAVixA0raD9IVmXqsylCrjRygw4+UOOGzPoxnQ6r/CnVL9i+mDncJo94tSkyrtuuQVBmrg==",
"requires": {
"async-limiter": "^1.0.0"
}
}
}
}
{
"name": "ws-framework",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"serve-handler": "^6.1.1",
"ws": "^7.1.2"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment