oc-client-browser
Version:
627 lines (562 loc) • 17.9 kB
JavaScript
/* globals __CLIENT_VERSION__, __REGISTERED_TEMPLATES_PLACEHOLDER__, __DEFAULT_RETRY_INTERVAL__, __DEFAULT_RETRY_LIMIT__, __DEFAULT_DISABLE_LOADER__, __DISABLE_LEGACY_TEMPLATES__, __EXTERNALS__ */
import { decode } from '@rdevis/turbo-stream';
export function createOc(oc) {
// If oc client is already inside the page, we do nothing.
if (oc.status) {
return oc;
}
oc.status = 'loading';
oc.conf = oc.conf || {};
oc.cmd = oc.cmd || [];
oc.renderedComponents = oc.renderedComponents || {};
oc.clientVersion = __CLIENT_VERSION__;
let isRequired = (name, value) => {
if (!value) {
throw name + ' parameter is required';
}
};
// The code
let $document = document;
let $window = window;
let noop = () => {};
let initialised = false;
let initialising = false;
let retries = {};
let isBool = a => typeof a == 'boolean';
let timeout = setTimeout;
let ocCmd = oc.cmd;
let ocConf = oc.conf;
let renderedComponents = oc.renderedComponents;
let dataRenderedAttribute = 'data-rendered';
let dataRenderingAttribute = 'data-rendering';
let logError = msg => console.log(msg);
let logInfo = msg => ocConf.debug && console.log(msg);
let handleFetchResponse = response => {
if (!response.ok) throw response;
if (response.headers.get('Content-Type') !== 'x-text/stream')
return response.json();
return decode(response.body).then(decoded => decoded.value);
};
// Constants
let RETRY_INTERVAL =
ocConf.retryInterval || Number(__DEFAULT_RETRY_INTERVAL__);
let RETRY_LIMIT = ocConf.retryLimit || Number(__DEFAULT_RETRY_LIMIT__);
let DISABLE_LOADER = isBool(ocConf.disableLoader)
? ocConf.disableLoader
: __DEFAULT_DISABLE_LOADER__;
let RETRY_SEND_NUMBER = ocConf.retrySendNumber || true;
let POLLING_INTERVAL = ocConf.pollingInterval || 500;
let OC_TAG = ocConf.tag || 'oc-component';
let MESSAGES_ERRORS_HREF_MISSING = 'Href parameter missing';
let MESSAGES_ERRORS_RETRY_FAILED =
'Failed to load % component ' + RETRY_LIMIT + ' times. Giving up';
let MESSAGES_ERRORS_LOADING_COMPILED_VIEW = 'Error getting compiled view: %';
let MESSAGES_ERRORS_RENDERING = 'Error rendering component: %, error: ';
let MESSAGES_ERRORS_RETRIEVING =
'Failed to retrieve the component. Retrying in ' +
RETRY_INTERVAL / 1000 +
' seconds...';
let MESSAGES_ERRORS_VIEW_ENGINE_NOT_SUPPORTED =
'Error loading component: view engine "%" not supported';
let MESSAGES_LOADING_COMPONENT = ocConf.loadingMessage || '';
let MESSAGES_RENDERED = "Component '%' correctly rendered";
let MESSAGES_RETRIEVING =
'Unrendered component found. Trying to retrieve it...';
let interpolate = (str, value) => str.replace('%', value);
let registeredTemplates = __REGISTERED_TEMPLATES_PLACEHOLDER__;
let externals = __EXTERNALS__;
let registerTemplates = (templates, overwrite) => {
templates = Array.isArray(templates) ? templates : [templates];
templates.map(template => {
if (overwrite || !registeredTemplates[template.type]) {
registeredTemplates[template.type] = {
externals: template.externals
};
}
});
};
if (ocConf.templates) {
registerTemplates(ocConf.templates, true);
}
let retry = (component, cb, failedRetryCb) => {
if (retries[component] == undefined) {
retries[component] = RETRY_LIMIT;
}
if (retries[component] <= 0) {
failedRetryCb();
} else {
timeout(() => {
cb(RETRY_LIMIT - retries[component] + 1);
}, RETRY_INTERVAL);
retries[component]--;
}
};
let addParametersToHref = (href, parameters) => {
return (
href + (~href.indexOf('?') ? '&' : '?') + new URLSearchParams(parameters)
);
};
let reanimateScripts = component => {
for (let script of Array.from(component.querySelectorAll('script'))) {
let newScript = $document.createElement('script');
newScript.textContent = script.textContent;
for (let attribute of Array.from(script.attributes)) {
newScript.setAttribute(attribute.name, attribute.value);
}
script.parentNode?.replaceChild(newScript, script);
}
};
let getHeaders = () => {
let globalHeaders = ocConf.globalHeaders;
return {
Accept: 'application/vnd.oc.unrendered+json',
'Content-Type': 'application/json',
...(typeof globalHeaders == 'function' ? globalHeaders() : globalHeaders)
};
};
oc.addStylesToHead = styles => {
let style = $document.createElement('style');
style.textContent = styles;
$document.head.appendChild(style);
};
let loadAfterReady = () => {
oc.ready(oc.renderUnloadedComponents);
};
oc.registerTemplates = templates => {
registerTemplates(templates);
loadAfterReady();
return registeredTemplates;
};
// A minimal require.js-ish that uses l.js
oc.require = (nameSpace, url, callback) => {
if (!callback) {
callback = url;
url = nameSpace;
nameSpace = undefined;
}
if (typeof nameSpace == 'string') {
nameSpace = [nameSpace];
}
let getObj = () => {
let base = $window;
if (nameSpace == undefined) {
return undefined;
}
for (let i in nameSpace) {
base = base[nameSpace[i]];
if (!base) {
return undefined;
}
}
return base;
};
let cbGetObj = () => {
callback(getObj());
};
if (!getObj()) {
ljs.load(url, cbGetObj);
} else {
cbGetObj();
}
};
let asyncRequireForEach = (toLoad, loaded, callback) => {
if (!callback) {
callback = loaded;
loaded = [];
}
if (!toLoad.length) {
callback(loaded);
} else {
let loading = toLoad[0];
oc.require(loading.global, loading.url, resolved => {
asyncRequireForEach(toLoad.slice(1), loaded.concat(resolved), callback);
});
}
};
oc.requireSeries = asyncRequireForEach;
let processHtml = (component, data, callback) => {
let setAttribute = component.setAttribute.bind(component);
let dataName = data.name;
let dataVersion = data.version;
setAttribute('id', data.id);
setAttribute(dataRenderedAttribute, true);
setAttribute(dataRenderingAttribute, false);
setAttribute('data-version', dataVersion);
setAttribute('data-id', data.ocId);
component.innerHTML = data.html;
// If the html contains <scripts> tags, innerHTML will not execute them.
// So we need to do it manually.
reanimateScripts(component);
if (data.key) {
setAttribute('data-hash', data.key);
}
if (dataName) {
setAttribute('data-name', dataName);
renderedComponents[dataName] = { version: dataVersion };
if (data.baseUrl) {
renderedComponents[dataName].baseUrl = data.baseUrl;
}
data.element = component;
oc.events.fire('oc:rendered', data);
}
callback();
};
let getData = (options, cb) => {
cb = cb || noop;
let version = options.version,
baseUrl = options.baseUrl,
name = options.name;
isRequired('version', version);
isRequired('baseUrl', baseUrl);
isRequired('name', name);
if (options.action) {
baseUrl = `${baseUrl}~actions/${options.action}/${options.name}/${
options.version || ''
}`;
}
let parameters = { ...ocConf.globalParameters, ...options.parameters };
let data = options.action
? parameters
: {
components: [
{
action: options.action,
name: name,
version: version,
parameters
}
]
};
let headers = getHeaders();
fetch(baseUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify(data)
})
.then(handleFetchResponse)
.then(apiResponse => {
if (!options.action) {
let response = apiResponse[0].response;
let err = response.error ? response.details || response.error : null;
cb(err, response.data, apiResponse[0]);
} else {
cb(null, apiResponse.data);
}
})
.catch(cb);
};
oc.getData = getData;
oc.getAction = options => {
return new Promise((resolve, reject) => {
let name = options.component;
getData(
{
json: true,
name: name,
...renderedComponents[name],
...options
},
(err, data) => {
if (err) {
reject(err);
} else {
if (data.component) {
let props = data.component.props;
delete props._staticPath;
delete props._baseUrl;
delete props._componentName;
delete props._componentVersion;
resolve(props);
} else {
resolve();
}
}
}
);
});
};
oc.build = options => {
isRequired('baseUrl', options.baseUrl);
isRequired('name', options.name);
let withFinalSlash = s => {
if (!s) return '';
return s.match(/\/$/) ? s : s + '/';
};
let href =
withFinalSlash(options.baseUrl) +
withFinalSlash(options.name) +
withFinalSlash(options.version);
if (options.parameters) {
href += '?';
for (let [key, value] of Object.entries(options.parameters)) {
if (/[+&=]/.test(value)) {
value = encodeURIComponent(value);
}
href += key + '=' + value + '&';
}
href = href.slice(0, -1);
}
return '<' + OC_TAG + ' href="' + href + '"></' + OC_TAG + '>';
};
oc.ready = callback => {
if (initialised) {
callback();
} else if (initialising) {
ocCmd.push(callback);
} else {
initialising = true;
let done = () => {
initialised = true;
initialising = false;
oc.events = (() => {
let listeners = {};
return {
fire(key, data) {
if (listeners[key]) {
for (let cb of listeners[key]) {
cb({ type: key }, data);
}
}
},
on(key, cb) {
if (!cb) {
throw new Error('Callback is required');
}
if (!listeners[key]) {
listeners[key] = [];
}
listeners[key].push(cb);
},
off(events, handler) {
if (typeof events === 'string') {
events = [events];
}
for (let event of events) {
if (listeners[event]) {
if (handler) {
listeners[event] = listeners[event].filter(
cb => cb !== handler
);
} else {
delete listeners[event];
}
}
}
},
reset() {
listeners = {};
}
};
})();
callback();
oc.events.fire('oc:ready', oc);
oc.status = 'ready';
ocCmd.map(cmd => {
cmd(oc);
});
oc.cmd = {
push: f => f(oc)
};
};
oc.requireSeries(externals, done);
}
};
oc.render = (compiledViewInfo, model, callback) => {
oc.ready(() => {
// TODO: integrate with oc-empty-response-handler module
if (model && model.__oc_emptyResponse == true) {
return callback(null, '');
}
let type = compiledViewInfo.type;
if (!__DISABLE_LEGACY_TEMPLATES__) {
if (type == 'jade' || type == 'handlebars') {
type = 'oc-template-' + type;
}
}
let template = registeredTemplates[type];
if (template) {
oc.require(
['oc', 'components', compiledViewInfo.key],
compiledViewInfo.src,
compiledView => {
if (!compiledView) {
callback(
interpolate(
MESSAGES_ERRORS_LOADING_COMPILED_VIEW,
compiledViewInfo.src
)
);
} else {
asyncRequireForEach(template.externals, () => {
try {
callback(
null,
!__DISABLE_LEGACY_TEMPLATES__ &&
type == 'oc-template-handlebars'
? $window.Handlebars.template(compiledView, [])(model)
: compiledView(model)
);
} catch (e) {
callback('' + e);
}
});
}
}
);
} else {
callback(
interpolate(
MESSAGES_ERRORS_VIEW_ENGINE_NOT_SUPPORTED,
compiledViewInfo.type
)
);
}
});
};
oc.renderNestedComponent = (component, callback) => {
oc.ready(() => {
// If the component is a jQuery object, we need to get the first element
component = component[0] || component;
let getAttribute = component.getAttribute.bind(component);
let setAttribute = component.setAttribute.bind(component);
let dataRendering = getAttribute(dataRenderingAttribute);
let dataRendered = getAttribute(dataRenderedAttribute);
let isRendering = dataRendering == 'true';
let isRendered = dataRendered == 'true';
if (!isRendering && !isRendered) {
logInfo(MESSAGES_RETRIEVING);
setAttribute(dataRenderingAttribute, true);
if (!DISABLE_LOADER) {
component.innerHTML =
'<div class="oc-loading">' + MESSAGES_LOADING_COMPONENT + '</div>';
}
oc.renderByHref(
{
href: getAttribute('href'),
id: getAttribute('id'),
element: component
},
(err, data) => {
if (err || !data) {
setAttribute(dataRenderingAttribute, false);
setAttribute(dataRenderedAttribute, false);
setAttribute('data-failed', true);
component.innerHTML = '';
oc.events.fire('oc:failed', {
originalError: err,
data: data,
component
});
logError(err);
callback();
} else {
processHtml(component, data, callback);
}
}
);
} else {
timeout(callback, POLLING_INTERVAL);
}
});
};
oc.renderByHref = (hrefOrOptions, retryNumberOrCallback, callback) => {
callback = callback || retryNumberOrCallback;
let ocId = Math.floor(Math.random() * 9999999999);
let retryNumber = hrefOrOptions.retryNumber || +retryNumberOrCallback || 0;
let href = hrefOrOptions.href || hrefOrOptions;
let id = hrefOrOptions.id || ocId;
let element = hrefOrOptions.element;
oc.ready(() => {
if (!href) {
callback(MESSAGES_ERRORS_RENDERING + MESSAGES_ERRORS_HREF_MISSING);
} else {
fetch(
addParametersToHref(href, {
...ocConf.globalParameters,
...(RETRY_SEND_NUMBER ? { __oc_Retry: retryNumber } : {})
}),
{
headers: getHeaders()
}
)
.then(handleFetchResponse)
.then(apiResponse => {
let template = apiResponse.template;
apiResponse.data.id = ocId;
apiResponse.data.element = element;
oc.render(template, apiResponse.data, (err, html) => {
if (err) {
callback(
interpolate(MESSAGES_ERRORS_RENDERING, apiResponse.href) + err
);
} else {
logInfo(interpolate(MESSAGES_RENDERED, template.src));
callback(null, {
id: id,
ocId: ocId,
html: html,
baseUrl: apiResponse.baseUrl,
key: template.key,
version: apiResponse.version,
name: apiResponse.name
});
}
});
})
.catch(err => {
if (err && err.status == 429) {
retries[href] = 0;
}
logError(MESSAGES_ERRORS_RETRIEVING);
retry(
href,
requestNumber => {
oc.renderByHref(
{
href: href,
retryNumber: requestNumber,
id: id,
element: element
},
callback
);
},
() => {
callback(interpolate(MESSAGES_ERRORS_RETRY_FAILED, href));
}
);
});
}
});
};
oc.renderUnloadedComponents = () => {
oc.ready(() => {
let unloadedComponents = $document.querySelectorAll(
`${OC_TAG}:not([data-rendered="true"]):not([data-failed="true"])`
);
unloadedComponents.forEach((unloadedComponent, idx) => {
oc.renderNestedComponent(unloadedComponent, () => {
if (idx == unloadedComponents.length - 1) {
oc.renderUnloadedComponents();
}
});
});
});
};
oc.load = (placeholder, href, callback) => {
oc.ready(() => {
callback = callback || noop;
if (placeholder) {
placeholder = placeholder[0] || placeholder;
placeholder.innerHTML = '<' + OC_TAG + ' href="' + href + '" />';
let newComponent = placeholder.querySelector(OC_TAG);
oc.renderNestedComponent(newComponent, () => {
callback(newComponent);
});
}
});
};
// render the components
loadAfterReady();
return oc;
}