directlink-react
Version:
DirectLink is a library for building single-page web applications that makes the world easier for ASP.NET Core developers who use React.
437 lines (409 loc) • 16.9 kB
JavaScript
// Copyright (c) 2018 Andrei Molchanov. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
/*global React, ReactDOM, components, signalR*/
let Utils = require('./utils');
let DefferedPromise = Utils.DeferredPromise;
let directlink = {
data: {},
instances: {},
scripts: {},
styles: {},
promises: {},
nocache: false,
hmr: false,
renderApp: (container, data) => {
directlink.container = container;
directlink.hub.start()
.then(() => {
if (data) {
directlink.render(data);
} else {
directlink.requestData(window.location.pathname);
}
window.onpopstate = directlink.history.popState;
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
directlink.history.current = { path: location.pathname, hash: location.hash };
})
.catch(error => {
container.innerHTML = '';
throw error || 'start signalr connection failed';
});
},
render: (data, requestAssets) => {
let requests = [];
if (requestAssets) {
let timestamp = directlink.nocache ? '?_=' + Date.now() : '';
for (let name in data.Scripts) {
if (directlink.scripts[name] !== data.Scripts[name]) {
requests.push(Utils.getScript(data.Scripts[name] + timestamp));
}
}
for (let name in data.Styles) {
if (directlink.styles[name] !== data.Styles[name]) {
requests.push(Utils.getStyle(data.Styles[name] + timestamp));
}
}
}
Promise.all(requests).then(() => {
directlink.scripts = Object.assign(directlink.scripts, data.Scripts);
directlink.styles = Object.assign(directlink.styles, data.Styles);
directlink.data = data;
document.title = data.Title;
if (!directlink.rendered) {
let render = directlink.container.childElementCount ? ReactDOM.hydrate : ReactDOM.render;
if (directlink.hmr) {
directlink.instances.HMR = render(React.createElement(HMR, { component: data.App }), directlink.container, directlink.onRendered);
} else {
render(React.createElement(components[data.App]), directlink.container, directlink.onRendered);
}
} else {
for (let key in data.States) {
let instance = directlink.instances[key];
if (instance) {
instance.setState(data.States[key]);
}
}
if (directlink.forceUpdate) {
directlink.instances.HMR.forceUpdate();
delete directlink.forceUpdate;
} else {
directlink.scrollIntoView(location.hash);
}
}
});
},
onRendered: () => {
directlink.rendered = true;
directlink.linked = Promise['resolve']();
directlink.scrollIntoView(location.hash);
},
init: instance => {
instance.name = Utils.getClassName(instance);
instance.scope = instance.props.scope || instance.context && instance.context.scope;
if (instance.scope) {
instance.fullname = instance.scope + '.' + instance.name;
}
if (instance.props.fullname) {
instance.fullname = instance.props.fullname;
}
if (!instance.fullname) {
instance.fullname = instance.name;
}
if (instance.props.id) {
instance.fullname += '$' + instance.props.id;
}
instance.state = directlink.data.States[instance.fullname] || {};
if (instance.name === directlink.data.App) {
instance.state.online = true;
}
if (global.signalR) {
directlink.instances[instance.fullname] = instance;
instance.invoke = (method, args) => {
let invocationId = Utils.getGuid();
let promise = new DefferedPromise((resolve, reject) => {
directlink.online()
.then(() => directlink.linked)
.then(() => {
if (args && !Array.isArray(args)) {
args = [args];
}
directlink.hub.instance
.invoke("invoke", invocationId, instance.fullname, instance.name, method, args)
.catch(error => {
delete directlink.promises[invocationId];
reject(error);
});
})
.catch(error => reject(error));
});
directlink.promises[invocationId] = promise;
return promise;
};
for (var i in directlink.data.Methods[instance.name]) {
var method = directlink.data.Methods[instance.name][i];
instance[method] = ((method) => (...args) => instance.invoke(method, [...args]))(method);
}
if (directlink.data.Bidirectional.indexOf(instance.name) !== -1) {
instance.bidirectional = true;
directlink.link(instance);
}
instance.updateId = (id) => {
directlink.unlink(instance);
delete directlink.instances[instance.fullname];
if (instance.fullname.indexOf('$') > 0) {
instance.fullname = instance.fullname.replace('$' + instance.props.id, '$' + id);
}
directlink.instances[instance.fullname] = instance;
directlink.link(instance);
};
}
},
link: instance => {
let props = Object.assign({}, instance.props);
delete props.scope;
delete props.fullname;
directlink.hub.instance.invoke("link", instance.fullname, instance.name, props);
},
unlink: instance => directlink.hub.instance.invoke("unlink", instance.fullname, instance.name),
dispose: instance => {
if (!directlink.hmr) {
if (instance.bidirectional) {
directlink.unlink(instance);
}
delete directlink.instances[instance.fullname];
}
},
requestData: (path, referer) => directlink.hub.instance.invoke("requestData", path, referer),
hub: {
instance: null,
connectionId: () => directlink.hub.instance && directlink.hub.instance.connection.connectionId,
init: () => {
let hub = new signalR.HubConnectionBuilder()
.withUrl("/directlink")
.configureLogging(signalR.LogLevel.Information)
.build();
hub.on("SetState", (fullname, state) => {
if (directlink.instances.hasOwnProperty(fullname)) {
directlink.instances[fullname].setState(state);
}
});
hub.on("Invoke", (fullname, method, args) => {
if (directlink.instances.hasOwnProperty(fullname)) {
if (args) {
directlink.instances[fullname][method](...args);
} else {
directlink.instances[fullname][method]();
}
}
});
hub.on("DataResponse", result => {
if (result.Data) {
directlink.render(result.Data, true);
return;
}
throw 'status code ' + result.StatusCode;
});
hub.on("InvokeResult", (invocationId, response) => {
let promise = directlink.promises[invocationId];
delete directlink.promises[invocationId];
if (response && response.StatusCode === 200) {
if (response.Result !== undefined) {
promise.resolve(response.Result);
} else {
promise.resolve();
}
return;
}
promise.reject(response && response.StatusCode ? 'status code ' + response.StatusCode : 'unexpected result');
});
hub.on("AssetsUpdate", () => {
if (directlink.hmr) {
directlink.forceUpdate = true;
directlink.requestData(window.location.pathname);
}
});
let onClose = error => {
if (error) {
console.error(error);
}
directlink.instances[directlink.data.App].setState({ online: false });
directlink.hub.reconnect('closed');
};
if (hub.onclose) {
hub.onclose(onClose);
} else {
hub.onClosed = onClose;
}
directlink.hub.instance = hub;
},
state: '',
start: () => {
directlink.hub.init();
return directlink.hub.instance.start()
.then(() => {
directlink.hub.reconnectCount = 0;
directlink.hub.state = 'connected';
if (directlink.rendered) {
directlink.linked = new Promise(resolve => {
for (let key in directlink.instances) {
let instance = directlink.instances[key];
if (instance.bidirectional) {
directlink.link(instance);
}
}
resolve();
});
}
if (directlink.rendered) {
directlink.instances[directlink.data.App].setState({ online: true });
}
})
.catch(error => {
setTimeout(() => { directlink.hub.reconnect('failed'); }, 0);
throw error || 'start signalr connection failed';
});
},
stop: () => directlink.hub.instance.connection.stop(),
reconnect: state => {
let hub = directlink.hub;
hub.state = state;
if (state === 'closed' || state === 'waited') {
hub.state = 'reconnecting';
hub.reconnectPromise = hub.start();
}
if (state === 'failed') {
hub.state = 'waiting';
let maxIndex = hub.reconnectTimeouts.length - 1;
directlink.hub.reconnectTimer = setTimeout(() => {
if (hub.state === 'waiting') {
if (hub.reconnectCount < maxIndex) {
hub.reconnectCount++;
}
hub.reconnect('waited');
}
}, hub.reconnectTimeouts[Math.min(hub.reconnectCount, maxIndex)]);
}
},
reconnectPromise: null,
reconnectTimer: null,
reconnectTimeouts: [1000, 5000, 20000, 60000],
reconnectCount: 0
},
online: () => new Promise((resolve, reject) => {
let hub = directlink.hub;
if (hub.instance.connection.connectionState === 1) {
resolve();
return;
}
if (hub.state === 'reconnecting') {
hub.reconnectPromise
.then(() => resolve())
.catch(() => reject('not connected'));
}
if (hub.state === 'waiting') {
clearTimeout(hub.reconnectTimer);
hub.reconnect('waited');
hub.reconnectPromise
.then(() => resolve())
.catch(() => reject('not connected'));
}
setTimeout(() => reject('not connected'), 10000);
}),
history: {
current: { path: null, hash: null },
pushState: (state, title, path) => {
let hash = '';
let idx = path.indexOf('#');
if (idx === 0) {
hash = path;
if (directlink.history.current.hash !== hash) {
history.pushState(state, title, directlink.history.current.path + hash);
}
directlink.scrollIntoView(hash);
return;
}
if (!path.startsWith('/')) {
let current = directlink.history.current.path;
let base = current.substring(0, current.lastIndexOf('/') + (path.length > 0 ? 1 : 0));
path = base + path;
if (idx > 0) {
idx += base.length;
}
}
if (idx > 0) {
hash = path.substring(idx);
path = path.substring(0, idx);
if (directlink.history.current.path === path) {
if (directlink.history.current.hash !== hash) {
history.pushState(state, title, path + hash);
}
directlink.scrollIntoView(hash);
return;
}
}
history.pushState(state, title, path + hash);
directlink.history.dispatch(path, hash);
},
popState: () => {
if (directlink.history.current.path === location.pathname) {
directlink.scrollIntoView(location.hash);
} else {
directlink.history.dispatch(location.pathname, location.hash);
}
},
dispatch: (path, hash) => {
let referer = directlink.history.current.path;
directlink.history.current = { path, hash };
directlink.online()
.then(() => directlink.requestData(path + hash, referer))
.catch(error => console.error(error));
},
},
scrollIntoView: hash => {
if (hash) {
try {
let element = document.querySelector(hash);
if (element !== null) {
element.scrollIntoView({ block: 'start', behavior: 'smooth' });
}
} catch (error) { console.log(error); }
return;
}
window.scrollTo(window.scrollX, 0);
}
};
let Link = props => {
return React.createElement(
"a", {
style: props.style,
className: props.className,
href: props.href,
onClick: event => {
event.preventDefault();
if (typeof props.onClick === "function") {
props.onClick(event);
}
directlink.history.pushState({}, "", props.href);
}
},
props.children
);
};
let Render = props => {
if (!props.component) {
console.error("Render requires 'component' property");
}
let _props = Object.assign({}, props);
delete _props.component;
_props.fullname = props.component && props.component.FullName;
return props.component && React.createElement(components[props.component.Name], _props);
};
let HMR = class HMR extends React.Component {
render() {
if (!this.props.component) {
console.error("HMR requires 'component' property");
}
return this.props.component && React.createElement(components[this.props.component]);
}
};
let Scope = props => {
if (!props.name) {
console.error("Scope requires 'name' property");
}
return React.Children.map(props.children, child => {
if (React.isValidElement(child)) {
return React.cloneElement(child, { scope: props.name });
}
return child;
});
};
module.exports = {
components: {},
directlink: directlink,
Link: Link,
Render: Render,
HMR: HMR,
Scope: Scope
};