mirror of
https://github.com/matrix-construct/construct
synced 2024-11-27 09:12:36 +01:00
616 lines
15 KiB
JavaScript
616 lines
15 KiB
JavaScript
/*
|
|
// Matrix Construct
|
|
//
|
|
// Copyright (C) Matrix Construct Developers, Authors & Contributors
|
|
// Copyright (C) 2016-2018 Jason Volk <jason@zemos.net>
|
|
//
|
|
// Permission to use, copy, modify, and/or distribute this software for any
|
|
// purpose with or without fee is hereby granted, provided that the above
|
|
// copyright notice and this permission notice is present in all copies. The
|
|
// full license for this software is available in the LICENSE file.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
/**
|
|
*/
|
|
mc.io.request = class
|
|
{
|
|
/** Create a new IO request. The url is normally undefined so it can
|
|
* be composed properly from the ctx. If the URL is defined it will
|
|
* override any composition.
|
|
*/
|
|
constructor(ctx = {}, callback = undefined)
|
|
{
|
|
if(callback)
|
|
ctx.callback = callback;
|
|
|
|
mc.io.request.constructor.call(this, ctx);
|
|
}
|
|
|
|
/** Multi-purpose function dealing with HTTP headers for both directions.
|
|
* - Note: It's better to set headers in the ctx.headers dictionary.
|
|
* + If key and value are defined, a request header is set.
|
|
* + If only key is defined, a response header is returned for that key.
|
|
* + If neither is defined, all response headers are returned as a string.
|
|
*/
|
|
header(key = undefined, val = undefined)
|
|
{
|
|
return mc.io.request.header.call(this, key, val);
|
|
}
|
|
|
|
/** Receive a promise which will resolve to the next XHR event.type.
|
|
*
|
|
* @param type - If a string, an event type name which can be registered
|
|
* with xhr i.e "progress". If a number, an XHR readyState.
|
|
*
|
|
* @returns Promise - await for the event
|
|
*/
|
|
promise(type)
|
|
{
|
|
return mc.io.request.promise.call(this, type);
|
|
}
|
|
|
|
/** Abort this request.
|
|
*
|
|
* @returns Promise - await for the request to be aborted;
|
|
* - promise resolves to the abort event or rejects with mc.error
|
|
*/
|
|
abort(reason = "")
|
|
{
|
|
mc.io.request.abort.call(this, reason);
|
|
return this.promise("abort");
|
|
}
|
|
|
|
/** Convenience to get a promise resolving to the final result
|
|
* data of this request, or rejecting with the final error.
|
|
*/
|
|
get response()
|
|
{
|
|
return this.promise("response");
|
|
}
|
|
|
|
/** Convenience to current xhr state
|
|
*/
|
|
get state()
|
|
{
|
|
return this.xhr.readyState;
|
|
}
|
|
|
|
/** Convenience to get the composed url stored in the ctx
|
|
*/
|
|
get url()
|
|
{
|
|
return this.ctx.url;
|
|
}
|
|
};
|
|
|
|
/** Default options for a request.
|
|
*/
|
|
mc.io.request.ctx =
|
|
{
|
|
// default response type expectation for xhr,
|
|
responseType: "json",
|
|
|
|
// default timeout in milliseconds
|
|
timeout: 30 * 1000,
|
|
|
|
// HTTP query string dictionary `?key=value&`
|
|
query: {},
|
|
|
|
headers:
|
|
{
|
|
// default request type submission, added to headers
|
|
"content-type": "application/json; charset=utf-8",
|
|
},
|
|
|
|
// Content object is turned into JSON when content-type is
|
|
// set for json (by default), otherwise assign content anything
|
|
// which xhr.send() can take.
|
|
content: {},
|
|
|
|
// defaulting
|
|
method: "GET",
|
|
prefix: "",
|
|
|
|
digest: false,
|
|
};
|
|
|
|
mc.io.request.constructor = function(ctx = {})
|
|
{
|
|
// Setup the context for this request.
|
|
this.ctx = ctx;
|
|
|
|
// Default structure for anything required and not included by the user.
|
|
Object.defaults(this.ctx, mc.io.request.ctx);
|
|
|
|
// Remove keys with values that won't be sent, but leave the potentially
|
|
// empty objects to maintain the structure.
|
|
Object.clean(this.ctx.query);
|
|
Object.clean(this.ctx.headers);
|
|
Object.clean(this.ctx.content);
|
|
|
|
// Dictionary holding the state of promises registered to resolve for events by type.
|
|
// To register a promise use the request.promise(type). The format of this structure:
|
|
// type => { reject: [], resolve: [] }.
|
|
this.promised = {};
|
|
|
|
// The top of the request stack is saved here to debug the request later.
|
|
this.stack = (new Error).stack;
|
|
|
|
// The URL is unconditionally regenerated (the url in ctx is overwritten); this is because
|
|
// the components may change between calls and we don't require the user to know how to
|
|
// manipulate the cached url value. The user should manipulate components in ctx only.
|
|
this.ctx.url = mc.io.request.constructor.url.call(this);
|
|
|
|
// Setup IO for this request.
|
|
this.xhr = mc.io.request.constructor.xhr.call(this);
|
|
|
|
// Insert request into active table. The request can be aborted hereafter.
|
|
mc.io.requests.insert(this);
|
|
|
|
this.started = mc.now();
|
|
|
|
// This should be done here for now
|
|
this.xhr.open(this.ctx.method, this.ctx.url);
|
|
|
|
console.log(this.ctx.method
|
|
+ " " + this.ctx.resource
|
|
+ " " + this.ctx.url
|
|
+ " sending " + maybe(() => this.ctx.content.length) + " bytes"
|
|
);
|
|
}
|
|
|
|
/** Construct XHR related for the request.
|
|
*
|
|
*/
|
|
mc.io.request.constructor.xhr = function()
|
|
{
|
|
let xhr = new XMLHttpRequest();
|
|
xhr.timeout = this.ctx.timeout;
|
|
xhr.responseType = this.ctx.responseType;
|
|
mc.io.request.constructor.xhr.bindings.call(this, xhr);
|
|
return xhr;
|
|
};
|
|
|
|
/** Walks the mc.io.request.on tree and binds those handlers to the request
|
|
* and registers them with xhr for both uploading and downloading.
|
|
*/
|
|
mc.io.request.constructor.xhr.bindings = function(xhr)
|
|
{
|
|
let options =
|
|
{
|
|
//passive: true,
|
|
};
|
|
|
|
let upload = function(handler, event)
|
|
{
|
|
event.upload = true;
|
|
handler.call(this, event);
|
|
mc.io.request.promise.resolve.call(this, event.type, event);
|
|
};
|
|
|
|
let download = function(handler, event)
|
|
{
|
|
handler.call(this, event);
|
|
mc.io.request.promise.resolve.call(this, event.type, event);
|
|
};
|
|
|
|
Object.each(mc.io.request.on, (name, handler) =>
|
|
{
|
|
xhr.addEventListener(name, download.bind(this, handler), options);
|
|
xhr.upload.addEventListener(name, upload.bind(this, handler), options);
|
|
});
|
|
};
|
|
|
|
/** Generates a full URL for this request based on the amalgam of
|
|
* context components.
|
|
*/
|
|
mc.io.request.constructor.url = function(base_url = mc.opts.base_url)
|
|
{
|
|
let ctx = this.ctx;
|
|
let query = !empty(ctx.query)? "?" + $.param(ctx.query, false) : "";
|
|
let prefix = ctx.prefix !== undefined? ctx.prefix : "";
|
|
let resource = ctx.resource !== undefined? ctx.resource : "/";
|
|
let version = mc.io.request.constructor.url.version.call(this, ctx);
|
|
let url = base_url + prefix + version + resource + query;
|
|
return url;
|
|
};
|
|
|
|
mc.io.request.constructor.url.version = function(ctx)
|
|
{
|
|
if(typeof(ctx.version) == "number")
|
|
return "r" + ctx.version + "/";
|
|
|
|
if(ctx.version === null)
|
|
return "";
|
|
|
|
if(!empty(ctx.version))
|
|
return ctx.version;
|
|
|
|
return "r" + mc.server.version + "/";
|
|
};
|
|
|
|
/** Called when the request reaches the xhr.DONE state
|
|
*/
|
|
mc.io.request.destructor = function()
|
|
{
|
|
mc.io.requests.remove(this);
|
|
mc.io.request.destructor.stats.call(this);
|
|
|
|
let xhr = this.xhr;
|
|
let ctx = this.ctx;
|
|
let type = xhr.responseType;
|
|
let bytes_up = this.uploaded();
|
|
let bytes_down = this.loaded();
|
|
console.log(xhr.status
|
|
+ " " + ctx.method
|
|
+ " " + ctx.resource
|
|
+ " sent:" + bytes_up
|
|
+ " recv:" + bytes_down
|
|
+ " " + type
|
|
);
|
|
};
|
|
|
|
/**
|
|
* TODO: byte counting should probably be done incrementally on progress
|
|
*/
|
|
mc.io.request.destructor.stats = function()
|
|
{
|
|
let bytes_up = this.uploaded();
|
|
let bytes_down = this.loaded();
|
|
mc.io.stats.sent.bytes += bytes_up;
|
|
mc.io.stats.recv.bytes += bytes_down;
|
|
};
|
|
|
|
/** Interrupt the request
|
|
*/
|
|
mc.io.request.abort = function(reason = "")
|
|
{
|
|
this.reason = reason;
|
|
if(this.xhr !== undefined)
|
|
this.xhr.abort();
|
|
}
|
|
|
|
mc.io.request.prototype.loaded = function()
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
mc.io.request.prototype.uploaded = function()
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
mc.io.request.prototype.total = function()
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
mc.io.request.prototype.uptotal = function()
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
/** User promise generation
|
|
*
|
|
* Receive a promise for an xhr event type which is resolved when this request
|
|
* gets a callback for that type. The resolution data will be the xhr event.
|
|
*
|
|
* Additionally, numerical keys promise to be resolved when the xhr.readyState
|
|
* meets that state.
|
|
*
|
|
* Additionally, the key "response" will return a promise which is settled when the
|
|
* request has finalized, resolving to the result data or rejecting with error.
|
|
*/
|
|
mc.io.request.promise = function(type)
|
|
{
|
|
if(type != "response" && typeof(type) != "number" && !(type in mc.io.request.on))
|
|
console.warn("Trying to register an unrecognized xhr event '" + type + "'");
|
|
|
|
if(!(type in this.promised))
|
|
mc.io.request.promise.init.call(this, type);
|
|
|
|
return new Promise((resolve, reject) =>
|
|
{
|
|
this.promised[type].resolve.push(resolve);
|
|
this.promised[type].reject.push(reject);
|
|
});
|
|
};
|
|
|
|
/** Called to resolve promises for an event.
|
|
*/
|
|
mc.io.request.promise.resolve = function(type, event)
|
|
{
|
|
if(!(type in this.promised))
|
|
return;
|
|
|
|
let promised = this.promised[type];
|
|
promised.resolve.forEach((resolve) => resolve(event));
|
|
mc.io.request.promise.clear.call(this, type);
|
|
}
|
|
|
|
/** Called to reject promises registered for a type with the error.
|
|
*/
|
|
mc.io.request.promise.reject = function(type, error)
|
|
{
|
|
if(!(type in this.promised))
|
|
return;
|
|
|
|
let promised = this.promised[type];
|
|
promised.reject.forEach((reject) => reject(error));
|
|
mc.io.request.promise.clear.call(this, type);
|
|
}
|
|
|
|
/** Called when a new type must be added to the promised collection.
|
|
*/
|
|
mc.io.request.promise.init = function(type)
|
|
{
|
|
this.promised[type] =
|
|
{
|
|
resolve: [],
|
|
reject: [],
|
|
};
|
|
};
|
|
|
|
/** Called when a promises for a type have been fulfilled
|
|
*/
|
|
mc.io.request.promise.clear = function(type)
|
|
{
|
|
if(!(type in this.promised))
|
|
return;
|
|
|
|
this.promised[type].resolve = [];
|
|
this.promised[type].reject = [];
|
|
};
|
|
|
|
/** Called to reject any promises registered.
|
|
*/
|
|
mc.io.request.promise.reject.all = function(error)
|
|
{
|
|
Object.each(this.promised, (type) =>
|
|
{
|
|
mc.io.request.promise.reject.call(this, type, error);
|
|
});
|
|
}
|
|
|
|
mc.io.request.on = {};
|
|
|
|
mc.io.request.on.readystatechange = function(event)
|
|
{
|
|
let state = this.xhr.readyState;
|
|
let handler = mc.io.request.on.readystatechange[state];
|
|
this.event = event;
|
|
|
|
// The promise is resolved for the user before the lib's handlers are called.
|
|
// This is because the DONE handler has to fulfill all outstanding promises for
|
|
// this request entirely -- that would include the promise on DONE itself.
|
|
mc.io.request.promise.resolve.call(this, state, event);
|
|
|
|
if(handler !== undefined)
|
|
handler.call(this, event, state);
|
|
}
|
|
|
|
mc.io.request.on.readystatechange[XMLHttpRequest.OPENED] = function(event)
|
|
{
|
|
// When headers are preset in the context they are auto registered.
|
|
if(!empty(this.ctx.headers))
|
|
mc.io.request.header.set.defaults.call(this);
|
|
|
|
let content = this.ctx.content;
|
|
let headers = this.ctx.headers;
|
|
let content_type = headers["content-type"];
|
|
|
|
// Handle automatic JSON. The ctx's content is always sent in default JSON
|
|
// mode, even when it's empty. The only way to prevent that so you can call
|
|
// send() yourself is to null it.
|
|
if(content != null && (!content_type || content_type.startsWith("application/json")))
|
|
{
|
|
let json = JSON.stringify(content);
|
|
this.xhr.send(json);
|
|
return;
|
|
}
|
|
|
|
// Handle automatic other
|
|
if(content)
|
|
this.xhr.send(content);
|
|
};
|
|
|
|
mc.io.request.on.readystatechange[XMLHttpRequest.HEADERS_RECEIVED] = function(event)
|
|
{
|
|
//console.log("" + xhr.getAllResponseHeaders());
|
|
};
|
|
|
|
mc.io.request.on.readystatechange[XMLHttpRequest.LOADING] = function(event)
|
|
{
|
|
};
|
|
|
|
mc.io.request.on.readystatechange[XMLHttpRequest.DONE] = function(event)
|
|
{
|
|
let code = parseInt(this.xhr.status);
|
|
|
|
// Case for file:// protocol which reports code 0 (since it's not HTTP)
|
|
// on success and failure; we test for success here and manually assign
|
|
// a 200 code.
|
|
if(!code && this.xhr.responseURL.startsWith("file://"))
|
|
code = defined(this.xhr.response)? 200 : 0;
|
|
|
|
mc.io.request.destructor.call(this);
|
|
switch(Math.floor(code / 100))
|
|
{
|
|
case 2: // 2xx
|
|
return mc.io.request.success.call(this, event);
|
|
|
|
case 3: // 3xx
|
|
case 4: // 4xx
|
|
case 5: // 5xx
|
|
case 0: // xhr error
|
|
default: // unknown/unhandled
|
|
return mc.io.request.error.call(this, event);
|
|
}
|
|
};
|
|
|
|
mc.io.request.on.abort = function(event)
|
|
{
|
|
//console.log("ABT " + this.ctx.method + " " + this.ctx.resource + " " + this.reason);
|
|
mc.io.request.promise.resolve.call(this, "abort", event);
|
|
}
|
|
|
|
mc.io.request.on.loadstart = function(event)
|
|
{
|
|
}
|
|
|
|
mc.io.request.on.progress = function(event)
|
|
{
|
|
if(event.upload)
|
|
mc.io.stats.sent.msgs++;
|
|
else
|
|
mc.io.stats.recv.msgs++;
|
|
|
|
}
|
|
|
|
mc.io.request.on.load = function(event)
|
|
{
|
|
}
|
|
|
|
mc.io.request.on.timeout = function(event)
|
|
{
|
|
console.log("TIM " + this.ctx.method + " " + this.ctx.resource + " " + this.ctx.url);
|
|
}
|
|
|
|
mc.io.request.on.loadend = function(event)
|
|
{
|
|
}
|
|
|
|
mc.io.request.on.error = function(event)
|
|
{
|
|
console.log("ERR " + this.ctx.method + " " + this.ctx.resource + " " + this.ctx.url);
|
|
}
|
|
|
|
mc.io.request.success = function(event)
|
|
{
|
|
let error = undefined;
|
|
let data = this.xhr.response;
|
|
mc.io.request.continuation.call(this, error, data);
|
|
};
|
|
|
|
mc.io.request.error = function(event)
|
|
{
|
|
let error = new mc.error(
|
|
{
|
|
message:
|
|
!empty(this.reason)?
|
|
this.reason:
|
|
this.response && this.xhr.responseType == "text"?
|
|
this.xhr.responseText:
|
|
this.started + this.ctx.timeout <= mc.now()?
|
|
"timeout":
|
|
maybe(() => this.event.detail)?
|
|
this.event.detail:
|
|
undefined,
|
|
|
|
name:
|
|
empty(this.xhr)?
|
|
"Network Request Not Possible":
|
|
this.xhr.statusText == "error"?
|
|
"Network Request Error":
|
|
this.xhr.statusText == "abort"?
|
|
"Network Request Canceled":
|
|
!empty(this.xhr.statusText)?
|
|
this.xhr.statusText:
|
|
this.started + this.ctx.timeout <= mc.now()?
|
|
"timeout":
|
|
!empty(this.reason)?
|
|
this.reason:
|
|
!window.navigator.onLine?
|
|
"disconnected":
|
|
this.started + (10 * 1000) < mc.now()?
|
|
"timeout":
|
|
"unknown/unhandled",
|
|
|
|
status:
|
|
this.xhr.status != 0? this.xhr.status : "Client",
|
|
|
|
m:
|
|
this.xhr.responseType == "json"? this.xhr.response : undefined,
|
|
|
|
request_stack:
|
|
this.stack,
|
|
|
|
event:
|
|
this.event,
|
|
|
|
element:
|
|
this.ctx.element,
|
|
});
|
|
|
|
//if(!empty(error.m))
|
|
// delete error.message;
|
|
|
|
let data = undefined;
|
|
mc.io.request.continuation.call(this, error, data);
|
|
};
|
|
|
|
mc.io.request.continuation = function(error, data)
|
|
{
|
|
try
|
|
{
|
|
if(this.ctx.callback)
|
|
this.ctx.callback(error, data);
|
|
|
|
if(error)
|
|
mc.io.request.promise.reject.all.call(this, error);
|
|
else
|
|
mc.io.request.promise.resolve.call(this, "response", data);
|
|
}
|
|
catch(exception)
|
|
{
|
|
mc.io.request.continuation.unhandled.call(this, exception);
|
|
}
|
|
finally
|
|
{
|
|
if(this.ctx.digest)
|
|
mc.ng.apply();
|
|
}
|
|
};
|
|
|
|
mc.io.request.continuation.unhandled = function(exception)
|
|
{
|
|
Object.defaults(exception,
|
|
{
|
|
element: this.ctx.element,
|
|
response_stack: exception.stack,
|
|
request_stack: this.stack,
|
|
});
|
|
|
|
mc.unhandled(exception);
|
|
};
|
|
|
|
mc.io.request.header = function(key = undefined, val = undefined)
|
|
{
|
|
if(key && val)
|
|
return mc.io.request.header.set.call(this, key, val);
|
|
|
|
if(key)
|
|
return mc.io.request.header.get.call(this, key);
|
|
|
|
return this.xhr.getAllResponseHeaders();
|
|
};
|
|
|
|
mc.io.request.header.get = function(key)
|
|
{
|
|
return this.xhr.getResponseHeader(key);
|
|
};
|
|
|
|
mc.io.request.header.set = function(key, val)
|
|
{
|
|
this.xhr.setRequestHeader(key, val);
|
|
};
|
|
|
|
mc.io.request.header.set.defaults = function()
|
|
{
|
|
Object.each(this.ctx.headers, (key, val) =>
|
|
{
|
|
mc.io.request.header.set.call(this, key, val);
|
|
});
|
|
};
|