@embeddable.com/sdk-core
Version:
Core Embeddable SDK module responsible for web-components bundling and publishing.
410 lines (350 loc) • 11.7 kB
text/typescript
import * as fs from "node:fs/promises";
import * as fsSync from "node:fs";
import yazl from "yazl";
import axios, {AxiosResponse} 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, shouldSkipModelCheck } 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 const EMBEDDABLE_FILES = /^(.*)\.embeddable\.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"]);
const skipModelCheck = shouldSkipModelCheck();
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, skipModelCheck);
} 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, skipModelCheck });
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");
config.pushEmbeddables && spinnerPushing.succeed("Embeddables published");
};
async function pushByApiKey(
config: ResolvedEmbeddableConfig,
spinner: any,
cubeVersion?: string,
skipModelCheck?: boolean,
) {
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,
skipModelCheck,
});
}
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('Unauthorized. Please login using "npm run embeddable:login"');
process.exit(1);
}
return token;
}
export async function buildArchive(config: ResolvedEmbeddableConfig) {
const spinnerArchive = ora("Building...").start();
if (!config.pushModels && !config.pushComponents && !config.pushEmbeddables) {
spinnerArchive.fail(
"Cannot push: pushModels, pushComponents, and pushEmbeddables are all 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],
]),
);
}
if (config.pushEmbeddables) {
const embeddableFilesList = await findFiles(
config.client.srcDir,
EMBEDDABLE_FILES,
);
filesList.push(
...embeddableFilesList.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 zip = new yazl.ZipFile();
if (!isDev) {
if (ctx.pushComponents) {
addDirectoryToZip(zip, ctx.client.buildDir);
}
// NOTE: for backward compatibility, keep the file name as global.css
if (fsSync.existsSync(ctx.client.customCanvasCss)) {
zip.addFile(ctx.client.customCanvasCss, "global.css", { compress: true });
}
}
for (const [name, filePath] of filesList) {
zip.addFile(filePath, name, { compress: true });
}
zip.end();
return new Promise<void>((resolve, reject) => {
const output = fsSync.createWriteStream(ctx.client.archiveFile);
zip.outputStream.pipe(output);
output.on("close", resolve);
output.on("error", reject);
});
}
function addDirectoryToZip(zip: yazl.ZipFile, dir: string) {
if (!fsSync.existsSync(dir)) {
return;
}
const entries = fsSync.readdirSync(dir, { recursive: true });
for (const entry of entries) {
const relativePath = String(entry);
const fullPath = path.join(dir, relativePath);
if (fsSync.statSync(fullPath).isFile()) {
zip.addFile(fullPath, relativePath, { compress: true });
}
}
}
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,
skipModelCheck,
}: { apiKey: string; email: string; message?: string; cubeVersion?: string; skipModelCheck?: boolean },
) {
const form = await createFormData(ctx.client.archiveFile, {
pushModels: ctx.pushModels,
pushComponents: ctx.pushComponents,
pushEmbeddables: ctx.pushEmbeddables,
starterEmbeddableIds: ctx.starterEmbeddables?.[ctx.region],
authorEmail: email,
description: message,
...(cubeVersion ? { cubeVersion } : {}),
...(skipModelCheck ? { skipModelCheck } : {}),
});
const response = await uploadFile(
form,
`${ctx.pushBaseUrl}/api/v1/bundle/upload`,
apiKey,
);
await fs.rm(ctx.client.archiveFile);
checkAndLogWarnings(response);
return { ...response.data, message, cubeVersion };
}
export async function sendBuild(
ctx: ResolvedEmbeddableConfig,
{
workspaceId,
token,
message,
cubeVersion,
skipModelCheck,
}: {
workspaceId: string;
token: string;
message?: string;
cubeVersion?: string;
skipModelCheck?: boolean;
},
) {
const form = await createFormData(ctx.client.archiveFile, {
pushModels: ctx.pushModels,
pushComponents: ctx.pushComponents,
pushEmbeddables: ctx.pushEmbeddables,
starterEmbeddableIds: ctx.starterEmbeddables?.[ctx.region],
authorEmail: "",
description: message,
...(cubeVersion ? { cubeVersion } : {}),
...(skipModelCheck ? { skipModelCheck } : {}),
});
const response = await uploadFile(
form,
`${ctx.pushBaseUrl}/bundle/${workspaceId}/upload`,
token,
);
const suppressedCodes = [
!ctx.pushModels && "WARN-001",
!ctx.pushComponents && "WARN-003",
!ctx.pushEmbeddables && "WARN-005",
].filter(Boolean) as string[];
checkAndLogWarnings(response, suppressedCodes);
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,
});
}
function checkAndLogWarnings(response: AxiosResponse, suppressedCodes: string[] = []) {
const suppressed = new Set(suppressedCodes);
const warnings = (response.data.warnings || []) as string[];
const visible = warnings.filter((w) => {
const warningCode = /^([^:\s]+)/.exec(w)?.[1] ?? "";
return !suppressed.has(warningCode);
});
if (visible.length > 0) {
ora().warn(visible.join("\n"));
}
}