@embeddable.com/sdk-core
Version:
Core Embeddable SDK module responsible for web-components bundling and publishing.
349 lines (294 loc) • 9.42 kB
text/typescript
import * as fs from "node:fs/promises";
import * as fsSync from "node:fs";
import archiver from "archiver";
import axios from "axios";
import ora, { Ora } from "ora";
import { initLogger, logError } from "./logger";
import * as path from "path";
import provideConfig from "./provideConfig";
// @ts-ignore
import reportErrorToRollbar from "./rollbar.mjs";
import { findFiles } from "@embeddable.com/sdk-utils";
import { getToken } from "./login";
import { checkBuildSuccess, checkNodeVersion, getArgumentByKey } from "./utils";
import { selectWorkspace } from "./workspaceUtils";
import { ResolvedEmbeddableConfig } from "./defineConfig";
// grab cube files
export const CUBE_FILES = /^(.*)\.cube\.(ya?ml|js)$/;
export const CLIENT_CONTEXT_FILES = /^(.*)\.cc\.ya?ml$/;
export const SECURITY_CONTEXT_FILES = /^(.*)\.sc\.ya?ml$/;
export default async () => {
await initLogger("push");
const breadcrumbs: string[] = [];
let spinnerPushing;
try {
checkNodeVersion();
breadcrumbs.push("checkNodeVersion");
const isBuildSuccess = await checkBuildSuccess();
const config = await provideConfig();
const cubeVersion = getArgumentByKey(["--cube-version"]);
if (!isBuildSuccess && config.pushComponents) {
console.error(
"Build failed or not completed. Please run `embeddable:build` first.",
);
process.exit(1);
}
if (process.argv.includes("--api-key") || process.argv.includes("-k")) {
spinnerPushing = ora("Using API key...").start();
breadcrumbs.push("push by api key");
try {
await pushByApiKey(config, spinnerPushing, cubeVersion);
} catch (error: any) {
if (error.response?.data?.errorCode === "BUILDER-998") {
spinnerPushing.fail(
`Authentication failure. Server responded with: "${error.response?.data?.errorMessage}".
Ensure that your API key is valid for the region specified in the embeddable.config.ts|js file.
You are using the following region: ${config.region.replace("legacy-", "")} (${config.previewBaseUrl.replace("https://", "")} via ${config.pushBaseUrl})
Read more about deployment regions at https://docs.embeddable.com/deployment/deployment-regions`,
);
process.exit(1);
}
spinnerPushing.fail("Publishing failed");
console.log(error.response?.data || error);
process.exit(1);
}
publishedSectionFeedback(config, spinnerPushing);
spinnerPushing.succeed("Published using API key");
return;
}
breadcrumbs.push("push by standard login");
const token = await verify(config);
spinnerPushing = ora()
.start()
.info("No API Key provided. Standard login will be used.");
breadcrumbs.push("select workspace");
const { workspaceId, name: workspaceName } = await selectWorkspace(
ora,
config,
token,
);
const workspacePreviewUrl = `${config.previewBaseUrl}/workspace/${workspaceId}`;
const message = getArgumentByKey(["--message", "-m"]);
breadcrumbs.push("build archive");
await buildArchive(config);
spinnerPushing.info(
`Publishing to ${workspaceName} using ${workspacePreviewUrl}...`,
);
breadcrumbs.push("send build");
await sendBuild(config, { workspaceId, token, message, cubeVersion });
publishedSectionFeedback(config, spinnerPushing);
spinnerPushing.succeed(
`Published to ${workspaceName} using ${workspacePreviewUrl}`,
);
} catch (error: any) {
spinnerPushing?.fail("Publishing failed");
await logError({ command: "push", breadcrumbs, error });
await reportErrorToRollbar(error);
console.log(error.response?.data || error);
process.exit(1);
}
};
const publishedSectionFeedback = (
config: ResolvedEmbeddableConfig,
spinnerPushing: Ora,
) => {
config.pushModels && spinnerPushing.succeed("Models published");
config.pushComponents && spinnerPushing.succeed("Components published");
};
async function pushByApiKey(
config: ResolvedEmbeddableConfig,
spinner: any,
cubeVersion?: string,
) {
const apiKey = getArgumentByKey(["--api-key", "-k"]);
if (!apiKey) {
spinner.fail("No API key provided");
process.exit(1);
}
const email = getArgumentByKey(["--email", "-e"]);
if (!email || !/\S+@\S+\.\S+/.test(email)) {
spinner.fail(
"Invalid email provided. Please provide a valid email using --email (-e) flag",
);
process.exit(1);
}
// message is optional
const message = getArgumentByKey(["--message", "-m"]);
await buildArchive(config);
return sendBuildByApiKey(config, {
apiKey,
email,
message,
cubeVersion,
});
}
async function verify(ctx: ResolvedEmbeddableConfig) {
if (ctx.pushComponents) {
try {
await fs.access(ctx.client.buildDir);
} catch (_e) {
console.error("No embeddable build was produced.");
process.exit(1);
}
}
// TODO: initiate login if no/invalid token.
const token = await getToken();
if (!token) {
console.error("Expired token. Please login again.");
process.exit(1);
}
return token;
}
export async function buildArchive(config: ResolvedEmbeddableConfig) {
const spinnerArchive = ora("Building...").start();
if (!config.pushModels && !config.pushComponents) {
spinnerArchive.fail(
"Cannot push: both pushModels and pushComponents are disabled",
);
process.exit(1);
}
const filesList: [string, string][] = [];
if (config.pushModels) {
const cubeFilesList = await findFiles(
config.client.modelsSrc || config.client.srcDir,
CUBE_FILES,
);
const securityContextFilesList = await findFiles(
config.client.presetsSrc || config.client.srcDir,
SECURITY_CONTEXT_FILES,
);
filesList.push(
...cubeFilesList.map((entry): [string, string] => [
path.basename(entry[1]),
entry[1],
]),
...securityContextFilesList.map((entry): [string, string] => [
path.basename(entry[1]),
entry[1],
]),
);
}
if (config.pushComponents) {
const clientContextFilesList = await findFiles(
config.client.presetsSrc || config.client.srcDir,
CLIENT_CONTEXT_FILES,
);
filesList.push(
...clientContextFilesList.map((entry): [string, string] => [
path.basename(entry[1]),
entry[1],
]),
);
}
await archive({
ctx: config,
filesList,
isDev: false,
});
return spinnerArchive.succeed("Bundling completed");
}
export async function archive(args: {
ctx: ResolvedEmbeddableConfig;
filesList: [string, string][];
isDev: boolean;
}) {
const { ctx, filesList, isDev } = args;
const output = fsSync.createWriteStream(ctx.client.archiveFile);
const archive = archiver.create("zip", {
zlib: { level: 9 },
});
archive.pipe(output);
if (!isDev) {
archive.directory(ctx.client.buildDir, false);
// NOTE: for backward compatibility, keep the file name as global.css
archive.file(ctx.client.customCanvasCss, {
name: "global.css",
});
}
for (const fileData of filesList) {
archive.file(fileData[1], {
name: fileData[0],
});
}
await archive.finalize();
return new Promise<void>((resolve: any, _reject) => {
output.on("close", () => resolve());
});
}
export async function createFormData(
filePath: string,
metadata: Record<string, any>,
) {
const { FormData, Blob } = await import("formdata-node");
const { fileFromPath } = await import("formdata-node/file-from-path");
const file = await fileFromPath(filePath, "embeddable-build.zip");
const form = new FormData();
form.set("file", file, "embeddable-build.zip");
const metadataBlob = new Blob([JSON.stringify(metadata)], {
type: "application/json",
});
form.set("request", metadataBlob, "request.json");
return form;
}
export async function sendBuildByApiKey(
ctx: ResolvedEmbeddableConfig,
{
apiKey,
email,
message,
cubeVersion,
}: { apiKey: string; email: string; message?: string; cubeVersion?: string },
) {
const form = await createFormData(ctx.client.archiveFile, {
pushModels: ctx.pushModels,
pushComponents: ctx.pushComponents,
authorEmail: email,
description: message,
...(cubeVersion ? { cubeVersion } : {}),
});
const response = await uploadFile(
form,
`${ctx.pushBaseUrl}/api/v1/bundle/upload`,
apiKey,
);
await fs.rm(ctx.client.archiveFile);
return { ...response.data, message, cubeVersion };
}
export async function sendBuild(
ctx: ResolvedEmbeddableConfig,
{
workspaceId,
token,
message,
cubeVersion,
}: {
workspaceId: string;
token: string;
message?: string;
cubeVersion?: string;
},
) {
const form = await createFormData(ctx.client.archiveFile, {
pushModels: ctx.pushModels,
pushComponents: ctx.pushComponents,
authorEmail: "",
description: message,
...(cubeVersion ? { cubeVersion } : {}),
});
await uploadFile(
form,
`${ctx.pushBaseUrl}/bundle/${workspaceId}/upload`,
token,
);
await fs.rm(ctx.client.archiveFile);
}
async function uploadFile(formData: any, url: string, token: string) {
return axios.post(url, formData, {
headers: {
"Content-Type": "multipart/form-data",
Authorization: `Bearer ${token}`,
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
}