express-dom
Version:
Prepare, render web pages - express middleware
355 lines (329 loc) • 9.43 kB
JavaScript
module.exports = function (trackerOpts) {
class Track {
native = {};
context;
constructor(context) {
this.context = context;
}
}
class AsyncTracker {
#tracks = [];
#sources = new Set();
#count = 0;
#idles = 0;
#resolve;
constructor(opts) {
this.debug = opts.debug;
const contexts = [window];
const promise = new Promise(ok => {
this.#resolve = ok;
});
for (const context of contexts) {
if (context != context.top) continue;
Object.defineProperty(context, `signal_${opts.id}`, {
enumerable: false,
configurable: false,
writable: false,
value: promise
});
const track = new Track(context);
this.#tracks.push(track);
this.#handleMicrotask(track);
this.#handleRequestAnimationFrame(track);
this.#handleTimeout(track, opts.timeout);
this.#handleReady(track);
this.#handleFetch(track);
this.#handleResponse(track);
this.#handleXHR(track);
this.#handleNodes(track);
this.#handlePromises(track);
// tracker must at least wait for this
context.document.addEventListener('DOMContentLoaded', () => { });
}
}
#wrapCb({ native, context }, fn) {
if (typeof fn != "function") return fn;
const it = this;
return (...args) => {
const id = it.creates('p');
try {
return fn(...args);
} finally {
native.queueMicrotask.call(context, () => it.completes(id));
}
};
}
#handlePromises({ native, context }) {
const it = this;
native.then = context.Promise.prototype.then;
context.Promise.prototype.then = function (res, rej) {
return native.then.call(this,
it.#wrapCb({ native, context }, res),
it.#wrapCb({ native, context }, rej)
);
};
}
#handleNode(node) {
const id = this.waits(node.nodeName + ' ' + (node.src ?? node.href));
const itc = () => {
node.removeEventListener('load', itc);
node.removeEventListener('error', itc);
this.completes(id);
};
node.addEventListener('load', itc);
node.addEventListener('error', itc);
}
#unhandleNode(node) {
this.completes(node.nodeName + ' ' + (node.src ?? node.href));
}
#handleNodes({ context }) {
const alls = new Set();
function guard(Cla, key) {
const prop = Object.getOwnPropertyDescriptor(Cla.prototype, key);
const propSet = prop.set;
prop.set = function (str) {
const ret = propSet.call(this, str);
if (!this.isConnected) alls.add(this);
return ret;
};
Object.defineProperty(Cla.prototype, key, prop);
}
guard(HTMLLinkElement, 'href');
guard(HTMLScriptElement, 'src');
function check(node) {
if (node.nodeType != Node.ELEMENT_NODE) return;
const tag = node.nodeName;
if (tag == "SCRIPT") {
return node.src && (!node.type || node.type == "text/javascript" || node.type == "module") && alls.has(node);
} else if (tag == "LINK") {
return node.href && node.rel == "stylesheet" && alls.has(node);
}
}
const observer = new MutationObserver(mutations => {
for (const { addedNodes, removedNodes } of mutations) {
if (addedNodes) for (const node of addedNodes) {
if (check(node)) this.#handleNode(node);
}
if (removedNodes) for (const node of removedNodes) {
if (check(node)) this.#unhandleNode(node);
}
}
});
observer.observe(context.document, {
childList: true,
subtree: true
});
}
#handleXHR({ native, context }) {
const it = this;
const events = ["abort", "error", "load"];
native.XMLHttpRequest = context.XMLHttpRequest;
context.XMLHttpRequest = class XMLHttpRequest extends native.XMLHttpRequest {
#id;
#done;
constructor() {
super();
this.#done = () => {
for (const ev of events) {
this.removeEventListener(ev, this.#done);
}
it.completes(this.#id);
};
}
static get [Symbol.species]() {
return XMLHttpRequest;
}
get [Symbol.toStringTag]() {
return 'XMLHttpRequest';
}
send(...args) {
for (const ev of events) {
this.addEventListener(ev, this.#done);
}
this.#id = it.creates('xhr');
try {
return super.send(...args);
} catch (err) {
it.completes(this.#id);
throw err;
}
}
};
}
#handleFetch({ native, context }) {
const it = this;
native.fetch = context.fetch;
context.fetch = function fetch(...args) {
const id = it.creates('fetch');
return native.fetch.apply(context, args).finally(() => {
native.queueMicrotask.call(context, () => {
it.completes(id);
});
});
};
}
#handleResponse({ native, context }) {
const it = this;
for (const meth of ['json', 'text', 'blob', 'formData', 'arrayBuffer']) {
const key = meth + 'Res';
native[key] = context.Response.prototype[meth];
context.Response.prototype[meth] = function () {
const id = it.creates(meth);
return native[key].call(this).finally(() => {
it.completes(id);
});
};
}
}
#handleReady({ native, context }) {
const it = this;
const { document: doc } = context;
native.removeEventListener = doc.removeEventListener;
native.addEventListener = doc.addEventListener;
native.bubbles = new Map();
native.captures = new Map();
class EventWatch {
constructor(name, fn) {
this.id = it.creates(doc.readyState == "loading" ? name : null);
this.fn = fn;
}
async handleEvent(e) {
try {
if (this.fn.handleEvent) await this.fn.handleEvent(e);
else if (typeof this.fn == "function") await this.fn(e);
} finally {
it.completes(this.id);
this.id = null;
}
}
}
doc.addEventListener = function (name, fn, cap) {
const ft = typeof fn;
if (name != "DOMContentLoaded" || !fn || !["object", "function"].includes(ft)) {
return native.addEventListener.call(doc, name, fn, cap);
}
const eMap = cap ? native.captures : native.bubbles;
if (eMap.has(fn)) return;
const watch = new EventWatch(name, fn);
eMap.set(fn, watch);
return native.addEventListener.call(doc, name, watch, cap);
};
doc.removeEventListener = function (name, fn, cap) {
const ft = typeof fn;
if (name != "DOMContentLoaded" || !fn || !["object", "function"].includes(ft)) {
return native.removeEventListener.call(doc, name, fn, cap);
}
const eMap = cap ? native.captures : native.bubbles;
const watch = eMap.get(fn);
if (!watch) return;
it.completes(watch.id);
return native.removeEventListener.call(doc, name, watch, cap);
};
}
#handleMicrotask({ native, context }) {
const it = this;
native.queueMicrotask = context.queueMicrotask;
context.queueMicrotask = function (fn) {
if (typeof fn != "function") {
return native.queueMicrotask.call(context, fn);
}
const id = it.creates('task');
native.queueMicrotask.call(context, () => {
try {
fn.call(this);
} finally {
it.completes(id);
}
});
};
}
#handleTimeout({ native, context }, timeout) {
const it = this;
native.setTimeout = context.setTimeout;
context.setTimeout = function (fn, to) {
if (timeout && to && to > timeout) to = timeout;
if (typeof fn != "function") {
return native.setTimeout.call(context, fn, to);
}
const rid = native.setTimeout.call(context, () => {
try {
fn.call(this);
} finally {
it.completes('to' + rid);
}
}, to);
it.waits('to' + rid);
return rid;
};
native.clearTimeout = context.clearTimeout;
context.clearTimeout = function (rid) {
native.clearTimeout.call(context, rid);
it.completes('to' + rid);
};
}
#handleRequestAnimationFrame({ native, context }) {
const it = this;
native.requestAnimationFrame = context.requestAnimationFrame;
context.requestAnimationFrame = function (fn) {
if (typeof fn != "function") {
return native.requestAnimationFrame.call(context, fn);
}
const rid = native.requestAnimationFrame.call(context, () => {
try {
fn.call(this);
} finally {
it.completes('raf' + rid);
}
});
it.waits('raf' + rid);
return rid;
};
native.cancelAnimationFrame = context.cancelAnimationFrame;
context.cancelAnimationFrame = function (rid) {
native.cancelAnimationFrame.call(context, rid);
it.completes('raf' + rid);
};
}
waits(id) {
if (id == null) return;
this.#sources?.add(id);
return id;
}
creates(prefix) {
if (prefix == null) return;
const id = `${prefix}${this.#count++}`;
this.debug?.("creates", id);
this.#sources?.add(id);
return id;
}
completes(id) {
if (id == null) return;
this.debug?.("completes", id);
if (this.#tracks == null) {
this.debug?.("destroyed");
return;
}
const { context, native } = this.#tracks[0];
// run after all microtasks
native.setTimeout.call(context, () => this.#done(id));
}
#done(id) {
this.#sources?.delete(id);
if (this.#sources?.size === 0) {
if (!this.debug) this.#destroy();
this.debug?.("idle", this.#idles++);
this.#resolve("idle");
}
}
#destroy() {
for (const { native, context } of this.#tracks) {
for (const [name, prim] of Object.entries(native)) {
context[name] = prim;
}
}
this.#tracks = null;
this.#sources = null;
}
}
new AsyncTracker(trackerOpts);
};