Merge pull request #26 from bwindels/bwindels/netreplay
Add utility to record and replay homeserver requests
This commit is contained in:
commit
3b1ad40408
5 changed files with 210 additions and 55 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,5 +1,7 @@
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
node_modules
|
node_modules
|
||||||
|
fetchlogs
|
||||||
|
sessionexports
|
||||||
bundle.js
|
bundle.js
|
||||||
target
|
target
|
||||||
|
|
16
src/main.js
16
src/main.js
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
@ -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";
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
this._promise = Promise.race([promise, abortPromise]);
|
|
||||||
} else {
|
} else {
|
||||||
this._promise = promise;
|
switch (response.status) {
|
||||||
this._controller = controller;
|
default:
|
||||||
|
throw new HomeServerError(method, url, response.body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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
66
src/matrix/net/fetch.js
Normal 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
108
src/matrix/net/replay.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Reference in a new issue