From 2c20e9a666d94e555e8031b467926f362e1710fe Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 19 Oct 2026 14:36:01 -0400 Subject: [PATCH 1/2] fix(studio): a lost preview server or a superseded edit no longer files as a crash --- .../studio/src/hooks/useFileTree.test.tsx | 34 ++++++++++++++++ packages/studio/src/hooks/useFileTree.ts | 26 +++++++++++-- packages/studio/src/main.tsx | 31 ++++----------- .../src/player/hooks/useTimelinePlayer.ts | 4 +- packages/studio/src/player/sceneSwap.test.ts | 13 +++++-- packages/studio/src/player/sceneSwap.ts | 3 ++ .../src/utils/rejectionTelemetry.test.ts | 39 +++++++++++++++++++ .../studio/src/utils/rejectionTelemetry.ts | 33 ++++++++++++++++ 8 files changed, 150 insertions(+), 33 deletions(-) create mode 100644 packages/studio/src/utils/rejectionTelemetry.test.ts create mode 100644 packages/studio/src/utils/rejectionTelemetry.ts diff --git a/packages/studio/src/hooks/useFileTree.test.tsx b/packages/studio/src/hooks/useFileTree.test.tsx index 91491bac7fe..8e940dd8c7f 100644 --- a/packages/studio/src/hooks/useFileTree.test.tsx +++ b/packages/studio/src/hooks/useFileTree.test.tsx @@ -9,7 +9,10 @@ vi.mock("../components/feedback/projectProvenance", () => ({ captureProjectProvenance: vi.fn(), })); +vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + import { captureProjectProvenance } from "../components/feedback/projectProvenance"; +import { trackStudioEvent } from "../utils/studioTelemetry"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -244,6 +247,37 @@ describe("useFileTree.refreshFileTree", () => { fetchSpy.mockRestore(); }); + it("resolves when the server is gone, and reports it once per unreachable streak", async () => { + const track = vi.mocked(trackStudioEvent); + track.mockClear(); + let serverUp = true; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + if (!serverUp) throw new TypeError("Failed to fetch"); + return new Response(JSON.stringify({ files: ["index.html"], compositions: ["index.html"] })); + }); + const handleRef = await renderHarness("project-a"); + + serverUp = false; + await act(async () => { + await handleRef.current?.refresh(); + await handleRef.current?.refresh(); + }); + expect(handleRef.current?.compositions).toEqual(["index.html"]); + expect(track).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenCalledWith("file_tree_refresh_failed", { + error_name: "TypeError", + error_message: "Failed to fetch", + }); + + serverUp = true; + await act(async () => handleRef.current?.refresh()); + serverUp = false; + await act(async () => handleRef.current?.refresh()); + expect(track).toHaveBeenCalledTimes(2); + + fetchSpy.mockRestore(); + }); + it("keeps the prior compositions list when a refresh response omits the field", async () => { let call = 0; const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { diff --git a/packages/studio/src/hooks/useFileTree.ts b/packages/studio/src/hooks/useFileTree.ts index bdcb64a120a..3ca70cf8ede 100644 --- a/packages/studio/src/hooks/useFileTree.ts +++ b/packages/studio/src/hooks/useFileTree.ts @@ -4,6 +4,8 @@ import { FONT_EXT } from "../utils/mediaTypes"; import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; import { captureProjectProvenance } from "../components/feedback/projectProvenance"; import { studioApiFetch } from "../utils/studioApiFetch"; +import { trackStudioEvent } from "../utils/studioTelemetry"; +import { errorProps } from "../utils/rejectionTelemetry"; import { encodeUrlPath } from "@hyperframes/parsers"; interface UseFileTreeOptions { @@ -23,6 +25,19 @@ interface FetchedFileTree { // and defeat useMemo/useCallback deps downstream (e.g. the `assets` memo below). const EMPTY_FILE_LIST: string[] = []; +type TreeListing = { files?: string[]; compositions?: string[] }; + +async function readTreeListing(projectId: string, signal: AbortSignal): Promise { + const res = await studioApiFetch(buildProjectApiPath(projectId), { signal }); + if (!res.ok) throw new Error(`tree fetch failed: ${res.status}`); + return res.json(); +} + +function reportRefreshFailure(err: unknown): void { + const { error_name, error_message } = errorProps(err); + trackStudioEvent("file_tree_refresh_failed", { error_name, error_message }); +} + /** * Holds the tree with the `projectId` it was fetched for; exposes empty/not-loaded * whenever that id doesn't match the current one, so a switch can't read another project's tree. @@ -74,6 +89,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) { const refreshRequestRef = useRef(0); const refreshAbortRef = useRef(null); + const failureReportedRef = useRef(false); const refreshFileTree = useCallback(async () => { const pid = projectIdRef.current; @@ -84,14 +100,16 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) { const requestId = ++refreshRequestRef.current; const controller = new AbortController(); refreshAbortRef.current = controller; - let data: { files?: string[]; compositions?: string[] }; + let data: TreeListing; try { - const res = await studioApiFetch(buildProjectApiPath(pid), { signal: controller.signal }); - data = await res.json(); + data = await readTreeListing(pid, controller.signal); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; - throw err; + if (!failureReportedRef.current) reportRefreshFailure(err); + failureReportedRef.current = true; + return; } + failureReportedRef.current = false; if (data.files && requestId === refreshRequestRef.current) { setFetched((prev) => prev?.projectId === pid diff --git a/packages/studio/src/main.tsx b/packages/studio/src/main.tsx index 63c0ab57106..ff8735e274a 100644 --- a/packages/studio/src/main.tsx +++ b/packages/studio/src/main.tsx @@ -6,6 +6,11 @@ import { ShowThemeToggle } from "./components/ThemeToggle"; import { StudioErrorBoundary } from "./components/StudioErrorBoundary"; import { readIconTokens } from "./styles/iconTokens"; import { trackStudioEvent } from "./utils/studioTelemetry"; +import { + errorProps, + isCompositionAssetError, + isExtensionOnlyStack, +} from "./utils/rejectionTelemetry"; import { prefetchPreviewForHash } from "./utils/previewPrefetch"; import { bindThumbnailPageLifecycle } from "./player/lib/thumbnailPageLifecycle"; import "./styles/studio.css"; @@ -17,30 +22,6 @@ prefetchPreviewForHash(window.location.hash); window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash)); trackStudioEvent("session_start"); -function errorProps(value: unknown): { - error_message: string; - error_name: string | null; - stack_trace: string | null; -} { - if (value instanceof Error) { - return { - error_message: value.message, - error_name: value.name, - stack_trace: value.stack?.slice(0, 4000) ?? null, - }; - } - return { error_message: String(value), error_name: null, stack_trace: null }; -} - -// fallow-ignore-next-line complexity -function isCompositionAssetError(msg: string, name: string | null): boolean { - if (msg.includes("Error fetching") && (msg.includes("404") || msg.includes("Not Found"))) - return true; - if (name === "EncodingError" || msg.includes("unsupported or unrecognizable format")) return true; - if (msg.includes("MEDIA_ERR_SRC_NOT_SUPPORTED")) return true; - return false; -} - const ERROR_CAP = 50; let errorCount = 0; let rejectionCount = 0; @@ -114,6 +95,8 @@ window.addEventListener("unhandledrejection", (event) => { } return; } + // Not ours: an extension's own promise. Dropped before the cap so it cannot silence ours. + if (isExtensionOnlyStack(props.stack_trace)) return; rejectionCount++; if (rejectionCount > ERROR_CAP) { diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index c5a57fb1557..b2715af6ba9 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -42,7 +42,7 @@ import { isStudioManualEditGestureLiveIn } from "../../components/editor/manualE import { createPreviewMessageHandler } from "./previewMessageRouter"; import { timelineElementsChanged } from "./timelinePlayerSync"; import { safeContentDocument } from "./timelineSyncHydration"; -import { sceneSwapFor } from "../sceneSwap"; +import { sceneSwapFor, supersededByNewerEdit } from "../sceneSwap"; import { whileScriptWrites } from "../previewReloading"; export interface UseTimelinePlayerOptions { @@ -489,7 +489,7 @@ export function useTimelinePlayer({ const refreshPlayer = useCallback(() => { const iframe = iframeRef.current; if (!iframe) return; - swapCancelRef.current?.abort(new Error("superseded by a newer edit")); + swapCancelRef.current?.abort(supersededByNewerEdit()); const cancel = new AbortController(); swapCancelRef.current = cancel; logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack })); diff --git a/packages/studio/src/player/sceneSwap.test.ts b/packages/studio/src/player/sceneSwap.test.ts index 62d882a0bb9..c3bb03915a1 100644 --- a/packages/studio/src/player/sceneSwap.test.ts +++ b/packages/studio/src/player/sceneSwap.test.ts @@ -5,7 +5,9 @@ import { announcePreviewDocumentLoaded, onPreviewContentReplaced, sceneSwapFor, + supersededByNewerEdit, } from "./sceneSwap"; +import { errorProps } from "../utils/rejectionTelemetry"; afterEach(() => { vi.useRealTimers(); @@ -83,10 +85,15 @@ describe("sceneSwapFor", () => { .mockImplementation(async () => new Response("")); const { iframe, swap } = swappableIframe(); const cancel = new AbortController(); - cancel.abort(new Error("superseded by a newer edit")); - await expect(sceneSwapFor(iframe)!("/preview", () => true, cancel.signal)).rejects.toThrow( - "superseded", + cancel.abort(supersededByNewerEdit()); + const rejection = await sceneSwapFor(iframe)!("/preview", () => true, cancel.signal).catch( + (error: unknown) => error, ); + // The rejection listener files AbortErrors as cancellations, never as unhandled failures. + expect(errorProps(rejection)).toMatchObject({ + error_name: "AbortError", + error_message: "superseded by a newer edit", + }); expect(fetchSpy).not.toHaveBeenCalled(); expect(swap).not.toHaveBeenCalled(); }); diff --git a/packages/studio/src/player/sceneSwap.ts b/packages/studio/src/player/sceneSwap.ts index bcabb4bf056..e48559d1ee5 100644 --- a/packages/studio/src/player/sceneSwap.ts +++ b/packages/studio/src/player/sceneSwap.ts @@ -71,6 +71,9 @@ export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonl meta.content = JSON.stringify(parts); } +export const supersededByNewerEdit = () => + new DOMException("superseded by a newer edit", "AbortError"); + /** Null when the preview cannot swap; rejects when superseded, cancelled or a full reload is needed. */ export function sceneSwapFor( iframe: HTMLIFrameElement, diff --git a/packages/studio/src/utils/rejectionTelemetry.test.ts b/packages/studio/src/utils/rejectionTelemetry.test.ts new file mode 100644 index 00000000000..8bb4167d176 --- /dev/null +++ b/packages/studio/src/utils/rejectionTelemetry.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isExtensionOnlyStack } from "./rejectionTelemetry"; + +describe("isExtensionOnlyStack", () => { + it("is true when every V8 frame is an extension script", () => { + const stack = [ + "TypeError: Cannot read properties of undefined (reading 'M_ID')", + " at Y (chrome-extension://abcdefghijklmnop/executors/200.js:1:761)", + " at E (chrome-extension://abcdefghijklmnop/executors/200.js:1:1442)", + ].join("\n"); + expect(isExtensionOnlyStack(stack)).toBe(true); + }); + + it("is true for a wallet's single-frame connect failure", () => { + const stack = + "i: Failed to connect to MetaMask\n at Object.connect (chrome-extension://abcdefghijklmnop/scripts/inpage.js:7:84292)"; + expect(isExtensionOnlyStack(stack)).toBe(true); + }); + + it("reads Firefox and Safari frames", () => { + expect(isExtensionOnlyStack("connect@moz-extension://1234-abcd/inpage.js:3:10\n")).toBe(true); + expect(isExtensionOnlyStack("f@safari-web-extension://1234-abcd/content.js:1:5")).toBe(true); + }); + + it("is false when a Studio frame is in the stack, even under an extension's fetch wrapper", () => { + const stack = [ + "TypeError: Failed to fetch", + " at o (chrome-extension://abcdefghijklmnop/frame_ant/frame_ant.js:2:18135)", + " at window.fetch (chrome-extension://abcdefghijklmnop/frame_ant/frame_ant.js:2:18200)", + " at Dt (http://localhost:3002/assets/index-abc.js:49:296453)", + ].join("\n"); + expect(isExtensionOnlyStack(stack)).toBe(false); + }); + + it("is false for a stack with no frames to judge", () => { + expect(isExtensionOnlyStack(null)).toBe(false); + expect(isExtensionOnlyStack("Error: chrome-extension://abcdefghijklmnop failed")).toBe(false); + }); +}); diff --git a/packages/studio/src/utils/rejectionTelemetry.ts b/packages/studio/src/utils/rejectionTelemetry.ts new file mode 100644 index 00000000000..a0b2b37ab11 --- /dev/null +++ b/packages/studio/src/utils/rejectionTelemetry.ts @@ -0,0 +1,33 @@ +export function errorProps(value: unknown): { + error_message: string; + error_name: string | null; + stack_trace: string | null; +} { + if (value instanceof Error) { + return { + error_message: value.message, + error_name: value.name, + stack_trace: value.stack?.slice(0, 4000) ?? null, + }; + } + return { error_message: String(value), error_name: null, stack_trace: null }; +} + +// fallow-ignore-next-line complexity +export function isCompositionAssetError(msg: string, name: string | null): boolean { + if (msg.includes("Error fetching") && (msg.includes("404") || msg.includes("Not Found"))) + return true; + if (name === "EncodingError" || msg.includes("unsupported or unrecognizable format")) return true; + if (msg.includes("MEDIA_ERR_SRC_NOT_SUPPORTED")) return true; + return false; +} + +// A frame is a line ending in `:line:col`, in both the V8 (`at f (url:1:2)`) and Gecko/WebKit (`f@url:1:2`) forms. +const STACK_FRAME = /:\d+:\d+\)?\s*$/; +const EXTENSION_URL = /\b(?:chrome|moz|safari|safari-web)-extension:\/\//; + +/** True when every frame is an extension's script: a failure no Studio code took part in. */ +export function isExtensionOnlyStack(stack: string | null): boolean { + const frames = stack?.split("\n").filter((line) => STACK_FRAME.test(line)) ?? []; + return frames.length > 0 && frames.every((line) => EXTENSION_URL.test(line)); +} From 7c5407a9e43f28870b7ca1ef74e8df21091da37f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 19 Oct 2026 14:56:05 -0400 Subject: [PATCH 2/2] fix(studio-server): a render's scratch files no longer stream changes or list as compositions --- packages/cli/src/server/fileWatcher.test.ts | 8 ++++++++ packages/cli/src/server/fileWatcher.ts | 19 ++----------------- packages/core/src/projectFolders.ts | 6 ++++++ .../src/services/render/renderDirOwner.ts | 4 +--- .../src/helpers/projectSignature.ts | 13 +++++-------- .../src/helpers/safePath.test.ts | 11 +++++++++++ .../studio-server/src/helpers/safePath.ts | 4 +++- 7 files changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/server/fileWatcher.test.ts b/packages/cli/src/server/fileWatcher.test.ts index 9e693d6b457..9d1f4084f13 100644 --- a/packages/cli/src/server/fileWatcher.test.ts +++ b/packages/cli/src/server/fileWatcher.test.ts @@ -48,6 +48,14 @@ describe("shouldWatchProjectFile", () => { expect(shouldWatchProjectFile(".waveform-cache/peaks.json")).toBe(false); }); + it("skips a render's scratch dirs, so a render beside the project does not stream changes", () => { + const work = "work-0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d-Ab12Cd"; + expect(shouldWatchProjectFile(`${work}/compiled/index.html`)).toBe(false); + expect(shouldWatchProjectFile(`${work}/frames/frame-000042.png`)).toBe(false); + expect(shouldWatchProjectFile(".video.mp4.hf-transaction-Ab12Cd/video.mp4")).toBe(false); + expect(shouldWatchProjectFile("work-notes/index.html")).toBe(true); + }); + it("skips the temp file of a save in flight, but not a user's own .tmp file", () => { expect(shouldWatchProjectFile("index.html.hf0a1b2c.tmp")).toBe(false); expect(shouldWatchProjectFile("compositions/intro.html.hf0a1b2c.tmp")).toBe(false); diff --git a/packages/cli/src/server/fileWatcher.ts b/packages/cli/src/server/fileWatcher.ts index fa77299df07..089325442df 100644 --- a/packages/cli/src/server/fileWatcher.ts +++ b/packages/cli/src/server/fileWatcher.ts @@ -1,6 +1,7 @@ import { lstatSync, readdirSync, watch, type Dirent, type FSWatcher } from "node:fs"; import { basename, dirname, join, relative, sep } from "node:path"; import { isAtomicTempPath } from "@hyperframes/core/atomic-file"; +import { isProjectIgnoredDir } from "@hyperframes/core/project-folders"; import { affectsProjectSignature, STUDIO_SIGNATURE_MANIFEST_PATHS, @@ -14,22 +15,6 @@ export interface ProjectWatcher { close(): void; } -const WATCHER_EXCLUDED_DIRS = new Set([ - ".cache", - ".git", - ".hyperframes", - ".next", - ".thumbnails", - ".transcode-cache", - ".vite", - ".waveform-cache", - "build", - "coverage", - "dist", - "node_modules", - "outputs", - "renders", -]); // A save reaches the preview QUIET_MS after the writes go quiet, but at most once per BURST_MS, // so a checkout or a multi-file tool doesn't start a rebuild for every file. const QUIET_MS = 30; @@ -38,7 +23,7 @@ const BURST_MS = 300; export function shouldWatchProjectFile(filename: string): boolean { if (!filename || isAtomicTempPath(filename)) return false; const parts = filename.split(/[\\/]+/); - return !parts.some((part) => WATCHER_EXCLUDED_DIRS.has(part)); + return !parts.some(isProjectIgnoredDir); } function isDirectory(path: string): boolean { diff --git a/packages/core/src/projectFolders.ts b/packages/core/src/projectFolders.ts index 10c5bd15dd8..f54a0047131 100644 --- a/packages/core/src/projectFolders.ts +++ b/packages/core/src/projectFolders.ts @@ -15,3 +15,9 @@ export const PREVIEW_IGNORED_DIRS: ReadonlySet = new Set([ "outputs", "renders", ]); + +export const RENDER_TEMP_DIR = + /^(work-[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}|hf-render|\..+\.hf-transaction)-[A-Za-z0-9]{6}$/; + +export const isProjectIgnoredDir = (name: string): boolean => + PREVIEW_IGNORED_DIRS.has(name) || RENDER_TEMP_DIR.test(name); diff --git a/packages/producer/src/services/render/renderDirOwner.ts b/packages/producer/src/services/render/renderDirOwner.ts index 2b546075ac1..a072f73313a 100644 --- a/packages/producer/src/services/render/renderDirOwner.ts +++ b/packages/producer/src/services/render/renderDirOwner.ts @@ -10,13 +10,11 @@ import { } from "node:fs"; import { hostname, uptime } from "node:os"; import { dirname, join } from "node:path"; +import { RENDER_TEMP_DIR } from "@hyperframes/core/project-folders"; const OWNER_FILE = "owner.json"; /** Where a staging dir parks the previous output while swapping in the new one. */ export const TRANSACTION_BACKUP = "backup"; -// The names mkdtemp gives a render's temp dirs: work--, hf-render- (Windows) and ..hf-transaction-. -const RENDER_TEMP_DIR = - /^(work-[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}|hf-render|\..+\.hf-transaction)-[A-Za-z0-9]{6}$/; /** A `--debug` render's work dir, named by its job id. */ export const RENDER_JOB_DIR = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/; diff --git a/packages/studio-server/src/helpers/projectSignature.ts b/packages/studio-server/src/helpers/projectSignature.ts index 4201592416e..c89b105f549 100644 --- a/packages/studio-server/src/helpers/projectSignature.ts +++ b/packages/studio-server/src/helpers/projectSignature.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { lstatSync, readFileSync, readdirSync, type Stats } from "node:fs"; import { extname, isAbsolute, relative, resolve, sep } from "node:path"; import { isAtomicTempPath } from "@hyperframes/core/atomic-file"; -import { PREVIEW_IGNORED_DIRS } from "@hyperframes/core/project-folders"; +import { isProjectIgnoredDir } from "@hyperframes/core/project-folders"; import type { ResolvedProject, StudioApiAdapter } from "../types.js"; const SIGNATURE_TEXT_EXTENSIONS = new Set([ @@ -34,10 +34,8 @@ export const STUDIO_SIGNATURE_MANIFEST_PATHS = [ * preview, so an unfiltered watcher discards the memo on roughly every request of * the one workload the memo exists for. * - * Note this is not `WATCHER_EXCLUDED_DIRS`, which is character-identical but - * excludes all of `.hyperframes/` — the signature deliberately reads two manifest - * files from inside it, so filtering with that set would stop motion-state saves - * from ever invalidating. + * The watcher shares `isProjectIgnoredDir` but drops all of `.hyperframes/`; the two + * manifest files read from inside it are let through first so motion-state saves invalidate. * * Every segment is tested, not just the parents, so a directory event on an * excluded dir itself (`unlinkDir .thumbnails`) is filtered too. The cost is that @@ -57,7 +55,7 @@ export function affectsProjectSignature(projectDir: string, changedPath: string) if (isRenderOutput(projectDir, changedPath, readRenderOutputsOrNone(projectDir))) return false; const segments = relativePath.split(sep); if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/") as never)) return true; - return !segments.some((segment) => PREVIEW_IGNORED_DIRS.has(segment)); + return !segments.some(isProjectIgnoredDir); } interface ProjectSignatureFile { @@ -90,8 +88,7 @@ function isTextContentEligible(file: string, size: number): boolean { ); } -const isSkippedEntry = (entry: string) => - PREVIEW_IGNORED_DIRS.has(entry) || isAtomicTempPath(entry); +const isSkippedEntry = (entry: string) => isProjectIgnoredDir(entry) || isAtomicTempPath(entry); function signatureEntryStat( projectDir: string, diff --git a/packages/studio-server/src/helpers/safePath.test.ts b/packages/studio-server/src/helpers/safePath.test.ts index 3977babdf16..02a2bdd3c95 100644 --- a/packages/studio-server/src/helpers/safePath.test.ts +++ b/packages/studio-server/src/helpers/safePath.test.ts @@ -52,6 +52,17 @@ describe("walkDir", () => { expect(files).not.toContain(".hyperframes/backup/snapshot.html"); }); + it("leaves a render's scratch dir out, so its compiled index.html is not listed as a composition", () => { + const projectDir = createProjectDir(); + const work = join(projectDir, "work-0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d-Ab12Cd"); + mkdirSync(join(work, "compiled"), { recursive: true }); + writeFileSync(join(work, "compiled", "index.html"), "scratch"); + writeFileSync(join(projectDir, "index.html"), "root"); + writeFileSync(join(projectDir, "video.mp4"), "output"); + + expect(walkDir(projectDir).sort()).toEqual(["index.html", "video.mp4"]); + }); + it.each(["EACCES", "EPERM", "ENOENT", "ENOTDIR"])( "skips a subfolder whose listing fails with %s", (code) => { diff --git a/packages/studio-server/src/helpers/safePath.ts b/packages/studio-server/src/helpers/safePath.ts index 99f12cdc42c..cb7221945a5 100644 --- a/packages/studio-server/src/helpers/safePath.ts +++ b/packages/studio-server/src/helpers/safePath.ts @@ -1,6 +1,7 @@ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { readdirSync, type Dirent } from "node:fs"; import { realpath, resolveWithinProject as resolveInProject } from "@hyperframes/core/safe-path"; +import { RENDER_TEMP_DIR } from "@hyperframes/core/project-folders"; import { STUDIO_MANUAL_EDITS_PATH } from "./manualEditsRenderScript.js"; import { STUDIO_MOTION_PATH } from "./studioMotionRenderScript.js"; @@ -114,7 +115,8 @@ export function walkDir(dir: string, prefix = ""): string[] { const files: string[] = []; for (const entry of entries) { const rel = prefix ? `${prefix}/${entry.name}` : entry.name; - if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue; + if (IGNORE_DIRS.has(entry.name) || RENDER_TEMP_DIR.test(entry.name) || shouldIgnoreDir(rel)) + continue; if (entry.isDirectory()) { files.push(...walkDir(join(dir, entry.name), rel)); } else {