Spaces:
Running
Running
| const path = require("node:path"); | |
| const { Blob } = require("node:buffer"); | |
| const { TextDecoder } = require("node:util"); | |
| const { | |
| HubApiError, | |
| createRepo, | |
| deleteFile, | |
| deleteRepo, | |
| downloadFile, | |
| listCommits, | |
| listDatasets, | |
| listFiles, | |
| listModels, | |
| listSpaces, | |
| modelInfo, | |
| pathsInfo, | |
| spaceInfo, | |
| uploadFile, | |
| whoAmI, | |
| datasetInfo, | |
| } = require("@huggingface/hub"); | |
| const HUB_URL = (process.env.HF_API_BASE || "https://huggingface.co").replace(/\/$/, ""); | |
| const MAX_DASHBOARD_ITEMS = 100; | |
| const MAX_COMMUNITY_ITEMS = 16; | |
| const MAX_FILE_LIST_ITEMS = 500; | |
| const MAX_FILE_PREVIEW_BYTES = 512 * 1024; | |
| const MAX_EDITABLE_FILE_BYTES = 256 * 1024; | |
| class HfApiError extends Error { | |
| constructor(statusCode, publicMessage, detail) { | |
| super(detail || publicMessage); | |
| this.name = "HfApiError"; | |
| this.statusCode = statusCode; | |
| this.publicMessage = publicMessage; | |
| } | |
| } | |
| function pluralFor(type) { | |
| return `${type}s`; | |
| } | |
| function repoRef(type, repoId) { | |
| return { type, name: repoId }; | |
| } | |
| function defaultTabFor(type) { | |
| if (type === "space") { | |
| return "app"; | |
| } | |
| if (type === "bucket") { | |
| return "files"; | |
| } | |
| return "overview"; | |
| } | |
| function hubRepoUrl(type, repoId) { | |
| if (type === "model") { | |
| return `${HUB_URL}/${repoId}`; | |
| } | |
| return `${HUB_URL}/${pluralFor(type)}/${repoId}`; | |
| } | |
| function spaceAppUrl(space) { | |
| if (!space.subdomain) { | |
| return hubRepoUrl("space", space.id); | |
| } | |
| return `https://${space.subdomain}.hf.space`; | |
| } | |
| function toIsoString(value) { | |
| if (!value) { | |
| return null; | |
| } | |
| if (value instanceof Date) { | |
| return Number.isNaN(value.getTime()) ? null : value.toISOString(); | |
| } | |
| const parsed = new Date(value); | |
| return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); | |
| } | |
| function splitRepoId(repoId) { | |
| const [owner, ...rest] = String(repoId || "").split("/"); | |
| return { | |
| owner: owner || "", | |
| name: rest.join("/"), | |
| }; | |
| } | |
| function normalizeRepoSegment(value, label) { | |
| const trimmed = String(value || "").trim(); | |
| if (!trimmed) { | |
| throw new HfApiError(400, `That ${label} is required.`); | |
| } | |
| if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) { | |
| throw new HfApiError(400, `That ${label} is invalid.`); | |
| } | |
| return trimmed; | |
| } | |
| function normalizeRepoIdInput(namespace, name) { | |
| return `${normalizeRepoSegment(namespace, "namespace")}/${normalizeRepoSegment(name, "name")}`; | |
| } | |
| function workspaceResourceUrl(type, repoId) { | |
| const { owner, name } = splitRepoId(repoId); | |
| return `/${pluralFor(type)}/${owner}/${name}/${defaultTabFor(type)}`; | |
| } | |
| function normalizeRemotePath(rawPath) { | |
| const value = String(rawPath || "") | |
| .trim() | |
| .replaceAll("\\", "/") | |
| .replace(/^\/+/, ""); | |
| if (!value) { | |
| return ""; | |
| } | |
| const normalized = path.posix.normalize(value); | |
| if (normalized === "." || normalized === "/") { | |
| return ""; | |
| } | |
| if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) { | |
| throw new HfApiError(400, "That file path is invalid."); | |
| } | |
| return normalized.replace(/^\/+/, ""); | |
| } | |
| function normalizeBranchName(branch) { | |
| const value = String(branch || "").trim(); | |
| if (!value) { | |
| return ""; | |
| } | |
| if (!/^[A-Za-z0-9._/-]+$/.test(value) || value.includes("..")) { | |
| throw new HfApiError(400, "That branch name is invalid."); | |
| } | |
| return value; | |
| } | |
| function dirname(remotePath) { | |
| const parent = path.posix.dirname(remotePath || ""); | |
| return parent === "." ? "" : parent; | |
| } | |
| async function collectAsync(generator, limit = Infinity) { | |
| const items = []; | |
| for await (const item of generator) { | |
| items.push(item); | |
| if (items.length >= limit) { | |
| break; | |
| } | |
| } | |
| return items; | |
| } | |
| function makeHubFetch(accessToken) { | |
| return async function hubFetch(input, init = {}) { | |
| const headers = new Headers(init.headers || {}); | |
| if (accessToken && !headers.has("Authorization")) { | |
| headers.set("Authorization", `Bearer ${accessToken}`); | |
| } | |
| return fetch(input, { | |
| ...init, | |
| headers, | |
| }); | |
| }; | |
| } | |
| async function parseJsonResponse(response) { | |
| if (!response) { | |
| return null; | |
| } | |
| const text = await response.text(); | |
| if (!text) { | |
| return null; | |
| } | |
| try { | |
| return JSON.parse(text); | |
| } catch (error) { | |
| return text; | |
| } | |
| } | |
| function toPermissionSummary(viewer, repoOwner) { | |
| const namespaces = new Set(viewer.namespaces || []); | |
| return { | |
| tokenRole: viewer.tokenRole, | |
| canAttemptWrite: viewer.tokenRole !== "read" && namespaces.has(repoOwner), | |
| repoOwner, | |
| }; | |
| } | |
| function summarizeViewer(identity) { | |
| const orgs = identity.type === "user" ? identity.orgs || [] : []; | |
| const namespaces = identity.type === "user" ? [identity.name, ...orgs.map((org) => org.name)] : [identity.name]; | |
| return { | |
| username: identity.name || identity.fullname || "hf-user", | |
| fullname: identity.fullname || identity.name || "Hugging Face user", | |
| avatarUrl: identity.avatarUrl || "", | |
| email: identity.email || "", | |
| type: identity.type || "user", | |
| isPro: Boolean(identity.isPro), | |
| tokenRole: identity.auth?.accessToken?.role || "read", | |
| tokenName: identity.auth?.accessToken?.displayName || "Access token", | |
| tokenCreatedAt: toIsoString(identity.auth?.accessToken?.createdAt), | |
| orgs: orgs.map((org) => ({ | |
| name: org.name, | |
| fullname: org.fullname || org.name, | |
| avatarUrl: org.avatarUrl || "", | |
| })), | |
| namespaces, | |
| profileUrl: identity.name ? `${HUB_URL}/${identity.name}` : HUB_URL, | |
| }; | |
| } | |
| function normalizeSpaceEntry(entry) { | |
| const repoName = entry.name || entry.id; | |
| const { owner, name } = splitRepoId(repoName); | |
| return { | |
| kind: "space", | |
| id: repoName, | |
| owner, | |
| name, | |
| title: entry.title || entry.cardData?.title || name, | |
| private: Boolean(entry.private), | |
| likes: entry.likes || 0, | |
| updatedAt: toIsoString(entry.updatedAt || entry.lastModified), | |
| createdAt: toIsoString(entry.createdAt), | |
| sdk: entry.sdk || entry.cardData?.sdk || null, | |
| runtimeStage: entry.runtime?.stage || null, | |
| subdomain: entry.subdomain || null, | |
| linkedModels: Array.isArray(entry.models) ? entry.models : [], | |
| linkedDatasets: Array.isArray(entry.datasets) ? entry.datasets : [], | |
| url: `/spaces/${owner}/${name}/app`, | |
| hubUrl: hubRepoUrl("space", repoName), | |
| }; | |
| } | |
| function normalizeModelEntry(entry) { | |
| const repoName = entry.name || entry.id; | |
| const { owner, name } = splitRepoId(repoName); | |
| return { | |
| kind: "model", | |
| id: repoName, | |
| owner, | |
| name, | |
| private: Boolean(entry.private), | |
| downloads: entry.downloads || 0, | |
| likes: entry.likes || 0, | |
| task: entry.task || entry.pipeline_tag || null, | |
| library: entry.library_name || null, | |
| updatedAt: toIsoString(entry.updatedAt || entry.lastModified), | |
| createdAt: toIsoString(entry.createdAt), | |
| url: `/models/${owner}/${name}/overview`, | |
| hubUrl: hubRepoUrl("model", repoName), | |
| }; | |
| } | |
| function normalizeDatasetEntry(entry) { | |
| const repoName = entry.name || entry.id; | |
| const { owner, name } = splitRepoId(repoName); | |
| return { | |
| kind: "dataset", | |
| id: repoName, | |
| owner, | |
| name, | |
| private: Boolean(entry.private), | |
| downloads: entry.downloads || 0, | |
| likes: entry.likes || 0, | |
| updatedAt: toIsoString(entry.updatedAt || entry.lastModified), | |
| createdAt: toIsoString(entry.createdAt), | |
| description: entry.description || entry.cardData?.description || "", | |
| url: `/datasets/${owner}/${name}/overview`, | |
| hubUrl: hubRepoUrl("dataset", repoName), | |
| }; | |
| } | |
| function namespaceValue(value) { | |
| if (!value) { | |
| return ""; | |
| } | |
| if (typeof value === "string") { | |
| return value; | |
| } | |
| if (typeof value === "object") { | |
| return String(value.name || value.id || value.slug || value.namespace || ""); | |
| } | |
| return ""; | |
| } | |
| function normalizeBucketRepoIdValue(value) { | |
| return String(value || "") | |
| .trim() | |
| .replace(/^buckets\//, "") | |
| .replace(/^bucket\//, ""); | |
| } | |
| function normalizeBucketEntry(entry, fallbackNamespace = "") { | |
| const rawName = normalizeBucketRepoIdValue( | |
| entry.name || entry.bucket || entry.slug || entry.bucketName || entry.repoName || "", | |
| ); | |
| const rawId = normalizeBucketRepoIdValue( | |
| entry.id || entry.repoId || entry.repo?.id || entry.path || (rawName.includes("/") ? rawName : ""), | |
| ); | |
| const namespace = | |
| namespaceValue(entry.namespace || entry.owner || entry.author || entry.repo?.owner || entry.createdBy) || | |
| String(fallbackNamespace || "").trim(); | |
| const bucketName = rawId | |
| ? splitRepoId(rawId).name | |
| : rawName.includes("/") | |
| ? splitRepoId(rawName).name | |
| : rawName; | |
| const id = rawId || (namespace && bucketName ? `${namespace}/${bucketName}` : rawName); | |
| const parsedId = splitRepoId(id); | |
| const owner = parsedId.name ? parsedId.owner : namespace || parsedId.owner; | |
| const name = parsedId.name || bucketName || entry.slug || ""; | |
| return { | |
| kind: "bucket", | |
| id, | |
| owner, | |
| name, | |
| private: Boolean(entry.private), | |
| size: Number(entry.size || entry.totalSize || entry.total_size || entry.usedStorage || entry.used_storage || 0), | |
| fileCount: Number( | |
| entry.fileCount || entry.totalFiles || entry.total_files || entry.objectCount || entry.object_count || 0, | |
| ), | |
| updatedAt: toIsoString(entry.updatedAt || entry.updated_at || entry.lastModified || entry.modifiedAt || entry.modified_at), | |
| createdAt: toIsoString(entry.createdAt || entry.created_at), | |
| region: entry.region || entry.location || entry.storageRegion || entry.storage_region || null, | |
| url: `/buckets/${owner}/${name}/files`, | |
| hubUrl: hubRepoUrl("bucket", id), | |
| }; | |
| } | |
| function compareByUpdatedDesc(a, b) { | |
| return (b.updatedAt || "").localeCompare(a.updatedAt || "") || a.id.localeCompare(b.id); | |
| } | |
| function uniqueById(items) { | |
| const seen = new Set(); | |
| return items.filter((item) => { | |
| if (seen.has(item.id)) { | |
| return false; | |
| } | |
| seen.add(item.id); | |
| return true; | |
| }); | |
| } | |
| function wrapHubError(error, fallbackMessage = "Hugging Face request failed.") { | |
| if (error instanceof HfApiError) { | |
| return error; | |
| } | |
| if (error instanceof HubApiError) { | |
| if (error.statusCode === 401) { | |
| return new HfApiError(401, "That Hugging Face access token is no longer valid.", error.message); | |
| } | |
| if (error.statusCode === 403) { | |
| return new HfApiError(403, "You do not have permission to do that with this access token.", error.message); | |
| } | |
| if (error.statusCode === 404) { | |
| return new HfApiError(404, "That Hugging Face resource was not found.", error.message); | |
| } | |
| return new HfApiError(502, fallbackMessage, error.message); | |
| } | |
| return new HfApiError(502, fallbackMessage, error && error.message ? error.message : String(error)); | |
| } | |
| async function safeList(fn) { | |
| try { | |
| return await fn(); | |
| } catch (error) { | |
| const wrapped = wrapHubError(error); | |
| if (wrapped.statusCode === 403 || wrapped.statusCode === 404) { | |
| return []; | |
| } | |
| throw wrapped; | |
| } | |
| } | |
| async function fetchJsonWithFallback(urls, accessToken) { | |
| const hubFetch = makeHubFetch(accessToken); | |
| let lastError = null; | |
| for (const url of urls) { | |
| try { | |
| const response = await hubFetch(url, { | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }); | |
| if (!response.ok) { | |
| if (response.status === 404) { | |
| lastError = new HfApiError(404, "Not found."); | |
| continue; | |
| } | |
| throw new HfApiError( | |
| response.status, | |
| response.status === 403 | |
| ? "You do not have permission to access that Hugging Face resource." | |
| : "Hugging Face returned an unexpected response.", | |
| ); | |
| } | |
| return response.json(); | |
| } catch (error) { | |
| lastError = error; | |
| } | |
| } | |
| if (lastError) { | |
| throw wrapHubError(lastError); | |
| } | |
| throw new HfApiError(404, "Not found."); | |
| } | |
| async function hubJson(accessToken, url, init = {}, fallbackMessage = "Hugging Face request failed.") { | |
| const response = await makeHubFetch(accessToken)(url, init).catch((error) => { | |
| throw wrapHubError(error, fallbackMessage); | |
| }); | |
| const payload = await parseJsonResponse(response); | |
| if (!response.ok) { | |
| const detail = | |
| payload && typeof payload === "object" | |
| ? payload.error || payload.message || payload.detail || JSON.stringify(payload) | |
| : payload || response.statusText; | |
| throw wrapHubError(new HfApiError(response.status, fallbackMessage, detail), fallbackMessage); | |
| } | |
| return payload; | |
| } | |
| async function optionalHubJson(accessToken, url, init = {}, fallbackMessage = "Hugging Face request failed.") { | |
| try { | |
| return await hubJson(accessToken, url, init, fallbackMessage); | |
| } catch (error) { | |
| const wrapped = wrapHubError(error, fallbackMessage); | |
| if (wrapped.statusCode === 403 || wrapped.statusCode === 404) { | |
| return null; | |
| } | |
| throw wrapped; | |
| } | |
| } | |
| function extractRows(payload, keys = []) { | |
| if (Array.isArray(payload)) { | |
| return payload; | |
| } | |
| for (const key of keys) { | |
| if (Array.isArray(payload?.[key])) { | |
| return payload[key]; | |
| } | |
| } | |
| return []; | |
| } | |
| function looksLikeSpaceConfigKey(key) { | |
| const text = String(key || "").trim(); | |
| return /^[A-Za-z_][A-Za-z0-9_]*$/.test(text) && (text.includes("_") || /\d/.test(text) || text === text.toUpperCase()); | |
| } | |
| function hasSpaceConfigFields(value) { | |
| if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| return false; | |
| } | |
| return [ | |
| "key", | |
| "name", | |
| "id", | |
| "description", | |
| "helper", | |
| "help", | |
| "value", | |
| "content", | |
| "rawValue", | |
| "updatedAt", | |
| "updated_at", | |
| "lastModified", | |
| "hidden", | |
| "masked", | |
| ].some((field) => field in value); | |
| } | |
| function extractSpaceConfigRows(payload, key) { | |
| const rows = extractRows(payload, [key, "items"]); | |
| if (rows.length) { | |
| return rows; | |
| } | |
| const containers = [payload?.[key], payload?.items, payload].filter( | |
| (entry, index, items) => | |
| entry && | |
| typeof entry === "object" && | |
| !Array.isArray(entry) && | |
| items.indexOf(entry) === index, | |
| ); | |
| for (const container of containers) { | |
| const objectRows = Object.entries(container) | |
| .filter(([entryKey, value]) => { | |
| if (Array.isArray(value)) { | |
| return false; | |
| } | |
| if (hasSpaceConfigFields(value)) { | |
| return true; | |
| } | |
| return looksLikeSpaceConfigKey(entryKey) && (value === null || ["string", "number", "boolean"].includes(typeof value)); | |
| }) | |
| .map(([entryKey, value]) => | |
| value && typeof value === "object" && !Array.isArray(value) | |
| ? { | |
| ...value, | |
| key: value.key || value.name || value.id || entryKey, | |
| } | |
| : { | |
| key: entryKey, | |
| value, | |
| }, | |
| ); | |
| if (objectRows.length) { | |
| return objectRows; | |
| } | |
| } | |
| return []; | |
| } | |
| function normalizeVisibility(value, isPrivate = false) { | |
| const normalized = String(value || "").trim().toLowerCase(); | |
| if (normalized === "public" || normalized === "private" || normalized === "protected") { | |
| return normalized; | |
| } | |
| return isPrivate ? "private" : "public"; | |
| } | |
| function buildNativeLinks(type, repoId) { | |
| const repoUrl = hubRepoUrl(type, repoId); | |
| const settingsUrl = `${repoUrl}/settings`; | |
| return { | |
| repoUrl, | |
| settingsUrl, | |
| analyticsUrl: settingsUrl, | |
| communityUrl: type === "bucket" ? repoUrl : `${repoUrl}/discussions`, | |
| storageUrl: settingsUrl, | |
| storageOverviewUrl: `${HUB_URL}/settings/repositories`, | |
| webhooksUrl: `${HUB_URL}/settings/webhooks`, | |
| xetUrl: settingsUrl, | |
| contributionsUrl: type === "bucket" ? repoUrl : `${repoUrl}/discussions`, | |
| }; | |
| } | |
| function normalizeSpaceConfigEntry(entry, kind) { | |
| const key = String(entry?.key || entry?.name || entry?.id || entry?.env || ""); | |
| return { | |
| key, | |
| description: entry?.description || entry?.helper || entry?.help || entry?.comment || "", | |
| value: | |
| kind === "variable" | |
| ? entry?.value ?? entry?.content ?? entry?.rawValue ?? entry?.default ?? null | |
| : null, | |
| updatedAt: toIsoString(entry?.updatedAt || entry?.updated_at || entry?.lastModified || entry?.createdAt), | |
| hidden: kind === "secret" || entry?.hidden === true || entry?.masked === true || entry?.value === undefined, | |
| }; | |
| } | |
| function normalizeWebhookEntry(entry) { | |
| const watched = Array.isArray(entry?.watched) | |
| ? entry.watched | |
| : Array.isArray(entry?.watchedItems) | |
| ? entry.watchedItems | |
| : []; | |
| return { | |
| id: String(entry?.id || entry?._id || ""), | |
| url: entry?.url || entry?.endpoint || "", | |
| domains: Array.isArray(entry?.domains) ? entry.domains : [], | |
| disabled: Boolean(entry?.disabled), | |
| watched: watched | |
| .map((item) => ({ | |
| type: String(item?.type || ""), | |
| name: String(item?.name || item?.id || ""), | |
| })) | |
| .filter((item) => item.type && item.name), | |
| }; | |
| } | |
| function filterWebhooksForResource(webhooks, type, repoId, owner) { | |
| return webhooks.filter((webhook) => | |
| webhook.watched.some( | |
| (item) => | |
| (item.type === type && item.name === repoId) || | |
| (item.type === "user" && item.name === owner) || | |
| (item.type === "org" && item.name === owner), | |
| ), | |
| ); | |
| } | |
| function normalizeAttachedBucket(entry) { | |
| const bucketId = String( | |
| entry?.bucket || | |
| entry?.bucketId || | |
| entry?.id || | |
| entry?.repoId || | |
| entry?.storageBucket || | |
| "", | |
| ); | |
| return { | |
| bucketId, | |
| mountPath: entry?.mountPath || entry?.mount_path || entry?.path || entry?.targetPath || "", | |
| mode: entry?.mode || entry?.type || "", | |
| }; | |
| } | |
| async function listSpaceVariables(accessToken, repoId) { | |
| const payload = await optionalHubJson( | |
| accessToken, | |
| `${HUB_URL}/api/spaces/${repoId}/variables`, | |
| { | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }, | |
| "Couldn't load that Space's variables.", | |
| ); | |
| return extractSpaceConfigRows(payload, "variables") | |
| .map((entry) => normalizeSpaceConfigEntry(entry, "variable")) | |
| .filter((entry) => entry.key) | |
| .sort((a, b) => a.key.localeCompare(b.key)); | |
| } | |
| async function listSpaceSecrets(accessToken, repoId) { | |
| const payload = await optionalHubJson( | |
| accessToken, | |
| `${HUB_URL}/api/spaces/${repoId}/secrets`, | |
| { | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }, | |
| "Couldn't load that Space's secrets.", | |
| ); | |
| return extractSpaceConfigRows(payload, "secrets") | |
| .map((entry) => normalizeSpaceConfigEntry(entry, "secret")) | |
| .filter((entry) => entry.key) | |
| .sort((a, b) => a.key.localeCompare(b.key)); | |
| } | |
| async function getSpaceRuntimeDetails(accessToken, repoId) { | |
| return optionalHubJson( | |
| accessToken, | |
| `${HUB_URL}/api/spaces/${repoId}/runtime`, | |
| { | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }, | |
| "Couldn't load that Space runtime.", | |
| ); | |
| } | |
| async function listAccountWebhooks(accessToken) { | |
| const payload = await optionalHubJson( | |
| accessToken, | |
| `${HUB_URL}/api/settings/webhooks`, | |
| { | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }, | |
| "Couldn't load your Hugging Face webhooks.", | |
| ); | |
| return extractRows(payload, ["webhooks", "items"]).map(normalizeWebhookEntry); | |
| } | |
| async function upsertSpaceConfigEntry(accessToken, repoId, kind, params) { | |
| const key = normalizeRepoSegment(params.key, `${kind} key`).toUpperCase(); | |
| const value = String(params.value || ""); | |
| const description = String(params.description || ""); | |
| if (!value) { | |
| throw new HfApiError(400, `That ${kind} value is required.`); | |
| } | |
| await hubJson( | |
| accessToken, | |
| `${HUB_URL}/api/spaces/${repoId}/${kind === "secret" ? "secrets" : "variables"}`, | |
| { | |
| method: "POST", | |
| headers: { | |
| Accept: "application/json", | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ | |
| key, | |
| name: key, | |
| value, | |
| description, | |
| }), | |
| }, | |
| `Couldn't save that Space ${kind}.`, | |
| ); | |
| return { ok: true, key }; | |
| } | |
| async function getViewer(accessToken) { | |
| try { | |
| const identity = await whoAmI({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| }); | |
| return summarizeViewer(identity); | |
| } catch (error) { | |
| throw wrapHubError(error, "Couldn't read your Hugging Face account details."); | |
| } | |
| } | |
| async function listOwnedSpaces(accessToken, namespaces, query = "") { | |
| const trimmedQuery = String(query || "").trim(); | |
| const results = await Promise.all( | |
| namespaces.map((owner) => | |
| safeList(async () => | |
| collectAsync( | |
| listSpaces({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| search: { | |
| owner, | |
| ...(trimmedQuery ? { query: trimmedQuery } : {}), | |
| }, | |
| additionalFields: ["author", "createdAt", "runtime", "subdomain", "tags", "models", "datasets"], | |
| }), | |
| MAX_DASHBOARD_ITEMS, | |
| ), | |
| ), | |
| ), | |
| ); | |
| return uniqueById(results.flat().map(normalizeSpaceEntry)).sort(compareByUpdatedDesc); | |
| } | |
| async function listOwnedModels(accessToken, namespaces, query = "") { | |
| const trimmedQuery = String(query || "").trim(); | |
| const results = await Promise.all( | |
| namespaces.map((owner) => | |
| safeList(async () => | |
| collectAsync( | |
| listModels({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| search: { | |
| owner, | |
| ...(trimmedQuery ? { query: trimmedQuery } : {}), | |
| }, | |
| additionalFields: ["author", "createdAt", "library_name", "sha", "spaces"], | |
| }), | |
| MAX_DASHBOARD_ITEMS, | |
| ), | |
| ), | |
| ), | |
| ); | |
| return uniqueById(results.flat().map(normalizeModelEntry)).sort(compareByUpdatedDesc); | |
| } | |
| async function listOwnedDatasets(accessToken, namespaces, query = "") { | |
| const trimmedQuery = String(query || "").trim(); | |
| const results = await Promise.all( | |
| namespaces.map((owner) => | |
| safeList(async () => | |
| collectAsync( | |
| listDatasets({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| search: { | |
| owner, | |
| ...(trimmedQuery ? { query: trimmedQuery } : {}), | |
| }, | |
| additionalFields: ["author", "createdAt", "description", "sha", "tags"], | |
| }), | |
| MAX_DASHBOARD_ITEMS, | |
| ), | |
| ), | |
| ), | |
| ); | |
| return uniqueById(results.flat().map(normalizeDatasetEntry)).sort(compareByUpdatedDesc); | |
| } | |
| function extractBucketRows(payload) { | |
| return extractRows(payload, ["buckets", "items", "data", "results"]); | |
| } | |
| function filterBucketsForNamespace(rows, namespace = "") { | |
| const trimmedNamespace = String(namespace || "").trim(); | |
| const normalizedRows = rows | |
| .map((entry) => normalizeBucketEntry(entry, trimmedNamespace)) | |
| .filter((bucket) => bucket.id && bucket.owner && bucket.name); | |
| if (!trimmedNamespace) { | |
| return normalizedRows; | |
| } | |
| return normalizedRows.filter((bucket) => bucket.owner === trimmedNamespace); | |
| } | |
| async function listBucketsForNamespace(accessToken, namespace, includeViewerFallback = false) { | |
| const urls = []; | |
| if (namespace) { | |
| urls.push(`${HUB_URL}/api/buckets?namespace=${encodeURIComponent(namespace)}`); | |
| urls.push(`${HUB_URL}/api/buckets?author=${encodeURIComponent(namespace)}`); | |
| urls.push(`${HUB_URL}/api/buckets?owner=${encodeURIComponent(namespace)}`); | |
| } | |
| if (includeViewerFallback || !namespace) { | |
| urls.push(`${HUB_URL}/api/buckets`); | |
| } | |
| let lastError = null; | |
| for (const url of urls) { | |
| try { | |
| const payload = await hubJson( | |
| accessToken, | |
| url, | |
| { | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }, | |
| "Couldn't load your buckets.", | |
| ); | |
| const rows = filterBucketsForNamespace(extractBucketRows(payload), namespace); | |
| if (rows.length) { | |
| return rows; | |
| } | |
| } catch (error) { | |
| const wrapped = wrapHubError(error, "Couldn't load your buckets."); | |
| if (wrapped.statusCode !== 403 && wrapped.statusCode !== 404) { | |
| lastError = wrapped; | |
| } | |
| } | |
| } | |
| if (lastError) { | |
| throw lastError; | |
| } | |
| return []; | |
| } | |
| async function listOwnedBuckets(accessToken, viewer, query = "") { | |
| const trimmedQuery = String(query || "").trim().toLowerCase(); | |
| const bucketGroups = await Promise.all( | |
| (viewer.namespaces || []).map((namespace) => | |
| safeList(async () => listBucketsForNamespace(accessToken, namespace, namespace === viewer.username)), | |
| ), | |
| ); | |
| const buckets = uniqueById(bucketGroups.flat()).sort(compareByUpdatedDesc); | |
| if (!trimmedQuery) { | |
| return buckets; | |
| } | |
| return buckets.filter((bucket) => { | |
| const haystack = `${bucket.owner}/${bucket.name}`.toLowerCase(); | |
| return haystack.includes(trimmedQuery); | |
| }); | |
| } | |
| async function getOwnedResourceIndex(accessToken, viewer, query = "") { | |
| const [spaces, models, datasets, buckets] = await Promise.all([ | |
| listOwnedSpaces(accessToken, viewer.namespaces, query), | |
| listOwnedModels(accessToken, viewer.namespaces, query), | |
| listOwnedDatasets(accessToken, viewer.namespaces, query), | |
| listOwnedBuckets(accessToken, viewer, query), | |
| ]); | |
| return { spaces, models, datasets, buckets }; | |
| } | |
| function searchResultsFromIndex(index, query) { | |
| const trimmedQuery = String(query || "").trim().toLowerCase(); | |
| if (!trimmedQuery) { | |
| return { | |
| total: 0, | |
| query: "", | |
| groups: [], | |
| }; | |
| } | |
| const groups = [ | |
| { type: "space", label: "Spaces", items: index.spaces }, | |
| { type: "model", label: "Models", items: index.models }, | |
| { type: "dataset", label: "Datasets", items: index.datasets }, | |
| { type: "bucket", label: "Buckets", items: index.buckets }, | |
| ] | |
| .map((group) => ({ | |
| ...group, | |
| items: group.items | |
| .filter((item) => | |
| `${item.owner}/${item.name} ${item.title || ""} ${item.description || ""}` | |
| .toLowerCase() | |
| .includes(trimmedQuery), | |
| ) | |
| .slice(0, 8), | |
| })) | |
| .filter((group) => group.items.length > 0); | |
| return { | |
| total: groups.reduce((sum, group) => sum + group.items.length, 0), | |
| query: trimmedQuery, | |
| groups, | |
| }; | |
| } | |
| function parseRoute(requestedPath) { | |
| const url = new URL(requestedPath || "/", "http://hf-home.local"); | |
| const segments = url.pathname.split("/").filter(Boolean); | |
| const search = url.searchParams; | |
| if (segments.length === 0) { | |
| return { kind: "dashboard", pathname: url.pathname, search }; | |
| } | |
| if (segments[0] === "settings") { | |
| return { kind: "settings", pathname: url.pathname, search }; | |
| } | |
| const collectionKinds = new Map([ | |
| ["spaces", "space"], | |
| ["models", "model"], | |
| ["datasets", "dataset"], | |
| ["buckets", "bucket"], | |
| ]); | |
| const collectionType = collectionKinds.get(segments[0]); | |
| if (!collectionType) { | |
| return { kind: "notFound", pathname: url.pathname, search }; | |
| } | |
| if (segments.length === 1) { | |
| return { | |
| kind: "collection", | |
| resourceType: collectionType, | |
| pathname: url.pathname, | |
| search, | |
| }; | |
| } | |
| const owner = segments[1]; | |
| const name = segments[2]; | |
| if (!owner || !name) { | |
| return { kind: "notFound", pathname: url.pathname, search }; | |
| } | |
| const repoId = `${owner}/${name}`; | |
| const defaultTab = | |
| collectionType === "space" ? "app" : collectionType === "bucket" ? "files" : "overview"; | |
| const tab = segments[3] || defaultTab; | |
| return { | |
| kind: "resource", | |
| resourceType: collectionType, | |
| owner, | |
| name, | |
| repoId, | |
| tab, | |
| branch: search.get("branch") || "", | |
| path: search.get("path") || "", | |
| pathname: url.pathname, | |
| search, | |
| }; | |
| } | |
| async function listRepoRefs(accessToken, type, repoId) { | |
| const urls = [ | |
| `${HUB_URL}/api/${pluralFor(type)}/${repoId}/refs`, | |
| `${HUB_URL}/api/${pluralFor(type)}/${repoId}/refs?include_prs=1`, | |
| ]; | |
| try { | |
| const payload = await fetchJsonWithFallback(urls, accessToken); | |
| const branches = Array.isArray(payload.branches) ? payload.branches : []; | |
| const tags = Array.isArray(payload.tags) ? payload.tags : []; | |
| const pullRequests = Array.isArray(payload.pullRequests) | |
| ? payload.pullRequests | |
| : Array.isArray(payload.prs) | |
| ? payload.prs | |
| : []; | |
| const normalizeRef = (ref) => ({ | |
| name: ref.name || ref.branch || ref.tag || "", | |
| ref: ref.ref || "", | |
| targetCommit: | |
| ref.targetCommit || ref.target_commit || ref.commit || ref.sha || ref.oid || null, | |
| }); | |
| return { | |
| branches: branches.map(normalizeRef).filter((branch) => branch.name), | |
| tags: tags.map(normalizeRef).filter((tag) => tag.name), | |
| pullRequests: pullRequests.map(normalizeRef).filter((pull) => pull.name), | |
| }; | |
| } catch (error) { | |
| return { | |
| branches: [{ name: "main", ref: "refs/heads/main", targetCommit: null }], | |
| tags: [], | |
| pullRequests: [], | |
| unavailable: true, | |
| }; | |
| } | |
| } | |
| async function getRecentCommits(accessToken, type, repoId, revision) { | |
| try { | |
| return ( | |
| await collectAsync( | |
| listCommits({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef(type, repoId), | |
| ...(revision ? { revision } : {}), | |
| }), | |
| MAX_COMMUNITY_ITEMS, | |
| ) | |
| ).map((commit) => ({ | |
| id: commit.oid, | |
| title: commit.title, | |
| message: commit.message, | |
| date: toIsoString(commit.date), | |
| authors: commit.authors || [], | |
| })); | |
| } catch (error) { | |
| return []; | |
| } | |
| } | |
| async function getRepoDiscussions(accessToken, type, repoId) { | |
| const urls = [ | |
| `${HUB_URL}/api/${pluralFor(type)}/${repoId}/discussions?p=0`, | |
| `${HUB_URL}/api/${pluralFor(type)}/${repoId}/discussions`, | |
| ]; | |
| try { | |
| const payload = await fetchJsonWithFallback(urls, accessToken); | |
| const rows = Array.isArray(payload) | |
| ? payload | |
| : Array.isArray(payload.discussions) | |
| ? payload.discussions | |
| : Array.isArray(payload.items) | |
| ? payload.items | |
| : []; | |
| return rows.slice(0, MAX_COMMUNITY_ITEMS).map((item) => ({ | |
| number: item.num || item.number || item.id || null, | |
| title: item.title || item.subject || "Untitled discussion", | |
| author: item.author?.name || item.author || item.user || "unknown", | |
| status: item.status || item.state || "open", | |
| type: item.type || (item.isPullRequest ? "pull_request" : "discussion"), | |
| createdAt: toIsoString(item.createdAt || item.created_at), | |
| updatedAt: toIsoString(item.updatedAt || item.updated_at || item.lastModified), | |
| url: item.url || `${hubRepoUrl(type, repoId)}/discussions/${item.num || item.number || ""}`, | |
| })); | |
| } catch (error) { | |
| return []; | |
| } | |
| } | |
| function formatPathEntry(entry) { | |
| const name = entry.path.split("/").pop() || entry.path; | |
| return { | |
| name, | |
| path: entry.path, | |
| type: entry.type, | |
| size: Number(entry.size || 0), | |
| oid: entry.oid || null, | |
| xetHash: entry.xetHash || null, | |
| uploadedAt: toIsoString(entry.uploadedAt), | |
| lastCommit: entry.lastCommit | |
| ? { | |
| id: entry.lastCommit.id, | |
| title: entry.lastCommit.title, | |
| date: toIsoString(entry.lastCommit.date), | |
| } | |
| : null, | |
| lfs: entry.lfs || null, | |
| securityFileStatus: entry.securityFileStatus || null, | |
| }; | |
| } | |
| function fileLooksText(bytes) { | |
| if (bytes.length === 0) { | |
| return true; | |
| } | |
| let suspicious = 0; | |
| const sample = bytes.subarray(0, Math.min(bytes.length, 1024)); | |
| for (const byte of sample) { | |
| if (byte === 0) { | |
| return false; | |
| } | |
| if (byte < 9 || (byte > 13 && byte < 32)) { | |
| suspicious += 1; | |
| } | |
| } | |
| return suspicious / sample.length < 0.08; | |
| } | |
| async function loadSelectedFile(accessToken, type, repoId, filePath, revision, metadata) { | |
| try { | |
| const blob = await downloadFile({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef(type, repoId), | |
| path: filePath, | |
| ...(type === "bucket" ? {} : { revision }), | |
| }); | |
| if (!blob) { | |
| return null; | |
| } | |
| const cappedBlob = blob.slice(0, MAX_FILE_PREVIEW_BYTES); | |
| const buffer = Buffer.from(await cappedBlob.arrayBuffer()); | |
| const textFile = fileLooksText(buffer); | |
| const content = textFile ? new TextDecoder("utf8").decode(buffer) : ""; | |
| return { | |
| path: filePath, | |
| size: blob.size, | |
| binary: !textFile, | |
| editable: textFile && blob.size <= MAX_EDITABLE_FILE_BYTES, | |
| truncated: blob.size > MAX_FILE_PREVIEW_BYTES, | |
| content, | |
| oid: metadata?.oid || null, | |
| uploadedAt: toIsoString(metadata?.uploadedAt), | |
| lastCommit: metadata?.lastCommit | |
| ? { | |
| id: metadata.lastCommit.id, | |
| title: metadata.lastCommit.title, | |
| date: toIsoString(metadata.lastCommit.date), | |
| } | |
| : null, | |
| downloadUrl: | |
| `/api/file/download?type=${encodeURIComponent(type)}` + | |
| `&repoId=${encodeURIComponent(repoId)}` + | |
| `&path=${encodeURIComponent(filePath)}` + | |
| (revision ? `&branch=${encodeURIComponent(revision)}` : ""), | |
| }; | |
| } catch (error) { | |
| return null; | |
| } | |
| } | |
| function buildBreadcrumbs(resourceType, owner, name, currentPath, branch) { | |
| const crumbs = [ | |
| { | |
| label: `${owner}/${name}`, | |
| href: | |
| `/${pluralFor(resourceType)}/${owner}/${name}/files` + | |
| (branch ? `?branch=${encodeURIComponent(branch)}` : ""), | |
| }, | |
| ]; | |
| if (!currentPath) { | |
| return crumbs; | |
| } | |
| const segments = currentPath.split("/").filter(Boolean); | |
| let running = ""; | |
| for (const segment of segments) { | |
| running = running ? `${running}/${segment}` : segment; | |
| crumbs.push({ | |
| label: segment, | |
| href: | |
| `/${pluralFor(resourceType)}/${owner}/${name}/files?path=${encodeURIComponent(running)}` + | |
| (branch ? `&branch=${encodeURIComponent(branch)}` : ""), | |
| }); | |
| } | |
| return crumbs; | |
| } | |
| async function buildFilesView(accessToken, type, owner, name, requestedBranch, requestedPath, branches) { | |
| const repoId = `${owner}/${name}`; | |
| const branchNames = new Set((branches || []).map((branch) => branch.name)); | |
| const branch = | |
| type === "bucket" | |
| ? "" | |
| : requestedBranch && branchNames.has(requestedBranch) | |
| ? requestedBranch | |
| : branches.find((entry) => entry.name === "main")?.name || branches[0]?.name || "main"; | |
| const safePath = normalizeRemotePath(requestedPath); | |
| let currentPath = ""; | |
| let selectedFile = null; | |
| let selectionInfo = null; | |
| if (safePath) { | |
| try { | |
| const details = await pathsInfo({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef(type, repoId), | |
| paths: [safePath], | |
| expand: true, | |
| ...(type === "bucket" ? {} : { revision: branch }), | |
| }); | |
| selectionInfo = details[0] || null; | |
| if (selectionInfo?.type === "directory") { | |
| currentPath = safePath; | |
| } else if (selectionInfo?.type === "file") { | |
| currentPath = dirname(safePath); | |
| } | |
| } catch (error) { | |
| currentPath = dirname(safePath); | |
| } | |
| } | |
| const rawEntries = await collectAsync( | |
| listFiles({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef(type, repoId), | |
| path: currentPath || undefined, | |
| recursive: false, | |
| expand: true, | |
| ...(type === "bucket" ? {} : { revision: branch }), | |
| }), | |
| MAX_FILE_LIST_ITEMS, | |
| ); | |
| const entries = rawEntries | |
| .map(formatPathEntry) | |
| .sort((left, right) => { | |
| if (left.type !== right.type) { | |
| return left.type === "directory" ? -1 : 1; | |
| } | |
| return left.name.localeCompare(right.name); | |
| }); | |
| if (selectionInfo?.type === "file") { | |
| selectedFile = await loadSelectedFile(accessToken, type, repoId, safePath, branch, selectionInfo); | |
| } | |
| return { | |
| branch, | |
| branches, | |
| currentPath, | |
| requestedPath: safePath, | |
| breadcrumbs: buildBreadcrumbs(type, owner, name, currentPath, branch), | |
| entries, | |
| selectedFile, | |
| branchUnavailable: type === "bucket", | |
| }; | |
| } | |
| async function getSpaceDetail(accessToken, viewer, owner, name, tab, branch, requestedPath) { | |
| const repoId = `${owner}/${name}`; | |
| const info = await spaceInfo({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| name: repoId, | |
| additionalFields: [ | |
| "author", | |
| "cardData", | |
| "createdAt", | |
| "datasets", | |
| "models", | |
| "resourceGroup", | |
| "runtime", | |
| "sha", | |
| "subdomain", | |
| "tags", | |
| "usedStorage", | |
| ], | |
| }).catch((error) => { | |
| throw wrapHubError(error, "Couldn't load that Space."); | |
| }); | |
| const refs = await listRepoRefs(accessToken, "space", repoId); | |
| const activeTab = new Set(["app", "files", "community", "settings"]).has(tab) ? tab : "app"; | |
| const files = activeTab === "files" ? await buildFilesView(accessToken, "space", owner, name, branch, requestedPath, refs.branches) : null; | |
| const commits = await getRecentCommits(accessToken, "space", repoId, files?.branch || refs.branches[0]?.name || "main"); | |
| const discussions = activeTab === "community" ? await getRepoDiscussions(accessToken, "space", repoId) : []; | |
| const runtime = activeTab === "settings" ? (await getSpaceRuntimeDetails(accessToken, repoId)) || info.runtime || null : info.runtime || null; | |
| const nativeLinks = buildNativeLinks("space", repoId); | |
| const settings = | |
| activeTab === "settings" | |
| ? await (async () => { | |
| const [variables, secrets, webhooks, buckets] = await Promise.all([ | |
| safeList(async () => listSpaceVariables(accessToken, repoId)), | |
| safeList(async () => listSpaceSecrets(accessToken, repoId)), | |
| safeList(async () => listAccountWebhooks(accessToken)), | |
| safeList(async () => listBucketsForNamespace(accessToken, owner, owner === viewer.username)), | |
| ]); | |
| const attachedBuckets = Array.isArray(runtime?.volumes) | |
| ? runtime.volumes.map(normalizeAttachedBucket).filter((entry) => entry.bucketId || entry.mountPath) | |
| : []; | |
| return { | |
| ...nativeLinks, | |
| visibility: normalizeVisibility(info.visibility, info.private), | |
| usedStorage: Number(info.usedStorage || runtime?.usedStorage || 0) || 0, | |
| resourceGroup: info.resourceGroup || null, | |
| variables, | |
| secrets, | |
| webhooks: filterWebhooksForResource(webhooks, "space", repoId, owner), | |
| buckets, | |
| attachedBuckets, | |
| supportsVisibility: true, | |
| supportsMove: true, | |
| supportsDelete: true, | |
| }; | |
| })() | |
| : { | |
| ...nativeLinks, | |
| visibility: normalizeVisibility(info.visibility, info.private), | |
| usedStorage: Number(info.usedStorage || 0) || 0, | |
| resourceGroup: info.resourceGroup || null, | |
| variables: [], | |
| secrets: [], | |
| webhooks: [], | |
| buckets: [], | |
| attachedBuckets: [], | |
| supportsVisibility: true, | |
| supportsMove: true, | |
| supportsDelete: true, | |
| }; | |
| return { | |
| kind: "space", | |
| title: info.title || info.cardData?.title || name, | |
| resourceType: "space", | |
| owner, | |
| name, | |
| id: repoId, | |
| tab: activeTab, | |
| private: Boolean(info.private), | |
| likes: info.likes || 0, | |
| sdk: info.sdk || info.cardData?.sdk || null, | |
| runtime, | |
| createdAt: toIsoString(info.createdAt), | |
| updatedAt: toIsoString(info.updatedAt || info.lastModified), | |
| sha: info.sha || null, | |
| subdomain: info.subdomain || null, | |
| appUrl: spaceAppUrl(info), | |
| hubUrl: hubRepoUrl("space", repoId), | |
| tags: Array.isArray(info.tags) ? info.tags : [], | |
| linkedModels: Array.isArray(info.models) ? info.models : [], | |
| linkedDatasets: Array.isArray(info.datasets) ? info.datasets : [], | |
| permissions: toPermissionSummary(viewer, owner), | |
| branches: refs.branches, | |
| files, | |
| community: { | |
| commits, | |
| discussions, | |
| nativeUrl: `${hubRepoUrl("space", repoId)}/discussions`, | |
| }, | |
| live: { | |
| logsUrl: `/api/spaces/${owner}/${name}/logs/stream?kind=container`, | |
| buildLogsUrl: `/api/spaces/${owner}/${name}/logs/stream?kind=build`, | |
| eventsUrl: `/api/spaces/${owner}/${name}/events/stream`, | |
| metricsUrl: `/api/spaces/${owner}/${name}/metrics/stream`, | |
| updatesUrl: | |
| `/api/resources/space/${owner}/${name}/updates/stream` + | |
| (files?.branch ? `?branch=${encodeURIComponent(files.branch)}` : ""), | |
| }, | |
| settings: { | |
| ...settings, | |
| sdk: info.sdk || info.cardData?.sdk || null, | |
| runtime, | |
| appUrl: spaceAppUrl(info), | |
| }, | |
| }; | |
| } | |
| async function getModelDetail(accessToken, viewer, owner, name, tab, branch, requestedPath) { | |
| const repoId = `${owner}/${name}`; | |
| const info = await modelInfo({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| name: repoId, | |
| additionalFields: [ | |
| "author", | |
| "cardData", | |
| "createdAt", | |
| "library_name", | |
| "pipeline_tag", | |
| "sha", | |
| "spaces", | |
| "tags", | |
| ], | |
| }).catch((error) => { | |
| throw wrapHubError(error, "Couldn't load that model."); | |
| }); | |
| const refs = await listRepoRefs(accessToken, "model", repoId); | |
| const activeTab = new Set(["overview", "files", "community", "settings"]).has(tab) ? tab : "overview"; | |
| const files = activeTab === "files" ? await buildFilesView(accessToken, "model", owner, name, branch, requestedPath, refs.branches) : null; | |
| const commits = await getRecentCommits(accessToken, "model", repoId, files?.branch || refs.branches[0]?.name || "main"); | |
| const discussions = activeTab === "community" ? await getRepoDiscussions(accessToken, "model", repoId) : []; | |
| return { | |
| kind: "model", | |
| resourceType: "model", | |
| owner, | |
| name, | |
| id: repoId, | |
| title: repoId, | |
| tab: activeTab, | |
| private: Boolean(info.private), | |
| likes: info.likes || 0, | |
| downloads: info.downloads || 0, | |
| task: info.pipeline_tag || null, | |
| library: info.library_name || null, | |
| createdAt: toIsoString(info.createdAt), | |
| updatedAt: toIsoString(info.updatedAt || info.lastModified), | |
| sha: info.sha || null, | |
| tags: Array.isArray(info.tags) ? info.tags : [], | |
| spaces: Array.isArray(info.spaces) ? info.spaces : [], | |
| hubUrl: hubRepoUrl("model", repoId), | |
| permissions: toPermissionSummary(viewer, owner), | |
| branches: refs.branches, | |
| files, | |
| overview: { | |
| description: info.cardData?.description || "", | |
| downloads: info.downloads || 0, | |
| likes: info.likes || 0, | |
| task: info.pipeline_tag || null, | |
| library: info.library_name || null, | |
| }, | |
| community: { | |
| commits, | |
| discussions, | |
| nativeUrl: `${hubRepoUrl("model", repoId)}/discussions`, | |
| }, | |
| live: { | |
| updatesUrl: | |
| `/api/resources/model/${owner}/${name}/updates/stream` + | |
| (files?.branch ? `?branch=${encodeURIComponent(files.branch)}` : ""), | |
| }, | |
| settings: { | |
| ...buildNativeLinks("model", repoId), | |
| visibility: normalizeVisibility(info.visibility, info.private), | |
| resourceGroup: info.resourceGroup || null, | |
| usedStorage: Number(info.usedStorage || 0) || 0, | |
| supportsVisibility: true, | |
| supportsMove: true, | |
| supportsDelete: true, | |
| }, | |
| }; | |
| } | |
| async function getDatasetDetail(accessToken, viewer, owner, name, tab, branch, requestedPath) { | |
| const repoId = `${owner}/${name}`; | |
| const info = await datasetInfo({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| name: repoId, | |
| additionalFields: ["author", "createdAt", "description", "files", "sha", "tags"], | |
| }).catch((error) => { | |
| throw wrapHubError(error, "Couldn't load that dataset."); | |
| }); | |
| const refs = await listRepoRefs(accessToken, "dataset", repoId); | |
| const activeTab = new Set(["overview", "files", "community", "settings"]).has(tab) ? tab : "overview"; | |
| const files = activeTab === "files" ? await buildFilesView(accessToken, "dataset", owner, name, branch, requestedPath, refs.branches) : null; | |
| const commits = await getRecentCommits(accessToken, "dataset", repoId, files?.branch || refs.branches[0]?.name || "main"); | |
| const discussions = activeTab === "community" ? await getRepoDiscussions(accessToken, "dataset", repoId) : []; | |
| return { | |
| kind: "dataset", | |
| resourceType: "dataset", | |
| owner, | |
| name, | |
| id: repoId, | |
| title: repoId, | |
| tab: activeTab, | |
| private: Boolean(info.private), | |
| likes: info.likes || 0, | |
| downloads: info.downloads || 0, | |
| createdAt: toIsoString(info.createdAt), | |
| updatedAt: toIsoString(info.updatedAt || info.lastModified), | |
| sha: info.sha || null, | |
| description: info.description || "", | |
| tags: Array.isArray(info.tags) ? info.tags : [], | |
| hubUrl: hubRepoUrl("dataset", repoId), | |
| permissions: toPermissionSummary(viewer, owner), | |
| branches: refs.branches, | |
| files, | |
| overview: { | |
| description: info.description || "", | |
| downloads: info.downloads || 0, | |
| likes: info.likes || 0, | |
| }, | |
| community: { | |
| commits, | |
| discussions, | |
| nativeUrl: `${hubRepoUrl("dataset", repoId)}/discussions`, | |
| }, | |
| live: { | |
| updatesUrl: | |
| `/api/resources/dataset/${owner}/${name}/updates/stream` + | |
| (files?.branch ? `?branch=${encodeURIComponent(files.branch)}` : ""), | |
| }, | |
| settings: { | |
| ...buildNativeLinks("dataset", repoId), | |
| visibility: normalizeVisibility(info.visibility, info.private), | |
| resourceGroup: info.resourceGroup || null, | |
| usedStorage: Number(info.usedStorage || 0) || 0, | |
| supportsVisibility: true, | |
| supportsMove: true, | |
| supportsDelete: true, | |
| }, | |
| }; | |
| } | |
| async function getBucketInfo(accessToken, repoId) { | |
| const directPayload = await optionalHubJson( | |
| accessToken, | |
| `${HUB_URL}/api/buckets/${repoId}`, | |
| { | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }, | |
| "Couldn't load that bucket.", | |
| ); | |
| if (directPayload) { | |
| return normalizeBucketEntry(directPayload); | |
| } | |
| const { owner, name } = splitRepoId(repoId); | |
| const buckets = await safeList(async () => listBucketsForNamespace(accessToken, owner, true)); | |
| const matched = | |
| buckets.find((bucket) => bucket.id === repoId) || | |
| buckets.find((bucket) => bucket.owner === owner && bucket.name === name); | |
| if (matched) { | |
| return matched; | |
| } | |
| return { | |
| kind: "bucket", | |
| id: repoId, | |
| owner, | |
| name, | |
| private: true, | |
| size: 0, | |
| fileCount: 0, | |
| updatedAt: null, | |
| createdAt: null, | |
| region: null, | |
| url: `/buckets/${owner}/${name}/files`, | |
| hubUrl: hubRepoUrl("bucket", repoId), | |
| }; | |
| } | |
| async function getBucketActivity(accessToken, repoId, currentPath = "") { | |
| const rows = await collectAsync( | |
| listFiles({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef("bucket", repoId), | |
| recursive: true, | |
| path: currentPath || undefined, | |
| expand: true, | |
| }), | |
| MAX_FILE_LIST_ITEMS, | |
| ); | |
| return rows | |
| .map(formatPathEntry) | |
| .filter((entry) => entry.type === "file") | |
| .sort((left, right) => (right.uploadedAt || "").localeCompare(left.uploadedAt || "")) | |
| .slice(0, MAX_COMMUNITY_ITEMS); | |
| } | |
| async function getBucketDetail(accessToken, viewer, owner, name, tab, requestedPath) { | |
| const repoId = `${owner}/${name}`; | |
| const info = await getBucketInfo(accessToken, repoId).catch((error) => { | |
| throw wrapHubError(error, "Couldn't load that bucket."); | |
| }); | |
| const activeTab = new Set(["files", "settings"]).has(tab) ? tab : "files"; | |
| const files = activeTab === "files" ? await buildFilesView(accessToken, "bucket", owner, name, "", requestedPath, []) : null; | |
| const activity = await getBucketActivity(accessToken, repoId, files?.currentPath || ""); | |
| const derivedFileCount = info.fileCount || activity.length || files?.entries.filter((entry) => entry.type === "file").length || 0; | |
| const derivedSize = info.size || activity.reduce((sum, entry) => sum + Number(entry.size || 0), 0); | |
| return { | |
| kind: "bucket", | |
| resourceType: "bucket", | |
| owner, | |
| name, | |
| id: repoId, | |
| title: repoId, | |
| tab: activeTab, | |
| private: Boolean(info.private), | |
| size: derivedSize, | |
| fileCount: derivedFileCount, | |
| createdAt: info.createdAt, | |
| updatedAt: info.updatedAt, | |
| region: info.region || null, | |
| hubUrl: hubRepoUrl("bucket", repoId), | |
| permissions: toPermissionSummary(viewer, owner), | |
| files, | |
| activity, | |
| live: { | |
| updatesUrl: | |
| `/api/resources/bucket/${owner}/${name}/updates/stream` + | |
| (files?.currentPath ? `?path=${encodeURIComponent(files.currentPath)}` : ""), | |
| }, | |
| settings: { | |
| ...buildNativeLinks("bucket", repoId), | |
| visibility: normalizeVisibility(info.visibility, info.private), | |
| storageType: "Mutable storage bucket", | |
| branchUnavailable: true, | |
| usedStorage: Number(derivedSize || 0) || 0, | |
| supportsVisibility: false, | |
| supportsMove: false, | |
| supportsDelete: true, | |
| }, | |
| }; | |
| } | |
| function collectionTitle(resourceType) { | |
| switch (resourceType) { | |
| case "space": | |
| return "Your Spaces"; | |
| case "model": | |
| return "Your Models"; | |
| case "dataset": | |
| return "Your Datasets"; | |
| case "bucket": | |
| return "Your Buckets"; | |
| default: | |
| return "Your Resources"; | |
| } | |
| } | |
| function summarizeResourceIndex(index) { | |
| return { | |
| spaces: index.spaces.length, | |
| models: index.models.length, | |
| datasets: index.datasets.length, | |
| buckets: index.buckets.length, | |
| }; | |
| } | |
| async function getCollectionDetail(accessToken, viewer, resourceType) { | |
| const index = await getOwnedResourceIndex(accessToken, viewer); | |
| const items = | |
| resourceType === "space" | |
| ? index.spaces | |
| : resourceType === "model" | |
| ? index.models | |
| : resourceType === "dataset" | |
| ? index.datasets | |
| : index.buckets; | |
| return { | |
| kind: "collection", | |
| resourceType, | |
| title: collectionTitle(resourceType), | |
| items, | |
| summary: summarizeResourceIndex(index), | |
| }; | |
| } | |
| async function getDashboardDetail(accessToken, viewer) { | |
| const index = await getOwnedResourceIndex(accessToken, viewer); | |
| return { | |
| kind: "dashboard", | |
| title: `${viewer.fullname}'s workspace`, | |
| summary: summarizeResourceIndex(index), | |
| resources: index, | |
| }; | |
| } | |
| async function getSettingsDetail(accessToken, viewer) { | |
| const index = await getOwnedResourceIndex(accessToken, viewer); | |
| return { | |
| kind: "settings", | |
| title: "Account settings", | |
| account: viewer, | |
| summary: summarizeResourceIndex(index), | |
| resources: { | |
| spaces: index.spaces.slice(0, 4), | |
| models: index.models.slice(0, 4), | |
| datasets: index.datasets.slice(0, 4), | |
| buckets: index.buckets.slice(0, 4), | |
| }, | |
| links: { | |
| profileUrl: viewer.profileUrl, | |
| webhooksUrl: `${HUB_URL}/settings/webhooks`, | |
| repositoriesUrl: `${HUB_URL}/settings/repositories`, | |
| tokensUrl: `${HUB_URL}/settings/tokens`, | |
| }, | |
| }; | |
| } | |
| async function getPageData(accessToken, requestedPath) { | |
| const viewer = await getViewer(accessToken); | |
| const route = parseRoute(requestedPath); | |
| try { | |
| let page; | |
| if (route.kind === "dashboard") { | |
| page = await getDashboardDetail(accessToken, viewer); | |
| } else if (route.kind === "settings") { | |
| page = await getSettingsDetail(accessToken, viewer); | |
| } else if (route.kind === "collection") { | |
| page = await getCollectionDetail(accessToken, viewer, route.resourceType); | |
| } else if (route.kind === "resource") { | |
| if (route.resourceType === "space") { | |
| page = await getSpaceDetail(accessToken, viewer, route.owner, route.name, route.tab, route.branch, route.path); | |
| } else if (route.resourceType === "model") { | |
| page = await getModelDetail(accessToken, viewer, route.owner, route.name, route.tab, route.branch, route.path); | |
| } else if (route.resourceType === "dataset") { | |
| page = await getDatasetDetail(accessToken, viewer, route.owner, route.name, route.tab, route.branch, route.path); | |
| } else { | |
| page = await getBucketDetail(accessToken, viewer, route.owner, route.name, route.tab, route.path); | |
| } | |
| } else { | |
| page = { | |
| kind: "notFound", | |
| title: "Not found", | |
| }; | |
| } | |
| return { | |
| viewer, | |
| route, | |
| page, | |
| }; | |
| } catch (error) { | |
| throw wrapHubError(error, "Couldn't load that page from Hugging Face."); | |
| } | |
| } | |
| async function getSearchResults(accessToken, requestedPath, query) { | |
| const viewer = await getViewer(accessToken); | |
| const index = await getOwnedResourceIndex(accessToken, viewer, query); | |
| const results = searchResultsFromIndex(index, query); | |
| return { | |
| viewer, | |
| route: parseRoute(requestedPath), | |
| ...results, | |
| }; | |
| } | |
| async function getResourceUpdates(accessToken, type, repoId, options = {}) { | |
| const safeBranch = type === "bucket" ? "" : normalizeBranchName(options.branch || ""); | |
| const safePath = normalizeRemotePath(options.path || ""); | |
| if (type === "bucket") { | |
| const activity = await getBucketActivity(accessToken, repoId, safePath); | |
| return { | |
| kind: "bucket", | |
| signature: activity.map((entry) => `${entry.path}:${entry.uploadedAt || ""}`).join("|"), | |
| items: activity, | |
| }; | |
| } | |
| const commits = await getRecentCommits(accessToken, type, repoId, safeBranch || "main"); | |
| return { | |
| kind: "repo", | |
| signature: commits.map((commit) => commit.id).join("|"), | |
| items: commits, | |
| }; | |
| } | |
| async function getDownloadBlob(accessToken, params) { | |
| const type = params.type; | |
| const repoId = params.repoId; | |
| const filePath = normalizeRemotePath(params.path); | |
| const branch = type === "bucket" ? "" : normalizeBranchName(params.branch || "main"); | |
| try { | |
| const blob = await downloadFile({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef(type, repoId), | |
| path: filePath, | |
| ...(type === "bucket" ? {} : { revision: branch }), | |
| }); | |
| if (!blob) { | |
| throw new HfApiError(404, "That file was not found."); | |
| } | |
| return { | |
| blob, | |
| fileName: filePath.split("/").pop() || "download", | |
| contentType: blob.type || "application/octet-stream", | |
| }; | |
| } catch (error) { | |
| throw wrapHubError(error, "Couldn't download that file."); | |
| } | |
| } | |
| function toCommitMessage(action, filePath) { | |
| return `${action} ${filePath} from HF Home`; | |
| } | |
| async function updateTextFile(accessToken, params) { | |
| const type = params.type; | |
| const repoId = params.repoId; | |
| const filePath = normalizeRemotePath(params.path); | |
| const branch = type === "bucket" ? "" : normalizeBranchName(params.branch || "main"); | |
| const content = String(params.content || ""); | |
| if (Buffer.byteLength(content, "utf8") > MAX_EDITABLE_FILE_BYTES) { | |
| throw new HfApiError(413, "That file is too large to edit in this interface."); | |
| } | |
| try { | |
| const result = await uploadFile({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef(type, repoId), | |
| branch: branch || undefined, | |
| parentCommit: params.parentCommit || undefined, | |
| file: { | |
| path: filePath, | |
| content: new Blob([content], { type: "text/plain;charset=utf-8" }), | |
| }, | |
| commitTitle: toCommitMessage("Update", filePath), | |
| commitDescription: "Updated from the authenticated HF Home interface.", | |
| }); | |
| return { | |
| ok: true, | |
| commit: result?.commit || null, | |
| }; | |
| } catch (error) { | |
| throw wrapHubError(error, "Couldn't save that file."); | |
| } | |
| } | |
| async function removeFile(accessToken, params) { | |
| const type = params.type; | |
| const repoId = params.repoId; | |
| const filePath = normalizeRemotePath(params.path); | |
| const branch = type === "bucket" ? "" : normalizeBranchName(params.branch || "main"); | |
| try { | |
| const result = await deleteFile({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef(type, repoId), | |
| path: filePath, | |
| branch: branch || undefined, | |
| parentCommit: params.parentCommit || undefined, | |
| commitTitle: toCommitMessage("Delete", filePath), | |
| commitDescription: "Deleted from the authenticated HF Home interface.", | |
| }); | |
| return { | |
| ok: true, | |
| commit: result?.commit || null, | |
| }; | |
| } catch (error) { | |
| throw wrapHubError(error, "Couldn't delete that file."); | |
| } | |
| } | |
| async function performSpaceAction(accessToken, repoId, action) { | |
| const safeAction = String(action || "").trim(); | |
| const allowed = new Set(["pause", "restart", "factory-restart"]); | |
| if (!allowed.has(safeAction)) { | |
| throw new HfApiError(400, "That Space action is not supported."); | |
| } | |
| const targetUrl = | |
| safeAction === "pause" | |
| ? `${HUB_URL}/api/spaces/${repoId}/pause` | |
| : `${HUB_URL}/api/spaces/${repoId}/restart${safeAction === "factory-restart" ? "?factory=true" : ""}`; | |
| const response = await makeHubFetch(accessToken)(targetUrl, { | |
| method: "POST", | |
| headers: { | |
| Accept: "application/json", | |
| }, | |
| }).catch((error) => { | |
| throw wrapHubError(error, "Couldn't reach Hugging Face for that Space action."); | |
| }); | |
| if (!response.ok) { | |
| throw wrapHubError(new HfApiError(response.status, "Space action failed.")); | |
| } | |
| try { | |
| return await response.json(); | |
| } catch (error) { | |
| return { ok: true }; | |
| } | |
| } | |
| async function createResource(accessToken, params) { | |
| const type = String(params.type || "").trim(); | |
| if (!["space", "model", "dataset", "bucket"].includes(type)) { | |
| throw new HfApiError(400, "That resource type is not supported."); | |
| } | |
| const repoId = normalizeRepoIdInput(params.namespace, params.name); | |
| const visibility = String(params.visibility || "").trim().toLowerCase(); | |
| const isPrivate = visibility === "private"; | |
| try { | |
| const created = await createRepo({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef(type, repoId), | |
| private: isPrivate, | |
| ...(type === "space" ? { sdk: String(params.sdk || "gradio").trim() || "gradio" } : {}), | |
| }); | |
| const createdRepoId = created?.id || repoId; | |
| return { | |
| ok: true, | |
| repoId: createdRepoId, | |
| url: workspaceResourceUrl(type, createdRepoId), | |
| }; | |
| } catch (error) { | |
| throw wrapHubError(error, "Couldn't create that resource."); | |
| } | |
| } | |
| async function moveResource(accessToken, params) { | |
| const type = String(params.type || "").trim(); | |
| if (!["space", "model", "dataset", "bucket"].includes(type)) { | |
| throw new HfApiError(400, "That resource type is not supported."); | |
| } | |
| const fromId = normalizeRepoIdInput(...Object.values(splitRepoId(params.fromId || ""))); | |
| const toId = normalizeRepoIdInput(...Object.values(splitRepoId(params.toId || ""))); | |
| const targetUrl = type === "bucket" ? `${HUB_URL}/api/buckets/move` : `${HUB_URL}/api/repos/move`; | |
| await hubJson( | |
| accessToken, | |
| targetUrl, | |
| { | |
| method: "POST", | |
| headers: { | |
| Accept: "application/json", | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify({ | |
| type, | |
| repoType: type, | |
| repo_type: type, | |
| fromId, | |
| from_id: fromId, | |
| fromRepo: fromId, | |
| toId, | |
| to_id: toId, | |
| toRepo: toId, | |
| }), | |
| }, | |
| "Couldn't rename or transfer that resource.", | |
| ); | |
| return { | |
| ok: true, | |
| repoId: toId, | |
| url: workspaceResourceUrl(type, toId), | |
| }; | |
| } | |
| async function updateResourceVisibility(accessToken, params) { | |
| const type = String(params.type || "").trim(); | |
| if (!["space", "model", "dataset"].includes(type)) { | |
| throw new HfApiError(400, "Visibility changes are only supported for Spaces, models, and datasets."); | |
| } | |
| const repoId = normalizeRepoIdInput(...Object.values(splitRepoId(params.repoId || ""))); | |
| const visibility = normalizeVisibility(params.visibility, params.private); | |
| const payload = { | |
| private: visibility === "private", | |
| visibility, | |
| }; | |
| await hubJson( | |
| accessToken, | |
| `${HUB_URL}/api/${pluralFor(type)}/${repoId}/settings`, | |
| { | |
| method: "PUT", | |
| headers: { | |
| Accept: "application/json", | |
| "Content-Type": "application/json", | |
| }, | |
| body: JSON.stringify(payload), | |
| }, | |
| "Couldn't update that resource's visibility.", | |
| ); | |
| return { | |
| ok: true, | |
| visibility, | |
| }; | |
| } | |
| async function deleteResource(accessToken, params) { | |
| const type = String(params.type || "").trim(); | |
| if (!["space", "model", "dataset", "bucket"].includes(type)) { | |
| throw new HfApiError(400, "That resource type is not supported."); | |
| } | |
| const repoId = normalizeRepoIdInput(...Object.values(splitRepoId(params.repoId || ""))); | |
| try { | |
| await deleteRepo({ | |
| accessToken, | |
| hubUrl: HUB_URL, | |
| repo: repoRef(type, repoId), | |
| }); | |
| return { ok: true }; | |
| } catch (error) { | |
| throw wrapHubError(error, "Couldn't delete that resource."); | |
| } | |
| } | |
| async function saveSpaceSecret(accessToken, repoId, params) { | |
| return upsertSpaceConfigEntry(accessToken, repoId, "secret", params); | |
| } | |
| async function saveSpaceVariable(accessToken, repoId, params) { | |
| return upsertSpaceConfigEntry(accessToken, repoId, "variable", params); | |
| } | |
| function buildSpaceStreamUrl(repoId, kind) { | |
| if (kind === "events") { | |
| return `${HUB_URL}/api/spaces/${repoId}/events`; | |
| } | |
| if (kind === "metrics") { | |
| return `${HUB_URL}/api/spaces/${repoId}/metrics`; | |
| } | |
| if (kind === "build") { | |
| return `${HUB_URL}/api/spaces/${repoId}/logs/build`; | |
| } | |
| return `${HUB_URL}/api/spaces/${repoId}/logs/run`; | |
| } | |
| module.exports = { | |
| HUB_URL, | |
| HfApiError, | |
| buildSpaceStreamUrl, | |
| createResource, | |
| deleteResource, | |
| getDownloadBlob, | |
| getPageData, | |
| getResourceUpdates, | |
| getSearchResults, | |
| getViewer, | |
| moveResource, | |
| normalizeBranchName, | |
| normalizeRemotePath, | |
| performSpaceAction, | |
| removeFile, | |
| saveSpaceSecret, | |
| saveSpaceVariable, | |
| updateTextFile, | |
| updateResourceVisibility, | |
| wrapHubError, | |
| }; | |