Packages
lustre
5.0.0
5.7.1
5.7.0
5.6.0
5.5.2
5.5.1
5.5.0
5.4.0
5.3.5
5.3.4
5.3.3
5.3.2
5.3.1
5.3.0
5.2.1
5.2.0
5.1.1
5.1.0
5.0.3
5.0.2
5.0.1
5.0.0
4.6.4
4.6.3
4.6.2
4.6.1
4.6.0
4.5.1
4.5.0
4.4.4
4.4.3
4.4.1
4.4.0
4.3.6
4.3.5
4.3.4
4.3.3
4.3.2
4.3.1
4.3.0
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.8
4.1.7
4.1.6
4.1.5
4.1.4
4.1.3
4.1.2
4.1.1
4.1.0
4.0.0
4.0.0-rc1
4.0.0-rc.2
3.1.4
3.1.3
3.1.2
3.1.1
3.1.0
3.0.12
3.0.11
3.0.10
3.0.9
3.0.8
3.0.7
3.0.6
3.0.5
3.0.4
3.0.3
3.0.2
3.0.1
3.0.0
3.0.0-rc.8
3.0.0-rc.7
3.0.0-rc.6
3.0.0-rc.5
3.0.0-rc.4
3.0.0-rc.3
3.0.0-rc.2
3.0.0-rc.1
2.0.1
2.0.0
1.3.0
1.2.0
1.1.0
1.0.0
Create HTML templates, single page applications, Web Components, and real-time server components in Gleam!
Current section
Files
Jump to
Current section
Files
src/lustre/runtime/client/server_component.ffi.mjs
// IMPORTS ---------------------------------------------------------------------
// 🚨 These imports need to point to the *build* directory. This module will be
// used as the entry module when running esbuild so we *cant* use imports relative
// to src/.
import { initialiseMetadata, Reconciler } from "../../../../build/dev/javascript/lustre/lustre/vdom/reconciler.ffi.mjs";
import { adoptStylesheets } from "../../../../build/dev/javascript/lustre/lustre/runtime/client/runtime.ffi.mjs";
import {
mount_kind,
reconcile_kind,
emit_kind,
attribute_changed_kind,
property_changed_kind,
event_fired_kind,
batch_kind,
} from "../../../../build/dev/javascript/lustre/lustre/runtime/transport.mjs";
//
export class ServerComponent extends HTMLElement {
static get observedAttributes() {
return ["route", "method"];
}
#shadowRoot;
#method = "ws";
#route = null;
#transport = null;
#adoptedStyleNodes = [];
#reconciler;
#remoteObservedAttributes = new Set();
#remoteObservedProperties = new Set();
#connected = false;
#changedAttributesQueue = [];
#observer = new MutationObserver((mutations) => {
const attributes = [];
for (const mutation of mutations) {
if (mutation.type !== "attributes") continue;
const name = mutation.attributeName;
if (!this.#connected || this.#remoteObservedAttributes.has(name)) {
attributes.push([name, this.getAttribute(name)]);
}
}
if (attributes.length === 1) {
const [name, value] = attributes[0];
this.#transport?.send({ kind: attribute_changed_kind, name, value });
} else if (attributes.length) {
this.#transport?.send({
kind: batch_kind,
messages: attributes.map(([name, value]) => ({
kind: attribute_changed_kind,
name,
value,
})),
});
} else {
this.#changedAttributesQueue.push(...attributes);
}
});
constructor() {
super();
this.internals = this.attachInternals();
this.#observer.observe(this, {
attributes: true,
});
}
connectedCallback() {
this.#method = this.getAttribute("method") || "ws";
for (const attribute of this.attributes) {
this.#changedAttributesQueue.push([attribute.name, attribute.value]);
}
const route = this.getAttribute("route")
if (route) {
this.#route = new URL(route, location.href);
this.#connect();
}
}
attributeChangedCallback(name, prev, next) {
switch (name) {
case prev !== next && "route": {
this.#route = new URL(next, location.href);
this.#connect();
return;
}
case "method": {
const normalised = next.toLowerCase();
if (normalised == this.#method) return;
if (["ws", "sse", "polling"].includes(normalised)) {
this.#method = normalised;
if (this.#method == "ws") {
if (this.#route.protocol == "https:") this.#route.protocol = "wss:";
if (this.#route.protocol == "http:") this.#route.protocol = "ws:";
}
this.#connect();
}
return;
}
}
}
async messageReceivedCallback(data) {
switch (data.kind) {
case mount_kind: {
this.#shadowRoot ??= this.attachShadow({
mode: data.open_shadow_root ? "open" : "closed",
});
while (this.#shadowRoot.firstChild) {
this.#shadowRoot.firstChild.remove()
}
initialiseMetadata(this.#shadowRoot);
this.#reconciler = new Reconciler(
this.#shadowRoot,
(event, path, name) => {
this.#transport?.send({
kind: event_fired_kind,
path,
name,
event,
});
},
{
useServerEvents: true,
},
);
this.#remoteObservedAttributes = new Set(data.observed_attributes);
const filteredQueuedAttributes = this.#changedAttributesQueue.filter(
([name]) => this.#remoteObservedAttributes.has(name),
);
if (filteredQueuedAttributes.length) {
this.#transport.send({
kind: batch_kind,
messages: filteredQueuedAttributes.map(([name, value]) => ({
kind: attribute_changed_kind,
name,
value,
})),
});
}
this.#changedAttributesQueue = [];
this.#remoteObservedProperties = new Set(data.observed_properties);
for (const name of this.#remoteObservedProperties) {
Object.defineProperty(this, name, {
get() {
return this[`_${name}`];
},
set(value) {
this[`_${name}`] = value;
this.#transport?.send({
kind: property_changed_kind,
name,
value,
});
},
});
}
if (data.will_adopt_styles) {
await this.#adoptStyleSheets();
}
this.#reconciler.mount(data.vdom);
// Once the component is mounted there is finally something displayed on
// the screen! Occassionally clients will want to know when this happens
// so they can kick off other work.
this.dispatchEvent(new CustomEvent("lustre:mount"));
break;
}
case reconcile_kind: {
this.#reconciler.push(data.patch);
break;
}
case emit_kind: {
this.dispatchEvent(new CustomEvent(data.name, { detail: data.data }));
break;
}
}
}
//
#connect() {
if (!this.#route || !this.#method) return;
if (this.#transport) this.#transport.close();
const onConnect = () => {
this.#connected = true;
this.dispatchEvent(new CustomEvent("lustre:connect"), {
detail: {
route: this.#route,
method: this.#method,
},
});
};
const onMessage = (data) => {
this.messageReceivedCallback(data);
};
const onClose = () => {
this.#connected = false;
this.dispatchEvent(new CustomEvent("lustre:close"), {
detail: {
route: this.#route,
method: this.#method,
},
});
};
const options = { onConnect, onMessage, onClose };
switch (this.#method) {
case "ws":
this.#transport = new WebsocketTransport(this.#route, options);
break;
case "sse":
this.#transport = new SseTransport(this.#route, options);
break;
case "polling":
this.#transport = new PollingTransport(this.#route, options);
break;
}
}
//
async #adoptStyleSheets() {
while (this.#adoptedStyleNodes.length) {
this.#adoptedStyleNodes.pop().remove();
this.#shadowRoot.firstChild.remove();
}
this.#adoptedStyleNodes = await adoptStylesheets(this.#shadowRoot);
this.#reconciler.offset = this.#adoptedStyleNodes.length;
}
}
// TRANSPORT OPTIONS -----------------------------------------------------------
class WebsocketTransport {
#url;
#socket;
#waitingForResponse = false;
#queue = [];
#onConnect;
#onMessage;
#onClose;
constructor(url, { onConnect, onMessage, onClose }) {
this.#url = url;
this.#socket = new WebSocket(this.#url);
this.#onConnect = onConnect;
this.#onMessage = onMessage;
this.#onClose = onClose;
this.#socket.onopen = () => {
this.#onConnect();
};
this.#socket.onmessage = ({ data }) => {
try {
this.#onMessage(JSON.parse(data));
} finally {
if (this.#queue.length) {
this.#socket.send(
JSON.stringify({
kind: batch_kind,
messages: this.#queue,
}),
);
} else {
this.#waitingForResponse = false;
}
this.#queue = [];
}
};
this.#socket.onclose = () => {
this.#onClose();
};
}
send(data) {
if (this.#waitingForResponse || this.#socket.readyState !== WebSocket.OPEN) {
this.#queue.push(data);
return;
} else {
this.#socket.send(JSON.stringify(data));
this.#waitingForResponse = true;
}
}
close() {
this.#socket.close();
}
}
class SseTransport {
#url;
#eventSource;
#onConnect;
#onMessage;
#onClose;
constructor(url, { onConnect, onMessage, onClose }) {
this.#url = url;
this.#eventSource = new EventSource(this.#url);
this.#onConnect = onConnect;
this.#onMessage = onMessage;
this.#onClose = onClose;
this.#eventSource.onopen = () => {
this.#onConnect();
};
this.#eventSource.onmessage = ({ data }) => {
try {
this.#onMessage(JSON.parse(data));
} catch {}
};
}
send(data) {}
close() {
this.#eventSource.close();
this.#onClose();
}
}
class PollingTransport {
#url;
#interval;
#timer;
#onConnect;
#onMessage;
#onClose;
constructor(url, { onConnect, onMessage, onClose, ...opts }) {
this.#url = url;
this.#onConnect = onConnect;
this.#onMessage = onMessage;
this.#onClose = onClose;
this.#interval = opts.interval ?? 5000;
this.#fetch().finally(() => {
this.#onConnect();
this.#timer = setInterval(() => this.#fetch(), this.#interval);
});
}
async send(data) {}
close() {
clearInterval(this.#timer);
this.#onClose();
}
#fetch() {
return fetch(this.#url)
.then((response) => response.json())
.then(this.#onMessage)
.catch(console.error);
}
}
// UTILS -----------------------------------------------------------------------
// It's important that this comes right at the bottom, otherwise the different
// transport classes would be undefined when the custom element is defined!
customElements.define("lustre-server-component", ServerComponent);