116 lines
5.0 KiB
JavaScript
116 lines
5.0 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
if (window.HockeyMappingRequests) return;
|
||
|
||
const lanes = new Map();
|
||
const superseded = () => ({ ok: false, reason: "mapping_superseded", superseded: true });
|
||
const cancelled = () => new DOMException("Mapping request cancelled", "AbortError");
|
||
|
||
function current(consumer) {
|
||
return !consumer.done && !consumer.signal?.aborted && consumer.isCurrent();
|
||
}
|
||
|
||
function finish(consumer, error, result) {
|
||
if (consumer.done) return;
|
||
const valid = current(consumer);
|
||
consumer.done = true;
|
||
consumer.signal?.removeEventListener("abort", consumer.onAbort);
|
||
if (!valid) consumer.resolve(superseded());
|
||
else if (error) consumer.reject(error);
|
||
else consumer.resolve(result);
|
||
}
|
||
|
||
async function send(job) {
|
||
let response;
|
||
let payload;
|
||
try {
|
||
// A full Mapping may contain hundreds of fields. A browser deadline does
|
||
// not stop the server/Agent and can turn the next click into a second push.
|
||
response = await fetch(job.path, { method: "POST", cache: "no-store", credentials: "same-origin" });
|
||
payload = await response.json();
|
||
} catch (cause) {
|
||
const error = new Error("Результат применения Mapping не подтверждён: связь с сервером прервалась. Проверьте Agent перед повтором.");
|
||
error.cause = cause;
|
||
error.code = "mapping_outcome_unknown";
|
||
throw error;
|
||
}
|
||
if (!response.ok) {
|
||
if (response.status === 401) window.location?.assign?.("/");
|
||
throw new Error(typeof payload?.detail === "string" ? payload.detail : payload?.detail?.message || `HTTP ${response.status}`);
|
||
}
|
||
if (!payload || typeof payload.ok !== "boolean") {
|
||
const error = new Error("Результат применения Mapping не подтверждён: некорректный ответ сервера. Проверьте Agent перед повтором.");
|
||
error.code = "mapping_outcome_unknown";
|
||
throw error;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
async function run(key, lane, job) {
|
||
lane.running = job;
|
||
try {
|
||
// Queued work can become obsolete while another match/language is sent.
|
||
if (!job.consumers.some(current)) {
|
||
job.consumers.forEach((consumer) => finish(consumer, null, superseded()));
|
||
return;
|
||
}
|
||
const result = await send(job);
|
||
job.consumers.forEach((consumer) => finish(consumer, null, result));
|
||
} catch (error) {
|
||
job.consumers.forEach((consumer) => finish(consumer, error));
|
||
} finally {
|
||
lane.running = null;
|
||
const next = lane.queued;
|
||
lane.queued = null;
|
||
if (next) void run(key, lane, next);
|
||
else lanes.delete(key);
|
||
}
|
||
}
|
||
|
||
function apply({ deviceId = "", scope = "device", language = "ru", context = "", isCurrent = () => true, signal, forceFresh = false } = {}) {
|
||
const device = String(deviceId || "").trim();
|
||
const key = scope === "all" ? "all" : device ? `device:${device}` : "active";
|
||
const path = scope === "all" ? "/api/hockey/admin/vmix-mapping/apply-all-active"
|
||
: device ? `/api/hockey/agents/devices/${encodeURIComponent(device)}/apply-mapping`
|
||
: "/api/hockey/agents/apply-active-mapping";
|
||
const signature = JSON.stringify([path, language === "en" ? "en" : "ru", String(context)]);
|
||
if (signal?.aborted) return Promise.reject(cancelled());
|
||
if (!isCurrent()) return Promise.resolve(superseded());
|
||
const lane = lanes.get(key) || { running: null, queued: null };
|
||
lanes.set(key, lane);
|
||
// A queued intent may already have changed server-side preferences/context.
|
||
// Returning to the running signature therefore needs a fresh resolver read;
|
||
// only uninterrupted duplicate intents can share the running HTTP request.
|
||
let job = lane.queued?.signature === signature ? lane.queued
|
||
: !forceFresh && !lane.queued && lane.running?.signature === signature ? lane.running : null;
|
||
if (forceFresh && lane.running) {
|
||
// A completed preference write invalidates the old resolver snapshot,
|
||
// even if intermediate intents never reached this request coordinator.
|
||
lane.running.consumers.forEach((consumer) => finish(consumer, null, superseded()));
|
||
}
|
||
if (!job) {
|
||
job = { path, signature, consumers: [] };
|
||
if (lane.queued) lane.queued.consumers.forEach((consumer) => finish(consumer, null, superseded()));
|
||
lane.queued = job;
|
||
}
|
||
const promise = new Promise((resolve, reject) => {
|
||
const consumer = { resolve, reject, isCurrent, signal, done: false, onAbort: null };
|
||
consumer.onAbort = () => {
|
||
if (consumer.done) return;
|
||
consumer.done = true;
|
||
signal.removeEventListener("abort", consumer.onAbort);
|
||
reject(cancelled());
|
||
};
|
||
signal?.addEventListener("abort", consumer.onAbort, { once: true });
|
||
job.consumers.push(consumer);
|
||
});
|
||
if (!lane.running) {
|
||
lane.queued = null;
|
||
void run(key, lane, job);
|
||
}
|
||
return promise;
|
||
}
|
||
|
||
window.HockeyMappingRequests = { apply };
|
||
})();
|