4 changed files with 506 additions and 1 deletions
@ -0,0 +1,107 @@
|
||||
/* |
||||
* Quick-n-dirty algebraic datatypes. |
||||
* |
||||
* These let us handle the possibility of failure without having to constantly write code to check for it. |
||||
* We can apply all of the transformations we need as if the data is present using `map`. |
||||
* If there's a None, or a FetchError, or a Pending, those are left untouched. |
||||
* |
||||
* I've used perhaps an odd bit of terminology from scalaz in `fold`. This is basically a `switch` statement: |
||||
* You pass it a set of functions to handle the various different states of the datatype, and if it finds the |
||||
* function it'll call it on its value. |
||||
* |
||||
* It's handy to have this in functional style when dealing with React as we can dispatch different ways of rendering |
||||
* really simply: |
||||
* ``` |
||||
* bundleFetchStatus.fold({ |
||||
* some: (fetchStatus) => <ProgressBar fetchsStatus={fetchStatus} />, |
||||
* }), |
||||
* ``` |
||||
*/ |
||||
|
||||
|
||||
class Optional { |
||||
static from(value) { |
||||
return value && Some.of(value) || None; |
||||
} |
||||
map(f) { |
||||
return this; |
||||
} |
||||
flatMap(f) { |
||||
return this; |
||||
} |
||||
fold({ none }) { |
||||
return none && none(); |
||||
} |
||||
} |
||||
class Some extends Optional { |
||||
constructor(value) { |
||||
super(); |
||||
this.value = value; |
||||
} |
||||
map(f) { |
||||
return Some.of(f(this.value)); |
||||
} |
||||
flatMap(f) { |
||||
return f(this.value); |
||||
} |
||||
fold({ some }) { |
||||
return some && some(this.value); |
||||
} |
||||
static of(value) { |
||||
return new Some(value); |
||||
} |
||||
} |
||||
const None = new Optional(); |
||||
|
||||
class FetchStatus { |
||||
constructor(opt = {}) { |
||||
this.opt = { at: Date.now(), ...opt }; |
||||
} |
||||
map(f) { |
||||
return this; |
||||
} |
||||
flatMap(f) { |
||||
return this; |
||||
} |
||||
} |
||||
class Success extends FetchStatus { |
||||
static of(value) { |
||||
return new Success(value); |
||||
} |
||||
constructor(value, opt) { |
||||
super(opt); |
||||
this.value = value; |
||||
} |
||||
map(f) { |
||||
return new Success(f(this.value), this.opt); |
||||
} |
||||
flatMap(f) { |
||||
return f(this.value, this.opt); |
||||
} |
||||
fold({ success }) { |
||||
return success instanceof Function ? success(this.value, this.opt) : undefined; |
||||
} |
||||
} |
||||
class Pending extends FetchStatus { |
||||
static of(opt) { |
||||
return new Pending(opt); |
||||
} |
||||
constructor(opt) { |
||||
super(opt); |
||||
} |
||||
fold({ pending }) { |
||||
return pending instanceof Function ? pending(this.opt) : undefined; |
||||
} |
||||
} |
||||
class FetchError extends FetchStatus { |
||||
static of(reason, opt) { |
||||
return new FetchError(reason, opt); |
||||
} |
||||
constructor(reason, opt) { |
||||
super(opt); |
||||
this.reason = reason; |
||||
} |
||||
fold({ error }) { |
||||
return error instanceof Function ? error(this.reason, this.opt) : undefined; |
||||
} |
||||
} |
@ -0,0 +1,319 @@
|
||||
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() { |
||||
const res = await fetch("../index.html"); |
||||
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(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(`/bundles/${bundle}/bundle.js.map`).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 [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); |
||||
|
||||
/* At startup, try to fill in the bundle name for the user */ |
||||
React.useEffect(() => { |
||||
getBundleName().then((name) => { |
||||
if (bundle === "" && validateBundle(name) !== None) { |
||||
setBundle(name); |
||||
} |
||||
}, console.log.bind(console)); |
||||
}, []); |
||||
|
||||
|
||||
/* ------------------------- */ |
||||
/* Follow user state changes */ |
||||
/* ------------------------- */ |
||||
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(value) |
||||
.pipe(rxjs.operators.map(Some.of)) |
||||
.subscribe(setBundleFetchStatus); |
||||
return () => subscription.unsubscribe(); |
||||
}, |
||||
none: () => setBundleFetchStatus(None), |
||||
}), |
||||
[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(`/bundles/${bundle}/${file}.map`) |
||||
.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(); |
||||
}, [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: '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, |
||||
}; |
@ -0,0 +1,79 @@
|
||||
<html> |
||||
<head> |
||||
<title>Rageshake decoder ring</title> |
||||
<script crossorigin src="https://unpkg.com/source-map@0.7.3/dist/source-map.js"></script> |
||||
<script> |
||||
sourceMap.SourceMapConsumer.initialize({ |
||||
"lib/mappings.wasm": "https://unpkg.com/source-map@0.7.3/lib/mappings.wasm" |
||||
}); |
||||
</script> |
||||
<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script> |
||||
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script> |
||||
<!--<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script> |
||||
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>--> |
||||
<script crossorigin src="https://unpkg.com/rxjs/bundles/rxjs.umd.min.js"></script> |
||||
<script src="datatypes.js"></script> |
||||
<script src="decoder.js"></script> |
||||
|
||||
<style> |
||||
@keyframes spin { |
||||
from {transform:rotate(0deg);} |
||||
to {transform:rotate(359deg);} |
||||
} |
||||
|
||||
body { |
||||
font-family: sans-serif |
||||
} |
||||
|
||||
.spinner { |
||||
animation: spin 4s infinite linear; |
||||
display: inline-block; |
||||
text-align: center; |
||||
vertical-align: middle; |
||||
font-size: larger; |
||||
} |
||||
|
||||
.progress { |
||||
padding-left: 0.5em; |
||||
padding-right: 0.5em; |
||||
} |
||||
|
||||
.bundle input { |
||||
width: 24ex; |
||||
} |
||||
|
||||
.valid::after { |
||||
content: "✓" |
||||
} |
||||
|
||||
label { |
||||
width: 3em; |
||||
margin-right: 1em; |
||||
display: inline-block; |
||||
} |
||||
|
||||
input:valid { |
||||
border: 1px solid green; |
||||
} |
||||
|
||||
.inputs > div { |
||||
margin-bottom: 0.5em; |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<header><h2>Decoder ring</h2></header> |
||||
<content id="main">Waiting for javascript to run...</content> |
||||
<script type="text/javascript"> |
||||
document.addEventListener("DOMContentLoaded", () => { |
||||
try { |
||||
ReactDOM.render(React.createElement(Decoder.BundlePicker), document.getElementById("main")) |
||||
} catch (e) { |
||||
const n = document.createElement("div"); |
||||
n.innerText = `Error starting: ${e.message}`; |
||||
document.getElementById("main").appendChild(n); |
||||
} |
||||
}); |
||||
</script> |
||||
</body> |
||||
</html> |
Loading…
Reference in new issue