import { escapeHtml } from "@intentic/base/escape";
import { FetchJsonError, fetchJson } from "@intentic/base/fetch-json";
// The zod-free half of an embed's wire and look, shared by the two embeds (webchat-widget, issue-sdk) and the daemon's
// doors behind them. Nothing imported but base's dependency-free subpaths: this bundles into someone else's page, and
// the contract's barrel would drag in zod (measured at 1.1 MB for two strings).
// Where an embed talks to: the daemon it came from, and the automation it is the public face of.
export interface EmbedEndpoint {
readonly base: string;
readonly automationId: string;
}
// The server's own refusal sentence ({"error": ...}), shown verbatim: it names exactly what to fix (e.g. "origin not
// allowed").
export class EmbedError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "EmbedError";
}
}
export const embedFailure = async (response: Response): Promise => {
const body = (await response.json().catch(() => undefined)) as { error?: unknown } | undefined;
return new EmbedError(typeof body?.error === "string" ? body.error : `request failed (${response.status})`, response.status);
};
// One route of a public door: `///`, the daemon's own spelling.
export const embedUrl = ({ base, automationId }: EmbedEndpoint, slug: string, path: string): string =>
`${base}/${slug}/${encodeURIComponent(automationId)}/${path}`;
// One GET against a public door, answered as JSON or refused with the server's own sentence. Base's transport gives it
// a deadline, so a door that stalls fails in 30 s instead of leaving the visitor's widget waiting on it for minutes,
// and names a timeout as one. The label is what a visitor reads when the server cannot be reached.
export const fetchEmbedJson = async (url: string): Promise => {
try {
// SAFETY: the caller names the shape its door answers; the embed bundles no schema library to check it with.
return (await fetchJson(url, { label: "The server" })) as T;
} catch (error) {
// A refusal's body arrives as the start of its text; embedFailure reads the sentence out of it as for any response.
throw error instanceof FetchJsonError && error.kind === "status" && error.status !== undefined
? await embedFailure(new Response(error.body ?? "", { status: error.status }))
: error;
}
};
// Find a nonce whose SHA-256 of `salt:nonce` has `difficulty` leading zero bits, signed to one caller's id so it can't
// be replayed elsewhere. Solved on the main thread, not a Worker: a host's CSP may forbid the blob: URL a Worker needs.
export interface PowChallenge {
readonly salt: string;
readonly difficulty: number;
}
export const fetchEmbedChallenge = (endpoint: EmbedEndpoint, slug: string, param: string, id: string): Promise =>
fetchEmbedJson(`${embedUrl(endpoint, slug, "challenge")}?${param}=${encodeURIComponent(id)}`);
const BATCH = 512;
// Leading zero bits of a digest, up to `wanted`. Stops at the first non-zero byte, so a miss costs one byte.
const leadingZeroBits = (digest: Uint8Array, wanted: number): number => {
let bits = 0;
for (const byte of digest) {
if (byte !== 0) {
// Math.clz32 counts 32-bit leading zeros; the byte sits in the low 8, so 24 are structural.
return bits + Math.clz32(byte) - 24;
}
bits += 8;
if (bits >= wanted) {
return bits;
}
}
return bits;
};
// Resolves to `:`, so the daemon can re-derive the challenge without storing one. insecure is for an
// http:// page: crypto.subtle needs a secure context, and the fix is the site's TLS.
export const solveProofOfWork = async (challenge: PowChallenge, insecure: string, onProgress?: (attempts: number) => void): Promise => {
if (crypto.subtle === undefined) {
throw new Error(insecure);
}
const encoder = new TextEncoder();
for (let nonce = 0; ; nonce += 1) {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(`${challenge.salt}:${nonce}`)));
if (leadingZeroBits(digest, challenge.difficulty) >= challenge.difficulty) {
return `${challenge.salt}:${nonce}`;
}
if (nonce % BATCH === BATCH - 1) {
onProgress?.(nonce + 1);
// Hands the main thread back so the page (and the "checking..." line) keeps painting.
// allow(primitive-tiers): a yield to the page's event loop, not a wait; this bundle imports no async helpers.
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
};
// localStorage can throw (Safari private mode, blocked storage); the embed still works, just minting a fresh id per
// load. Silent by design: an embed must never put an error in the console.
export const readStored = (name: string): string | undefined => {
try {
return window.localStorage.getItem(name) ?? undefined;
} catch {
return undefined;
}
};
export const writeStored = (name: string, value: string): void => {
try {
window.localStorage.setItem(name, value);
} catch {
// No storage: this load keeps the value in memory, the next one mints another.
}
};
// A per-browser id, not identity and not a secret: it threads a visitor's messages into one conversation, or bounds a
// rate window per tab.
export const storedId = (name: string): string => {
const existing = readStored(name);
if (existing !== undefined && existing !== "") {
return existing;
}
const minted = crypto.randomUUID();
writeStored(name, minted);
return minted;
};
// The daemon to talk to is this script's own origin, the one thing a copy-paste can't get wrong; data-base overrides it
// behind a reverse proxy. currentScript is read at module scope, valid only synchronously; querySelector is the
// fallback.
export const embedScript = (srcMatch: string): HTMLScriptElement | null =>
(document.currentScript as HTMLScriptElement | null) ?? document.querySelector(`script[src*="${srcMatch}"]`);
// Undefined with no automation id, the one mistake worth flagging: otherwise the embed is silently absent.
export const embedEndpointOf = (script: HTMLScriptElement): EmbedEndpoint | undefined => {
const automationId = script.dataset["automation"];
if (automationId === undefined || automationId === "") {
return undefined;
}
return { automationId, base: (script.dataset["base"] ?? new URL(script.src, window.location.href).origin).replace(/\/$/, "") };
};
// The frames of a Visitor chat reply, streamed as server-sent events on the POST that sent the message: a chunk of the
// answer, the notice that the owner reviews it first, the one failure sentence a visitor sees, and the end of the turn.
export const WEBCHAT_EVENT = { delta: "delta", pending: "pending", error: "error", done: "done" } as const;
// Intentic's brand orange (`--color-brand-600`): the accent of an embed nobody configured, so it still looks like the
// product, and what an embed paints when it cannot read the configured one.
export const DEFAULT_ACCENT = "#e47100";
// The red, green and blue of a `#rgb` or `#rrggbb` colour, undefined for anything else. The embeds do their colour maths
// here rather than in CSS: an unsupported color-mix() is invalid at computed-value time and would paint nothing.
export const parseHex = (value: string): [number, number, number] | undefined => {
const digits = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim())?.[1];
if (digits === undefined) {
return undefined;
}
const full = digits.length === 3 ? [...digits].map((char) => char + char).join("") : digits;
const channel = (at: number): number => Number.parseInt(full.slice(at, at + 2), 16);
return [channel(0), channel(2), channel(4)];
};
// One sRGB channel on WCAG's linear scale.
const linear = (channel: number): number => {
const unit = channel / 255;
return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4;
};
// WCAG relative luminance; an unreadable colour scores 0, which only ever picks a label and is never shown.
const luminance = (color: string): number => {
const [r, g, b] = parseHex(color) ?? [0, 0, 0];
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
};
const contrast = (a: number, b: number): number => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
// The label on a solid accent: the embed's own dark ink or white, whichever contrasts more. Measured, not assumed:
// white on the default orange is 3.15:1, under AA, where either embed's dark ink clears 5:1, and comparing the two works
// for any accent a customer picks.
export const onAccent = (accent: string, dark: string): string => {
const field = luminance(accent);
return contrast(field, luminance(dark)) >= contrast(field, luminance("#ffffff")) ? dark : "#ffffff";
};
// Text for innerHTML, safe both between tags and inside a quoted attribute: neither an owner's config nor what a
// visitor typed is trusted with markup. Base's one escaper, re-exported for the embeds that import it from here.
export { escapeHtml };