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, endpoint); 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, };