@builder.io/sdk
Version:
711 lines (710 loc) • 24.4 kB
JavaScript
var __assign =
(this && this.__assign) ||
Object.assign ||
function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
console.log('loaded?');
import sortBy from 'lodash-es/sortBy';
import omit from 'lodash-es/omit';
import throttle from 'lodash-es/throttle';
import includes from 'lodash-es/includes';
import queryString from 'query-string';
import parser from 'ua-parser-js';
// import { ContentModelType } from '../../app/models/content.model';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import stringify from 'json-stable-stringify';
// import { humanCase } from '../../app/functions/human-case.function';
import Cookies from 'js-cookie';
import url from 'url';
import 'whatwg-fetch';
import { EventCapturer } from './classes/event-capturer.class';
// Annoying workaround for module loading
var stableStringify = stringify.default || stringify;
var anyParser = parser;
export var isBrowser = typeof window !== 'undefined';
export var isIframe = isBrowser && window.top !== window.self;
var fetch = (isBrowser && window.fetch) || require('node-fetch').default;
// Workaround for oddly random module loading issues
var UaParser = typeof anyParser.default === 'function' ? anyParser.default : anyParser;
export function BuilderComponent(info) {
if (info === void 0) {
info = {};
}
return function(component) {
var spec = __assign({}, info, { class: component });
if (!spec.name) {
spec.name = component.name;
}
if (
!Builder.components.find(function(item) {
return item.name === spec.name;
})
) {
Builder.components.push(spec);
// TODO: serialize component name and inputs
if (isBrowser) {
window.top.postMessage(
{
type: 'builder.registerComponent',
data: omit(spec, 'class'),
},
'*'
);
}
}
};
}
var Builder = /** @class */ (function() {
function Builder(apiKey) {
if (apiKey === void 0) {
apiKey = null;
}
var _this = this;
this.apiKey = apiKey;
this.eventsQueue = [];
this.throttledClearEventsQueue = throttle(function() {
_this.processEventsQueue();
}, 100);
this.isUsed = false;
this.editingMode$ = new BehaviorSubject(isIframe);
// TODO: decorator to do this stuff with the get/set (how do with typing too? compiler?)
this.editingModel$ = new BehaviorSubject(null);
this.userAgent = (typeof navigator === 'object' && navigator.userAgent) || '';
this.blockContentLoading = '';
this.observersByModelType = {};
this.getContentQueue = null;
this.priorContentQueue = null;
// TODO: how prune deprecated tests
this.testCookiePrefix = 'builder.tests';
// in the browser ensure this is a singleton
// FIXME: why are there attempts to load this twice?
if (isBrowser) {
// TODO: ensure certain events only bound one time as static property
}
if (isBrowser) {
this.bindMessageListeners();
}
if (isIframe) {
this.loadFullStory();
}
}
Builder.loadIncrementalDom = function() {
if (!this.isBrowser || this.incrementalDomLoaded) {
return;
}
console.info('Loading incremental DOM...');
var url = 'https://ajax.googleapis.com/ajax/libs/incrementaldom/0.5.1/incremental-dom-min.js';
var script = document.createElement('script');
script.src = url;
document.head.appendChild(script);
this.incrementalDomLoaded = true;
};
Builder.patchDom = function(selector, vtree) {
var _this = this;
if (!this.isBrowser) {
return;
}
var element = document.querySelector(selector);
if (!element) {
return;
}
if (!IncrementalDOM) {
console.warn('IncrementalDOM not loaded!');
return;
}
IncrementalDOM.patch(element, function() {
_this.vTreeToIncrementalDom(vtree);
});
};
Builder.patchHtml = function(selector, html) {
if (!this.isBrowser) {
return;
}
var element = document.querySelector(selector);
if (!element) {
return;
}
element.innerHTML = html;
};
Builder.vTreeToIncrementalDom = function(tree) {
var _this = this;
if (tree.type === 'VirtualNode') {
var node = tree;
// TODO: handle other properties/attributes
var properties = [];
for (var key in node.properties) {
var value = node.properties[key];
if (key === 'attributes') {
for (var attrKey in value) {
properties.push(attrKey);
properties.push(value[attrKey]);
}
} else {
properties.push(key);
properties.push(value);
}
}
IncrementalDOM.elementOpen.apply(
IncrementalDOM,
[node.tagName, undefined, undefined].concat(properties)
);
node.children.forEach(function(node) {
return _this.vTreeToIncrementalDom(tree);
});
IncrementalDOM.elementClose(node.tagName);
} else {
IncrementalDOM.text(tree.text);
}
};
Object.defineProperty(Builder, 'editingPage', {
get: function() {
return this._editingPage;
},
set: function(editingPage) {
this._editingPage = editingPage;
if (isBrowser && isIframe) {
if (editingPage) {
// this.loadIncrementalDom();
document.body.classList.add('builder-editing-page');
} else {
document.body.classList.remove('builder-editing-page');
}
}
},
enumerable: true,
configurable: true,
});
// TODO: style guide, etc off this system as well?
Builder.component = function(info) {
var _this = this;
if (info === void 0) {
info = {};
}
return function(component) {
var spec = __assign({}, info, { class: component });
if (!spec.name) {
spec.name = component.name;
}
if (
!_this.components.find(function(item) {
return item.name === spec.name;
})
) {
_this.components.push(spec);
// TODO: serialize component name and inputs
if (isBrowser) {
window.top.postMessage(
{
type: 'builder.registerComponent',
data: omit(spec, 'class'),
},
'*'
);
}
}
};
};
Object.defineProperty(Builder, 'Component', {
get: function() {
return this.component;
},
enumerable: true,
configurable: true,
});
Builder.prototype.processEventsQueue = function() {
if (!this.eventsQueue.length) {
return;
}
var events = this.eventsQueue;
this.eventsQueue = [];
// TODO: centralize this
var host =
this.getLocation().host === 'localhost:4205' ? 'http://localhost:5000' : 'https://builder.io';
fetch(host + '/api/v1/track', {
method: 'POST',
body: JSON.stringify({ events: events }),
headers: {
'content-type': 'application/json',
},
mode: 'cors',
});
};
Object.defineProperty(Builder.prototype, 'editingMode', {
get: function() {
return this.editingMode$.value;
},
set: function(value) {
if (value !== this.editingMode) {
this.editingMode$.next(value);
}
},
enumerable: true,
configurable: true,
});
Object.defineProperty(Builder.prototype, 'editingModel', {
get: function() {
return this.editingModel$.value;
},
set: function(value) {
if (value !== this.editingModel) {
this.editingModel$.next(value);
}
},
enumerable: true,
configurable: true,
});
Builder.prototype.setUserAgent = function(userAgent) {
this.userAgent = userAgent;
};
Builder.prototype.track = function(eventName, properties) {
if (properties === void 0) {
properties = {};
}
if (isIframe || !isBrowser) {
return;
}
// batch events
this.eventsQueue.push({
type: 'impression',
data: __assign({}, properties),
});
this.throttledClearEventsQueue();
};
Builder.prototype.trackImpression = function(contentId, variationId) {
if (isIframe || !isBrowser) {
return;
}
// TODO: use this.track method
this.eventsQueue.push({
type: 'impression',
data: {
contentId: contentId,
variationId: variationId !== contentId ? variationId : undefined,
ownerId: this.apiKey,
},
});
this.throttledClearEventsQueue();
};
Builder.prototype.trackInteraction = function(contentId, variationId) {
if (isIframe || !isBrowser) {
return;
}
// TODO: use this.track method
this.eventsQueue.push({
type: 'click',
data: {
contentId: contentId,
variationId: variationId !== contentId ? variationId : undefined,
ownerId: this.apiKey,
},
});
this.throttledClearEventsQueue();
};
Builder.prototype.component = function(info) {
if (info === void 0) {
info = {};
}
return Builder.component(info);
};
Builder.prototype.bindMessageListeners = function() {
var _this = this;
// TODO: handle race condition of content already loading
// TODO: move to another file
if (isBrowser) {
addEventListener('message', function(event) {
if (!_this.isUsed) {
return;
}
var url = new URL(event.origin);
var allowedHosts = ['builder.io', 'localhost'];
if (!includes(allowedHosts, url.hostname)) {
return;
}
var data = event.data;
if (data) {
switch (data.type) {
case 'builder.patchDom': {
Builder.patchDom(data.data.selector, data.data.tree);
break;
}
case 'builder.patchHtml': {
Builder.patchHtml(data.data.selector, data.data.html);
break;
}
case 'builder.contentUpdate':
var model = data.data.modelName;
var contentData = data.data.data; // hmmm...
var observer = _this.observersByModelType[model];
if (observer) {
observer.next([contentData]);
}
break;
case 'builder.getComponents':
// TODO: serialize component name and inputs
window.top.postMessage(
{
type: 'builder.components',
data: Builder.components.map(function(item) {
return omit(item, 'class');
}),
},
'*'
);
break;
case 'builder.editingModel':
_this.editingModel = data.data.model;
break;
case 'builder.registerComponent':
var componentData = data.data;
Builder.components.push(componentData);
break;
case 'builder.blockContentLoading':
if (typeof data.data.model === 'string') {
_this.blockContentLoading = data.data.model;
}
break;
case 'builder.editingMode':
var editingMode = data.data;
if (editingMode) {
_this.editingMode = true;
document.body.classList.add('builder-editing');
} else {
_this.editingMode = false;
document.body.classList.remove('builder-editing');
}
break;
case 'builder.editingPageMode':
var editingPageMode = data.data;
Builder.editingPage = editingPageMode;
break;
case 'builder.overrideUserAttributes':
var userAttributes = data.data;
Object.assign(Builder.overrideUserAttributes, data.data);
_this.flushGetContentQueue(true);
// TODO: refetch too
break;
case 'builder.overrideTestGroup':
var _a = data.data,
variationId = _a.variationId,
contentId = _a.contentId;
if (variationId && contentId) {
_this.setTestCookie(contentId, variationId);
_this.flushGetContentQueue(true);
}
case 'builder.evaluate': {
var text = data.data.text;
var args = data.data.arguments || [];
var id = data.data.id;
// tslint:disable-next-line:no-function-constructor-with-string-args
var fn = new Function(text);
var result = fn.apply(_this, args);
window.top.postMessage(
{
type: 'builder.evaluateResult',
data: {
result: result,
id: id,
text: text,
},
},
'*'
);
}
}
}
});
}
};
Builder.prototype.init = function(apiKey) {
this.apiKey = apiKey;
return this;
};
Builder.prototype.getLocation = function() {
return (typeof location === 'object' && url.parse(location.href)) || {};
};
Builder.prototype.getUserAttributes = function(userAgent) {
if (userAgent === void 0) {
userAgent = this.userAgent;
}
this.isUsed = true;
if (!userAgent) {
console.warn(
'No user agent set! For help on how to set this please contact steve@builder.io'
);
}
var ua = new UaParser(userAgent);
// FIXME
var url = this.getLocation();
var device = ua.getDevice();
// TODO: get these from exension as well
return __assign(
{
queryString: url.search,
urlPath: url.pathname,
// Removinf for now because of cache keys
// referrer: document.referrer,
// language: navigator.language.split('-')[0],
device: device.type || 'desktop',
operatingSystem: (ua.getOS().name || '').toLowerCase() || undefined,
browser: (ua.getBrowser().name || '').toLowerCase() || undefined,
},
Builder.overrideUserAttributes
);
};
Builder.prototype.setUserAttributes = function(options) {
Object.assign(Builder.overrideUserAttributes, options);
};
Builder.prototype.loadFullStory = function() {
// TODO: check that the iframe's parent is buidler.io
if (!Builder.isIframe) {
return;
}
// If fullstory already loaded return
if (window['_fs_org']) {
return;
}
var script = document.createElement('script');
script.innerHTML =
"\n window['_fs_run_in_iframe'] = true\n window['_fs_debug'] = false;\n window['_fs_host'] = 'fullstory.com';\n window['_fs_org'] = 'B9193';\n window['_fs_namespace'] = 'FS';\n (function(m,n,e,t,l,o,g,y){\n if (e in m) {if(m.console && m.console.log) { m.console.log('FullStory namespace conflict. Please set window[\"_fs_namespace\"].');} return;}\n g=m[e]=function(a,b){g.q?g.q.push([a,b]):g._api(a,b);};g.q=[];\n o=n.createElement(t);o.async=1;o.src='https://'+_fs_host+'/s/fs.js';\n y=n.getElementsByTagName(t)[0];y.parentNode.insertBefore(o,y);\n g.identify=function(i,v){g(l,{uid:i});if(v)g(l,v)};g.setUserVars=function(v){g(l,v)};\n y=\"rec\";g.shutdown=function(i,v){g(y,!1)};g.restart=function(i,v){g(y,!0)};\n y=\"consent\";g[y]=function(a){g(y,!arguments.length||a)};\n g.identifyAccount=function(i,v){o='account';v=v||{};v.acctId=i;g(o,v)};\n g.clearUserCookie=function(){};\n })(window,document,window['_fs_namespace'],'script','user');\n ";
document.head.appendChild(script);
};
// TODO: also take priority or group name/number so can
// group fast stuff to show right away and slow or lower priority after'
// how does graphql defer work?
// TODO: add defaultContent to regular getContent method
Builder.prototype.queueGetContent = function(modelName, options) {
var _this = this;
if (options === void 0) {
options = {};
}
var initialContent = options.initialContent;
if (!initialContent) {
if (!this.getContentQueue) {
this.getContentQueue = [];
setTimeout(function() {
_this.flushGetContentQueue();
});
}
this.getContentQueue.push(modelName);
}
return new Observable(function(observer) {
_this.observersByModelType[modelName] = observer;
if (initialContent) {
// setImmediate(() => {
observer.next(initialContent);
// });
}
});
};
Builder.prototype.requestUrl = function(url) {
return fetch(url).then(function(res) {
return res.json();
});
};
Builder.prototype.flushGetContentQueue = function(usePastQueue) {
var _this = this;
if (usePastQueue === void 0) {
usePastQueue = false;
}
if (!this.apiKey) {
throw new Error('Builder needs to be initialized with an API key!');
}
if (!usePastQueue && !this.getContentQueue) {
return;
}
var queryParams = {};
var pageQueryParams =
typeof location !== 'undefined' ? queryString.parse(location.search) : undefined || {};
// TODO: merge in the attribute from query string ones
queryParams.userAttributes = stableStringify(this.getUserAttributes());
var queue = (usePastQueue ? this.priorContentQueue : this.getContentQueue) || [];
if (!usePastQueue) {
this.priorContentQueue = queue;
this.getContentQueue = null;
}
// TODO: cachebust if bd.noCache in request, also perhaps if in iframe
// if (options.cachebust) {
// queryParams.t = Date.now().toString();
// }
var cachebust = isIframe || pageQueryParams.cachebust;
if (cachebust) {
queryParams.cachebuster = Date.now().toString();
}
var hasParams = Object.keys(queryParams).length > 0;
var host =
this.getLocation().host === 'localhost:4205' ? 'http://localhost:5000' : 'https://builder.io';
var modelNames = queue.join(',');
// FIXME: have a "core" SDK that doesn't implement http,
// so SDKs like angular can use it's own http method
var promise = this.requestUrl(
host +
'/api/v1/content/' +
this.apiKey +
'/' +
modelNames +
(queryParams && hasParams ? '?' + queryString.stringify(queryParams) : '')
)
.then(function(result) {
for (var _i = 0, queue_1 = queue; _i < queue_1.length; _i++) {
var modelName = queue_1[_i];
if (modelName === _this.blockContentLoading) {
continue;
}
var observer = _this.observersByModelType[modelName];
if (!observer) {
return;
}
var data = result[modelName];
var sorted = sortBy(data, function(item) {
return item.priority;
});
var testModifiedResults = _this.processResultsForTests(sorted);
observer.next(testModifiedResults);
// observer.next(sorted);
}
})
.catch(function(err) {
for (var _i = 0, queue_2 = queue; _i < queue_2.length; _i++) {
var modelName = queue_2[_i];
var observer = _this.observersByModelType[modelName];
if (!observer) {
return;
}
observer.error(err);
}
});
};
Builder.prototype.processResultsForTests = function(results) {
var _this = this;
var mappedResults = results.map(function(item) {
if (!item.variations) {
return item;
}
var cookieValue = _this.getTestCookie(item.id);
var cookieVariation = cookieValue === item.id ? item : item.variations[cookieValue];
if (cookieVariation) {
return __assign({}, item, { data: cookieVariation.data, variationId: cookieValue });
}
if (item.variations) {
var n = 0;
var random = Math.random();
for (var id in item.variations) {
var variation = item.variations[id];
var testRatio = variation.testRatio;
n += testRatio;
if (random < n) {
_this.setTestCookie(item.id, variation.id);
return __assign({}, item, { data: variation.data, variationId: variation.id });
}
}
}
_this.setTestCookie(item.id, item.id);
return item;
});
if (isIframe) {
window.top.postMessage(
{ type: 'builder.contentResults', data: { results: mappedResults } },
'*'
);
}
return mappedResults;
};
Builder.prototype.getTestCookie = function(contentId) {
return this.getCookie(this.testCookiePrefix + '.' + contentId);
};
Builder.prototype.setTestCookie = function(contentId, variationId) {
return this.setCookie(this.testCookiePrefix + '.' + contentId, variationId, {
expires: 30,
});
};
Builder.prototype.getCookie = function(name) {
return Cookies.get(name);
};
Builder.prototype.setCookie = function(name, value, options) {
return Cookies.set(name, value, options);
};
// TODO:˝ param overrides
// TODO: gather user attributes
// Forward query to a server call so params like no cache or overrides can be extraced and applied
Builder.prototype.getContent = function(modelName, options) {
var _this = this;
if (options === void 0) {
options = {};
}
if (!this.apiKey) {
throw new Error('Builder needs to be initialized with an API key!');
}
var queryParams =
(typeof options.queryString === 'string'
? queryString.parse(options.queryString)
: typeof location !== 'undefined' ? queryString.parse(location.search) : undefined) || {};
// TODO: merge in the attribute from query string ones
queryParams.userAttributes = stableStringify(this.getUserAttributes());
// TODO: cachebust if bd.noCache in request, also perhaps if in iframe
if (options.cachebust || isIframe) {
queryParams.cachebuster = Date.now().toString();
}
var hasParams = Object.keys(queryParams).length > 0;
// TODO: check for query params or global variable for overriding content (e.g. what to use for preview)
// TODO: query params and sorting
// TODO: with extension set up live connection to this query and send updates
// TODO: return observable with subscribe function that returns unsubscriber?
// TODO: (shorter than uuid) org namespace
// /api/v1/everlane-1/homepage-2
// "space" names? everlane-qa everlane-prod etc
// TODO: if in preview (maybe any iframe?) always no cache
// HACK: have a global option for setting dev
var host =
options.dev || this.getLocation().host === 'localhost:4205'
? 'http://localhost:5000'
: 'https://builder.io';
return new Observable(function(observer) {
// TODO: will there by use cases of multiple separate requests for same model type?
_this.observersByModelType[modelName] = observer;
// FIXME: have a "core" SDK that doesn't implement http,
// so SDKs like angular can use it's own http method
var promise = _this
.requestUrl(
host +
'/api/v1/content/' +
_this.apiKey +
'/' +
modelName +
(queryParams && hasParams ? '?' + queryString.stringify(queryParams) : '')
)
.then(function(data) {
return data[modelName];
})
.then(function(list) {
return sortBy(list, function(item) {
return item.priority;
});
})
.then(function(result) {
if (modelName === _this.blockContentLoading) {
return;
}
var testModifiedResults = _this.processResultsForTests(result);
observer.next(testModifiedResults);
})
.catch(function(err) {
observer.error(err);
});
});
};
Builder.eventCapturer = isIframe ? new EventCapturer().listen() : null;
Builder.components = [];
Builder.incrementalDomLoaded = false;
Builder._editingPage = false;
Builder.isIframe = isIframe;
Builder.isBrowser = isBrowser;
Builder.overrideUserAttributes = {};
return Builder;
})();
export { Builder };