@discoveryjs/cli
Version: 
CLI tools to serve & build projects based on Discovery.js
445 lines (368 loc) • 16.1 kB
JavaScript
/* eslint-env browser */
/* global SINGLE_FILE, MODEL_RESET_CACHE */
import { preloader } from '@discoveryjs/discovery/src/preloader.js';
import { inflate, decodeBase64 } from './inflate/decoder.js';
export const colorSchemeOptions = ({
    darkmode,
    darkmodePersistent,
    colorScheme = darkmode,
    colorSchemePersistent = darkmodePersistent
}) =>
    preloader.colorScheme
        ? { colorScheme, colorSchemePersistent }
        : { darkmode: colorScheme, darkmodePersistent: colorSchemePersistent };
export const loadStyle = SINGLE_FILE
    ? url => document.querySelector(`style[type="discovery/style"][src=${JSON.stringify(url)}]`).firstChild.nodeValue
    : url => ({ type: 'link', href: url });
export function load(module, styles, setup, dataLoaderOptions) {
    const container = document.body;
    const dataRequestId = String(Math.random()).slice(2, 18).padStart(16, '0');
    const loadData = preloader({
        ...dataLoaderOptions,
        loadDataOptions: {
            ...dataLoaderOptions.loadDataOptions,
            encodings: dataLoaderOptions.encodings,
            fetch: { headers: {
                // using Cache-Control to prevent stalling requests in Chromium & Safari
                // see https://stackoverflow.com/questions/27513994/chrome-stalls-when-making-multiple-requests-to-same-resource
                'Cache-Control': 'no-cache, no-transform',
                'x-data-request-id': dataRequestId
            } }
        },
        container
    });
    // alter loader API
    alterLoaderPush();
    // status of awating data generation
    if (dataLoaderOptions.dataSource === 'url' && dataLoaderOptions.data) {
        awaitingDataStatus(loadData, dataRequestId);
    }
    // main part
    return Promise.all([
        module,
        loadData
    ]).then(([init, dataset]) =>
        init({ ...setup, styles }, loadData.progressbar, loadData.disposeEmbed?.(), dataset)
    ).then(() => {
        loadData.el.remove();
    }, (error) => {
        const discoveryEl = document.querySelector('body > .discovery');
        const actionButtonsEl = document.createElement('div');
        if (MODEL_RESET_CACHE && setup.model?.cacheReset) {
            const resetBtn = document.createElement('button', 'view-button');
            resetBtn.className = 'view-button';
            resetBtn.innerHTML = 'Reload with no cache';
            resetBtn.onclick = () => fetch('drop-cache').then(() => location.reload());
            actionButtonsEl.append(resetBtn);
        }
        if (actionButtonsEl.firstChild) {
            actionButtonsEl.className = 'action-buttons';
            loadData.progressbar.el.before(actionButtonsEl);
        }
        if (!error.supressLoadDataError) {
            const el = document.createElement('pre');
            const errorTypeBadgeEl = document.createElement('div');
            const errorText = String(error);
            let errorStack = String(error.stack || '');
            if (errorStack.startsWith(errorText)) {
                errorStack = errorStack.slice(errorText.length);
            }
            errorTypeBadgeEl.className = 'error-type-badge';
            errorTypeBadgeEl.dataset.type = error.isFetchError
                ? 'server'
                : 'client';
            el.className = 'error';
            el.append(
                errorTypeBadgeEl,
                errorText + (errorStack ? '\n' + errorStack.replace(/^[\r\n]+/, '') : '')
            );
            loadData.progressbar.el.replaceWith(el);
        }
        loadData.disposeEmbed?.();
        loadData.progressbar.dispose();
        discoveryEl && discoveryEl.remove();
    });
}
function alterLoaderPush() {
    const { push: origLoaderPush } = window.discoveryLoader || {};
    if (typeof origLoaderPush === 'function') {
        window.discoveryLoader.push = function(chunk, binary, compressed) {
            if (compressed) {
                chunk = binary
                    ? inflate(chunk).slice()
                    : new TextDecoder().decode(inflate(chunk));
            } else if (binary) {
                chunk = decodeBase64(chunk);
            }
            origLoaderPush(chunk);
        };
    }
}
function createDataStatusBlock() {
    const dataStatusEl = document.createElement('div');
    dataStatusEl.className = 'data-status';
    dataStatusEl.innerHTML =
        '<div class="header">Getting data: <span class="elapsed-time"></span></div>' +
        '<div class="output"></div>';
    dataStatusEl.firstChild.addEventListener('click', function() {
        dataStatusEl.classList.toggle('collapsed');
    }, true);
    dataStatusEl.elapsedTimeEl = dataStatusEl.querySelector(':scope > .header > .elapsed-time');
    return dataStatusEl;
}
// shows status for a long awaiting data request
function awaitingDataStatus(loadData, dataRequestId) {
    let supressLoadDataError = false;
    let allowServerTimeUpdate = true;
    let isServerError = false;
    const dataStatusEl = createDataStatusBlock();
    const activateDataStatusBlock = () => {
        if (loadData.progressbar.value.stage === 'request' && !dataStatusEl.parentNode) {
            loadData.progressbar.el.getRootNode().append(dataStatusEl);
        }
    };
    const dataStatusListenInit = setTimeout(() => {
        if (loadData.progressbar.value.stage !== 'request') {
            return;
        }
        loadData.progressbar.subscribe(({ stage }, unsubscribe) => {
            if (stage !== 'request') {
                unsubscribe();
                dataStatusEl.classList.add('finished');
                dataStatusEventSource.close();
                allowServerTimeUpdate = false;
            }
        });
        const dataStatusEventSource = new EventSource('data-status?data-request-id=' + dataRequestId);
        let planTreeMap = null;
        let startTime;
        let serverTime;
        let optimisticServerTimeUpdateTimer;
        let optimisticServerTimeUpdateFrom;
        let lastStderrEl;
        const updateServerTime = (newServerTime) => {
            if (!allowServerTimeUpdate ||
                !isFinite(newServerTime) ||
                (serverTime !== undefined && newServerTime <= serverTime)) {
                return;
            }
            clearTimeout(optimisticServerTimeUpdateTimer);
            serverTime = Number(newServerTime);
            if (startTime && serverTime - startTime >= 1000) {
                dataStatusEl.elapsedTimeEl.textContent =
                    duration(serverTime - startTime, supressLoadDataError ? 1 : 0);
            }
            if (planTreeMap) {
                for (const { started, elapsedTimeEl } of planTreeMap.values()) {
                    if (started) {
                        elapsedTimeEl.textContent = duration(serverTime - started, 1);
                    }
                }
            }
            optimisticServerTimeUpdateFrom = Date.now();
            optimisticServerTimeUpdateTimer = setTimeout(
                () => updateServerTime(serverTime + (Date.now() - optimisticServerTimeUpdateFrom) - 5),
                42
            );
        };
        // dataStatusEventSource.addEventListener('open', () => {
        //     setTimeout(activateDataStatusBlock, 3000);
        // }, { once: true });
        dataStatusEventSource.addEventListener('message', message => {
            try {
                const data = JSON.parse(message.data);
                switch (data.type) {
                    case 'start':
                        setTimeout(activateDataStatusBlock, 3000);
                        startTime = data.timestamp;
                        isServerError = true;
                        break;
                    case 'finish':
                        // do nothing
                        break;
                    case 'crash':
                        supressLoadDataError = true;
                        activateDataStatusBlock();
                        if (planTreeMap) {
                            for (const { started, el } of planTreeMap.values()) {
                                if (started) {
                                    el.classList.add('crashed');
                                    el.classList.remove('started');
                                    el.classList.toggle('collapsed', el !== lastStderrEl);
                                }
                            }
                        }
                        break;
                    case 'stderr':
                    case 'stdout': {
                        const frameEl = document.createElement('div');
                        frameEl.className = data.type;
                        frameEl.append(String(data.chunk));
                        if (!planTreeMap) {
                            activateDataStatusBlock();
                            dataStatusEl.lastChild.append(frameEl);
                            scrollIntoViewIfNeeded(frameEl);
                        } else {
                            const step = planTreeMap.get(data.stepId);
                            if (step) {
                                step.contentEl.append(frameEl);
                                step.el.classList.add('has-output');
                                scrollIntoViewIfNeeded(frameEl);
                                if (data.type == 'stderr') {
                                    lastStderrEl = step.el;
                                }
                            }
                        }
                        break;
                    }
                    case 'plan': {
                        const planTree = createPlanTree(data.plan.steps);
                        planTreeMap = planTree.map;
                        dataStatusEl.lastChild.innerHTML = '';
                        dataStatusEl.lastChild.appendChild(planTree.el);
                        if (planTreeMap) {
                            activateDataStatusBlock();
                        }
                        break;
                    }
                    case 'plan-step-event': {
                        const step = planTreeMap.get(data.stepId);
                        if (step) {
                            switch (data.stepEvent) {
                                case 'start':
                                    step.started = data.timestamp;
                                    step.el.classList.add('started');
                                    scrollIntoViewIfNeeded(step.el);
                                    break;
                                case 'finish':
                                    step.elapsedTimeEl.textContent = duration(data.timestamp - step.started);
                                    step.started = false;
                                    step.el.classList.remove('started');
                                    step.el.classList.add('finished');
                                    break;
                                case 'summary':
                                    step.summaryEl.innerHTML = numDelim(data.data);
                                    break;
                                default:
                                    console.warn('Unhandled data status SSE pipeline step event', data);
                            }
                        } else {
                            console.warn('Pipeline step not found', data);
                        }
                        break;
                    }
                    default:
                        console.warn('Unhandled data status SSE event', data);
                }
                updateServerTime(data.timestamp);
            } catch (e) {
                console.error('SSE message parse error', e);
            }
        });
        dataStatusEventSource.addEventListener('server-time', ({ data }) => {
            updateServerTime(data);
        });
        dataStatusEventSource.addEventListener('done', () => {
            dataStatusEventSource.close();
            allowServerTimeUpdate = false;
        });
    }, 150);
    loadData.then(
        () => clearTimeout(dataStatusListenInit),
        (error) => {
            allowServerTimeUpdate = false;
            if (!isServerError) {
                return;
            }
            if (supressLoadDataError) {
                error.supressLoadDataError = true;
                loadData.el.classList.add('generate-data-crash');
            } else {
                dataStatusEl.classList.add('compliment-error', 'collapsed');
            }
            for (const el of dataStatusEl.querySelectorAll('.plan-step.started')) {
                el.classList.remove('started');
            }
            dataStatusEl.classList.add('crashed');
            dataStatusEl.firstChild.firstChild.textContent = 'Retrieving data failed ' +
                (dataStatusEl.elapsedTimeEl.textContent ? 'in ' : '');
        }
    );
}
function scrollIntoViewIfNeeded(el) {
    try {
        if (typeof el.scrollIntoViewIfNeeded === 'function') {
            el.scrollIntoViewIfNeeded(false);
        } else {
            el.scrollIntoView({ block: 'nearest' });
        }
    } catch (e) {}
}
function createPlanTree(steps, level = 0, map = new Map()) {
    const listEl = document.createElement('ul');
    listEl.className = 'plan-step-list';
    listEl.style.setProperty('--level', level);
    for (const step of steps) {
        const stepEl = listEl.appendChild(document.createElement('li'));
        const stepHeaderEl = stepEl.appendChild(document.createElement('div'));
        const stepHeaderToggleEl = stepHeaderEl.appendChild(document.createElement('span'));
        const stepHeaderStatusEl = stepHeaderEl.appendChild(document.createElement('span'));
        const stepHeaderContentEl = stepHeaderEl.appendChild(document.createElement('span'));
        const stepHeaderSummaryEl = stepHeaderEl.appendChild(document.createElement('span'));
        const stepHeaderElapsedTimeEl = stepHeaderEl.appendChild(document.createElement('span'));
        const stepContentEl = stepEl.appendChild(document.createElement('div'));
        map.set(step.id, {
            step,
            el: stepEl,
            elapsedTimeEl: stepHeaderElapsedTimeEl,
            summaryEl: stepHeaderSummaryEl,
            contentEl: stepContentEl,
            started: false
        });
        stepEl.className = 'plan-step collapsed';
        stepHeaderEl.className = 'plan-step__header';
        stepHeaderEl.addEventListener('click', () => stepEl.classList.toggle('collapsed'));
        stepHeaderToggleEl.className = 'plan-step__header-toggle';
        stepHeaderStatusEl.className = 'plan-step__header-status';
        stepHeaderContentEl.className = 'plan-step__header-content';
        stepHeaderContentEl.textContent = step.name || 'Untitled';
        stepHeaderSummaryEl.className = 'plan-step__header-summary';
        stepHeaderElapsedTimeEl.className = 'plan-step__elapsed-time';
        stepContentEl.className = 'plan-step__content';
        if (step.steps) {
            stepEl.append(createPlanTree(step.steps, level + 1, map).el);
        }
    }
    return { el: listEl, map: map.size ? map : null };
}
function escapeHtml(str) {
    return str
        .replace(/&/g, '&')
        .replace(/"/g, '"')
        .replace(/</g, '<')
        .replace(/>/g, '>');
}
function numDelim(value, escape = true) {
    const strValue = escape && typeof value !== 'number'
        ? escapeHtml(String(value))
        : String(value);
    if (strValue.length > 3) {
        return strValue.replace(
            /\.\d+(eE[-+]?\d+)?|\B(?=(\d{3})+(\D|$))/g,
            m => m || '<span class="num-delim"></span>'
        );
    }
    return strValue;
}
function duration(value, prec = 1) {
    if (value < 1000) {
        return value + 'ms';
    }
    if (value < 10000) {
        return (value / 1000).toFixed(prec) + 's';
    }
    if (value < 60000) {
        return Math.round(value / 1000) + 's';
    }
    return `${Math.floor(value / 60000)}:${String(Math.floor(value / 1000) % 60).padStart(2, '0')}`;
}