@hot-updater/plugin-core
Version:
React Native OTA solution for self-hosted
309 lines (307 loc) • 16.1 kB
JavaScript
import { calculatePagination } from "./calculatePagination.js";
import { createDatabasePlugin } from "./createDatabasePlugin.js";
import { orderBy } from "es-toolkit";
import semver from "semver";
//#region src/createBlobDatabasePlugin.ts
function removeBundleInternalKeys(bundle) {
const { _updateJsonKey, _oldUpdateJsonKey,...pureBundle } = bundle;
return pureBundle;
}
function normalizeTargetAppVersion(version) {
if (!version) return null;
let normalized = version.replace(/\s+/g, " ").trim();
normalized = normalized.replace(/([><=~^]+)\s+(\d)/g, (_match, operator, digit) => `${operator}${digit}`);
return normalized;
}
function isExactVersion(version) {
if (!version) return false;
const normalized = normalizeTargetAppVersion(version);
if (!normalized) return false;
return semver.valid(normalized) !== null;
}
/**
* Get all normalized semver versions for a version string.
* This handles the case where clients may request with different normalized forms.
*
* Examples:
* - "1.0.0" generates ["1.0.0", "1.0", "1"]
* - "2.1.0" generates ["2.1.0", "2.1"]
* - "1.2.3" generates ["1.2.3"]
*/
function getSemverNormalizedVersions(version) {
const normalized = normalizeTargetAppVersion(version) || version;
const coerced = semver.coerce(normalized);
if (!coerced) return [normalized];
const versions = /* @__PURE__ */ new Set();
versions.add(coerced.version);
if (coerced.patch === 0) versions.add(`${coerced.major}.${coerced.minor}`);
if (coerced.minor === 0 && coerced.patch === 0) versions.add(`${coerced.major}`);
return Array.from(versions);
}
/**
* Creates a blob storage-based database plugin with lazy initialization.
*
* @param name - The name of the database plugin
* @param factory - Function that creates blob storage operations from config
* @returns A double-curried function that lazily initializes the database plugin
*/
const createBlobDatabasePlugin = ({ name, factory }) => {
return (config, hooks) => {
const { listObjects, loadObject, uploadObject, deleteObject, invalidatePaths, apiBasePath } = factory(config);
const bundlesMap = /* @__PURE__ */ new Map();
const pendingBundlesMap = /* @__PURE__ */ new Map();
const PLATFORMS = ["ios", "android"];
async function reloadBundles() {
bundlesMap.clear();
const platformPromises = PLATFORMS.map(async (platform) => {
const filePromises = (await listUpdateJsonKeys(platform)).map(async (key) => {
return (await loadObject(key) ?? []).map((bundle) => ({
...bundle,
_updateJsonKey: key
}));
});
return (await Promise.all(filePromises)).flat();
});
const allBundles = (await Promise.all(platformPromises)).flat();
for (const bundle of allBundles) bundlesMap.set(bundle.id, bundle);
for (const [id, bundle] of pendingBundlesMap.entries()) bundlesMap.set(id, bundle);
return orderBy(allBundles, [(v) => v.id], ["desc"]);
}
/**
* Updates target-app-versions.json for each channel on the given platform.
* Returns true if the file was updated, false if no changes were made.
*/
async function updateTargetVersionsForPlatform(platform) {
const pattern = /* @__PURE__ */ new RegExp(`^[^/]+/${platform}/[^/]+/update\\.json$`);
const keysByChannel = (await listObjects("")).filter((key) => pattern.test(key)).reduce((acc, key) => {
const channel = key.split("/")[0];
acc[channel] = acc[channel] || [];
acc[channel].push(key);
return acc;
}, {});
const updatedTargetFiles = /* @__PURE__ */ new Set();
for (const channel of Object.keys(keysByChannel)) {
const updateKeys = keysByChannel[channel];
const targetKey = `${channel}/${platform}/target-app-versions.json`;
const currentVersions = updateKeys.map((key) => key.split("/")[2]);
const oldTargetVersions = await loadObject(targetKey) ?? [];
const newTargetVersions = oldTargetVersions.filter((v) => currentVersions.includes(v));
for (const v of currentVersions) if (!newTargetVersions.includes(v)) newTargetVersions.push(v);
if (JSON.stringify(oldTargetVersions) !== JSON.stringify(newTargetVersions)) {
await uploadObject(targetKey, newTargetVersions);
updatedTargetFiles.add(`/${targetKey}`);
}
}
return updatedTargetFiles;
}
/**
* Lists update.json keys for a given platform.
*
* - If a channel is provided, only that channel's update.json files are listed.
* - Otherwise, all channels for the given platform are returned.
*/
async function listUpdateJsonKeys(platform, channel) {
const prefix = channel ? platform ? `${channel}/${platform}/` : `${channel}/` : "";
const pattern = channel ? platform ? /* @__PURE__ */ new RegExp(`^${channel}/${platform}/[^/]+/update\\.json$`) : /* @__PURE__ */ new RegExp(`^${channel}/[^/]+/[^/]+/update\\.json$`) : platform ? /* @__PURE__ */ new RegExp(`^[^/]+/${platform}/[^/]+/update\\.json$`) : /^[^/]+\/[^/]+\/[^/]+\/update\.json$/;
return listObjects(prefix).then((keys) => keys.filter((key) => pattern.test(key)));
}
return createDatabasePlugin({
name,
factory: () => ({
async getBundleById(bundleId) {
const pendingBundle = pendingBundlesMap.get(bundleId);
if (pendingBundle) return removeBundleInternalKeys(pendingBundle);
const bundle = bundlesMap.get(bundleId);
if (bundle) return removeBundleInternalKeys(bundle);
return (await reloadBundles()).find((bundle$1) => bundle$1.id === bundleId) ?? null;
},
async getBundles(options) {
let allBundles = await reloadBundles();
const { where, limit, offset } = options;
if (where) allBundles = allBundles.filter((bundle) => {
return Object.entries(where).every(([key, value]) => value === void 0 || value === null || bundle[key] === value);
});
const total = allBundles.length;
let paginatedData = allBundles.map(removeBundleInternalKeys);
if (offset > 0) paginatedData = paginatedData.slice(offset);
if (limit) paginatedData = paginatedData.slice(0, limit);
return {
data: paginatedData,
pagination: calculatePagination(total, {
limit,
offset
})
};
},
async getChannels() {
const total = (await reloadBundles()).length;
const result = await this.getBundles({
limit: total,
offset: 0
});
return [...new Set(result.data.map((bundle) => bundle.channel))];
},
async commitBundle({ changedSets }) {
if (changedSets.length === 0) return;
const changedBundlesByKey = {};
const removalsByKey = {};
const pathsToInvalidate = /* @__PURE__ */ new Set();
let isTargetAppVersionChanged = false;
for (const { operation, data } of changedSets) {
if (data.targetAppVersion !== void 0) isTargetAppVersionChanged = true;
if (operation === "insert") {
const target = normalizeTargetAppVersion(data.targetAppVersion) ?? data.fingerprintHash;
if (!target) throw new Error("target not found");
const key = `${data.channel}/${data.platform}/${target}/update.json`;
const bundleWithKey = {
...data,
_updateJsonKey: key
};
bundlesMap.set(data.id, bundleWithKey);
pendingBundlesMap.set(data.id, bundleWithKey);
changedBundlesByKey[key] = changedBundlesByKey[key] || [];
changedBundlesByKey[key].push(removeBundleInternalKeys(bundleWithKey));
pathsToInvalidate.add(`/${key}`);
if (data.fingerprintHash) pathsToInvalidate.add(`${apiBasePath}/fingerprint/${data.platform}/${data.fingerprintHash}/${data.channel}/*`);
else if (data.targetAppVersion) if (!isExactVersion(data.targetAppVersion)) pathsToInvalidate.add(`${apiBasePath}/app-version/${data.platform}/*`);
else {
const normalizedVersions = getSemverNormalizedVersions(data.targetAppVersion);
for (const version of normalizedVersions) pathsToInvalidate.add(`${apiBasePath}/app-version/${data.platform}/${version}/${data.channel}/*`);
}
continue;
}
if (operation === "delete") {
let bundle$1 = pendingBundlesMap.get(data.id);
if (!bundle$1) bundle$1 = bundlesMap.get(data.id);
if (!bundle$1) throw new Error("Bundle to delete not found");
bundlesMap.delete(data.id);
pendingBundlesMap.delete(data.id);
const key = bundle$1._updateJsonKey;
removalsByKey[key] = removalsByKey[key] || [];
removalsByKey[key].push(bundle$1.id);
pathsToInvalidate.add(`/${key}`);
if (bundle$1.fingerprintHash) pathsToInvalidate.add(`${apiBasePath}/fingerprint/${bundle$1.platform}/${bundle$1.fingerprintHash}/${bundle$1.channel}/*`);
else if (bundle$1.targetAppVersion) if (!isExactVersion(bundle$1.targetAppVersion)) pathsToInvalidate.add(`${apiBasePath}/app-version/${bundle$1.platform}/*`);
else {
const normalizedVersions = getSemverNormalizedVersions(bundle$1.targetAppVersion);
for (const version of normalizedVersions) pathsToInvalidate.add(`${apiBasePath}/app-version/${bundle$1.platform}/${version}/${bundle$1.channel}/*`);
}
continue;
}
let bundle = pendingBundlesMap.get(data.id);
if (!bundle) bundle = bundlesMap.get(data.id);
if (!bundle) throw new Error("targetBundleId not found");
if (operation === "update") {
const newChannel = data.channel !== void 0 ? data.channel : bundle.channel;
const newPlatform = data.platform !== void 0 ? data.platform : bundle.platform;
const target = data.fingerprintHash ?? bundle.fingerprintHash ?? normalizeTargetAppVersion(data.targetAppVersion) ?? normalizeTargetAppVersion(bundle.targetAppVersion);
if (!target) throw new Error("target not found");
const newKey = `${newChannel}/${newPlatform}/${target}/update.json`;
if (newKey !== bundle._updateJsonKey) {
const oldKey = bundle._updateJsonKey;
removalsByKey[oldKey] = removalsByKey[oldKey] || [];
removalsByKey[oldKey].push(bundle.id);
changedBundlesByKey[newKey] = changedBundlesByKey[newKey] || [];
const updatedBundle$1 = {
...bundle,
...data
};
updatedBundle$1._oldUpdateJsonKey = oldKey;
updatedBundle$1._updateJsonKey = newKey;
bundlesMap.set(data.id, updatedBundle$1);
pendingBundlesMap.set(data.id, updatedBundle$1);
changedBundlesByKey[newKey].push(removeBundleInternalKeys(updatedBundle$1));
pathsToInvalidate.add(`/${oldKey}`);
pathsToInvalidate.add(`/${newKey}`);
const oldChannel = bundle.channel;
const newChannel$1 = data.channel;
if (oldChannel !== newChannel$1) {
pathsToInvalidate.add(`/${oldChannel}/${bundle.platform}/target-app-versions.json`);
pathsToInvalidate.add(`/${newChannel$1}/${bundle.platform}/target-app-versions.json`);
if (bundle.fingerprintHash) {
pathsToInvalidate.add(`${apiBasePath}/fingerprint/${bundle.platform}/${bundle.fingerprintHash}/${oldChannel}/*`);
pathsToInvalidate.add(`${apiBasePath}/fingerprint/${bundle.platform}/${bundle.fingerprintHash}/${newChannel$1}/*`);
}
if (bundle.targetAppVersion) if (!isExactVersion(bundle.targetAppVersion)) pathsToInvalidate.add(`${apiBasePath}/app-version/${bundle.platform}/*`);
else {
const normalizedVersions = getSemverNormalizedVersions(bundle.targetAppVersion);
for (const version of normalizedVersions) {
pathsToInvalidate.add(`${apiBasePath}/app-version/${bundle.platform}/${version}/${oldChannel}/*`);
pathsToInvalidate.add(`${apiBasePath}/app-version/${bundle.platform}/${version}/${newChannel$1}/*`);
}
}
}
if (updatedBundle$1.fingerprintHash) pathsToInvalidate.add(`${apiBasePath}/fingerprint/${bundle.platform}/${updatedBundle$1.fingerprintHash}/${updatedBundle$1.channel}/*`);
else if (updatedBundle$1.targetAppVersion) {
if (!isExactVersion(updatedBundle$1.targetAppVersion)) pathsToInvalidate.add(`${apiBasePath}/app-version/${updatedBundle$1.platform}/*`);
else {
const normalizedVersions = getSemverNormalizedVersions(updatedBundle$1.targetAppVersion);
for (const version of normalizedVersions) pathsToInvalidate.add(`${apiBasePath}/app-version/${updatedBundle$1.platform}/${version}/${updatedBundle$1.channel}/*`);
}
if (bundle.targetAppVersion && bundle.targetAppVersion !== updatedBundle$1.targetAppVersion) if (!isExactVersion(bundle.targetAppVersion)) pathsToInvalidate.add(`${apiBasePath}/app-version/${bundle.platform}/*`);
else {
const oldNormalizedVersions = getSemverNormalizedVersions(bundle.targetAppVersion);
for (const version of oldNormalizedVersions) pathsToInvalidate.add(`${apiBasePath}/app-version/${bundle.platform}/${version}/${bundle.channel}/*`);
}
}
continue;
}
const currentKey = bundle._updateJsonKey;
const updatedBundle = {
...bundle,
...data
};
bundlesMap.set(data.id, updatedBundle);
pendingBundlesMap.set(data.id, updatedBundle);
changedBundlesByKey[currentKey] = changedBundlesByKey[currentKey] || [];
changedBundlesByKey[currentKey].push(removeBundleInternalKeys(updatedBundle));
pathsToInvalidate.add(`/${currentKey}`);
if (updatedBundle.fingerprintHash) pathsToInvalidate.add(`${apiBasePath}/fingerprint/${updatedBundle.platform}/${updatedBundle.fingerprintHash}/${updatedBundle.channel}/*`);
else if (updatedBundle.targetAppVersion) {
if (!isExactVersion(updatedBundle.targetAppVersion)) pathsToInvalidate.add(`${apiBasePath}/app-version/${updatedBundle.platform}/*`);
else {
const normalizedVersions = getSemverNormalizedVersions(updatedBundle.targetAppVersion);
for (const version of normalizedVersions) pathsToInvalidate.add(`${apiBasePath}/app-version/${updatedBundle.platform}/${version}/${updatedBundle.channel}/*`);
}
if (bundle.targetAppVersion && bundle.targetAppVersion !== updatedBundle.targetAppVersion) if (!isExactVersion(bundle.targetAppVersion)) pathsToInvalidate.add(`${apiBasePath}/app-version/${bundle.platform}/*`);
else {
const oldNormalizedVersions = getSemverNormalizedVersions(bundle.targetAppVersion);
for (const version of oldNormalizedVersions) pathsToInvalidate.add(`${apiBasePath}/app-version/${bundle.platform}/${version}/${bundle.channel}/*`);
}
}
}
}
for (const oldKey of Object.keys(removalsByKey)) await (async () => {
const updatedBundles = (await loadObject(oldKey) ?? []).filter((b) => !removalsByKey[oldKey].includes(b.id));
updatedBundles.sort((a, b) => b.id.localeCompare(a.id));
if (updatedBundles.length === 0) await deleteObject(oldKey);
else await uploadObject(oldKey, updatedBundles);
})();
for (const key of Object.keys(changedBundlesByKey)) await (async () => {
const currentBundles = await loadObject(key) ?? [];
const pureBundles = changedBundlesByKey[key].map((bundle) => bundle);
for (const changedBundle of pureBundles) {
const index = currentBundles.findIndex((b) => b.id === changedBundle.id);
if (index >= 0) currentBundles[index] = changedBundle;
else currentBundles.push(changedBundle);
}
currentBundles.sort((a, b) => b.id.localeCompare(a.id));
await uploadObject(key, currentBundles);
})();
const updatedTargetFilePaths = /* @__PURE__ */ new Set();
if (isTargetAppVersionChanged) for (const platform of PLATFORMS) {
const updatedPaths = await updateTargetVersionsForPlatform(platform);
for (const path of updatedPaths) updatedTargetFilePaths.add(path);
}
for (const path of updatedTargetFilePaths) pathsToInvalidate.add(path);
const encondedPaths = /* @__PURE__ */ new Set();
for (const path of pathsToInvalidate) encondedPaths.add(encodeURI(path));
await invalidatePaths(Array.from(encondedPaths));
pendingBundlesMap.clear();
}
})
})({}, hooks);
};
};
//#endregion
export { createBlobDatabasePlugin };