Files
cadSet/text-to-cad/viewer/scripts/upload-catalog-to-blob.mjs
2026-07-22 19:38:36 +08:00

1225 lines
36 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
inlineStepGlbArtifactPathForSource,
stepParameterPathForStepSource,
} from "cadjs/common/stepSidecars.mjs";
import {
CAD_CATALOG_SCHEMA_VERSION,
isServedCadAsset,
scanCadDirectory,
VIEWER_SKIPPED_DIRECTORIES,
} from "../src/server/catalog/cadDirectoryScanner.mjs";
import {
pathIsInsideOrEqual,
toPosixPath,
} from "cadjs/lib/pathUtils.mjs";
import {
CATALOG_BLOB_CACHE_CONTROL_MAX_AGE_SECONDS,
contentTypeForFileRef,
createVercelBlobAssetBackend,
normalizeVercelBlobCatalog,
} from "../src/server/vercelBlobAssetBackend.mjs";
import {
envValue,
normalizeViewerAssetBackend,
vercelBlobConfigFromEnv,
VIEWER_ASSET_BACKENDS,
} from "../src/server/viewerEnv.mjs";
export const DEFAULT_UPLOAD_IGNORE_FILE = ".vieweruploadignore";
export const DEFAULT_UPLOAD_EXCLUDE_PATTERNS = Object.freeze([
"/mechbench/",
"/mechbench2/",
"/7dof_arm/",
"*.py",
]);
const DEFAULT_UPLOAD_CONCURRENCY = 4;
const CACHE_BUSTED_UPLOAD_EXTENSIONS = new Set([".js", ".mjs"]);
const GIT_LFS_POINTER_PREFIX = "version https://git-lfs.github.com/spec/v1";
function usage() {
return `Usage:
npm --prefix viewer run upload:blob -- [directory] [options]
Uploads a CAD Viewer catalog and viewer-supported assets to Vercel Blob.
Options:
--ignore-file <file> Gitignore-style exclude file. May be repeated.
--exclude <pattern> Gitignore-style exclude pattern. May be repeated.
--concurrency <n> Concurrent uploads. Defaults to ${DEFAULT_UPLOAD_CONCURRENCY}.
--skip-existing Skip assets whose content is already in the remote catalog.
--fetch-missing-lfs Fetch Git LFS pointers only for assets that must be uploaded.
-h, --help Show this help.
Environment:
VIEWER_VERCEL_BLOB_PREFIX
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN or BLOB_READ_WRITE_TOKEN
VIEWER_ASSET_BACKEND=vercel-blob (optional)
Default excludes:
${DEFAULT_UPLOAD_EXCLUDE_PATTERNS.join("\n ")}`;
}
function cleanPosixPath(value) {
const normalized = path.posix.normalize(String(value || "").trim().replace(/\\/g, "/").replace(/^\/+/, ""));
return normalized && normalized !== "." && !normalized.startsWith("../") ? normalized : "";
}
function fileStats(filePath) {
try {
const stats = fs.statSync(filePath, { bigint: true });
return stats.isFile() ? stats : null;
} catch {
return null;
}
}
function sha256File(filePath) {
const hash = crypto.createHash("sha256");
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.alloc(1024 * 1024);
let bytesRead = 0;
do {
bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
if (bytesRead > 0) {
hash.update(buffer.subarray(0, bytesRead));
}
} while (bytesRead > 0);
} finally {
fs.closeSync(fd);
}
return hash.digest("hex");
}
function fileAssetMetadata(filePath) {
const stats = fileStats(filePath);
if (!stats) {
return null;
}
return {
hash: sha256File(filePath),
bytes: Number(stats.size),
};
}
function readGitLfsPointer(filePath) {
const stats = fileStats(filePath);
if (!stats || Number(stats.size) > 1024) {
return null;
}
let text = "";
try {
text = fs.readFileSync(filePath, "utf-8");
} catch {
return null;
}
if (!text.startsWith(GIT_LFS_POINTER_PREFIX)) {
return null;
}
const oid = /^oid sha256:([a-f0-9]{64})$/mi.exec(text)?.[1] || "";
const sizeText = /^size ([0-9]+)$/mi.exec(text)?.[1] || "";
return {
oid,
size: sizeText ? Number(sizeText) : 0,
};
}
function comparableFileAssetMetadata(filePath) {
const pointer = readGitLfsPointer(filePath);
if (pointer?.oid) {
return {
hash: pointer.oid,
bytes: pointer.size,
gitLfsPointer: true,
};
}
const metadata = fileAssetMetadata(filePath);
return metadata
? {
...metadata,
gitLfsPointer: false,
}
: null;
}
function cacheBustedUploadFileRef({ fileRef, hash }) {
const normalizedRef = cleanPosixPath(fileRef);
const extension = path.posix.extname(normalizedRef).toLowerCase();
const normalizedHash = String(hash || "").trim().replace(/[^a-fA-F0-9]/g, "").slice(0, 16);
if (!normalizedRef || !normalizedHash || !CACHE_BUSTED_UPLOAD_EXTENSIONS.has(extension)) {
return normalizedRef;
}
return `${normalizedRef.slice(0, -extension.length)}.${normalizedHash}${extension}`;
}
function escapeRegExp(value) {
return String(value).replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
function globToRegExpSource(pattern) {
let source = "";
for (let index = 0; index < pattern.length;) {
const char = pattern[index];
if (char === "*") {
if (pattern[index + 1] === "*") {
source += ".*";
index += 2;
} else {
source += "[^/]*";
index += 1;
}
continue;
}
if (char === "?") {
source += "[^/]";
index += 1;
continue;
}
source += escapeRegExp(char);
index += 1;
}
return source;
}
function normalizeIgnoreLine(line) {
const trimmed = String(line || "").trim();
if (!trimmed || trimmed.startsWith("#")) {
return "";
}
return trimmed.startsWith("\\#") ? trimmed.slice(1) : trimmed;
}
export function parseIgnorePatterns(text) {
return String(text || "")
.split(/\r?\n/)
.map(normalizeIgnoreLine)
.filter(Boolean);
}
function compileIgnoreRule(rawPattern) {
let pattern = normalizeIgnoreLine(rawPattern);
if (!pattern) {
return null;
}
let negated = false;
if (pattern.startsWith("!")) {
negated = true;
pattern = normalizeIgnoreLine(pattern.slice(1));
}
if (!pattern) {
return null;
}
const directoryOnly = pattern.endsWith("/");
const anchored = pattern.startsWith("/");
const normalizedPattern = pattern.replace(/^\/+|\/+$/g, "");
if (!normalizedPattern) {
return null;
}
const basenameOnly = !normalizedPattern.includes("/");
const regexSource = globToRegExpSource(normalizedPattern);
const regex = basenameOnly
? new RegExp(`(?:^|/)${regexSource}${directoryOnly ? "(?:/|$)" : "$"}`)
: new RegExp(`${anchored ? "^" : "(?:^|.*/)"}${regexSource}${directoryOnly ? "(?:/|$)" : "$"}`);
return {
negated,
pattern,
regex,
};
}
export function createIgnoreMatcher(patterns = []) {
const rules = patterns
.map(compileIgnoreRule)
.filter(Boolean);
return ({ relativePath = "", isDirectory = false } = {}) => {
const normalizedPath = cleanPosixPath(relativePath);
if (!normalizedPath) {
return false;
}
const target = isDirectory ? `${normalizedPath}/` : normalizedPath;
let ignored = false;
for (const rule of rules) {
if (rule.regex.test(target)) {
ignored = !rule.negated;
}
}
return ignored;
};
}
function readIgnoreFile(ignoreFile, cwd) {
const resolved = path.resolve(cwd, ignoreFile);
return parseIgnorePatterns(fs.readFileSync(resolved, "utf-8"));
}
function resolveUploadRoot({
directory = "",
rootDir = "",
env = process.env,
cwd = process.cwd(),
} = {}) {
if (rootDir) {
throw new Error("--root-dir has been removed; pass the upload directory as the positional argument.");
}
const callerRoot = path.resolve(env.INIT_CWD || cwd);
const rawRootDir = directory || ".";
const resolvedDirectory = path.resolve(callerRoot, rawRootDir);
return {
repoRoot: resolvedDirectory,
rootDir: "",
rootPath: resolvedDirectory,
};
}
function includePathFromIgnoreMatcher(ignoreMatcher) {
return ({ relativePath, isDirectory }) => !ignoreMatcher({ relativePath, isDirectory });
}
function shouldSkipUploadDirectory(name) {
return VIEWER_SKIPPED_DIRECTORIES.has(name) || String(name || "").startsWith(".");
}
function rootRelativePath(rootPath, filePath) {
const relativePath = path.relative(path.resolve(rootPath), path.resolve(filePath));
if (!pathIsInsideOrEqual(filePath, rootPath)) {
return "";
}
return cleanPosixPath(toPosixPath(relativePath));
}
function isPythonSourceFileRef(fileRef) {
const raw = String(fileRef || "").trim().replace(/\\/g, "/");
let pathname = raw.split(/[?#]/)[0];
try {
pathname = new URL(raw).pathname;
} catch {
// Plain catalog refs are expected here.
}
return path.posix.extname(cleanPosixPath(pathname)).toLowerCase() === ".py";
}
function addUploadFile(uploadFiles, { rootPath, filePath }) {
const fileRef = rootRelativePath(rootPath, filePath);
if (!fileRef) {
return;
}
const stats = fileStats(filePath);
if (!stats) {
return;
}
uploadFiles.set(fileRef, {
fileRef,
filePath,
bytes: Number(stats.size),
});
}
function maybeAddAbsoluteUploadFile(uploadFiles, {
rootPath,
filePath,
includePath = null,
}) {
const fileRef = rootRelativePath(rootPath, filePath);
if (!fileRef) {
return null;
}
if (includePath && includePath({ filePath, relativePath: fileRef, isDirectory: false }) === false) {
return null;
}
addUploadFile(uploadFiles, { rootPath, filePath });
return uploadFiles.get(fileRef) || null;
}
function collectServedAssetFiles(rootPath, {
scanRootPath = rootPath,
includePath = null,
uploadFiles = new Map(),
} = {}) {
let entries = [];
try {
entries = fs.readdirSync(rootPath, { withFileTypes: true });
} catch {
return uploadFiles;
}
for (const entry of entries) {
const entryPath = path.join(rootPath, entry.name);
const relativePath = rootRelativePath(scanRootPath, entryPath);
if (entry.isDirectory()) {
if (
!shouldSkipUploadDirectory(entry.name) &&
(!includePath || includePath({ filePath: entryPath, relativePath, isDirectory: true }) !== false)
) {
collectServedAssetFiles(entryPath, { scanRootPath, includePath, uploadFiles });
}
continue;
}
if (
entry.isFile() &&
(!includePath || includePath({ filePath: entryPath, relativePath, isDirectory: false }) !== false) &&
isServedCadAsset(entryPath)
) {
addUploadFile(uploadFiles, { rootPath: scanRootPath, filePath: entryPath });
}
}
return uploadFiles;
}
function resolveExistingFileInsideRoot({ repoRoot, rootPath, fileRef }) {
const normalizedRef = cleanPosixPath(fileRef);
if (!normalizedRef) {
return null;
}
const candidates = [
path.resolve(rootPath, normalizedRef),
path.resolve(repoRoot, normalizedRef),
];
for (const candidate of [...new Set(candidates)]) {
if (pathIsInsideOrEqual(candidate, rootPath) && fileStats(candidate)) {
return {
filePath: candidate,
fileRef: rootRelativePath(rootPath, candidate),
};
}
}
return null;
}
function sourceFileRefsForEntry(entry, { includeSourceCode = true } = {}) {
const refs = [
entry?.sourceFile,
entry?.source?.file,
entry?.source?.path,
entry?.source?.sourcePath,
].map(cleanPosixPath).filter(Boolean);
return includeSourceCode ? refs : refs.filter((ref) => !isPythonSourceFileRef(ref));
}
function maybeAddCatalogRef(uploadFiles, {
repoRoot,
rootPath,
includePath,
fileRef,
}) {
const resolved = resolveExistingFileInsideRoot({ repoRoot, rootPath, fileRef });
if (!resolved) {
return null;
}
if (includePath && includePath({
filePath: resolved.filePath,
relativePath: resolved.fileRef,
isDirectory: false,
}) === false) {
return null;
}
addUploadFile(uploadFiles, { rootPath, filePath: resolved.filePath });
return resolved;
}
function addCatalogReferencedFiles(uploadFiles, {
catalog,
repoRoot,
rootPath,
includePath,
}) {
for (const entry of Array.isArray(catalog?.entries) ? catalog.entries : []) {
const fileRef = cleanPosixPath(entry?.file);
maybeAddCatalogRef(uploadFiles, { repoRoot, rootPath, includePath, fileRef });
const extension = path.posix.extname(fileRef).toLowerCase();
if (extension === ".step" || extension === ".stp") {
const stepPath = path.resolve(rootPath, fileRef);
maybeAddAbsoluteUploadFile(uploadFiles, {
rootPath,
filePath: inlineStepGlbArtifactPathForSource(stepPath),
includePath,
});
maybeAddAbsoluteUploadFile(uploadFiles, {
rootPath,
filePath: stepParameterPathForStepSource(stepPath),
includePath,
});
}
maybeAddCatalogRef(uploadFiles, {
repoRoot,
rootPath,
includePath,
fileRef: entry?.relations?.urdf?.file,
});
for (const sourceRef of sourceFileRefsForEntry(entry, { includeSourceCode: false })) {
maybeAddCatalogRef(uploadFiles, { repoRoot, rootPath, includePath, fileRef: sourceRef });
}
}
return uploadFiles;
}
function normalizedHash(value) {
return String(value || "").trim().toLowerCase().replace(/^sha256:/i, "");
}
function normalizedBytes(value) {
const number = Number(value || 0);
return Number.isFinite(number) && number >= 0 ? number : 0;
}
function pathnameFromUrl(url) {
try {
return new URL(url).pathname.replace(/^\/+/, "");
} catch {
return "";
}
}
function catalogUploadRecord({ fileRef, url = "", hash = "", bytes = undefined } = {}) {
const normalizedRef = cleanPosixPath(fileRef);
const normalizedUrl = String(url || "").trim();
if (!normalizedRef || !normalizedUrl) {
return null;
}
const normalized = {
fileRef: normalizedRef,
filePath: "",
hash: normalizedHash(hash),
blobFileRef: cleanPosixPath(pathnameFromUrl(normalizedUrl)) || normalizedRef,
url: normalizedUrl,
pathname: pathnameFromUrl(normalizedUrl),
};
const bytesNumber = Number(bytes);
if (String(bytes ?? "").trim() && Number.isFinite(bytesNumber)) {
normalized.bytes = bytesNumber;
}
return normalized;
}
function addExistingCatalogUpload(uploads, upload) {
if (upload?.fileRef && upload?.url) {
uploads.set(upload.fileRef, upload);
}
}
function catalogFieldUpload(fileRef, value) {
if (!value || typeof value !== "object") {
return null;
}
return catalogUploadRecord({
fileRef,
url: value.url,
hash: value.hash,
bytes: value.bytes,
});
}
function cacheBustedHashFromUrl(url, extension) {
let basename = "";
try {
basename = path.posix.basename(new URL(url).pathname);
} catch {
return "";
}
const escapedExtension = escapeRegExp(extension);
const match = new RegExp(`\\.([a-fA-F0-9]{16})${escapedExtension}$`).exec(basename);
return match?.[1]?.toLowerCase() || "";
}
function existingCatalogUploads(catalog, { rootPath } = {}) {
const uploads = new Map();
for (const entry of Array.isArray(catalog?.entries) ? catalog.entries : []) {
const fileRef = cleanPosixPath(entry?.file);
if (!fileRef) {
continue;
}
const extension = path.posix.extname(fileRef).toLowerCase();
if (extension === ".step" || extension === ".stp") {
addExistingCatalogUpload(uploads, catalogFieldUpload(fileRef, {
...(entry?.step && typeof entry.step === "object" ? entry.step : {}),
url: entry?.stepUrl || entry?.step?.url,
hash: entry?.stepHash || entry?.step?.hash,
bytes: entry?.stepBytes || entry?.step?.bytes,
}));
if (rootPath) {
const stepPath = path.resolve(rootPath, fileRef);
const glbRef = rootRelativePath(rootPath, inlineStepGlbArtifactPathForSource(stepPath));
addExistingCatalogUpload(uploads, catalogUploadRecord({
fileRef: glbRef,
url: entry?.url,
hash: entry?.hash,
bytes: entry?.bytes,
}));
const moduleRef = rootRelativePath(rootPath, stepParameterPathForStepSource(stepPath));
const moduleUrl = String(entry?.moduleUrl || "").trim();
addExistingCatalogUpload(uploads, catalogUploadRecord({
fileRef: moduleRef,
url: moduleUrl,
hash: cacheBustedHashFromUrl(moduleUrl, path.posix.extname(moduleRef).toLowerCase()),
bytes: entry?.moduleBytes,
}));
}
} else {
addExistingCatalogUpload(uploads, catalogFieldUpload(fileRef, entry));
}
const relationUrdf = entry?.relations?.urdf;
addExistingCatalogUpload(uploads, catalogFieldUpload(relationUrdf?.file, relationUrdf));
const source = entry?.source;
if (source && !isPythonSourceFileRef(source?.file || source?.sourcePath || "")) {
addExistingCatalogUpload(uploads, catalogFieldUpload(source?.file || source?.sourcePath, source));
}
}
return uploads;
}
function uploadMatchesExistingCatalog(uploadFile, existingUpload) {
const existingHash = normalizedHash(existingUpload?.hash);
if (!existingHash) {
return false;
}
const metadata = comparableFileAssetMetadata(uploadFile.filePath);
const localHash = normalizedHash(metadata?.hash);
if (!localHash || localHash !== existingHash) {
return false;
}
const existingBytes = normalizedBytes(existingUpload?.bytes);
const localBytes = normalizedBytes(metadata?.bytes);
return !existingBytes || !localBytes || existingBytes === localBytes;
}
function findGitRoot({ cwd = process.cwd(), rootPath = "" } = {}) {
const candidates = [cwd, rootPath].filter(Boolean);
for (const candidate of candidates) {
const result = spawnSync("git", ["-C", candidate, "rev-parse", "--show-toplevel"], {
encoding: "utf-8",
});
if (result.status === 0) {
return String(result.stdout || "").trim();
}
}
return "";
}
function gitRelativePath(gitRoot, filePath) {
const relativePath = path.relative(path.resolve(gitRoot), path.resolve(filePath));
if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
return "";
}
return toPosixPath(relativePath);
}
function ensureGitLfsFiles({
uploadFiles,
cwd = process.cwd(),
rootPath = "",
logger = console,
} = {}) {
const pointerFiles = uploadFiles
.filter((uploadFile) => readGitLfsPointer(uploadFile.filePath))
.sort((a, b) => a.fileRef.localeCompare(b.fileRef));
if (!pointerFiles.length) {
return 0;
}
const gitRoot = findGitRoot({ cwd, rootPath });
if (!gitRoot) {
throw new Error("Cannot fetch Git LFS pointers because the upload directory is not inside a git worktree.");
}
const includePaths = pointerFiles
.map((uploadFile) => gitRelativePath(gitRoot, uploadFile.filePath))
.filter(Boolean);
if (includePaths.length !== pointerFiles.length) {
throw new Error("Cannot fetch Git LFS pointers because one or more upload files are outside the git worktree.");
}
logger.log?.(`Fetching ${includePaths.length} Git LFS object(s) for missing Blob uploads.`);
const result = spawnSync("git", [
"-C",
gitRoot,
"lfs",
"pull",
"--include",
includePaths.join(","),
"--exclude",
"",
], {
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`git lfs pull failed while fetching missing Blob upload assets (exit ${result.status ?? "unknown"}).`);
}
const remainingPointers = pointerFiles.filter((uploadFile) => readGitLfsPointer(uploadFile.filePath));
if (remainingPointers.length) {
throw new Error(
`Git LFS did not materialize ${remainingPointers.length} upload asset(s): ` +
remainingPointers.map((uploadFile) => uploadFile.fileRef).join(", ")
);
}
return pointerFiles.length;
}
function assertNoGitLfsPointers(uploadFiles) {
const pointerFile = uploadFiles.find((uploadFile) => readGitLfsPointer(uploadFile.filePath));
if (pointerFile) {
throw new Error(
`${pointerFile.fileRef} is a Git LFS pointer. ` +
"Use --fetch-missing-lfs to fetch only missing Blob upload assets before uploading."
);
}
}
async function uploadOneFile(backend, uploadFile) {
const metadata = fileAssetMetadata(uploadFile.filePath);
if (!metadata) {
throw new Error(`Upload asset is not a file: ${uploadFile.fileRef}`);
}
const preparedUploadFile = {
...uploadFile,
...metadata,
};
const blobFileRef = cacheBustedUploadFileRef(preparedUploadFile);
const upload = await backend.writeAsset({
fileRef: blobFileRef,
body: fs.createReadStream(preparedUploadFile.filePath),
contentType: contentTypeForFileRef(preparedUploadFile.fileRef),
});
return {
upload,
uploadFile: preparedUploadFile,
blobFileRef,
};
}
async function uploadFilesToBlob({
backend,
uploadFiles,
concurrency = DEFAULT_UPLOAD_CONCURRENCY,
logger = console,
existingUploads = new Map(),
skipExisting = false,
fetchMissingLfs = false,
cwd = process.cwd(),
rootPath = "",
}) {
const allFiles = [...uploadFiles.values()].sort((a, b) => a.fileRef.localeCompare(b.fileRef));
const uploads = new Map();
const queue = [];
let skippedCount = 0;
for (const uploadFile of allFiles) {
const existingUpload = skipExisting ? existingUploads.get(uploadFile.fileRef) : null;
if (existingUpload && uploadMatchesExistingCatalog(uploadFile, existingUpload)) {
logger.log?.(`Skipping existing ${uploadFile.fileRef}`);
uploads.set(uploadFile.fileRef, {
...uploadFile,
...existingUpload,
fileRef: uploadFile.fileRef,
filePath: uploadFile.filePath,
});
skippedCount += 1;
continue;
}
queue.push(uploadFile);
}
let fetchedLfsFiles = 0;
if (fetchMissingLfs) {
fetchedLfsFiles = ensureGitLfsFiles({ uploadFiles: queue, cwd, rootPath, logger });
} else {
assertNoGitLfsPointers(queue);
}
let index = 0;
const workerCount = Math.max(1, Math.min(Number(concurrency) || DEFAULT_UPLOAD_CONCURRENCY, queue.length || 1));
let uploadedCount = 0;
async function worker() {
for (;;) {
const uploadFile = queue[index];
index += 1;
if (!uploadFile) {
return;
}
logger.log?.(`Uploading ${uploadFile.fileRef}`);
const {
upload,
uploadFile: preparedUploadFile,
blobFileRef,
} = await uploadOneFile(backend, uploadFile);
uploadedCount += 1;
uploads.set(preparedUploadFile.fileRef, {
...preparedUploadFile,
blobFileRef,
url: upload?.url || "",
pathname: upload?.pathname || "",
});
}
}
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return {
uploads,
uploadedCount,
skippedCount,
fetchedLfsFiles,
};
}
function uploadedAssetForRef(uploads, fileRef) {
return uploads.get(cleanPosixPath(fileRef)) || null;
}
function rewriteAssetFields(target, upload) {
if (!upload?.url) {
return target;
}
const next = {
...target,
url: upload.url,
};
if (upload.hash) {
next.hash = upload.hash;
}
if (Number.isFinite(Number(upload.bytes))) {
next.bytes = Number(upload.bytes);
}
return next;
}
function containsPythonSourceReference(value) {
if (typeof value === "string") {
return isPythonSourceFileRef(value);
}
if (Array.isArray(value)) {
return value.some(containsPythonSourceReference);
}
if (value && typeof value === "object") {
return Object.values(value).some(containsPythonSourceReference);
}
return false;
}
const SOURCE_CODE_CATALOG_KEYS = new Set([
"source",
"sourceFile",
"sourcePath",
"sourceStatus",
"sourceUrl",
]);
function stripSourceCodeReferences(value, key = "") {
if (Array.isArray(value)) {
return value
.map((item) => stripSourceCodeReferences(item))
.filter((item) => item !== undefined);
}
if (!value || typeof value !== "object") {
return SOURCE_CODE_CATALOG_KEYS.has(key) && containsPythonSourceReference(value)
? undefined
: value;
}
if (SOURCE_CODE_CATALOG_KEYS.has(key) && containsPythonSourceReference(value)) {
return undefined;
}
const stripped = {};
for (const [childKey, childValue] of Object.entries(value)) {
const nextValue = stripSourceCodeReferences(childValue, childKey);
if (nextValue !== undefined) {
stripped[childKey] = nextValue;
}
}
return stripped;
}
function rewriteCatalogEntry(entry, {
uploads,
repoRoot,
rootPath,
}) {
const nextEntry = { ...entry };
const fileRef = cleanPosixPath(entry?.file);
const extension = path.posix.extname(fileRef).toLowerCase();
if (extension === ".step" || extension === ".stp") {
const stepUpload = uploadedAssetForRef(uploads, fileRef);
if (stepUpload) {
nextEntry.step = rewriteAssetFields({
...(nextEntry.step && typeof nextEntry.step === "object" ? nextEntry.step : {}),
file: fileRef,
}, stepUpload);
}
const glbRef = rootRelativePath(
rootPath,
inlineStepGlbArtifactPathForSource(path.resolve(rootPath, fileRef)),
);
const glbUpload = uploadedAssetForRef(uploads, glbRef);
if (glbUpload) {
Object.assign(nextEntry, rewriteAssetFields({}, glbUpload));
}
const moduleRef = rootRelativePath(
rootPath,
stepParameterPathForStepSource(path.resolve(rootPath, fileRef)),
);
const moduleUpload = uploadedAssetForRef(uploads, moduleRef);
if (moduleUpload?.url) {
nextEntry.moduleUrl = moduleUpload.url;
}
} else {
const outputUpload = uploadedAssetForRef(uploads, fileRef);
if (outputUpload) {
Object.assign(nextEntry, rewriteAssetFields({}, outputUpload));
}
}
const relationUrdfRef = cleanPosixPath(nextEntry.relations?.urdf?.file);
const relationUrdfUpload = uploadedAssetForRef(uploads, relationUrdfRef);
if (relationUrdfUpload) {
nextEntry.relations = {
...nextEntry.relations,
urdf: rewriteAssetFields({
...nextEntry.relations.urdf,
file: relationUrdfRef,
}, relationUrdfUpload),
};
}
const sourceRef = sourceFileRefsForEntry(entry, { includeSourceCode: false })
.map((ref) => resolveExistingFileInsideRoot({ repoRoot, rootPath, fileRef: ref }))
.find(Boolean)?.fileRef || "";
const sourceUpload = uploadedAssetForRef(uploads, sourceRef);
if (sourceUpload) {
nextEntry.source = rewriteAssetFields({
...(nextEntry.source && typeof nextEntry.source === "object" ? nextEntry.source : {}),
file: sourceUpload.fileRef,
sourcePath: sourceUpload.fileRef,
}, sourceUpload);
}
return stripSourceCodeReferences(nextEntry);
}
export function rewriteCatalogForBlob(catalog, {
uploads,
repoRoot,
rootPath,
} = {}) {
return normalizeVercelBlobCatalog({
schemaVersion: CAD_CATALOG_SCHEMA_VERSION,
entries: (Array.isArray(catalog?.entries) ? catalog.entries : []).map((entry) => (
rewriteCatalogEntry(entry, { uploads, repoRoot, rootPath })
)),
});
}
// Skip-existing publishes leave unchanged assets as unfetched Git LFS
// pointers, so artifact validation cannot read them and stamps false
// missing_step_topology warnings into the catalog. For those entries, the
// remote catalog entry with the same content hash carries the status computed
// when the real bytes were last scanned, so reuse it.
export function carryForwardRemoteArtifactStatuses(catalog, {
existingCatalog,
rootPath,
} = {}) {
const remoteEntries = Array.isArray(existingCatalog?.entries) ? existingCatalog.entries : [];
const localEntries = Array.isArray(catalog?.entries) ? catalog.entries : [];
if (!remoteEntries.length || !localEntries.length || !rootPath) {
return catalog;
}
const remoteByFile = new Map(remoteEntries.map((entry) => [String(entry?.file || ""), entry]));
let changed = false;
const entries = localEntries.map((entry) => {
const artifact = entry?.artifact;
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact) || artifact.ok !== false) {
return entry;
}
const glbPath = cleanPosixPath(String(artifact.glbPath || ""));
if (!glbPath || !readGitLfsPointer(path.join(rootPath, ...glbPath.split("/")))) {
return entry;
}
const remoteEntry = remoteByFile.get(String(entry?.file || ""));
const localHash = String(entry?.hash || "");
if (!remoteEntry || !localHash || String(remoteEntry.hash || "") !== localHash) {
return entry;
}
changed = true;
if (remoteEntry.artifact === undefined) {
const { artifact: _droppedArtifact, ...cleanEntry } = entry;
return cleanEntry;
}
return { ...entry, artifact: remoteEntry.artifact };
});
return changed ? { ...catalog, entries } : catalog;
}
export function catalogJsonBody(catalog) {
return JSON.stringify({
schemaVersion: CAD_CATALOG_SCHEMA_VERSION,
entries: Array.isArray(catalog?.entries) ? catalog.entries : [],
}, null, 2);
}
export async function uploadCatalogJsonToBlob({
backend,
catalog,
catalogPath = "catalog.json",
} = {}) {
if (!backend || typeof backend.writeAsset !== "function") {
throw new Error("Blob catalog upload requires a writable asset backend.");
}
return backend.writeAsset({
fileRef: catalogPath || "catalog.json",
body: catalogJsonBody(catalog),
contentType: "application/json; charset=utf-8",
cacheControlMaxAge: CATALOG_BLOB_CACHE_CONTROL_MAX_AGE_SECONDS,
});
}
function resolveIgnorePatterns({ rootPath, ignoreFiles = [], excludePatterns = [], cwd = process.cwd() }) {
const patterns = [...DEFAULT_UPLOAD_EXCLUDE_PATTERNS];
const defaultIgnoreFile = path.join(rootPath, DEFAULT_UPLOAD_IGNORE_FILE);
if (fs.existsSync(defaultIgnoreFile)) {
patterns.push(...readIgnoreFile(defaultIgnoreFile, cwd));
}
for (const ignoreFile of ignoreFiles) {
patterns.push(...readIgnoreFile(ignoreFile, cwd));
}
patterns.push(...excludePatterns);
return patterns;
}
export function parseUploadArgs(argv, env = process.env) {
if (env.VIEWER_LOCAL_ROOT_DIR || env.VIEWER_LOCAL_WORKSPACE_ROOT) {
throw new Error("VIEWER_LOCAL_ROOT_DIR and VIEWER_LOCAL_WORKSPACE_ROOT have been removed; pass the upload directory as the positional argument.");
}
const options = {
directory: "",
ignoreFiles: [],
excludePatterns: [],
concurrency: DEFAULT_UPLOAD_CONCURRENCY,
skipExisting: false,
fetchMissingLfs: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = () => {
index += 1;
if (index >= argv.length) {
throw new Error(`Missing value for ${arg}`);
}
return argv[index];
};
if (arg === "-h" || arg === "--help") {
return { ...options, help: true };
}
if (arg === "--root-dir" || arg.startsWith("--root-dir=")) {
throw new Error("--root-dir has been removed; pass the upload directory as the positional argument.");
}
if (arg === "--ignore-file") {
options.ignoreFiles.push(readValue());
continue;
}
if (arg === "--exclude") {
options.excludePatterns.push(readValue());
continue;
}
if (arg === "--concurrency") {
options.concurrency = Number(readValue());
continue;
}
if (arg === "--skip-existing") {
options.skipExisting = true;
continue;
}
if (arg === "--fetch-missing-lfs") {
options.fetchMissingLfs = true;
continue;
}
if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
}
if (options.directory) {
throw new Error(`Unexpected extra directory: ${arg}`);
}
options.directory = arg;
}
return options;
}
function assertVercelUploadEnv(env = process.env) {
const requestedBackend = envValue(env, "VIEWER_ASSET_BACKEND");
if (
requestedBackend &&
normalizeViewerAssetBackend(requestedBackend) !== VIEWER_ASSET_BACKENDS.VERCEL_BLOB
) {
throw new Error("Blob upload requires VIEWER_ASSET_BACKEND=vercel-blob when VIEWER_ASSET_BACKEND is set.");
}
const config = vercelBlobConfigFromEnv(env);
if (!config.prefix) {
throw new Error("VIEWER_VERCEL_BLOB_PREFIX is required.");
}
if (!config.token) {
throw new Error("VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN or BLOB_READ_WRITE_TOKEN is required for Blob uploads.");
}
return config;
}
export async function uploadCatalogDirectoryToVercelBlob({
directory = "",
rootDir = "",
ignoreFiles = [],
excludePatterns = [],
concurrency = DEFAULT_UPLOAD_CONCURRENCY,
skipExisting = false,
fetchMissingLfs = false,
env = process.env,
cwd = process.cwd(),
client = null,
fetchImpl = globalThis.fetch,
logger = console,
} = {}) {
const uploadRoot = resolveUploadRoot({ directory, rootDir, env, cwd });
const ignorePatterns = resolveIgnorePatterns({
rootPath: uploadRoot.rootPath,
ignoreFiles,
excludePatterns,
cwd,
});
const ignoreMatcher = createIgnoreMatcher(ignorePatterns);
const includePath = includePathFromIgnoreMatcher(ignoreMatcher);
let catalog = scanCadDirectory({
repoRoot: uploadRoot.repoRoot,
rootDir: uploadRoot.rootDir,
includePath,
});
const uploadFiles = collectServedAssetFiles(uploadRoot.rootPath, {
scanRootPath: uploadRoot.rootPath,
includePath,
});
addCatalogReferencedFiles(uploadFiles, {
catalog,
repoRoot: uploadRoot.repoRoot,
rootPath: uploadRoot.rootPath,
includePath,
});
const config = assertVercelUploadEnv(env);
const backend = createVercelBlobAssetBackend({
...config,
client,
fetchImpl,
readOnly: false,
});
let existingUploads = new Map();
let existingCatalog = null;
if (skipExisting) {
try {
existingCatalog = await backend.readCatalog();
existingUploads = existingCatalogUploads(existingCatalog, {
rootPath: uploadRoot.rootPath,
});
logger.log?.(`Loaded remote catalog with ${existingUploads.size} upload asset reference(s).`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/404|not found/i.test(message)) {
throw error;
}
logger.log?.(`No remote catalog found at ${backend.catalogPath}; uploading all catalog assets.`);
}
}
const uploadResult = await uploadFilesToBlob({
backend,
uploadFiles,
concurrency,
logger,
existingUploads,
skipExisting,
fetchMissingLfs,
cwd,
rootPath: uploadRoot.rootPath,
});
const uploads = uploadResult.uploads;
if (skipExisting) {
for (const [fileRef, existingUpload] of existingUploads) {
if (!uploads.has(fileRef)) {
uploads.set(fileRef, existingUpload);
}
}
}
if (uploadResult.fetchedLfsFiles > 0) {
catalog = scanCadDirectory({
repoRoot: uploadRoot.repoRoot,
rootDir: uploadRoot.rootDir,
includePath,
});
}
const blobCatalog = carryForwardRemoteArtifactStatuses(
rewriteCatalogForBlob(catalog, {
uploads,
repoRoot: uploadRoot.repoRoot,
rootPath: uploadRoot.rootPath,
}),
{
existingCatalog,
rootPath: uploadRoot.rootPath,
},
);
const catalogFileRef = config.catalogPath || "catalog.json";
logger.log?.(`Uploading ${catalogFileRef}`);
const catalogUpload = await uploadCatalogJsonToBlob({
backend,
catalog: blobCatalog,
catalogPath: catalogFileRef,
});
return {
...uploadRoot,
prefix: backend.prefix,
catalogPath: backend.catalogPath,
catalogUrl: catalogUpload?.url || "",
ignoredPatterns: ignorePatterns,
uploadedFiles: uploadResult.uploadedCount,
skippedFiles: uploadResult.skippedCount,
fetchedLfsFiles: uploadResult.fetchedLfsFiles,
catalogEntries: blobCatalog.entries.length,
catalog: blobCatalog,
};
}
async function main(argv = process.argv.slice(2)) {
const options = parseUploadArgs(argv);
if (options.help) {
console.log(usage());
return;
}
const result = await uploadCatalogDirectoryToVercelBlob(options);
console.log(JSON.stringify({
prefix: result.prefix,
catalogPath: result.catalogPath,
catalogUrl: result.catalogUrl,
uploadedFiles: result.uploadedFiles,
skippedFiles: result.skippedFiles,
fetchedLfsFiles: result.fetchedLfsFiles,
catalogEntries: result.catalogEntries,
ignoredPatterns: result.ignoredPatterns,
}, null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}