356 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
			
		
		
	
	
			356 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
class StartupError extends Error {}
 | 
						|
 | 
						|
/*
 | 
						|
 * We need to know the bundle path before we can fetch the sourcemap files.  In a production environment, we can guess
 | 
						|
 * it using this.
 | 
						|
 */
 | 
						|
async function getBundleName(baseUrl) {
 | 
						|
    const res = await fetch(new URL("index.html", baseUrl).toString());
 | 
						|
    if (!res.ok) {
 | 
						|
        throw new StartupError(`Couldn't fetch index.html to prefill bundle; ${res.status} ${res.statusText}`);
 | 
						|
    }
 | 
						|
    const index = await res.text();
 | 
						|
    return index
 | 
						|
        .split("\n")
 | 
						|
        .map((line) => line.match(/<script src="bundles\/([^/]+)\/bundle.js"/))
 | 
						|
        .filter((result) => result)
 | 
						|
        .map((result) => result[1])[0];
 | 
						|
}
 | 
						|
 | 
						|
function validateBundle(value) {
 | 
						|
    return value.match(/^[0-9a-f]{20}$/) ? Some.of(value) : None;
 | 
						|
}
 | 
						|
 | 
						|
/* A custom fetcher that abandons immediately upon getting a response.
 | 
						|
 * The purpose of this is just to validate that the user entered a real bundle, and provide feedback.
 | 
						|
 */
 | 
						|
const bundleCache = new Map();
 | 
						|
function bundleSubject(baseUrl, bundle) {
 | 
						|
    if (!bundle.match(/^[0-9a-f]{20}$/)) throw new Error("Bad input");
 | 
						|
    if (bundleCache.has(bundle)) {
 | 
						|
        return bundleCache.get(bundle);
 | 
						|
    }
 | 
						|
    const fetcher = new rxjs.BehaviorSubject(Pending.of());
 | 
						|
    bundleCache.set(bundle, fetcher);
 | 
						|
 | 
						|
    fetch(new URL(`bundles/${bundle}/bundle.js.map`, baseUrl).toString()).then((res) => {
 | 
						|
        res.body.cancel(); /* Bail on the download immediately - it could be big! */
 | 
						|
        const status = res.ok;
 | 
						|
        if (status) {
 | 
						|
            fetcher.next(Success.of());
 | 
						|
        } else {
 | 
						|
            fetcher.next(FetchError.of(`Failed to fetch: ${res.status} ${res.statusText}`));
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    return fetcher;
 | 
						|
}
 | 
						|
 | 
						|
/*
 | 
						|
 * Convert a ReadableStream of bytes into an Observable of a string
 | 
						|
 * The observable will emit a stream of Pending objects and will concatenate
 | 
						|
 * the number of bytes received with whatever pendingContext has been supplied.
 | 
						|
 * Finally, it will emit a Success containing the result.
 | 
						|
 * You'd use this on a Response.body.
 | 
						|
 */
 | 
						|
function observeReadableStream(readableStream, pendingContext = {}) {
 | 
						|
    let bytesReceived = 0;
 | 
						|
    let buffer = "";
 | 
						|
    const pendingSubject = new rxjs.BehaviorSubject(Pending.of({ ...pendingContext, bytesReceived }));
 | 
						|
    const throttledPending = pendingSubject.pipe(rxjs.operators.throttleTime(100));
 | 
						|
    const resultObservable = new rxjs.Subject();
 | 
						|
    const reader = readableStream.getReader();
 | 
						|
    const utf8Decoder = new TextDecoder("utf-8");
 | 
						|
    function readNextChunk() {
 | 
						|
        reader.read().then(({ done, value }) => {
 | 
						|
            if (done) {
 | 
						|
                pendingSubject.complete();
 | 
						|
                resultObservable.next(Success.of(buffer));
 | 
						|
                return;
 | 
						|
            }
 | 
						|
            bytesReceived += value.length;
 | 
						|
            pendingSubject.next(Pending.of({ ...pendingContext, bytesReceived }));
 | 
						|
            /* string concatenation is apparently the most performant way to do this */
 | 
						|
            buffer += utf8Decoder.decode(value);
 | 
						|
            readNextChunk();
 | 
						|
        });
 | 
						|
    }
 | 
						|
    readNextChunk();
 | 
						|
    return rxjs.concat(throttledPending, resultObservable);
 | 
						|
}
 | 
						|
 | 
						|
/*
 | 
						|
 * A wrapper which converts the browser's `fetch()` mechanism into an Observable.  The Observable then provides us with
 | 
						|
 * a stream of datatype values: first, a sequence of Pending objects that keep us up to date with the download progress,
 | 
						|
 * finally followed by either a Success or Failure object.  React then just has to render each of these appropriately.
 | 
						|
 */
 | 
						|
const fetchCache = new Map();
 | 
						|
function fetchAsSubject(endpoint) {
 | 
						|
    if (fetchCache.has(endpoint)) {
 | 
						|
        // TODO: expiry/retry logic here?
 | 
						|
        return fetchCache.get(endpoint);
 | 
						|
    }
 | 
						|
    const fetcher = new rxjs.BehaviorSubject(Pending.of());
 | 
						|
    fetchCache.set(endpoint, fetcher);
 | 
						|
 | 
						|
    fetch(endpoint).then((res) => {
 | 
						|
        if (!res.ok) {
 | 
						|
            fetcher.next(FetchError.of(`Failed to fetch endpoint ${endpoint}: ${res.status} ${res.statusText}`));
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        const contentLength = res.headers.get("content-length");
 | 
						|
        const context = contentLength ? { length: parseInt(contentLength) } : {};
 | 
						|
 | 
						|
        const streamer = observeReadableStream(res.body, context);
 | 
						|
        streamer.subscribe((value) => {
 | 
						|
            fetcher.next(value);
 | 
						|
        });
 | 
						|
    });
 | 
						|
    return fetcher;
 | 
						|
}
 | 
						|
 | 
						|
/* ===================== */
 | 
						|
/* ==== React stuff ==== */
 | 
						|
/* ===================== */
 | 
						|
/* Rather than importing an entire build infrastructure, for now we just use React without JSX */
 | 
						|
const e = React.createElement;
 | 
						|
 | 
						|
/*
 | 
						|
 * Provides user feedback given a FetchStatus object.
 | 
						|
 */
 | 
						|
function ProgressBar({ fetchStatus }) {
 | 
						|
    return e(
 | 
						|
        "span",
 | 
						|
        { className: "progress " },
 | 
						|
        fetchStatus.fold({
 | 
						|
            pending: ({ bytesReceived, length }) => {
 | 
						|
                if (!bytesReceived) {
 | 
						|
                    return e("span", { className: "spinner" }, "\u29b5");
 | 
						|
                }
 | 
						|
                const kB = Math.floor((10 * bytesReceived) / 1024) / 10;
 | 
						|
                if (!length) {
 | 
						|
                    return e("span", null, `Fetching (${kB}kB)`);
 | 
						|
                }
 | 
						|
                const percent = Math.floor((100 * bytesReceived) / length);
 | 
						|
                return e("span", null, `Fetching (${kB}kB) ${percent}%`);
 | 
						|
            },
 | 
						|
            success: () => e("span", null, "\u2713"),
 | 
						|
            error: (reason) => {
 | 
						|
                return e("span", { className: "error" }, `\u2717 ${reason}`);
 | 
						|
            },
 | 
						|
        }),
 | 
						|
    );
 | 
						|
}
 | 
						|
 | 
						|
/*
 | 
						|
 * The main component.
 | 
						|
 */
 | 
						|
function BundlePicker() {
 | 
						|
    const [baseUrl, setBaseUrl] = React.useState(new URL("..", window.location).toString());
 | 
						|
    const [bundle, setBundle] = React.useState("");
 | 
						|
    const [file, setFile] = React.useState("");
 | 
						|
    const [line, setLine] = React.useState("1");
 | 
						|
    const [column, setColumn] = React.useState("");
 | 
						|
    const [result, setResult] = React.useState(None);
 | 
						|
    const [bundleFetchStatus, setBundleFetchStatus] = React.useState(None);
 | 
						|
    const [fileFetchStatus, setFileFetchStatus] = React.useState(None);
 | 
						|
 | 
						|
    /* On baseUrl change, try to fill in the bundle name for the user */
 | 
						|
    React.useEffect(() => {
 | 
						|
        console.log("DEBUG", baseUrl);
 | 
						|
        getBundleName(baseUrl).then((name) => {
 | 
						|
            console.log("DEBUG", name);
 | 
						|
            if (bundle === "" && validateBundle(name) !== None) {
 | 
						|
                setBundle(name);
 | 
						|
            }
 | 
						|
        }, console.log.bind(console));
 | 
						|
    }, [baseUrl]);
 | 
						|
 | 
						|
    /* ------------------------- */
 | 
						|
    /* Follow user state changes */
 | 
						|
    /* ------------------------- */
 | 
						|
    const onBaseUrlChange = React.useCallback((event) => {
 | 
						|
        const value = event.target.value;
 | 
						|
        setBaseUrl(value);
 | 
						|
    }, []);
 | 
						|
 | 
						|
    const onBundleChange = React.useCallback((event) => {
 | 
						|
        const value = event.target.value;
 | 
						|
        setBundle(value);
 | 
						|
    }, []);
 | 
						|
 | 
						|
    const onFileChange = React.useCallback((event) => {
 | 
						|
        const value = event.target.value;
 | 
						|
        setFile(value);
 | 
						|
    }, []);
 | 
						|
 | 
						|
    const onLineChange = React.useCallback((event) => {
 | 
						|
        const value = event.target.value;
 | 
						|
        setLine(value);
 | 
						|
    }, []);
 | 
						|
 | 
						|
    const onColumnChange = React.useCallback((event) => {
 | 
						|
        const value = event.target.value;
 | 
						|
        setColumn(value);
 | 
						|
    }, []);
 | 
						|
 | 
						|
    /* ------------------------------------------------ */
 | 
						|
    /* Plumb data-fetching observables through to React */
 | 
						|
    /* ------------------------------------------------ */
 | 
						|
 | 
						|
    /* Whenever a valid bundle name is input, go see if it's a real bundle on the server */
 | 
						|
    React.useEffect(
 | 
						|
        () =>
 | 
						|
            validateBundle(bundle).fold({
 | 
						|
                some: (value) => {
 | 
						|
                    const subscription = bundleSubject(baseUrl, value)
 | 
						|
                        .pipe(rxjs.operators.map(Some.of))
 | 
						|
                        .subscribe(setBundleFetchStatus);
 | 
						|
                    return () => subscription.unsubscribe();
 | 
						|
                },
 | 
						|
                none: () => setBundleFetchStatus(None),
 | 
						|
            }),
 | 
						|
        [baseUrl, bundle],
 | 
						|
    );
 | 
						|
 | 
						|
    /* Whenever a valid javascript file is input, see if it corresponds to a sourcemap file and initiate a fetch
 | 
						|
     * if so. */
 | 
						|
    React.useEffect(() => {
 | 
						|
        if (!file.match(/.\.js$/) || validateBundle(bundle) === None) {
 | 
						|
            setFileFetchStatus(None);
 | 
						|
            return;
 | 
						|
        }
 | 
						|
        const observable = fetchAsSubject(new URL(`bundles/${bundle}/${file}.map`, baseUrl).toString()).pipe(
 | 
						|
            rxjs.operators.map((fetchStatus) =>
 | 
						|
                fetchStatus.flatMap((value) => {
 | 
						|
                    try {
 | 
						|
                        return Success.of(JSON.parse(value));
 | 
						|
                    } catch (e) {
 | 
						|
                        return FetchError.of(e);
 | 
						|
                    }
 | 
						|
                }),
 | 
						|
            ),
 | 
						|
            rxjs.operators.map(Some.of),
 | 
						|
        );
 | 
						|
        const subscription = observable.subscribe(setFileFetchStatus);
 | 
						|
        return () => subscription.unsubscribe();
 | 
						|
    }, [baseUrl, bundle, file]);
 | 
						|
 | 
						|
    /*
 | 
						|
     * Whenever we have a valid fetched sourcemap, and a valid line, attempt to find the original position from the
 | 
						|
     * sourcemap.
 | 
						|
     */
 | 
						|
    React.useEffect(() => {
 | 
						|
        // `fold` dispatches on the datatype, like a switch statement
 | 
						|
        fileFetchStatus.fold({
 | 
						|
            some: (fetchStatus) =>
 | 
						|
                // `fold` just returns null for all of the cases that aren't `Success` objects here
 | 
						|
                fetchStatus.fold({
 | 
						|
                    success: (value) => {
 | 
						|
                        if (!line) return setResult(None);
 | 
						|
                        const pLine = parseInt(line);
 | 
						|
                        const pCol = parseInt(column);
 | 
						|
                        sourceMap.SourceMapConsumer.with(value, undefined, (consumer) =>
 | 
						|
                            consumer.originalPositionFor({ line: pLine, column: pCol }),
 | 
						|
                        ).then((result) => setResult(Some.of(JSON.stringify(result))));
 | 
						|
                    },
 | 
						|
                }),
 | 
						|
            none: () => setResult(None),
 | 
						|
        });
 | 
						|
    }, [fileFetchStatus, line, column]);
 | 
						|
 | 
						|
    /* ------ */
 | 
						|
    /* Render */
 | 
						|
    /* ------ */
 | 
						|
    return e(
 | 
						|
        "div",
 | 
						|
        {},
 | 
						|
        e(
 | 
						|
            "div",
 | 
						|
            { className: "inputs" },
 | 
						|
            e(
 | 
						|
                "div",
 | 
						|
                { className: "baseUrl" },
 | 
						|
                e("label", { htmlFor: "baseUrl" }, "Base URL"),
 | 
						|
                e("input", {
 | 
						|
                    name: "baseUrl",
 | 
						|
                    required: true,
 | 
						|
                    pattern: ".+",
 | 
						|
                    onChange: onBaseUrlChange,
 | 
						|
                    value: baseUrl,
 | 
						|
                }),
 | 
						|
            ),
 | 
						|
            e(
 | 
						|
                "div",
 | 
						|
                { className: "bundle" },
 | 
						|
                e("label", { htmlFor: "bundle" }, "Bundle"),
 | 
						|
                e("input", {
 | 
						|
                    name: "bundle",
 | 
						|
                    required: true,
 | 
						|
                    pattern: "[0-9a-f]{20}",
 | 
						|
                    onChange: onBundleChange,
 | 
						|
                    value: bundle,
 | 
						|
                }),
 | 
						|
                bundleFetchStatus.fold({
 | 
						|
                    some: (fetchStatus) => e(ProgressBar, { fetchStatus }),
 | 
						|
                    none: () => null,
 | 
						|
                }),
 | 
						|
            ),
 | 
						|
            e(
 | 
						|
                "div",
 | 
						|
                { className: "file" },
 | 
						|
                e("label", { htmlFor: "file" }, "File"),
 | 
						|
                e("input", {
 | 
						|
                    name: "file",
 | 
						|
                    required: true,
 | 
						|
                    pattern: ".+\\.js",
 | 
						|
                    onChange: onFileChange,
 | 
						|
                    value: file,
 | 
						|
                }),
 | 
						|
                fileFetchStatus.fold({
 | 
						|
                    some: (fetchStatus) => e(ProgressBar, { fetchStatus }),
 | 
						|
                    none: () => null,
 | 
						|
                }),
 | 
						|
            ),
 | 
						|
            e(
 | 
						|
                "div",
 | 
						|
                { className: "line" },
 | 
						|
                e("label", { htmlFor: "line" }, "Line"),
 | 
						|
                e("input", {
 | 
						|
                    name: "line",
 | 
						|
                    required: true,
 | 
						|
                    pattern: "[0-9]+",
 | 
						|
                    onChange: onLineChange,
 | 
						|
                    value: line,
 | 
						|
                }),
 | 
						|
            ),
 | 
						|
            e(
 | 
						|
                "div",
 | 
						|
                { className: "column" },
 | 
						|
                e("label", { htmlFor: "column" }, "Column"),
 | 
						|
                e("input", {
 | 
						|
                    name: "column",
 | 
						|
                    required: true,
 | 
						|
                    pattern: "[0-9]+",
 | 
						|
                    onChange: onColumnChange,
 | 
						|
                    value: column,
 | 
						|
                }),
 | 
						|
            ),
 | 
						|
        ),
 | 
						|
        e(
 | 
						|
            "div",
 | 
						|
            null,
 | 
						|
            result.fold({
 | 
						|
                none: () => "Select a bundle, file and line",
 | 
						|
                some: (value) => e("pre", null, value),
 | 
						|
            }),
 | 
						|
        ),
 | 
						|
    );
 | 
						|
}
 | 
						|
 | 
						|
/* Global stuff */
 | 
						|
window.Decoder = {
 | 
						|
    BundlePicker,
 | 
						|
};
 |