@localazy/cdn-client
Version:
Node.js module that allows you to easily interact with the Localazy CDN.
643 lines (642 loc) • 17.6 kB
JavaScript
class Api {
context;
constructor(context) {
this.context = context;
}
async fetchLocale(options) {
if (this.context.cache.has(options)) {
return new Promise((resolve) => {
resolve(this.context.cache.get(options));
});
}
return this.context.client.get(options.metafileLocale.uri);
}
async fetchMetafile() {
return await this.context.client.get(this.context.metafile.params.jsonPath);
}
}
class MemoryCacheAdapter {
map;
constructor() {
this.map = /* @__PURE__ */ new Map();
}
get(key) {
return this.map.get(key);
}
has(key) {
return this.map.has(key);
}
set(key, value) {
this.map.set(key, value);
}
flush() {
this.map = /* @__PURE__ */ new Map();
}
}
const isString = (value) => typeof value === "string";
const isUndefined = (value) => typeof value === "undefined";
const isArray = (value) => Array.isArray(value);
const isPlainObject = (value) => Object.prototype.toString.call(value) === "[object Object]";
const uniq = (array) => [...new Set(array)];
const uniqBy = (array, predicate) => {
const seen = {};
return array.filter((item) => {
const key = predicate(item);
if (!Object.hasOwn(seen, key)) {
seen[key] = true;
return true;
}
return false;
});
};
class LocalesCache {
context;
cacheAdapter;
constructor(context) {
this.context = context;
this.cacheAdapter = new MemoryCacheAdapter();
}
setIfMissed(options) {
const { metafileFile, metafileLocale, data } = options;
const key = this.keyFromMetafile({
metafileFile,
metafileLocale
});
if (!this.cacheAdapter.has(key)) {
this.cacheAdapter.set(key, data);
}
}
has(options) {
const key = this.keyFromMetafile(options);
return this.cacheAdapter.has(key);
}
get(options) {
const key = this.keyFromMetafile(options);
return this.cacheAdapter.get(key);
}
flush() {
this.cacheAdapter.flush();
}
keyFromMetafile(options) {
const { metafileFile, metafileLocale } = options;
const productFlavors = [...uniq(metafileFile.productFlavors)].sort().join("-");
const indices = [
this.context.metafile.params.cdnId,
metafileFile.id,
metafileFile.file,
metafileFile.path,
metafileFile.library,
metafileFile.module,
metafileFile.buildType,
productFlavors,
metafileLocale.locale,
metafileLocale.timestamp.toString()
];
return indices.filter((key) => key !== "").join("-");
}
}
class ResponseFactory {
context;
constructor(context) {
this.context = context;
}
createCdnResponse(options) {
const {
requests,
responses,
hasSingleFileResponse,
hasSingleLocaleResponse
} = options;
if (responses.length === 0 || typeof responses[0] === "undefined") {
return {};
}
this.cacheResponses(requests, responses);
return hasSingleFileResponse && hasSingleLocaleResponse ? responses[0] : ResponseFactory.transformResponses(options);
}
cacheResponses(requests, responses) {
responses.forEach((response, index) => {
if (typeof requests[index] !== "undefined") {
const { metafileFile, metafileLocale } = requests[index];
if (metafileFile && metafileLocale) {
this.context.cache.setIfMissed({ metafileFile, metafileLocale, data: response });
}
}
});
}
static transformResponses(options) {
const { requests, responses, hasSingleFileResponse } = options;
return responses.reduce((acc, cur, index) => {
if (typeof requests[index] !== "undefined") {
const { metafileFile, metafileLocale } = requests[index];
if (metafileFile && metafileLocale) {
if (hasSingleFileResponse) {
acc[metafileLocale.locale] = cur;
} else {
if (!acc[metafileFile.id]) {
acc[metafileFile.id] = {};
}
acc[metafileFile.id][metafileLocale.locale] = cur;
}
}
}
return acc;
}, {});
}
}
class Context {
metafile;
cdn;
client;
api;
cache;
responseFactory;
constructor(options) {
this.metafile = options.metafileContext;
this.cdn = options.cdn;
this.client = options.client;
this.api = new Api(this);
this.cache = new LocalesCache(this);
this.responseFactory = new ResponseFactory(this);
}
}
class MetafileFile {
id;
file;
path;
library;
module;
buildType;
timestamp;
productFlavors;
locales;
baseUrl;
constructor(options) {
this.id = options.id;
this.file = options.file;
this.path = options.path;
this.library = options.library;
this.module = options.module;
this.buildType = options.buildType;
this.timestamp = options.timestamp;
this.productFlavors = options.productFlavors;
this.locales = options.locales;
this.baseUrl = options.baseUrl;
}
toCdnFile() {
return {
id: this.id,
file: this.file,
path: this.path,
library: this.library,
module: this.module,
buildType: this.buildType,
productFlavors: this.productFlavors,
locales: this.locales.map(
(locale) => ({
locale: locale.locale,
isBaseLocale: locale.isBaseLocale,
uri: `${this.baseUrl}${locale.uri}`
})
)
};
}
}
class MetafileLocale {
language;
region;
script;
isRtl;
name;
localizedName;
uri;
timestamp;
baseLocale;
constructor(options, baseLocale) {
this.language = options.language;
this.region = options.region;
this.script = options.script;
this.isRtl = options.isRtl;
this.name = options.name;
this.localizedName = options.localizedName;
this.uri = options.uri;
this.timestamp = options.timestamp;
this.baseLocale = baseLocale;
}
get locale() {
if (this.language && this.region && this.script) {
return `${this.language}_${this.region}#${this.script}`;
}
if (this.language && this.script) {
return `${this.language}#${this.script}`;
}
if (this.language && this.region) {
return `${this.language}_${this.region}`;
}
return this.language;
}
get isBaseLocale() {
return this.locale === this.baseLocale;
}
toCdnLocale() {
return {
locale: this.locale,
isBaseLocale: this.isBaseLocale,
language: this.language,
region: this.region,
script: this.script,
isRtl: this.isRtl,
name: this.name,
localizedName: this.localizedName
};
}
}
class MetafileData {
projectUrl;
baseLocale;
locales;
timestamp;
files;
filesMap;
constructor(options, params) {
this.projectUrl = options.projectUrl;
this.timestamp = options.timestamp;
this.files = MetafileData.filesFactory(options.files, options.baseLocale, params);
this.filesMap = MetafileData.filesMapFactory(this.files);
this.locales = MetafileData.localesFactory(this.files);
this.baseLocale = this.locales.find((locale) => locale.isBaseLocale);
}
static createEmpty(params) {
return new MetafileData(
{
projectUrl: "",
baseLocale: "",
timestamp: 0,
files: {}
},
params
);
}
static filesFactory(files, baseLocale, params) {
return Object.keys(files).reduce((acc, cur) => {
if (typeof files[cur] !== "undefined") {
const locales = files[cur].locales.map(
(locale) => new MetafileLocale(locale, baseLocale)
);
acc.push(
new MetafileFile({
...files[cur],
id: cur,
locales,
baseUrl: params.baseUrl
})
);
}
return acc;
}, []);
}
static filesMapFactory(files) {
return files.reduce((acc, cur) => {
acc[cur.id] = cur;
return acc;
}, {});
}
static localesFactory(files) {
const locales = files.reduce((acc, cur) => {
acc.push(...cur.locales.map((locale) => locale.toCdnLocale()));
return acc;
}, []);
return uniqBy(locales, (cdnLocale) => cdnLocale.locale);
}
}
class MetafileParams {
options;
constructor(metafileUrl) {
this.options = MetafileParams.parseMetafileUrl(metafileUrl);
}
get url() {
return this.options.url;
}
get baseUrl() {
return this.options.baseUrl;
}
get cdnId() {
return this.options.cdnId;
}
get jsonPath() {
return this.options.jsonPath;
}
static parseMetafileUrl(metafileUrl) {
let url;
try {
url = new URL(metafileUrl);
} catch {
throw new Error('Invalid param: "options.metafile" cannot be parsed as url.');
}
const matches = /^\/(.*?)\/(.*?)\.(v2.json|js|ts)$/.exec(url.pathname);
if (matches === null || matches.length !== 4 || typeof matches[1] === "undefined" || typeof matches[2] === "undefined") {
throw new Error('Invalid param: "options.metafile" contains invalid metafile url.');
}
const cdnId = matches[1];
const tagId = matches[2];
return {
url: metafileUrl,
baseUrl: url.origin,
cdnId,
jsonPath: `/${cdnId}/${tagId}.v2.json`
};
}
}
class MetafileContext {
params;
parsedData;
data;
constructor(options) {
this.params = new MetafileParams(options.metafile);
this.parsedData = null;
this.data = MetafileData.createEmpty(this.params);
}
setMetafile(metafile) {
this.parsedData = metafile;
this.data = new MetafileData(metafile, this.params);
}
}
class FetchHttpAdapter {
baseUrl;
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async get(url) {
const response = await fetch(`${this.baseUrl}${url}`);
const contentType = response.headers.get("content-type");
const isJson = contentType === "application/json5" || contentType === "application/json";
if (response.status >= 400) {
throw new Error(`Request failed with status code ${response.status.toString()}`);
}
let result;
if (isJson) {
result = await response.json();
} else {
result = await response.text();
}
return result;
}
}
class CdnBase {
context;
constructor(context) {
this.context = context;
}
}
class CdnCache extends CdnBase {
flush = () => {
this.context.cache.flush();
};
}
class CdnMetafile extends CdnBase {
get projectUrl() {
return this.context.metafile.data.projectUrl;
}
get baseLocale() {
return this.context.metafile.data.baseLocale;
}
get url() {
return this.context.metafile.params.url;
}
get files() {
return this.context.metafile.data.files.map((file) => file.toCdnFile());
}
locales = (options) => {
const { excludeBaseLocale } = options || {};
const { locales } = this.context.metafile.data;
return excludeBaseLocale ? locales.filter((cdnLocale) => !cdnLocale.isBaseLocale) : locales;
};
refresh = async () => {
const response = await this.context.api.fetchMetafile();
this.context.metafile.setMetafile(response);
};
switch = async (options) => {
this.context.metafile.params = new MetafileParams(options.metafile);
await this.refresh();
};
}
class LocalesMap {
data;
context;
constructor(options) {
this.context = options.context;
this.data = options.data || {};
}
}
class Request {
files;
localesMap;
hasSingleFileResponse;
hasSingleLocaleResponse;
context;
constructor(context) {
this.files = [];
this.localesMap = new LocalesMap({ context });
this.hasSingleFileResponse = false;
this.hasSingleLocaleResponse = false;
this.context = context;
}
async execute() {
const payload = this.getPromises();
const promises = payload.map(
(item) => item[0]
);
const requests = payload.map(
(item) => item[1]
);
const responses = await Promise.all(promises);
return this.context.responseFactory.createCdnResponse({
requests,
responses,
localesMap: this.localesMap,
hasSingleFileResponse: this.hasSingleFileResponse,
hasSingleLocaleResponse: this.hasSingleLocaleResponse
});
}
getPromises() {
return this.files.reduce(
(acc, cur) => {
if (typeof this.localesMap.data?.[cur.id] !== "undefined") {
acc.push(
...this.localesMap.data[cur.id].map(
(metafileLocale) => {
const request = {
metafileFile: cur,
metafileLocale
};
return [this.context.api.fetchLocale(request), request];
}
)
);
}
return acc;
},
[]
);
}
}
class RequestBuilder {
context;
request;
constructor(context) {
this.context = context;
this.request = new Request(this.context);
}
addFiles(files) {
if (!(isPlainObject(files) || isString(files) || isUndefined(files) || isArray(files))) {
throw new Error('Invalid param: "request.files" must be object, array, string or undefined.');
}
if (isArray(files)) {
files.forEach((i) => {
if (!(isPlainObject(i) || isString(i))) {
throw new Error('Invalid param: array "request.files" must contain objects or strings.');
}
});
}
if (isString(files)) {
this.request.hasSingleFileResponse = true;
const file = this.context.metafile.data.files.find(
(i) => i.id === files
);
if (!(file instanceof MetafileFile)) {
throw new Error(`File not found: "${files}".`);
}
this.request.files = [file];
} else if (isUndefined(files)) {
this.request.files = [...this.context.metafile.data.files];
} else if (isArray(files)) {
this.request.files = files.map((file) => {
let metafileFile;
if (isString(file)) {
const foundFile = this.context.metafile.data.files.find(
(i) => i.id === file
);
if (isUndefined(foundFile)) {
throw new Error(`File not found: "${file}".`);
}
metafileFile = foundFile;
} else {
const foundFile = this.context.metafile.data.files.find(
(i) => i.id === file.id
);
if (isUndefined(foundFile)) {
throw new Error(`File not found: "${file.id}".`);
}
metafileFile = foundFile;
}
return metafileFile;
});
} else if (isPlainObject(files)) {
this.request.hasSingleFileResponse = true;
const foundFile = this.context.metafile.data.files.find(
(i) => i.id === files.id
);
if (isUndefined(foundFile)) {
throw new Error(`File not found: "${files.id}".`);
}
this.request.files = [foundFile];
}
return this;
}
addLocales(locales, excludeBaseLocale) {
if (!(isString(locales) || isUndefined(locales) || isArray(locales))) {
throw new Error('Invalid param: "request.locales" must be array, string or undefined.');
}
if (isArray(locales)) {
locales.forEach((i) => {
if (!isString(i)) {
throw new Error('Invalid param: array "request.locales" must contain strings.');
}
});
}
if (isString(locales)) {
this.request.hasSingleLocaleResponse = true;
this.request.files.reduce((acc, cur) => {
acc.data[cur.id] = cur.locales.filter(
(metafileLocale) => metafileLocale.locale === locales
);
return acc;
}, this.request.localesMap);
} else if (isUndefined(locales)) {
this.request.files.reduce((acc, cur) => {
acc.data[cur.id] = excludeBaseLocale ? cur.locales.filter(
(metafileLocale) => !metafileLocale.isBaseLocale
) : cur.locales;
return acc;
}, this.request.localesMap);
} else if (isArray(locales)) {
this.request.files.reduce((acc, cur) => {
acc.data[cur.id] = cur.locales.filter(
(metafileLocale) => locales.includes(metafileLocale.locale)
);
return acc;
}, this.request.localesMap);
}
return this;
}
getCdnRequest() {
const result = this.request;
this.request = new Request(this.context);
return result;
}
}
class CdnClient {
metafile;
cache;
context;
static version = "1.5.13";
constructor(options) {
const metafileContext = new MetafileContext(options);
const client = new FetchHttpAdapter(metafileContext.params.baseUrl);
this.context = new Context({ metafileContext, cdn: this, client });
this.metafile = new CdnMetafile(this.context);
this.cache = new CdnCache(this.context);
}
fetch = async (options) => {
const { files, locales, excludeBaseLocale } = options || {};
const requestBuilder = new RequestBuilder(this.context).addFiles(files).addLocales(locales, excludeBaseLocale);
return requestBuilder.getCdnRequest().execute();
};
static async create(options) {
if (!options) {
throw new Error('Invalid param: missing required "options" parameter.');
}
if (!isString(options.metafile)) {
throw new Error('Invalid param: "options.metafile" must be string.');
}
const cdn = new CdnClient(options);
await cdn.metafile.refresh();
return cdn;
}
}
export {
Api,
CdnBase,
CdnCache,
CdnClient,
CdnMetafile,
Context,
FetchHttpAdapter,
LocalesCache,
LocalesMap,
MemoryCacheAdapter,
MetafileContext,
MetafileData,
MetafileFile,
MetafileLocale,
MetafileParams,
Request,
RequestBuilder,
ResponseFactory,
isArray,
isPlainObject,
isString,
isUndefined,
uniq,
uniqBy
};
//# sourceMappingURL=localazy-cdn-client.js.map