UNPKG

@budibase/server

Version:
290 lines (253 loc) • 8.13 kB
import { features, objectStore } from "@budibase/backend-core" import { sdk, utils } from "@budibase/shared-core" import { FeatureFlag } from "@budibase/types" import fs from "fs" import path, { join } from "path" import { ObjectStoreBuckets } from "../../constants" import env from "../../environment" import { resolve } from "../centralPath" import { TOP_LEVEL_PATH } from "./filesystem" export function devClientLibPath() { return require.resolve("@budibase/client") } /** * Client library paths in the object store: * Previously, the entire client library package was downloaded from NPM * as a tarball and extracted to the object store, even though only the manifest * was ever needed. Therefore we need to support old apps which may still have * the manifest at this location for the first update. * * The paths for the in-use version are: * {appId}/manifest.json * {appId}/budibase-client.js * {appId}/_chunks/... * {appId}/... (and any other app files) * * The paths for the backups are: * {appId}/.bak/manifest.json * {appId}/.bak/budibase-client.js * {appId}/.bak/_chunks/... * {appId}/.bak/... (complete folder backup) * * We don't rely on NPM at all any more, as when updating to the latest version * we pull both the manifest and client bundle from the server's dependencies * in the local file system. */ /** * Backs up the current client library version by copying the entire app folder * to a backup location in the object store. Only the one previous version is * stored as a backup, which can be reverted to. * @param appId The app ID to backup * @returns {Promise<void>} */ export async function backupClientLibrary(appId: string) { appId = sdk.applications.getProdAppID(appId) // First, remove any existing backup folder try { await objectStore.deleteFolder(ObjectStoreBuckets.APPS, `${appId}/.bak`) } catch (error) { // Ignore errors if backup doesn't exist } await forEachObject(appId, async fileKey => { if (fileKey.includes("/.bak/") || fileKey.endsWith(".bak")) { return } const tmpPath = await objectStore.retrieveToTmp( ObjectStoreBuckets.APPS, fileKey ) const backupKey = fileKey.replace(appId, `${appId}/.bak`) await objectStore.upload({ bucket: ObjectStoreBuckets.APPS, filename: backupKey, path: tmpPath, }) }) } /** * Uploads the latest version of the component manifest and the client library * to the object store, overwriting the existing version. * @param appId The app ID to update * @returns {Promise<void>} */ export async function updateClientLibrary(appId: string) { appId = sdk.applications.getProdAppID(appId) let manifest: string, client: string let dependencies = [] if (env.isDev()) { const clientPath = devClientLibPath() // Load the symlinked version in dev which is always the newest const distFolder = path.dirname(clientPath) manifest = join(path.dirname(distFolder), "manifest.json") client = clientPath const chunksDir = join(distFolder, "chunks") dependencies = fs .readdirSync(chunksDir) .filter(f => f.endsWith(".js")) .map(f => join(chunksDir, f)) } else { // Load the bundled version in prod manifest = resolve(TOP_LEVEL_PATH, "client", "manifest.json") client = resolve(TOP_LEVEL_PATH, "client", "budibase-client.js") const chunksDir = join(resolve(TOP_LEVEL_PATH, "client"), "chunks") dependencies = fs .readdirSync(chunksDir) .filter(f => f.endsWith(".js")) .map(f => join(chunksDir, f)) } // Upload latest manifest and client library const files = [ { filename: join(appId, "manifest.json"), stream: fs.createReadStream(manifest), }, { filename: join(appId, "budibase-client.js"), stream: fs.createReadStream(client), }, ...dependencies.map(dependency => ({ filename: join(appId, "chunks", path.basename(dependency)), stream: fs.createReadStream(dependency), })), ] const manifestSrc = fs.promises.readFile(manifest, "utf8") await Promise.all([ objectStore.streamUploadMany({ bucket: ObjectStoreBuckets.APPS, files, }), manifestSrc, ]) const uploadedFiles = files.map(file => file.filename) const filesToDelete: string[] = [] await utils.parallelForeach( objectStore.listAllObjects(objectStore.ObjectStoreBuckets.APPS, appId), async file => { const key = file.Key if (!key) { return } if (!key.startsWith(`${appId}/chunks/`)) { return } if (!uploadedFiles.includes(key)) { filesToDelete.push(key) } }, 5 ) if (filesToDelete.length) { await objectStore.deleteFiles( objectStore.ObjectStoreBuckets.APPS, filesToDelete ) } return JSON.parse(await manifestSrc) } /** * Reverts the version of the client library and manifest to the previously * used version for an app by restoring the entire backed up folder. * @param appId The app ID to revert * @returns {Promise<void>} */ export async function revertClientLibrary(appId: string) { appId = sdk.applications.getProdAppID(appId) let manifestContent let hasBackup = false const restoredFiles = new Set<string>() // First, restore all files from the backup folder await forEachObject(`${appId}/.bak`, async filePath => { hasBackup = true // Download the backup file to temp const tmpPath = await objectStore.retrieveToTmp( ObjectStoreBuckets.APPS, filePath ) // Restore to original location const restoreKey = filePath.replace(`${appId}/.bak`, appId) restoredFiles.add(restoreKey) // Read manifest content if this is the manifest file if (restoreKey.endsWith("manifest.json")) { manifestContent = await fs.promises.readFile(tmpPath, "utf8") } await objectStore.upload({ bucket: ObjectStoreBuckets.APPS, filename: restoreKey, path: tmpPath, }) }) // After successful restore, clean up any extra files that weren't in backup if (hasBackup) { await forEachObject(appId, async filePath => { if ( !filePath.includes("/.bak/") && !filePath.endsWith(".bak") && !restoredFiles.has(filePath) ) { await objectStore.deleteFile(ObjectStoreBuckets.APPS, filePath) } }) } // If no backup folder found, try to find old .bak files if (!hasBackup) { await forEachObject(appId, async filePath => { if (!filePath.endsWith(".bak")) { return } hasBackup = true // Restore .bak file to original location const restoreKey = filePath.replace(".bak", "") // For manifest file, we need to read the content to return it if (restoreKey.endsWith("manifest.json")) { const tmpPath = await objectStore.retrieveToTmp( ObjectStoreBuckets.APPS, filePath ) manifestContent = await fs.promises.readFile(tmpPath, "utf8") } // For all other files, use streaming const { stream } = await objectStore.getReadStream( ObjectStoreBuckets.APPS, filePath ) await objectStore.streamUpload({ bucket: ObjectStoreBuckets.APPS, filename: restoreKey, stream, }) }) } if (!hasBackup) { throw new Error(`No backup found for app ${appId}`) } if (!manifestContent) { throw new Error(`No manifest found in backup for app ${appId}`) } return JSON.parse(manifestContent) } const forEachObject = ( path: string, task: (fileKey: string) => Promise<void> ) => utils.parallelForeach( objectStore.listAllObjects(ObjectStoreBuckets.APPS, path), async file => { if (!file.Key) { throw new Error("file.Key must be defined") } await task(file.Key) }, 5 ) export async function shouldServeLocally() { if (env.isDev()) { if (await features.isEnabled(FeatureFlag.DEV_USE_CLIENT_FROM_STORAGE)) { return false } return true } if (env.isTest()) { return true } return false }