| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355 |
- 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,
- };
|