src/BackendConnector.js
import { pushPath, isString } from './utils.js';
import baseLogger from './logger.js';
import EventEmitter from './EventEmitter.js';
const removePending = (q, name) => {
if (q.pending[name] !== undefined) {
delete q.pending[name];
q.pendingCount--;
}
};
class Connector extends EventEmitter {
constructor(backend, store, services, options = {}) {
super();
this.backend = backend;
this.store = store;
this.services = services;
this.languageUtils = services.languageUtils;
this.options = options;
this.logger = baseLogger.create('backendConnector');
this.waitingReads = [];
this.maxParallelReads = options.maxParallelReads || 10;
this.readingCalls = 0;
this.maxRetries = options.maxRetries >= 0 ? options.maxRetries : 5;
this.retryTimeout = options.retryTimeout >= 1 ? options.retryTimeout : 350;
this.state = {};
this.queue = [];
this.backend?.init?.(services, options.backend, options);
}
queueLoad(languages, namespaces, options, callback) {
// find what needs to be loaded
const toLoad = {};
const pending = {};
const toLoadLanguages = {};
const toLoadNamespaces = {};
languages.forEach((lng) => {
let hasAllNamespaces = true;
namespaces.forEach((ns) => {
const name = `${lng}|${ns}`;
if (!options.reload && this.store.hasResourceBundle(lng, ns)) {
this.state[name] = 2; // loaded
} else if (this.state[name] < 0) {
// nothing to do for err
} else if (this.state[name] === 1) {
if (pending[name] === undefined) pending[name] = true;
} else {
this.state[name] = 1; // pending
hasAllNamespaces = false;
if (pending[name] === undefined) pending[name] = true;
if (toLoad[name] === undefined) toLoad[name] = true;
if (toLoadNamespaces[ns] === undefined) toLoadNamespaces[ns] = true;
}
});
if (!hasAllNamespaces) toLoadLanguages[lng] = true;
});
if (Object.keys(toLoad).length || Object.keys(pending).length) {
this.queue.push({
pending,
pendingCount: Object.keys(pending).length,
loaded: {},
errors: [],
callback,
});
}
return {
toLoad: Object.keys(toLoad),
pending: Object.keys(pending),
toLoadLanguages: Object.keys(toLoadLanguages),
toLoadNamespaces: Object.keys(toLoadNamespaces),
};
}
loaded(name, err, data) {
const s = name.split('|');
const lng = s[0];
const ns = s[1];
if (err) this.emit('failedLoading', lng, ns, err);
if (!err && data) {
this.store.addResourceBundle(lng, ns, data, undefined, undefined, { skipCopy: true });
}
// set loaded
this.state[name] = err ? -1 : 2;
if (err && data) this.state[name] = 0;
// consolidated loading done in this run - only emit once for a loaded namespace
const loaded = {};
// callback if ready
this.queue.forEach((q) => {
pushPath(q.loaded, [lng], ns);
removePending(q, name);
if (err) q.errors.push(err);
if (q.pendingCount === 0 && !q.done) {
// only do once per loaded -> this.emit('loaded', q.loaded);
Object.keys(q.loaded).forEach((l) => {
if (!loaded[l]) loaded[l] = {};
const loadedKeys = q.loaded[l];
if (loadedKeys.length) {
loadedKeys.forEach((n) => {
if (loaded[l][n] === undefined) loaded[l][n] = true;
});
}
});
/* eslint no-param-reassign: 0 */
q.done = true;
if (q.errors.length) {
q.callback(q.errors);
} else {
q.callback();
}
}
});
// emit consolidated loaded event
this.emit('loaded', loaded);
// remove done load requests
this.queue = this.queue.filter((q) => !q.done);
}
read(lng, ns, fcName, tried = 0, wait = this.retryTimeout, callback) {
if (!lng.length) return callback(null, {}); // noting to load
// Limit parallelism of calls to backend
// This is needed to prevent trying to open thousands of
// sockets or file descriptors, which can cause failures
// and actually make the entire process take longer.
if (this.readingCalls >= this.maxParallelReads) {
this.waitingReads.push({ lng, ns, fcName, tried, wait, callback });
return;
}
this.readingCalls++;
const resolver = (err, data) => {
this.readingCalls--;
if (this.waitingReads.length > 0) {
const next = this.waitingReads.shift();
this.read(next.lng, next.ns, next.fcName, next.tried, next.wait, next.callback);
}
if (err && data /* = retryFlag */ && tried < this.maxRetries) {
setTimeout(() => {
this.read.call(this, lng, ns, fcName, tried + 1, wait * 2, callback);
}, wait);
return;
}
callback(err, data);
};
const fc = this.backend[fcName].bind(this.backend);
if (fc.length === 2) {
// no callback
try {
const r = fc(lng, ns);
if (r && typeof r.then === 'function') {
// promise
r.then((data) => resolver(null, data)).catch(resolver);
} else {
// sync
resolver(null, r);
}
} catch (err) {
resolver(err);
}
return;
}
// normal with callback
return fc(lng, ns, resolver);
}
/* eslint consistent-return: 0 */
prepareLoading(languages, namespaces, options = {}, callback) {
if (!this.backend) {
this.logger.warn('No backend was added via i18next.use. Will not load resources.');
return callback && callback();
}
if (isString(languages)) languages = this.languageUtils.toResolveHierarchy(languages);
if (isString(namespaces)) namespaces = [namespaces];
const toLoad = this.queueLoad(languages, namespaces, options, callback);
if (!toLoad.toLoad.length) {
if (!toLoad.pending.length) callback(); // nothing to load and no pendings...callback now
return null; // pendings will trigger callback
}
toLoad.toLoad.forEach((name) => {
this.loadOne(name);
});
}
load(languages, namespaces, callback) {
this.prepareLoading(languages, namespaces, {}, callback);
}
reload(languages, namespaces, callback) {
this.prepareLoading(languages, namespaces, { reload: true }, callback);
}
loadOne(name, prefix = '') {
const s = name.split('|');
const lng = s[0];
const ns = s[1];
this.read(lng, ns, 'read', undefined, undefined, (err, data) => {
if (err) this.logger.warn(`${prefix}loading namespace ${ns} for language ${lng} failed`, err);
if (!err && data)
this.logger.log(`${prefix}loaded namespace ${ns} for language ${lng}`, data);
this.loaded(name, err, data);
});
}
saveMissing(languages, namespace, key, fallbackValue, isUpdate, options = {}, clb = () => {}) {
if (
this.services?.utils?.hasLoadedNamespace &&
!this.services?.utils?.hasLoadedNamespace(namespace)
) {
this.logger.warn(
`did not save key "${key}" as the namespace "${namespace}" was not yet loaded`,
'This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!',
);
return;
}
// ignore non valid keys
if (key === undefined || key === null || key === '') return;
if (this.backend?.create) {
const opts = {
...options,
isUpdate,
};
const fc = this.backend.create.bind(this.backend);
if (fc.length < 6) {
// no callback
try {
let r;
if (fc.length === 5) {
// future callback-less api for i18next-locize-backend
r = fc(languages, namespace, key, fallbackValue, opts);
} else {
r = fc(languages, namespace, key, fallbackValue);
}
if (r && typeof r.then === 'function') {
// promise
r.then((data) => clb(null, data)).catch(clb);
} else {
// sync
clb(null, r);
}
} catch (err) {
clb(err);
}
} else {
// normal with callback
fc(languages, namespace, key, fallbackValue, clb /* unused callback */, opts);
}
}
// write to store to avoid resending
if (!languages || !languages[0]) return;
this.store.addResource(languages[0], namespace, key, fallbackValue);
}
}
export default Connector;