@traxjs/trax
Version:
Reactive state management
521 lines (494 loc) • 19.8 kB
text/typescript
import { tmd } from "./core";
import { LinkedList } from "./linkedlist";
import { LogData, StreamListEvent, StreamEvent, EventStream, SubscriptionId, ProcessingContext, traxEvents, ProcessingContextData, TraxLogTraxProcessingCtxt, TraxLogPropGet, TraxLogProcDirty, TraxLogPropSet, TraxLogObjectCreate, TraxLogObjectDispose, TraxLogProcSkipped, ConsoleOutput, traxEventTypes } from "./types";
/**
* Resolve function used in the await map
* This function will check the event details
* If the event matches the awaitEvent args, then it resolves the pending promise and returns true
* Otherwise it will return false and keep the promis unfullfiled
*/
type awaitResolve = (e: StreamEvent) => boolean;
const CONSOLE_OUTPUT_VALUES = new Set<ConsoleOutput>(["", "Main", "AllButGet", "All"]);
const STL_TRX = "color: #65abff";
const STL_NONE = 'color: ';
const STL_DATA = "color: #e08d00;font-weight:bold"; // orange
const STL_EVT_TYPE = "color: #00ff00;font-weight:bold"; // green
/**
* Create an event stream
* The key passed as argument will be used to authorize events with reserved types
* @param internalSrcKey
* @returns
*/
export function createEventStream(internalSrcKey: any, dataStringifier?: (data: any) => string, onCycleComplete?: () => void): EventStream {
let size = 0;
let maxSize = 1000;
let head: StreamListEvent | undefined;
let tail: StreamListEvent | undefined;
const awaitMap = new Map<string, awaitResolve[]>();
const consumers: ((e: StreamEvent) => void)[] = [];
let consoleOutput: ConsoleOutput = "";
// ----------------------------------------------
// cycle id managment
let cycleCount = -1; // cycle counter, incremented for each new cycle
let eventCount = -1; // event counter, incremented for each new event, reset for each new cycle
let cycleCompletePromise: null | Promise<void> = null;
let cycleTimeMs = 0; // Time stamp used to process elapsed time values
function generateId() {
if (cycleCompletePromise === null) {
// no current cycle defined
eventCount = -1;
cycleCount++;
cycleCompletePromise = Promise.resolve().then(processCycleEnd);
logCycleEvent(traxEvents.CycleStart);
}
eventCount++;
return cycleCount + ":" + eventCount;
}
function processCycleEnd() {
emptyPcStack();
logCycleEvent(traxEvents.CycleComplete);
cycleCompletePromise = null;
}
function logCycleEvent(type: "!CS" | "!CC") {
if (type === traxEvents.CycleComplete && onCycleComplete) {
onCycleComplete();
}
const ts = Date.now();
const elapsedTime = cycleTimeMs !== 0 ? ts - cycleTimeMs : 0;
cycleTimeMs = ts;
logEvent(type, { elapsedTime }, internalSrcKey);
}
// ----------------------------------------------
// Processing context
const START = 1, PAUSE = 2, END = 3;
// Processing context stack
const pcStack = new LinkedList<ProcessingContext>();
function stackPc(pc: ProcessingContext) {
pcStack.add(pc);
}
function unstackPc(pc: ProcessingContext) {
let last = pcStack.shift();
while (last && last !== pc) {
error("[trax/processing context] Contexts must be ended or paused before parent:", last.id);
last = pcStack.shift();
}
}
function emptyPcStack() {
// check that the Processing Context stack is empty at the end of a cycle
if (pcStack.size !== 0) {
let pc = pcStack.shift();
while (pc) {
error("[trax/processing context] Contexts must be ended or paused before cycle ends:", pc.id);
pc = pcStack.shift();
}
}
}
function createProcessingContext(logId: string, data: ProcessingContextData): ProcessingContext {
let state = START;
const evtData = {
processId: logId,
...data
}
const pc = {
get id() {
return logId;
},
pause() {
if (state !== START) {
error("[trax/processing context] Only started or resumed contexts can be paused:", logId);
} else {
unstackPc(pc);
logEvent(traxEvents.ProcessingPause, evtData, internalSrcKey);
state = PAUSE;
}
},
resume() {
if (state !== PAUSE) {
error("[trax/processing context] Only paused contexts can be resumed:", logId);
} else {
stackPc(pc);
logEvent(traxEvents.ProcessingResume, evtData, internalSrcKey);
state = START;
}
},
end() {
if (state === END) {
error("[trax/processing context] Contexts cannot be ended twice:", logId);
} else {
unstackPc(pc);
logEvent(traxEvents.ProcessingEnd, evtData, internalSrcKey);
state = END;
}
}
}
stackPc(pc);
return pc;
}
function error(...data: LogData[]) {
logEvent(traxEvents.Error, mergeMessageData(data));
}
function logEvent(type: string, data?: LogData, src?: any, parentId?: string) {
let evt: StreamListEvent;
if (size >= maxSize && maxSize > 1) {
evt = head!;
head = head!.next;
size--;
evt.id = "";
evt.type = "";
evt.next = evt.data = evt.parentId = undefined;
} else {
evt = { id: "", type: "" }
}
format(internalSrcKey, evt, type, dataStringifier, data, src);
evt.id = generateId();
evt.parentId = parentId;
if (evt.type !== "") {
if (head === undefined) {
head = tail = evt;
size = 1;
} else {
// append to tail
tail!.next = evt;
tail = evt;
size++;
}
for (const c of consumers) {
try {
c({ id: evt.id, type: evt.type, data: evt.data });
} catch (ex) { }
}
resolveAwaitPromises(evt.type, evt);
}
if (consoleOutput) {
// output the event on the console (debug mode)
const etp = evt.type;
let ok = false;
if (consoleOutput === "All") {
ok = true;
} else if (consoleOutput === "AllButGet") {
ok = etp !== traxEvents.Get;
} else if (consoleOutput === "Main") {
if (!traxEventTypes.has(etp)) {
ok = true; // custom event
} else {
ok = (etp === traxEvents.Error
|| etp === traxEvents.Info
|| etp === traxEvents.Warning
|| etp === traxEvents.ProcessingResume
|| etp === traxEvents.ProcessorDirty
|| etp === traxEvents.ProcessorSkipped
|| etp === traxEvents.Set
|| etp === traxEvents.ProcessingStart
);
}
}
if (ok && etp !== traxEvents.CycleStart && etp !== traxEvents.CycleComplete) {
let data = formatEventData(evt.type, evt.data, true);
let pid = "";
if (evt.parentId) {
pid = " - parent:%c" + evt.parentId;
}
const msg = `%cTRX %c${evt.id} %c${evt.type}${data ? "%c - " + data : ""}${pid}`;
const msgNoStyle = msg.replaceAll("%c", "");
const count = Math.floor((msg.length - msgNoStyle.length) / 2);
const args: any[] = [msg, STL_TRX, STL_NONE, STL_EVT_TYPE];
for (let i = 3; count > i; i++) {
args.push(i % 2 ? STL_NONE : STL_DATA);
}
console.log.apply(console, args);
}
}
return evt;
}
function resolveAwaitPromises(eventType: string, e: StreamEvent) {
const resolveList = awaitMap.get(eventType);
if (resolveList) {
const ls = resolveList.filter((resolve) => {
// if resolve returns true => resolution was ok, so we remove the resolve callback from the list
return !resolve({ id: e.id, type: e.type, data: e.data, parentId: e.parentId });
});
if (ls.length === 0) {
awaitMap.delete(eventType);
} else {
awaitMap.set(eventType, ls);
}
}
}
return {
get consoleOutput() {
return consoleOutput
},
set consoleOutput(v: ConsoleOutput) {
if (CONSOLE_OUTPUT_VALUES.has(v)) {
consoleOutput = v;
} else {
const values = `"${[...CONSOLE_OUTPUT_VALUES].join('" or "')}"`;
console.log(`%cTRX %cInvalid consoleOutput value - should be either %c${values}`, STL_TRX, 'color: ', STL_DATA);
}
},
event(type: string, data?: LogData, src?: any) {
logEvent(type, data, src);
},
info(...data: LogData[]) {
logEvent(traxEvents.Info, mergeMessageData(data));
},
warn(...data: LogData[]) {
logEvent(traxEvents.Warning, mergeMessageData(data));
},
error,
set maxSize(sz: number) {
const prev = maxSize;
if (sz < 0) {
maxSize = -1; // no limits
} else if (sz < 2) {
maxSize = 2; // minimum size to remove corner cases
} else {
maxSize = sz;
}
if (maxSize > 0 && maxSize < prev && maxSize < size) {
// we need to remove the oldest elements
let count = size - maxSize;
while (head && count) {
head = head.next;
count--;
size--;
}
}
},
startProcessingContext(data: ProcessingContextData, src?: any): ProcessingContext {
// prevent reserved names usage
let name = data.name;
if (name.charAt(0) === "!" && src !== internalSrcKey) {
error(`Processing Context name cannot start with reserved prefix: ${name}`);
data.name = name.replace(/\!+/, "");
}
const last = pcStack.peek();
const parentId = last ? last.id : undefined;
const evt = logEvent(traxEvents.ProcessingStart, data, internalSrcKey, parentId);
return createProcessingContext(evt.id, data);
},
get maxSize(): number {
return maxSize;
},
get size() {
return size;
},
scan(eventProcessor: (itm: StreamEvent) => void | boolean) {
let itm = head, process = true;
while (process && itm) {
try {
if (eventProcessor(itm) === false) {
process = false;
};
itm = itm.next;
} catch (ex) { }
}
},
lastEvent(): StreamEvent | undefined {
return tail;
},
async awaitEvent(eventType: string, targetData?: string | number | boolean | Record<string, string | number | boolean>): Promise<StreamEvent> {
if (eventType === "" || eventType === "*") {
logEvent(traxEvents.Error, `[trax/eventStream.await] Invalid event type: '${eventType}'`);
return { id: tail!.id, type: tail!.type, data: tail!.data };
}
let resolveList = awaitMap.get(eventType);
if (resolveList === undefined) {
resolveList = [];
awaitMap.set(eventType, resolveList);
}
let r: any, p = new Promise((resolve: (e: StreamEvent) => void) => {
r = resolve;
});
resolveList.push((e: StreamEvent) => {
if (checkPropMatch(e, targetData)) {
r(e);
return true;
}
return false;
});
return p;
},
subscribe(eventType: string | "*", callback: (e: StreamEvent) => void): SubscriptionId {
let fn: (e: StreamEvent) => void;
if (eventType === "*") {
fn = (e: StreamEvent) => callback(e);
} else {
fn = (e: StreamEvent) => {
if (e.type === eventType) {
callback(e);
}
};
}
consumers.push(fn);
return fn;
},
unsubscribe(subscriptionId: SubscriptionId): boolean {
const idx = consumers.indexOf(subscriptionId as any);
if (idx > -1) {
consumers.splice(idx, 1);
return true;
}
return false;
}
}
}
function checkPropMatch(e: StreamEvent, targetData?: string | number | boolean | Record<string, string | number | boolean | RegExp>): boolean {
if (targetData === undefined || e.data === undefined) return true;
const data = JSON.parse(e.data);
if (typeof targetData !== "object" && targetData !== null) {
return data === targetData;
} else if (targetData !== null && data !== null && typeof data === "object") {
for (const k of Object.keys(targetData)) {
const value = (data as any)[k];
const target = targetData[k];
if (target instanceof RegExp) {
if (typeof value === "string") {
if (value.match(target) === null) return false;
} else {
return false;
}
} else if (value !== targetData[k]) {
return false;
}
}
return true;
}
return false;
}
function mergeMessageData(data: LogData[]): LogData | undefined {
let curMessage = "";
const output: LogData[] = [];
for (let d of data) {
const tp = typeof d;
if (tp === "string" || tp === "number" || tp === "boolean" || d === null) {
if (curMessage) {
curMessage += " " + d;
} else {
curMessage = "" + d;
}
} else {
if (curMessage) {
output.push(curMessage);
curMessage = "";
}
output.push(d);
}
}
if (curMessage) {
output.push(curMessage);
}
if (output.length === 0) return undefined;
if (output.length === 1) {
return output[0];
}
return output;
}
function format(internalSrcKey: any, entry: StreamListEvent, type: string, dataStringifier?: (data: any) => string, data?: LogData, src?: any) {
let hasError = false;
let errMsg = "";
if (type === "") {
hasError = true;
errMsg = "Event type cannot be empty";
} else {
if (type.charAt(0) === "!"
&& src !== internalSrcKey
&& type !== traxEvents.Error
&& type !== traxEvents.Warning
&& type !== traxEvents.Info) {
// reserved
hasError = true;
errMsg = "Event type cannot start with reserved prefix: " + type;
} else {
entry.type = type;
if (data !== undefined) {
try {
if (dataStringifier) {
entry.data = dataStringifier(data);
} else {
entry.data = JSON.stringify(data);
}
} catch (ex) {
hasError = true;
errMsg = "Event strinfication error: " + ex;
}
} else {
data = undefined;
}
}
}
if (hasError) {
// transform event into an error event
entry.type = traxEvents.Error;
entry.data = errMsg;
}
}
export function formatEventData(eventType: string, data?: any, styleDelimiter = false) {
if (!data || !eventType || eventType.charAt(0) !== "!") return data;
const c = styleDelimiter ? "%c" : "";
try {
const sd = JSON.parse("" + data);
if (eventType === traxEvents.CycleStart || eventType === traxEvents.CycleComplete) {
return `${c}0`; // 0 = elapsedTime
} else if (eventType === traxEvents.Info
|| eventType === traxEvents.Warning
|| eventType === traxEvents.Error) {
return `${c}${data.replace(/"/g, "")}`;
} else if (eventType === traxEvents.ProcessingPause
|| eventType === traxEvents.ProcessingResume
|| eventType === traxEvents.ProcessingEnd) {
const d = JSON.parse(data);
return `${(d as any).processId}`;
} else if (eventType === traxEvents.ProcessingStart) {
const d = sd as TraxLogTraxProcessingCtxt;
if (d.name === "!StoreInit") {
return `${d.name} (${c}${d.storeId}${c})`;
} else if (d.name === "!Compute") {
const R = d.isRenderer ? "R" : "";
return `${d.name} #${d.computeCount} (${c}${d.processorId}${c}) P${d.processorPriority}${R} ${d.trigger}`;
} else if (d.name === "!Reconciliation") {
return `${d.name} #${d.index} - ${d.processorCount} processor${d.processorCount !== 1 ? "s" : ""}`;
} else if (d.name === "!ArrayUpdate") {
return `${d.name} (${c}${d.objectId}${c})`;
} else {
return `${c}${(d as any).name}`;
}
} else if (eventType === traxEvents.New) {
const d = sd as TraxLogObjectCreate;
if (d.objectId === undefined) return data;
return `${d.objectType}: ${c}${d.objectId}`;
} else if (eventType === traxEvents.Dispose) {
const d = sd as TraxLogObjectDispose;
if (d.objectId === undefined) return data;
return `${c}${d.objectId}`;
} else if (eventType === traxEvents.Get) {
const d = sd as TraxLogPropGet;
return `${c}${d.objectId}.${d.propName}${c} -> ${c}${stringify(d.propValue)}`;
} else if (eventType === traxEvents.Set) {
const d = sd as TraxLogPropSet;
return `${c}${d.objectId}.${d.propName}${c} = ${c}${stringify(d.toValue)}${c} (prev: ${stringify(d.fromValue)})`;
} else if (eventType === traxEvents.ProcessorDirty) {
const d = sd as TraxLogProcDirty;
return `${c}${d.processorId}${c} <- ${c}${d.objectId}.${d.propName}`;
} else if (eventType === traxEvents.ProcessorSkipped) {
const d = sd as TraxLogProcSkipped;
return `${c}${d.processorId}`;
}
} catch (ex) { }
return data;
}
function stringify(v: any) {
if (v === undefined) {
return "undefined";
} else if (v === null) {
return "null";
} else if (typeof v === "object") {
const md = tmd(v);
if (md) return md.id;
return JSON.stringify(v);
} else if (typeof v === "string") {
return "'" + v.replace(/\'/g, "\\'") + "'"
} else {
return "" + v;
}
}