stackchain-dashboard/frontend/context-poller.js
timmy 2253c40577
All checks were successful
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
fix: scope live revisions to worker generations (#383)
2026-08-09 08:14:18 +00:00

143 lines
3.9 KiB
JavaScript

function buildLiveRevisionQuery(revisions = {}) {
const params = new URLSearchParams();
const tokenPattern = /^[0-9a-f]{16}\.[0-9]{1,20}$/;
['context', 'events', 'notifications'].forEach((section) => {
const revision = revisions[section];
if (typeof revision === 'string' && tokenPattern.test(revision)) {
params.set(section + '_revision', revision);
}
});
return params.toString();
}
function createContextPoller({
fetchContext,
onSnapshot,
onError,
isHidden = () => false,
setTimer = setTimeout,
clearTimer = clearTimeout,
setDeadlineTimer = setTimeout,
clearDeadlineTimer = clearTimeout,
intervalMs = 8000,
timeoutMs = 12000,
}) {
let activeRequest = null;
let timer = null;
let stopped = false;
let revisions = {};
let retainedSnapshot = null;
function cancelTimer() {
if (timer !== null) clearTimer(timer);
timer = null;
}
function schedule() {
cancelTimer();
if (stopped || isHidden()) return;
timer = setTimer(() => {
timer = null;
refresh();
}, intervalMs);
}
function abortError() {
const error = new Error('Live update superseded.');
error.name = 'AbortError';
return error;
}
function supersede(request) {
if (!request || request.settled) return;
request.superseded = true;
request.controller.abort();
request.rejectDeadline(abortError());
}
function refresh(options = {}) {
if (stopped || isHidden()) return Promise.resolve(null);
if (activeRequest && !options.force) return activeRequest.promise;
if (activeRequest) supersede(activeRequest);
const controller = new AbortController();
let rejectDeadline;
const deadlinePromise = new Promise((resolve, reject) => {
rejectDeadline = reject;
});
const requestState = {
controller,
deadline: null,
promise: null,
rejectDeadline,
settled: false,
superseded: false,
};
activeRequest = requestState;
requestState.deadline = setDeadlineTimer(() => {
if (requestState.settled || requestState.superseded) return;
controller.abort();
const error = new Error(`Live update timed out after ${timeoutMs}ms.`);
error.name = 'TimeoutError';
rejectDeadline(error);
}, timeoutMs);
let request;
try {
request = fetchContext({ ...revisions }, { signal: controller.signal });
} catch (error) {
request = Promise.reject(error);
}
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
.then((snapshot) => {
if (activeRequest !== requestState) return retainedSnapshot;
const changedSections = ['context', 'events', 'notifications'].filter(
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
);
retainedSnapshot = retainedSnapshot ? { ...retainedSnapshot, ...snapshot } : { ...snapshot };
revisions = { ...revisions, ...(snapshot.revisions || {}) };
onSnapshot(retainedSnapshot, changedSections);
return retainedSnapshot;
})
.catch((error) => {
if (activeRequest === requestState && !stopped) onError(error);
return null;
})
.finally(() => {
requestState.settled = true;
clearDeadlineTimer(requestState.deadline);
if (activeRequest !== requestState) return;
activeRequest = null;
schedule();
});
return requestState.promise;
}
function setVisible(visible) {
cancelTimer();
if (!visible) return Promise.resolve(null);
return refresh({ force: true });
}
return {
start: refresh,
refresh,
setVisible,
stop() {
stopped = true;
cancelTimer();
if (activeRequest) {
const request = activeRequest;
activeRequest = null;
supersede(request);
}
},
};
}
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createContextPoller;
}