UNPKG

@hot-updater/plugin-core

Version:

React Native OTA solution for self-hosted

312 lines (310 loc) 16.5 kB
const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs'); const require_calculatePagination = require('./calculatePagination.cjs'); const require_createDatabasePlugin = require('./createDatabasePlugin.cjs'); let es_toolkit = require("es-toolkit"); es_toolkit = require_rolldown_runtime.__toESM(es_toolkit); let semver = require("semver"); semver = require_rolldown_runtime.__toESM(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.default.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.default.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 (0, es_toolkit.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 require_createDatabasePlugin.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: require_calculatePagination.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 exports.createBlobDatabasePlugin = createBlobDatabasePlugin;