import { isFollowed, passkey } from "./passkey.js";
import { Telemetry } from "./telemetry.js";
export const SCREEN = "application/vnd.sofie.screen+json";
const UNREACHABLE = "The server could not be reached. Try again.";
const FAILED = "Something went wrong. Try again.";
export class Runtime {
constructor(screens, onChange) {
this.screens = screens;
this.onChange = onChange;
this.state = null;
this.unanswered = new WeakMap();
this.telemetry = null;
this.autosubmitted = null;
this.navigation = null;
this.shownHref = null;
this.navigations = 0;
}
start(element = document.getElementById("sofie-screen")) {
const envelope = JSON.parse(element.textContent);
this.telemetry = Telemetry.start();
history.replaceState({ sofie: true }, "", location.href);
this.shownHref = location.href;
addEventListener("popstate", () => this.popped());
document.addEventListener("click", (event) => this.intercept(event));
document.addEventListener("click", (event) => passkey(event, this));
this.show(envelope, {}, []);
}
show(envelope, submitted, notice) {
this.state = { envelope, submitted, notice };
document.title = envelope.title;
this.onChange(this.state);
}
rendered(envelope) {
if (this.autosubmitted === envelope) return;
this.autosubmitted = envelope;
for (const form of document.querySelectorAll("form[data-sofie-autosubmit]")) form.requestSubmit();
}
popped() {
if (isSamePage(location.href, this.shownHref)) return;
this.shownHref = location.href;
this.navigate(location.href, false);
}
intercept(event) {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const link = event.target.closest?.("a[href]");
if (!link || link.target || link.hasAttribute("download")) return;
const target = new URL(link.href);
if (target.origin !== location.origin || isInPage(link.href)) return;
event.preventDefault();
this.navigate(target.href, true);
}
follow(href) {
return (event) => {
event.preventDefault();
this.navigate(new URL(href, location.href).href, true);
};
}
filter() {
return (event) => {
event.preventDefault();
const query = new URLSearchParams(new FormData(event.currentTarget));
this.navigate(new URL(`?${query}`, location.href).href, true);
};
}
act(post, fields) {
return (event) => {
event.preventDefault();
const body = fields === undefined ? null : new Blob([JSON.stringify(fields)], { type: "application/json" });
this.post(event.currentTarget, post, body, null);
};
}
submit(post, formId) {
return (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const body = form.enctype === "multipart/form-data" ? data : new URLSearchParams(data);
this.post(form, post, body, { formId, input: nested(data) });
};
}
navigate(href, push) {
this.navigations += 1;
return this.load(href, push);
}
async load(href, push) {
this.navigation?.abort();
const navigation = new AbortController();
this.navigation = navigation;
const response = await this.request(href, { method: "GET", signal: navigation.signal });
if (!response) return;
try {
await this.answer(response, { push, href });
} catch (error) {
if (!navigation.signal.aborted) throw error;
}
}
async post(element, url, body, refused) {
if (!this.unanswered.has(element)) this.unanswered.set(element, newKey());
const headers = { "Idempotency-Key": this.unanswered.get(element) };
const navigations = this.navigations;
const response = await this.request(new URL(url, location.href).href, { method: "POST", body, headers });
if (!response) return;
this.unanswered.delete(element);
if (this.navigations !== navigations) return;
const shown = await this.answer(response, { push: response.redirected, href: location.href, refused });
if (shown && element.tagName === "FORM") element.reset();
}
async request(href, init) {
const headers = { ...init.headers, Accept: SCREEN };
const traceparent = this.telemetry?.traceparent();
if (traceparent) headers.traceparent = traceparent;
try {
return await fetch(href, { ...init, credentials: "same-origin", headers });
} catch {
if (init.signal?.aborted) return null;
this.notify([UNREACHABLE]);
return null;
}
}
async answer(response, { push, href, refused }) {
if (response.status === 401) {
location.assign(href);
return false;
}
if (response.status === 204) {
await this.load(location.href, false);
return true;
}
const isJson = (response.headers.get("content-type") ?? "").startsWith("application/json");
if (!isJson) {
if (response.ok) location.assign(response.url);
else this.notify([FAILED]);
return false;
}
const body = await response.json();
if (response.ok && isFollowed(body.location)) {
location.assign(body.location);
return false;
}
if (response.ok && body.screen) {
if (!this.screens[body.screen]) {
location.assign(response.url);
return false;
}
if (push) {
history.pushState({ sofie: true }, "", response.url);
this.shownHref = response.url;
this.telemetry?.navigated();
}
this.show(body, {}, []);
return true;
}
const notice = body.notice ?? (body.error ? [body.error] : [FAILED]);
const submitted = { ...this.state.submitted };
if (refused && Array.isArray(body.errors)) {
const errors = Object.fromEntries(body.errors.map(({ field, message }) => [field, message]));
submitted[refused.formId] = { input: refused.input, errors };
}
this.show(this.state.envelope, submitted, notice);
return false;
}
notify(notice) {
this.show(this.state.envelope, this.state.submitted, notice);
}
}
function nested(data) {
const input = {};
for (const [name, value] of data) {
if (typeof value !== "string") continue;
const path = name.split(".");
let into = input;
for (const part of path.slice(0, -1)) {
into[part] = typeof into[part] === "object" ? into[part] : {};
into = into[part];
}
into[path[path.length - 1]] = value;
}
return input;
}
function newKey() {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function isInPage(href) {
return href.includes("#") && isSamePage(href, location.href);
}
function isSamePage(href, other) {
if (!other) return false;
const target = new URL(href);
const current = new URL(other);
return target.pathname === current.pathname && target.search === current.search;
}