import { basename, dirname, join, posix, relative, resolve } from "node:path"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { parseHTML } from "linkedom"; import AdmZip from "adm-zip"; import ignore, { type Ignore } from "ignore"; import { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "@hyperframes/core"; import { buildAuthHeaders } from "../auth/client.js"; import { tryResolveCredential } from "../auth/index.js"; import { writeProjectLink } from "./projectLink.js"; const IGNORED_DIRS = new Set([".git", "node_modules", "dist", ".next", "coverage"]); const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]); const HYPERFRAMES_IGNORE_FILE = ".hyperframesignore"; const DEFAULT_PROJECT_IGNORE = ["/renders/", "/snapshots/"]; const PUBLISH_CONTENT_TYPE = "application/zip"; const PUBLISH_METADATA_TIMEOUT_MS = 30_000; const PUBLISH_UPLOAD_MIN_TIMEOUT_MS = 120_000; const PUBLISH_TRANSPORT_ATTEMPTS = 2; const PUBLISH_RETRY_DELAY_MS = 200; // Conservative floor — most connections are faster, but this prevents // premature aborts on slow/unstable networks (hotel wifi, tethering). const PUBLISH_UPLOAD_BYTES_PER_SECOND = 500_000; export interface PublishArchiveResult { buffer: Buffer; fileCount: number; } export interface PublishedProjectResponse { projectId: string; title: string; fileCount: number; url: string; claimToken: string; /** True when the project is owned by the authenticated publisher (created-and-owned or updated in place). */ claimed: boolean; } interface StagedUploadResponse { uploadUrl: string; uploadKey: string; contentType: string; uploadHeaders: Record; expiresInSeconds: number; } type JsonRecord = Record; function isRecord(value: unknown): value is JsonRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } function dataRecord(payload: unknown): JsonRecord | null { if (!isRecord(payload) || !isRecord(payload["data"])) return null; return payload["data"]; } function stringField(record: JsonRecord, key: string): string | null { const value = record[key]; return typeof value === "string" ? value : null; } function parsePublishedProjectResponse(payload: unknown): PublishedProjectResponse | null { const data = dataRecord(payload); if (!data) return null; const projectId = stringField(data, "project_id"); const title = stringField(data, "title"); const url = stringField(data, "url"); const claimToken = stringField(data, "claim_token") ?? ""; const claimed = data["claimed"] === true; const fileCount = data["file_count"]; if (!projectId || !title || !url || typeof fileCount !== "number") { return null; } // Anonymous publishes must return a claim token; owned (claimed) ones need none. if (!claimed && !claimToken) { return null; } return { projectId, title, fileCount, url, claimToken, claimed, }; } function parseStagedUploadResponse( payload: unknown, archiveByteLength: number, ): StagedUploadResponse | null { const data = dataRecord(payload); if (!data) return null; const uploadUrl = stringField(data, "upload_url"); const uploadKey = stringField(data, "upload_key"); const contentType = stringField(data, "content_type") || PUBLISH_CONTENT_TYPE; if (!uploadUrl || !uploadKey) return null; const rawExpires = data["expires_in_seconds"]; const expiresInSeconds = typeof rawExpires === "number" && rawExpires > 0 ? rawExpires : 1800; return { uploadUrl, uploadKey, contentType, uploadHeaders: getUploadHeaders(data, uploadUrl, contentType, archiveByteLength), expiresInSeconds, }; } function getUploadHeaders( data: JsonRecord, uploadUrl: string, contentType: string, archiveByteLength: number, ): Record { const headers: Record = {}; const uploadHeaders = data["upload_headers"]; if (isRecord(uploadHeaders)) { for (const [key, value] of Object.entries(uploadHeaders)) { if (typeof value === "string" && key.trim()) { headers[key] = value; } } } if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) { headers["content-type"] = contentType; } const signedHeaders = new URL(uploadUrl).searchParams.get("X-Amz-SignedHeaders"); if ( signedHeaders?.split(";").includes("x-amz-server-side-encryption") && !Object.keys(headers).some((key) => key.toLowerCase() === "x-amz-server-side-encryption") ) { headers["x-amz-server-side-encryption"] = "AES256"; } if ( signedHeaders?.split(";").includes("content-length") && !Object.keys(headers).some((key) => key.toLowerCase() === "content-length") ) { headers["content-length"] = String(archiveByteLength); } return headers; } async function readJson(response: Response): Promise { return response .clone() .json() .catch(() => null); } async function readErrorMessage(response: Response, fallback: string): Promise { const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const payload = await readJson(response); if (isRecord(payload) && typeof payload["message"] === "string") { return payload["message"]; } } if (response.status === 403 && response.headers.get("cf-mitigated") === "challenge") { return "Publish upload was blocked before reaching HyperFrames. Please retry after staged uploads are available."; } const text = await response.text().catch(() => ""); return text.trim() ? `${fallback}: ${text.trim().slice(0, 180)}` : fallback; } function systemErrorMetadata(value: unknown): string[] { if (!isRecord(value)) return []; const metadata: string[] = []; if (typeof value["code"] === "string") metadata.push(value["code"]); if (typeof value["syscall"] === "string") metadata.push(`syscall=${value["syscall"]}`); if (typeof value["errno"] === "string" || typeof value["errno"] === "number") { metadata.push(`errno=${value["errno"]}`); } return metadata; } function redactUrlQuery(message: string): string { return message.replace(/(https?:\/\/[^\s?]+)\?[^\s]+/gu, "$1?[redacted]"); } function proxySupportHint(): string { const proxyConfigured = ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"].some((key) => Boolean(process.env[key]?.trim()), ); const proxyEnabled = process.env["NODE_USE_ENV_PROXY"] === "1" || process.execArgv.includes("--use-env-proxy") || process.env["NODE_OPTIONS"]?.split(/\s+/u).includes("--use-env-proxy") === true; if (!proxyConfigured || proxyEnabled) return ""; return ( ". Proxy variables are set but ignored by Node fetch; if this network requires them, retry with " + "NODE_USE_ENV_PROXY=1 (Node 22.21+)" ); } function describeFetchFailure(error: unknown): string { const message = error instanceof Error ? error.message : String(error); const cause = error instanceof Error ? error.cause : undefined; const causeMessage = cause instanceof Error ? cause.message : ""; const metadata = [...systemErrorMetadata(cause), ...systemErrorMetadata(error)].filter( (value, index, all) => all.indexOf(value) === index, ); const distinctCauseMessage = causeMessage && causeMessage !== message ? causeMessage : ""; const detail = [metadata.join(", "), distinctCauseMessage].filter(Boolean).join(": "); return `${redactUrlQuery(message)}${detail ? ` (${redactUrlQuery(detail)})` : ""}${proxySupportHint()}`; } function isRequestTimeout(error: unknown): boolean { return ( error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError") ); } function waitBeforePublishRetry(): Promise { return new Promise((resolve) => setTimeout(resolve, PUBLISH_RETRY_DELAY_MS)); } async function fetchForPublish( input: string, createInit: () => RequestInit, failureStage: string, attempts = 1, ): Promise { if (attempts < 1) throw new RangeError("Publish fetch attempts must be at least 1"); let lastError: unknown; let attemptsMade = 0; for (let attempt = 1; attempt <= attempts; attempt += 1) { attemptsMade = attempt; try { return await fetch(input, createInit()); } catch (error) { lastError = error; if (isRequestTimeout(error) || attempt === attempts) break; await waitBeforePublishRetry(); } } const attemptDetail = attemptsMade > 1 ? ` after ${attemptsMade} attempts` : ""; throw new Error(`${failureStage}${attemptDetail}: ${describeFetchFailure(lastError)}`, { cause: lastError instanceof Error ? lastError : undefined, }); } export function uploadTimeoutMs(byteLength: number): number { return Math.max( PUBLISH_UPLOAD_MIN_TIMEOUT_MS, Math.ceil((byteLength / PUBLISH_UPLOAD_BYTES_PER_SECOND) * 1000), ); } function shouldIgnoreSegment(segment: string): boolean { return segment.startsWith(".") || IGNORED_DIRS.has(segment) || IGNORED_FILES.has(segment); } function createProjectIgnore(rootDir: string): Ignore { const matcher = ignore().add(DEFAULT_PROJECT_IGNORE); const ignorePath = join(rootDir, HYPERFRAMES_IGNORE_FILE); if (existsSync(ignorePath)) { matcher.add(readFileSync(ignorePath, "utf-8")); } return matcher; } function collectProjectFiles( rootDir: string, currentDir: string, paths: string[], matcher: Ignore, ): void { for (const entry of readdirSync(currentDir, { withFileTypes: true })) { if (shouldIgnoreSegment(entry.name)) continue; const absolutePath = join(currentDir, entry.name); const relativePath = relative(rootDir, absolutePath).replaceAll("\\", "/"); if (!relativePath) continue; if (entry.isDirectory()) { if (matcher.ignores(`${relativePath}/`)) continue; collectProjectFiles(rootDir, absolutePath, paths, matcher); continue; } if (!statSync(absolutePath).isFile()) continue; if (matcher.ignores(relativePath)) continue; paths.push(relativePath); } } const EXT_ASSETS_PREFIX = "_ext"; interface ExternalAssetContext { absProjectDir: string; fileContents: Map; externalMap: Map; usedArchivePaths: Set; } function addExternalAsset(ctx: ExternalAssetContext, absPath: string): string { const existing = ctx.externalMap.get(absPath); if (existing) return existing; const rel = relative(ctx.absProjectDir, absPath).replaceAll("\\", "/"); const stripped = rel.replace(/^(?:\.\.\/)+/, ""); let archivePath = `${EXT_ASSETS_PREFIX}/${stripped}`; if (ctx.usedArchivePaths.has(archivePath)) { const ext = posix.extname(archivePath); const base = archivePath.slice(0, archivePath.length - ext.length); let i = 2; while (ctx.usedArchivePaths.has(`${base}_${i}${ext}`)) i++; archivePath = `${base}_${i}${ext}`; } ctx.fileContents.set(archivePath, readFileSync(absPath)); ctx.externalMap.set(absPath, archivePath); ctx.usedArchivePaths.add(archivePath); return archivePath; } function tryResolveExternal( ctx: ExternalAssetContext, rawPath: string, referrerAbsDir: string, ): string | null { if (isNonRelativeUrl(rawPath)) return null; const absPath = resolve(referrerAbsDir, rawPath); if (isPathInside(absPath, ctx.absProjectDir)) return null; try { if (!existsSync(absPath) || !statSync(absPath).isFile()) return null; } catch { return null; } return addExternalAsset(ctx, absPath); } function rewriteCssUrls( ctx: ExternalAssetContext, css: string, referrerAbsDir: string, entryPath: string, ): { css: string; modified: boolean } { let modified = false; const rewritten = css.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => { const archivePath = tryResolveExternal(ctx, (rawUrl || "").trim(), referrerAbsDir); if (!archivePath) return full; modified = true; return `url(${quote || ""}${posix.relative(posix.dirname(entryPath), archivePath)}${quote || ""})`; }); return { css: rewritten, modified }; } /** Resolves a raw attribute value (plus the referrer's absolute directory) to * the archive path it should point at, or `null` to leave it untouched. */ export type HtmlAttributeResolver = (rawValue: string, referrerAbsDir: string) => string | null; interface RewriteHtmlAttributesOptions { /** Attributes to inspect (default: src + href, matching the external-asset * localization use case below). */ attrs?: string[]; /** CSS selector narrowing which elements are inspected (default: derived * from `attrs`, e.g. `"[src], [href]"`). Callers that only care about one * tag (e.g. `