const ACCESS_TOKEN = 'ORDINARY_ACCESS_TOKEN';
const REFRESH_TOKEN = 'ORDINARY_REFRESH_TOKEN';
const TOKEN_SIGNING_KEY = 'ORDINARY_TOKEN_SIGNING_KEY';
const SCHEMA = 'ORDINARY_SCHEMA';
const CORRELATION = 'ORDINARY_CORRELATION_ID';
const DEFAULT_CLIENT_EXP_S = 3;
const MIN_EVENT_DELAY_S = 5;
const MAX_EVENT_DELAY_S = 10;
const MAX_EVENT_BUFFER = 20;
const MAX_EVENT_BATCH = 15;
export const ALG = {
'Sha256': 'SHA-256',
};
function getCorrelationId() {
const correlationId = sessionStorage.getItem(CORRELATION);
if (correlationId) {
return correlationId;
}
const newCorrelationId = crypto.randomUUID();
sessionStorage.setItem(CORRELATION, newCorrelationId);
return newCorrelationId;
}
export async function getHeaders({
cookies = true,
requestId = true,
correlationId = true,
authorization = false,
} = {}) {
const token = authorization ? await getAccess(cookies) : new Uint8Array([]);
return {
...(requestId ? {'x-request-id': crypto.randomUUID()} : {}),
...(correlationId ? {'x-correlation-id': getCorrelationId()} : {}),
...(authorization ? {'authorization': `Bearer ${base64encode(token)}`} : {}),
}
}
export async function getSchema() {
const schema = localStorage.getItem(SCHEMA);
if (schema) {
const [compression, encoded] = schema.split(',');
const decoded = base64decode(encoded);
const decompressed = decompress(decoded, 'application/json', compression);
const json = await decompressed.json();
if (json.version === '{{ version }}') {
return json;
}
}
let json;
try {
const res = await fetch('/.ordinary/schema', {
headers: await getHeaders(),
});
json = await res.json();
} catch (e) {
json = {
version: '{{ version }}',
flags: [],
auth: {
cookies_enabled: false,
}
};
}
const [compression, compressed] = await compress(JSON.stringify(json), 'application/json');
const encoded = base64encode(compressed);
localStorage.setItem(SCHEMA, `${compression},${encoded}`);
return json;
}
export async function getFlag(flagName) {
const FLAG = `ORDINARY_FLAG_${flagName.toUpperCase()}`;
const flag = localStorage.getItem(FLAG);
if (!flag) {
const schema = await getSchema();
for (let i = 0; i < schema.flags.length; i += 1) {
const flag = schema.flags[i];
if (flag.name === flagName) {
const select = Math.floor(Math.random() * 101);
let acc = 0;
for (let j = 0; j < flag.options.length; j += 1) {
const option = flag.options[j];
acc += option.percentage;
if (acc >= select) {
localStorage.setItem(FLAG, option.name);
return option.name;
}
}
}
}
}
return flag;
}
async function getFlags() {
const schema = await getSchema();
const flags = {};
for (let i = 0; i < schema.flags.length; i += 1) {
const name = schema.flags[i].name;
flags[name] = await getFlag(name);
}
return flags;
}
export function base64encode(value) {
return value.toBase64({
omitPadding: true,
alphabet: "base64url"
});
}
export function base64decode(value) {
return Uint8Array.fromBase64(value, {
alphabet: "base64url",
});
}
async function compress(value, mime) {
const blob = new Blob([value], {type: mime});
const deflate = await (new Response(blob.stream().pipeThrough(
new CompressionStream('deflate'),
))).bytes();
return ['deflate', deflate];
}
function decompress(value, mime, compression) {
const blob = new Blob([value], {type: mime});
return (new Response(blob.stream().pipeThrough(
new DecompressionStream(compression),
)));
}
export async function tryCompress(value, mime, force) {
const [compression, compressed] = await compress(value, mime)
const useCompressed = compressed.length < value.length || force;
return {
body: useCompressed ? compressed : value,
headers: {
'content-type': mime,
...(useCompressed ? {'content-encoding': compression} : {}),
},
};
}
export function setTokenSigningKey(key) {
const encoded = base64encode(key);
localStorage.setItem(TOKEN_SIGNING_KEY, encoded);
localStorage.removeItem(REFRESH_TOKEN);
localStorage.removeItem(ACCESS_TOKEN);
}
async function trySigningToken(token) {
const encoded = localStorage.getItem(TOKEN_SIGNING_KEY);
if (!encoded) return token;
const exp = new ArrayBuffer(8);
const buf = new DataView(exp);
buf.setBigUint64(0, BigInt(Math.floor(Date.now() / 1000) + DEFAULT_CLIENT_EXP_S), false);
const withExp = new Uint8Array([...token, ...new Uint8Array(exp)]);
const decoded = base64decode(encoded);
const signingKey = await crypto.subtle.importKey(
'pkcs8',
decoded,
{
name: 'Ed25519'
},
false,
['sign']
);
const signature = await crypto.subtle.sign("Ed25519", signingKey, withExp);
return new Uint8Array([...withExp, ...new Uint8Array(signature)]);
}
export function setRefresh(token) {
const encoded = base64encode(token);
localStorage.setItem(REFRESH_TOKEN, encoded);
localStorage.removeItem(ACCESS_TOKEN);
}
async function refetchAccess(withCookies) {
const encoded = localStorage.getItem(REFRESH_TOKEN);
if (!encoded) {
throw new Error('not logged in.');
}
const refresh_token = base64decode(encoded);
const exp = new DataView(refresh_token.buffer).getBigUint64(0, false);
if (exp < Math.round(new Date().getTime() / 1000)) {
localStorage.removeItem(REFRESH_TOKEN);
localStorage.removeItem(ACCESS_TOKEN);
throw new Error('refresh token expired.');
}
const signed_token = await trySigningToken(refresh_token);
let access_res;
const schema = await getSchema();
if (withCookies && schema.auth.cookies_enabled) {
access_res = await fetch('/accounts/access/cookies', {
credentials: 'include',
headers: {...await getHeaders(), authorization: `Bearer ${base64encode(signed_token)}`},
});
} else {
access_res = await fetch('/accounts/access', {
headers: {...await getHeaders(), authorization: `Bearer ${base64encode(signed_token)}`},
});
}
const token = await access_res.bytes();
setAccess(token);
return token;
}
function setAccess(token) {
const encoded = base64encode(token);
localStorage.setItem(ACCESS_TOKEN, encoded);
}
export async function getAccess(withCookies) {
const encoded = localStorage.getItem(ACCESS_TOKEN);
let access_token;
if (!encoded) {
access_token = await refetchAccess(withCookies);
} else {
access_token = base64decode(encoded);
const exp = new DataView(access_token.buffer).getBigUint64(0, false);
if (exp < Math.round(new Date().getTime() / 1000)) {
access_token = await refetchAccess(withCookies);
}
}
return await trySigningToken(access_token);
}
async function getActualDelayS() {
const clientConfig = (await getSchema())?.logging?.client;
const maxDelayS = clientConfig?.max_delay ?? MAX_EVENT_DELAY_S;
const minDelayS = clientConfig?.min_delay ?? MIN_EVENT_DELAY_S;
return Math.floor(Math.random() * (maxDelayS - minDelayS + 1) + minDelayS);
}
let eventFlushTimeout = null;
async function flushEventsDelayed(db) {
if (eventFlushTimeout) clearTimeout(eventFlushTimeout);
eventFlushTimeout = setTimeout(() => {
flushEvents(db);
}, (await getActualDelayS()) * 1000);
}
async function flushEvents(db, immediate) {
const clientConfig = (await getSchema())?.logging?.client;
const maxBuffer = clientConfig?.max_buffer ?? MAX_EVENT_BUFFER;
const maxBatch = clientConfig?.max_buffer ?? MAX_EVENT_BATCH;
const actualDelayS = await getActualDelayS();
const now = new Date();
now.setHours(now.getSeconds() - actualDelayS);
const tsThreshold = now.toISOString();
const eventsStore = db
.transaction("events", "readwrite")
.objectStore("events");
const lvlIndex = eventsStore.index("lvl");
const outEvents = immediate ? [immediate] : [];
let lastCount = 0;
lvlIndex.openCursor().onsuccess = async (evt) => {
const cursor = evt.target.result;
if (cursor) {
lastCount = lvlIndex.count();
if (cursor.value.ts < tsThreshold || lastCount > maxBuffer || outEvents.length < maxBatch) {
outEvents.push(cursor.value);
eventsStore.delete(cursor.primaryKey);
cursor.continue();
return;
}
}
if (outEvents.length) {
const req = await tryCompress(
JSON.stringify(outEvents),
'application/json',
true
);
if (navigator.sendBeacon) {
navigator.sendBeacon("/events", req.body);
}
if (lastCount) await flushEventsDelayed(db);
}
};
}
async function recordEvent(lvl, msg, fields) {
const now = new Date();
const flags = await getFlags();
const event = {
ts: now.toISOString(),
lvl,
version: '{{ version }}',
correlation: getCorrelationId(),
path: location.pathname,
query: location.search ? location.search : null,
fragment: location.hash ? location.hash : null,
fields: fields ?? null,
flags: Object.getOwnPropertyNames(flags).length ? flags : null,
msg,
};
const eventsDB = indexedDB.open("OrdinaryEvents", 1);
eventsDB.onerror = (evt) => {
console.error(evt);
};
eventsDB.onupgradeneeded = (evt) => {
const db = evt.target.result;
const eventsStore = db.createObjectStore(
"events",
{keyPath: "ts", autoIncrement: true}
);
eventsStore.createIndex("lvl", "lvl", {unique: false});
};
eventsDB.onsuccess = (evt) => {
const db = evt.target.result;
if (lvl === 'error') {
flushEvents(db, event);
} else {
const eventsStore = db
.transaction("events", "readwrite")
.objectStore("events")
eventsStore.add(event);
flushEventsDelayed(db);
}
};
}
export const events = {
trace: (msg, fields) => recordEvent('trace', msg, fields),
debug: (msg, fields) => recordEvent('debug', msg, fields),
info: (msg, fields) => recordEvent('info', msg, fields),
warn: (msg, fields) => recordEvent('warn', msg, fields),
error: (msg, fields) => recordEvent('error', msg, fields),
};