Merge pull request #26 from bwindels/bwindels/netreplay

Add utility to record and replay homeserver requests
This commit is contained in:
Bruno Windels 2019-12-23 13:31:53 +00:00 committed by GitHub
commit 3b1ad40408
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 210 additions and 55 deletions

2
.gitignore vendored
View file

@ -1,5 +1,7 @@
*.sublime-project *.sublime-project
*.sublime-workspace *.sublime-workspace
node_modules node_modules
fetchlogs
sessionexports
bundle.js bundle.js
target target

View file

@ -1,4 +1,6 @@
import HomeServerApi from "./matrix/hs-api.js"; 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 StorageFactory from "./matrix/storage/idb/create.js";
import SessionsStore from "./matrix/sessions-store/localstorage/SessionsStore.js"; import SessionsStore from "./matrix/sessions-store/localstorage/SessionsStore.js";
import BrawlViewModel from "./domain/BrawlViewModel.js"; import BrawlViewModel from "./domain/BrawlViewModel.js";
@ -6,9 +8,21 @@ import BrawlView from "./ui/web/BrawlView.js";
export default async function main(container) { export default async function main(container) {
try { 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({ const vm = new BrawlViewModel({
storageFactory: new StorageFactory(), 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"), sessionStore: new SessionsStore("brawl_sessions_v1"),
clock: Date //just for `now` fn clock: Date //just for `now` fn
}); });

View file

@ -1,30 +1,25 @@
import { import {
HomeServerError, HomeServerError,
RequestAbortError,
NetworkError
} from "./error.js"; } from "./error.js";
class RequestWrapper { class RequestWrapper {
constructor(promise, controller) { constructor(method, url, requestResult) {
if (!controller) { this._requestResult = requestResult;
const abortPromise = new Promise((_, reject) => { this._promise = this._requestResult.response().then(response => {
this._controller = { // ok?
abort() { if (response.status >= 200 && response.status < 300) {
const err = new Error("fetch request aborted"); return response.body;
err.name = "AbortError"; } else {
reject(err); switch (response.status) {
} default:
}; throw new HomeServerError(method, url, response.body);
}); }
this._promise = Promise.race([promise, abortPromise]); }
} else { });
this._promise = promise;
this._controller = controller;
}
} }
abort() { abort() {
this._controller.abort(); return this._requestResult.abort();
} }
response() { 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 { 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? // 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 // 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._accessToken = accessToken;
this._requestFn = request;
} }
_url(csPath) { _url(csPath) {
@ -66,42 +61,12 @@ export default class HomeServerApi {
headers.append("Content-Type", "application/json"); headers.append("Content-Type", "application/json");
bodyString = JSON.stringify(body); bodyString = JSON.stringify(body);
} }
const controller = typeof AbortController === "function" ? new AbortController() : null; const requestResult = this._requestFn(url, {
// TODO: set authenticated headers with second arguments, cache them
let promise = fetch(url, {
method, method,
headers, headers,
body: bodyString, body: bodyString,
signal: controller && controller.signal,
mode: "cors",
credentials: "omit",
referrer: "no-referrer",
cache: "no-cache",
}); });
promise = promise.then(async (response) => { return new RequestWrapper(method, url, requestResult);
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);
} }
_post(csPath, queryParams, body) { _post(csPath, queryParams, body) {

66
src/matrix/net/fetch.js Normal file
View file

@ -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);
}

108
src/matrix/net/replay.js Normal file
View file

@ -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;
}
}