diff --git a/.gitignore b/.gitignore index 479c5590..e38531b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.sublime-project *.sublime-workspace node_modules +fetchlogs +sessionexports bundle.js target diff --git a/src/main.js b/src/main.js index 6d6ed505..9e973bb9 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,6 @@ import HomeServerApi from "./matrix/hs-api.js"; +// import {RecordRequester, ReplayRequester} from "./matrix/net/replay.js"; +import fetchRequest from "./matrix/net/fetch.js"; import StorageFactory from "./matrix/storage/idb/create.js"; import SessionsStore from "./matrix/sessions-store/localstorage/SessionsStore.js"; import BrawlViewModel from "./domain/BrawlViewModel.js"; @@ -6,9 +8,21 @@ import BrawlView from "./ui/web/BrawlView.js"; export default async function main(container) { try { + // to replay: + // const fetchLog = await (await fetch("/fetchlogs/constrainterror.json")).json(); + // const replay = new ReplayRequester(fetchLog, {delay: false}); + // const request = replay.request; + + // to record: + // const recorder = new RecordRequester(fetchRequest); + // const request = recorder.request; + // window.getBrawlFetchLog = () => recorder.log(); + + // normal network: + const request = fetchRequest; const vm = new BrawlViewModel({ storageFactory: new StorageFactory(), - createHsApi: (homeServer, accessToken = null) => new HomeServerApi(homeServer, accessToken), + createHsApi: (homeServer, accessToken = null) => new HomeServerApi({homeServer, accessToken, request}), sessionStore: new SessionsStore("brawl_sessions_v1"), clock: Date //just for `now` fn }); diff --git a/src/matrix/hs-api.js b/src/matrix/hs-api.js index 2c9b5548..d4f9e1f7 100644 --- a/src/matrix/hs-api.js +++ b/src/matrix/hs-api.js @@ -1,30 +1,25 @@ import { HomeServerError, - RequestAbortError, - NetworkError } from "./error.js"; class RequestWrapper { - constructor(promise, controller) { - if (!controller) { - const abortPromise = new Promise((_, reject) => { - this._controller = { - abort() { - const err = new Error("fetch request aborted"); - err.name = "AbortError"; - reject(err); - } - }; - }); - this._promise = Promise.race([promise, abortPromise]); - } else { - this._promise = promise; - this._controller = controller; - } + constructor(method, url, requestResult) { + this._requestResult = requestResult; + this._promise = this._requestResult.response().then(response => { + // ok? + if (response.status >= 200 && response.status < 300) { + return response.body; + } else { + switch (response.status) { + default: + throw new HomeServerError(method, url, response.body); + } + } + }); } abort() { - this._controller.abort(); + return this._requestResult.abort(); } response() { @@ -32,13 +27,13 @@ class RequestWrapper { } } -// todo: everywhere here, encode params in the url that could have slashes ... mainly event ids? export default class HomeServerApi { - constructor(homeserver, accessToken) { + constructor({homeServer, accessToken, request}) { // store these both in a closure somehow so it's harder to get at in case of XSS? // one could change the homeserver as well so the token gets sent there, so both must be protected from read/write - this._homeserver = homeserver; + this._homeserver = homeServer; this._accessToken = accessToken; + this._requestFn = request; } _url(csPath) { @@ -66,42 +61,12 @@ export default class HomeServerApi { headers.append("Content-Type", "application/json"); bodyString = JSON.stringify(body); } - const controller = typeof AbortController === "function" ? new AbortController() : null; - // TODO: set authenticated headers with second arguments, cache them - let promise = fetch(url, { + const requestResult = this._requestFn(url, { method, headers, body: bodyString, - signal: controller && controller.signal, - mode: "cors", - credentials: "omit", - referrer: "no-referrer", - cache: "no-cache", }); - promise = promise.then(async (response) => { - if (response.ok) { - return await response.json(); - } else { - switch (response.status) { - default: - throw new HomeServerError(method, url, await response.json()) - } - } - }, err => { - if (err.name === "AbortError") { - throw new RequestAbortError(); - } else if (err instanceof TypeError) { - // Network errors are reported as TypeErrors, see - // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#Checking_that_the_fetch_was_successful - // this can either mean user is offline, server is offline, or a CORS error (server misconfiguration). - // - // One could check navigator.onLine to rule out the first - // but the 2 later ones are indistinguishable from javascript. - throw new NetworkError(`${method} ${url}: ${err.message}`); - } - throw err; - }); - return new RequestWrapper(promise, controller); + return new RequestWrapper(method, url, requestResult); } _post(csPath, queryParams, body) { diff --git a/src/matrix/net/fetch.js b/src/matrix/net/fetch.js new file mode 100644 index 00000000..48a13969 --- /dev/null +++ b/src/matrix/net/fetch.js @@ -0,0 +1,66 @@ +import { + RequestAbortError, + NetworkError +} from "../error.js"; + +class RequestResult { + constructor(promise, controller) { + if (!controller) { + const abortPromise = new Promise((_, reject) => { + this._controller = { + abort() { + const err = new Error("fetch request aborted"); + err.name = "AbortError"; + reject(err); + } + }; + }); + this._promise = Promise.race([promise, abortPromise]); + } else { + this._promise = promise; + this._controller = controller; + } + } + + abort() { + this._controller.abort(); + } + + response() { + return this._promise; + } +} + +export default function fetchRequest(url, options) { + const controller = typeof AbortController === "function" ? new AbortController() : null; + if (controller) { + options = Object.assign(options, { + signal: controller.signal + }); + } + options = Object.assign(options, { + mode: "cors", + credentials: "omit", + referrer: "no-referrer", + cache: "no-cache", + }); + const promise = fetch(url, options).then(async response => { + const {status} = response; + const body = await response.json(); + return {status, body}; + }, err => { + if (err.name === "AbortError") { + throw new RequestAbortError(); + } else if (err instanceof TypeError) { + // Network errors are reported as TypeErrors, see + // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#Checking_that_the_fetch_was_successful + // this can either mean user is offline, server is offline, or a CORS error (server misconfiguration). + // + // One could check navigator.onLine to rule out the first + // but the 2 later ones are indistinguishable from javascript. + throw new NetworkError(`${options.method} ${url}: ${err.message}`); + } + throw err; + }); + return new RequestResult(promise, controller); +} diff --git a/src/matrix/net/replay.js b/src/matrix/net/replay.js new file mode 100644 index 00000000..b6ddcb74 --- /dev/null +++ b/src/matrix/net/replay.js @@ -0,0 +1,108 @@ +import { + RequestAbortError, + NetworkError +} from "../error.js"; + +class RequestLogItem { + constructor(url, options) { + this.url = url; + this.options = options; + this.error = null; + this.body = null; + this.status = status; + this.start = performance.now(); + this.end = 0; + } + + async handleResponse(response) { + this.end = performance.now(); + this.status = response.status; + this.body = response.body; + } + + handleError(err) { + this.end = performance.now(); + this.error = { + aborted: err instanceof RequestAbortError, + network: err instanceof NetworkError, + message: err.message, + }; + } +} + +export class RecordRequester { + constructor(request) { + this._origRequest = request; + this._requestLog = []; + this.request = this.request.bind(this); + } + + request(url, options) { + const requestItem = new RequestLogItem(url, options); + this._requestLog.push(requestItem); + try { + const requestResult = this._origRequest(url, options); + requestResult.response().then(response => { + requestItem.handleResponse(response); + }); + return requestResult; + } catch (err) { + requestItem.handleError(err); + throw err; + } + } + + log() { + return this._requestLog; + } +} + +export class ReplayRequester { + constructor(log, options) { + this._log = log.slice(); + this._options = options; + this.request = this.request.bind(this); + } + + request(url, options) { + const idx = this._log.findIndex(item => { + return item.url === url && options.method === item.options.method; + }); + if (idx === -1) { + return new ReplayRequestResult({status: 404}, options); + } else { + const [item] = this._log.splice(idx, 1); + return new ReplayRequestResult(item, options); + } + } +} + +class ReplayRequestResult { + constructor(item, options) { + this._item = item; + this._options = options; + this._aborted = false; + } + + abort() { + this._aborted = true; + } + + async response() { + if (this._options.delay) { + const delay = this._item.end - this._item.start; + await new Promise(resolve => setTimeout(resolve, delay)); + } + if (this._item.error || this._aborted) { + const error = this._item.error; + if (error.aborted || this._aborted) { + throw new RequestAbortError(error.message); + } else if (error.network) { + throw new NetworkError(error.message); + } else { + throw new Error(error.message); + } + } + return this._item; + } +}