decoder.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. class StartupError extends Error {}
  2. /*
  3. * We need to know the bundle path before we can fetch the sourcemap files. In a production environment, we can guess
  4. * it using this.
  5. */
  6. async function getBundleName(baseUrl) {
  7. const res = await fetch(new URL("index.html", baseUrl).toString());
  8. if (!res.ok) {
  9. throw new StartupError(`Couldn't fetch index.html to prefill bundle; ${res.status} ${res.statusText}`);
  10. }
  11. const index = await res.text();
  12. return index
  13. .split("\n")
  14. .map((line) => line.match(/<script src="bundles\/([^/]+)\/bundle.js"/))
  15. .filter((result) => result)
  16. .map((result) => result[1])[0];
  17. }
  18. function validateBundle(value) {
  19. return value.match(/^[0-9a-f]{20}$/) ? Some.of(value) : None;
  20. }
  21. /* A custom fetcher that abandons immediately upon getting a response.
  22. * The purpose of this is just to validate that the user entered a real bundle, and provide feedback.
  23. */
  24. const bundleCache = new Map();
  25. function bundleSubject(baseUrl, bundle) {
  26. if (!bundle.match(/^[0-9a-f]{20}$/)) throw new Error("Bad input");
  27. if (bundleCache.has(bundle)) {
  28. return bundleCache.get(bundle);
  29. }
  30. const fetcher = new rxjs.BehaviorSubject(Pending.of());
  31. bundleCache.set(bundle, fetcher);
  32. fetch(new URL(`bundles/${bundle}/bundle.js.map`, baseUrl).toString()).then((res) => {
  33. res.body.cancel(); /* Bail on the download immediately - it could be big! */
  34. const status = res.ok;
  35. if (status) {
  36. fetcher.next(Success.of());
  37. } else {
  38. fetcher.next(FetchError.of(`Failed to fetch: ${res.status} ${res.statusText}`));
  39. }
  40. });
  41. return fetcher;
  42. }
  43. /*
  44. * Convert a ReadableStream of bytes into an Observable of a string
  45. * The observable will emit a stream of Pending objects and will concatenate
  46. * the number of bytes received with whatever pendingContext has been supplied.
  47. * Finally, it will emit a Success containing the result.
  48. * You'd use this on a Response.body.
  49. */
  50. function observeReadableStream(readableStream, pendingContext = {}) {
  51. let bytesReceived = 0;
  52. let buffer = "";
  53. const pendingSubject = new rxjs.BehaviorSubject(Pending.of({ ...pendingContext, bytesReceived }));
  54. const throttledPending = pendingSubject.pipe(rxjs.operators.throttleTime(100));
  55. const resultObservable = new rxjs.Subject();
  56. const reader = readableStream.getReader();
  57. const utf8Decoder = new TextDecoder("utf-8");
  58. function readNextChunk() {
  59. reader.read().then(({ done, value }) => {
  60. if (done) {
  61. pendingSubject.complete();
  62. resultObservable.next(Success.of(buffer));
  63. return;
  64. }
  65. bytesReceived += value.length;
  66. pendingSubject.next(Pending.of({ ...pendingContext, bytesReceived }));
  67. /* string concatenation is apparently the most performant way to do this */
  68. buffer += utf8Decoder.decode(value);
  69. readNextChunk();
  70. });
  71. }
  72. readNextChunk();
  73. return rxjs.concat(throttledPending, resultObservable);
  74. }
  75. /*
  76. * A wrapper which converts the browser's `fetch()` mechanism into an Observable. The Observable then provides us with
  77. * a stream of datatype values: first, a sequence of Pending objects that keep us up to date with the download progress,
  78. * finally followed by either a Success or Failure object. React then just has to render each of these appropriately.
  79. */
  80. const fetchCache = new Map();
  81. function fetchAsSubject(endpoint) {
  82. if (fetchCache.has(endpoint)) {
  83. // TODO: expiry/retry logic here?
  84. return fetchCache.get(endpoint);
  85. }
  86. const fetcher = new rxjs.BehaviorSubject(Pending.of());
  87. fetchCache.set(endpoint, fetcher);
  88. fetch(endpoint).then((res) => {
  89. if (!res.ok) {
  90. fetcher.next(FetchError.of(`Failed to fetch endpoint ${endpoint}: ${res.status} ${res.statusText}`));
  91. return;
  92. }
  93. const contentLength = res.headers.get("content-length");
  94. const context = contentLength ? { length: parseInt(contentLength) } : {};
  95. const streamer = observeReadableStream(res.body, context);
  96. streamer.subscribe((value) => {
  97. fetcher.next(value);
  98. });
  99. });
  100. return fetcher;
  101. }
  102. /* ===================== */
  103. /* ==== React stuff ==== */
  104. /* ===================== */
  105. /* Rather than importing an entire build infrastructure, for now we just use React without JSX */
  106. const e = React.createElement;
  107. /*
  108. * Provides user feedback given a FetchStatus object.
  109. */
  110. function ProgressBar({ fetchStatus }) {
  111. return e(
  112. "span",
  113. { className: "progress " },
  114. fetchStatus.fold({
  115. pending: ({ bytesReceived, length }) => {
  116. if (!bytesReceived) {
  117. return e("span", { className: "spinner" }, "\u29b5");
  118. }
  119. const kB = Math.floor((10 * bytesReceived) / 1024) / 10;
  120. if (!length) {
  121. return e("span", null, `Fetching (${kB}kB)`);
  122. }
  123. const percent = Math.floor((100 * bytesReceived) / length);
  124. return e("span", null, `Fetching (${kB}kB) ${percent}%`);
  125. },
  126. success: () => e("span", null, "\u2713"),
  127. error: (reason) => {
  128. return e("span", { className: "error" }, `\u2717 ${reason}`);
  129. },
  130. }),
  131. );
  132. }
  133. /*
  134. * The main component.
  135. */
  136. function BundlePicker() {
  137. const [baseUrl, setBaseUrl] = React.useState(new URL("..", window.location).toString());
  138. const [bundle, setBundle] = React.useState("");
  139. const [file, setFile] = React.useState("");
  140. const [line, setLine] = React.useState("1");
  141. const [column, setColumn] = React.useState("");
  142. const [result, setResult] = React.useState(None);
  143. const [bundleFetchStatus, setBundleFetchStatus] = React.useState(None);
  144. const [fileFetchStatus, setFileFetchStatus] = React.useState(None);
  145. /* On baseUrl change, try to fill in the bundle name for the user */
  146. React.useEffect(() => {
  147. console.log("DEBUG", baseUrl);
  148. getBundleName(baseUrl).then((name) => {
  149. console.log("DEBUG", name);
  150. if (bundle === "" && validateBundle(name) !== None) {
  151. setBundle(name);
  152. }
  153. }, console.log.bind(console));
  154. }, [baseUrl]);
  155. /* ------------------------- */
  156. /* Follow user state changes */
  157. /* ------------------------- */
  158. const onBaseUrlChange = React.useCallback((event) => {
  159. const value = event.target.value;
  160. setBaseUrl(value);
  161. }, []);
  162. const onBundleChange = React.useCallback((event) => {
  163. const value = event.target.value;
  164. setBundle(value);
  165. }, []);
  166. const onFileChange = React.useCallback((event) => {
  167. const value = event.target.value;
  168. setFile(value);
  169. }, []);
  170. const onLineChange = React.useCallback((event) => {
  171. const value = event.target.value;
  172. setLine(value);
  173. }, []);
  174. const onColumnChange = React.useCallback((event) => {
  175. const value = event.target.value;
  176. setColumn(value);
  177. }, []);
  178. /* ------------------------------------------------ */
  179. /* Plumb data-fetching observables through to React */
  180. /* ------------------------------------------------ */
  181. /* Whenever a valid bundle name is input, go see if it's a real bundle on the server */
  182. React.useEffect(
  183. () =>
  184. validateBundle(bundle).fold({
  185. some: (value) => {
  186. const subscription = bundleSubject(baseUrl, value)
  187. .pipe(rxjs.operators.map(Some.of))
  188. .subscribe(setBundleFetchStatus);
  189. return () => subscription.unsubscribe();
  190. },
  191. none: () => setBundleFetchStatus(None),
  192. }),
  193. [baseUrl, bundle],
  194. );
  195. /* Whenever a valid javascript file is input, see if it corresponds to a sourcemap file and initiate a fetch
  196. * if so. */
  197. React.useEffect(() => {
  198. if (!file.match(/.\.js$/) || validateBundle(bundle) === None) {
  199. setFileFetchStatus(None);
  200. return;
  201. }
  202. const observable = fetchAsSubject(new URL(`bundles/${bundle}/${file}.map`, baseUrl).toString()).pipe(
  203. rxjs.operators.map((fetchStatus) =>
  204. fetchStatus.flatMap((value) => {
  205. try {
  206. return Success.of(JSON.parse(value));
  207. } catch (e) {
  208. return FetchError.of(e);
  209. }
  210. }),
  211. ),
  212. rxjs.operators.map(Some.of),
  213. );
  214. const subscription = observable.subscribe(setFileFetchStatus);
  215. return () => subscription.unsubscribe();
  216. }, [baseUrl, bundle, file]);
  217. /*
  218. * Whenever we have a valid fetched sourcemap, and a valid line, attempt to find the original position from the
  219. * sourcemap.
  220. */
  221. React.useEffect(() => {
  222. // `fold` dispatches on the datatype, like a switch statement
  223. fileFetchStatus.fold({
  224. some: (fetchStatus) =>
  225. // `fold` just returns null for all of the cases that aren't `Success` objects here
  226. fetchStatus.fold({
  227. success: (value) => {
  228. if (!line) return setResult(None);
  229. const pLine = parseInt(line);
  230. const pCol = parseInt(column);
  231. sourceMap.SourceMapConsumer.with(value, undefined, (consumer) =>
  232. consumer.originalPositionFor({ line: pLine, column: pCol }),
  233. ).then((result) => setResult(Some.of(JSON.stringify(result))));
  234. },
  235. }),
  236. none: () => setResult(None),
  237. });
  238. }, [fileFetchStatus, line, column]);
  239. /* ------ */
  240. /* Render */
  241. /* ------ */
  242. return e(
  243. "div",
  244. {},
  245. e(
  246. "div",
  247. { className: "inputs" },
  248. e(
  249. "div",
  250. { className: "baseUrl" },
  251. e("label", { htmlFor: "baseUrl" }, "Base URL"),
  252. e("input", {
  253. name: "baseUrl",
  254. required: true,
  255. pattern: ".+",
  256. onChange: onBaseUrlChange,
  257. value: baseUrl,
  258. }),
  259. ),
  260. e(
  261. "div",
  262. { className: "bundle" },
  263. e("label", { htmlFor: "bundle" }, "Bundle"),
  264. e("input", {
  265. name: "bundle",
  266. required: true,
  267. pattern: "[0-9a-f]{20}",
  268. onChange: onBundleChange,
  269. value: bundle,
  270. }),
  271. bundleFetchStatus.fold({
  272. some: (fetchStatus) => e(ProgressBar, { fetchStatus }),
  273. none: () => null,
  274. }),
  275. ),
  276. e(
  277. "div",
  278. { className: "file" },
  279. e("label", { htmlFor: "file" }, "File"),
  280. e("input", {
  281. name: "file",
  282. required: true,
  283. pattern: ".+\\.js",
  284. onChange: onFileChange,
  285. value: file,
  286. }),
  287. fileFetchStatus.fold({
  288. some: (fetchStatus) => e(ProgressBar, { fetchStatus }),
  289. none: () => null,
  290. }),
  291. ),
  292. e(
  293. "div",
  294. { className: "line" },
  295. e("label", { htmlFor: "line" }, "Line"),
  296. e("input", {
  297. name: "line",
  298. required: true,
  299. pattern: "[0-9]+",
  300. onChange: onLineChange,
  301. value: line,
  302. }),
  303. ),
  304. e(
  305. "div",
  306. { className: "column" },
  307. e("label", { htmlFor: "column" }, "Column"),
  308. e("input", {
  309. name: "column",
  310. required: true,
  311. pattern: "[0-9]+",
  312. onChange: onColumnChange,
  313. value: column,
  314. }),
  315. ),
  316. ),
  317. e(
  318. "div",
  319. null,
  320. result.fold({
  321. none: () => "Select a bundle, file and line",
  322. some: (value) => e("pre", null, value),
  323. }),
  324. ),
  325. );
  326. }
  327. /* Global stuff */
  328. window.Decoder = {
  329. BundlePicker,
  330. };