Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions packages/cli/src/server/fileWatcher.fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ describe("createProjectWatcher on a real directory", () => {
rmSync(dir, { recursive: true, force: true });
});

const watchProject = async () => {
const watchProject = (projectDir = dir) => {
const seen: string[] = [];
watcher = createProjectWatcher(dir);
watcher = createProjectWatcher(projectDir);
watcher.addListener((path) => seen.push(path));
await new Promise((resolve) => setTimeout(resolve, 100));
return seen;
};
const expectReported = async (seen: string[], path: string) => {
Expand All @@ -35,7 +34,7 @@ describe("createProjectWatcher on a real directory", () => {
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), "v0");
writeFileSync(join(dir, "compositions", "scene.html"), "v0");
const seen = await watchProject();
const seen = watchProject();

for (const path of ["index.html", join("compositions", "scene.html")]) {
replaceByRename(join(dir, path), "stamped");
Expand All @@ -47,10 +46,71 @@ describe("createProjectWatcher on a real directory", () => {
}
});

it.runIf(process.platform === "linux")(
"follows a project folder replaced by rename",
async () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-root-"));
const project = join(dir, "project");
const replacement = join(dir, "replacement");
mkdirSync(project);
mkdirSync(join(replacement, "scenes"), { recursive: true });
writeFileSync(join(project, "index.html"), "before");
writeFileSync(join(replacement, "index.html"), "after");
writeFileSync(join(replacement, "scenes", "intro.html"), "new scene");
const seen = watchProject(project);

renameSync(project, join(dir, "previous"));
renameSync(replacement, project);
await expectReported(seen, ".");
writeFileSync(join(project, "index.html"), "edited root");
await expectReported(seen, "index.html");
writeFileSync(join(project, "scenes", "intro.html"), "edited scene");
await expectReported(seen, join("scenes", "intro.html"));
},
);

it.runIf(process.platform === "linux")(
"re-arms after the project path is absent between replacements",
async () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-gap-"));
const project = join(dir, "project");
mkdirSync(project);
writeFileSync(join(project, "index.html"), "before");
const seen = watchProject(project);

renameSync(project, join(dir, "previous"));
await expectReported(seen, ".");
mkdirSync(project);
writeFileSync(join(project, "index.html"), "after");
await expectReported(seen, ".");
writeFileSync(join(project, "index.html"), "later edit");
await expectReported(seen, "index.html");
},
);

it.runIf(process.platform === "linux")(
"follows a replaced subdirectory's new inode",
async () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-subdir-"));
const scenes = join(dir, "scenes");
mkdirSync(scenes);
mkdirSync(join(dir, "replacement"));
writeFileSync(join(scenes, "intro.html"), "before");
writeFileSync(join(dir, "replacement", "intro.html"), "after");
const seen = watchProject();

renameSync(scenes, join(dir, "previous"));
renameSync(join(dir, "replacement"), scenes);
await expectReported(seen, "scenes");
writeFileSync(join(scenes, "intro.html"), "later edit");
await expectReported(seen, join("scenes", "intro.html"));
},
);

it("reports Studio's manifest writes inside .hyperframes", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
mkdirSync(join(dir, ".hyperframes"));
const seen = await watchProject();
const seen = watchProject();

writeFileSync(join(dir, ".hyperframes", "studio-motion.json"), "{}");
await expectReported(seen, join(".hyperframes", "studio-motion.json"));
Expand All @@ -60,7 +120,7 @@ describe("createProjectWatcher on a real directory", () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
mkdirSync(join(dir, "scene"));
mkdirSync(join(dir, "scenes"));
const seen = await watchProject();
const seen = watchProject();

rmSync(join(dir, "scene"), { recursive: true });
await expectReported(seen, "scene");
Expand All @@ -70,7 +130,7 @@ describe("createProjectWatcher on a real directory", () => {

it("reports files in a directory created after it started", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
const seen = await watchProject();
const seen = watchProject();

mkdirSync(join(dir, "scenes"));
await expectReported(seen, "scenes");
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/server/fileWatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,29 @@ describe("createProjectWatcher", () => {
},
);

it.runIf(process.platform === "linux")("closes every watch it opened, its parent's too", () => {
const projectWatcher = createProjectWatcher("/fake/project/dir");
const opened = vi.mocked(watch).mock.calls.map(([path]) => path);
expect(opened).toContain("/fake/project");
projectWatcher.close();
expect(mockWatcher.close).toHaveBeenCalledTimes(opened.length);
});

it.runIf(process.platform === "linux")(
"keeps reporting project files when its parent cannot be watched",
() => {
vi.useFakeTimers();
fakeDirs.unwatchable = "/fake/project";
const projectWatcher = createProjectWatcher("/fake/project/dir");
const listener = vi.fn();
projectWatcher.addListener(listener);
mockWatcher.emit("change", "change", "index.html");
vi.advanceTimersByTime(30);
expect(listener).toHaveBeenCalledExactlyOnceWith("index.html");
projectWatcher.close();
},
);

it("degrades to no live reload when the project root cannot be watched", () => {
fakeDirs.unwatchable = "/fake/project/dir";
let projectWatcher: ReturnType<typeof createProjectWatcher> | null = null;
Expand Down
62 changes: 41 additions & 21 deletions packages/cli/src/server/fileWatcher.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { lstatSync, readdirSync, watch, type FSWatcher } from "node:fs";
import { join, relative, sep } from "node:path";
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 { affectsProjectSignature } from "@hyperframes/studio-server";
import {
affectsProjectSignature,
STUDIO_SIGNATURE_MANIFEST_PATHS,
} from "@hyperframes/studio-server";

export type FileChangeListener = (relativePath: string) => void;

Expand Down Expand Up @@ -70,43 +73,63 @@ function watchProjectTree(
}
}
};
const watchDirectory = (dir: string) => {
if (directories.has(dir)) return;
const watchDirectory = (dir: string, movedIn = false) => {
if (directories.has(dir)) unwatch(dir);
let watcher: FSWatcher;
try {
watcher = watch(dir, { persistent: true }, (event, name) => {
if (!name) return;
const path = join(dir, name.toString());
onChange(relative(projectDir, path));
if (event !== "rename") return;
if (isDirectory(path)) descend(path);
if (isDirectory(path)) descend(path, true);
else unwatch(path);
});
} catch (error) {
// One unwatchable subdirectory (EACCES, inotify limit) must not cost the rest of the tree.
if (dir === projectDir) throw error;
} catch {
// A directory can vanish during replacement; its parent reports its return.
return;
}
watcher.on("error", () => unwatch(dir));
directories.set(dir, watcher);
let entries: string[] = [];
walkChildren(dir, movedIn);
};
const walkChildren = (dir: string, movedIn: boolean) => {
let entries: Dirent[] = [];
try {
entries = readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(dir, entry.name));
entries = readdirSync(dir, { withFileTypes: true });
} catch {
// Gone before we could list it; its parent reports the removal.
}
for (const child of entries) descend(child);
for (const entry of entries) {
const child = join(dir, entry.name);
// A folder moved in brings files that never get an event of their own, and drops others.
if (entry.isDirectory()) descend(child, movedIn);
else if (movedIn) onChange(relative(projectDir, child));
}
if (movedIn && relative(projectDir, dir) === ".hyperframes") {
for (const manifest of STUDIO_SIGNATURE_MANIFEST_PATHS) onChange(manifest);
}
};
// `.hyperframes/` itself holds the two manifests the signature reads; nothing below it matters.
const descend = (dir: string) => {
const descend = (dir: string, movedIn = false) => {
const rel = relative(projectDir, dir);
if (shouldWatchProjectFile(rel) || rel === ".hyperframes") watchDirectory(dir);
if (shouldWatchProjectFile(rel) || rel === ".hyperframes") watchDirectory(dir, movedIn);
};

let parent: FSWatcher | null = null;
try {
parent = watch(dirname(projectDir), { persistent: true }, (event, name) => {
if (event !== "rename" || name?.toString() !== basename(projectDir)) return;
watchDirectory(projectDir);
onChange(".");
});
parent.on("error", () => parent?.close());
} catch {
// The project can remain watchable even when its parent is not.
}
watchDirectory(projectDir);
return () => {
parent?.close();
for (const watcher of directories.values()) watcher.close();
directories.clear();
};
Expand All @@ -121,11 +144,8 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {

try {
closeTree = watchProjectTree(projectDir, (relativePath) => {
// The reload filter excludes all of `.hyperframes/`, but two files in
// there feed the preview signature and Studio writes one of them at
// runtime — dropping those at ingest left the CLI server's ETag stale
// until restart. Admit them here and let the reload listener re-apply
// its own filter, so what triggers a browser reload is unchanged.
// Studio's two manifests affect the signature despite the .hyperframes exclusion.
// Admit them here; the reload listener still applies its own filter.
if (
!shouldWatchProjectFile(relativePath) &&
!affectsProjectSignature(projectDir, join(projectDir, relativePath))
Expand Down
132 changes: 132 additions & 0 deletions packages/cli/src/server/studioServer.folderReplacement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { compositionsAffectedBy } from "@hyperframes/studio-server";
import { createStudioServer, type StudioServer } from "./studioServer.js";
import { cleanupStudioServerRoot, makeStudioServerRoot } from "./studioServerTestFixture.js";

let root: string;
let server: StudioServer | undefined;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;

afterEach(async () => {
await reader?.cancel();
if (server) cleanupStudioServerRoot(server, root);
});

it.runIf(process.platform === "linux")(
"refreshes every composition and cached preview when its project directory is replaced",
async () => {
const fixture = makeStudioServerRoot("hf-preview-replace-");
root = fixture.root;
const { projectDir } = fixture;
const rootHtml = (label: string) => `<html><head></head><body>
<div data-composition-id="main" data-width="320" data-height="180">
<p>${label}</p><div data-composition-id="intro" data-composition-src="scenes/intro.html" data-start="0" data-duration="1"></div>
</div></body></html>`;
const scene = (
text: string,
) => `<template><div data-composition-id="intro" data-width="320" data-height="180">
<h1>${text}</h1></div></template>`;
mkdirSync(join(projectDir, "scenes"));
writeFileSync(join(projectDir, "index.html"), rootHtml("Initial"));
writeFileSync(join(projectDir, "scenes/intro.html"), scene("Original"));
expect(compositionsAffectedBy(projectDir, "index.html")).toBeNull();
writeFileSync(join(projectDir, "index.html"), rootHtml("Unchanged root"));
server = createStudioServer({ projectDir, projectName: "film" });
expect(compositionsAffectedBy(projectDir, "index.html")).toEqual(["index.html"]);
const app = server.app;
const endpoint = "/api/projects/film/preview/comp/scenes/intro.html";
const before = await app.request(endpoint);
expect(before.status).toBe(200);
expect(await before.text()).toContain("Original");
const beforeTag = before.headers.get("etag");
expect(beforeTag).toBeTruthy();

const response = await app.request("/api/events");
reader = response.body!.getReader();
const deliveries: string[] = [];
const consume = async () => {
while (true) {
const next = await reader!.read();
if (next.done) return;
deliveries.push(new TextDecoder().decode(next.value));
}
};
const consuming = consume();
try {
const replacement = join(root, "replacement");
mkdirSync(join(replacement, "scenes"), { recursive: true });
writeFileSync(join(replacement, "index.html"), readFileSync(join(projectDir, "index.html")));
writeFileSync(join(replacement, "scenes/intro.html"), scene("Replacement"));
renameSync(projectDir, join(root, "previous"));
renameSync(replacement, projectDir);

await vi.waitFor(() => {
const event = deliveries
.join("")
.split("\n")
.find((line) => line.startsWith("data:") && line.includes('"path":"."'));
expect(event).toBeDefined();
expect(JSON.parse(event!.slice(5))).toMatchObject({
path: ".",
projectId: "film",
affectsPreview: true,
affectedCompositions: null,
});
});
const after = await app.request(endpoint, { headers: { "If-None-Match": beforeTag! } });
expect(after.status).toBe(200);
expect(after.headers.get("etag")).not.toBe(beforeTag);
expect(await after.text()).toContain("Replacement");

writeFileSync(join(projectDir, "scenes/intro.html"), scene("Later edit"));
await vi.waitFor(async () => {
const later = await app.request(endpoint);
expect(later.status).toBe(200);
expect(await later.text()).toContain("Later edit");
});
} finally {
await reader.cancel();
await consuming;
}
},
);

it.runIf(process.platform === "linux").each([
{ kept: "a changed manifest", file: "studio-motion.json", content: '{"intro":{"opacity":0.5}}' },
{ kept: "no manifest", file: "arbitrary.txt", content: "not a manifest" },
])(
"refreshes the cached preview when a populated .hyperframes folder is replaced by one with $kept",
async ({ file, content }) => {
const fixture = makeStudioServerRoot("hf-preview-replace-manifests-");
root = fixture.root;
const { projectDir } = fixture;
writeFileSync(
join(projectDir, "index.html"),
`<html><body><div data-composition-id="main" data-width="320" data-height="180"></div></body></html>`,
);
mkdirSync(join(projectDir, ".hyperframes"));
writeFileSync(join(projectDir, ".hyperframes/studio-motion.json"), "{}");
server = createStudioServer({ projectDir, projectName: "film" });
const endpoint = "/api/projects/film/preview/comp/index.html";
const before = await server.app.request(endpoint);
expect(before.status).toBe(200);
const beforeTag = before.headers.get("etag");
expect(beforeTag).toBeTruthy();

const replacement = join(root, "next-manifests");
mkdirSync(replacement);
writeFileSync(join(replacement, file), content);
renameSync(join(projectDir, ".hyperframes"), join(root, "previous-manifests"));
renameSync(replacement, join(projectDir, ".hyperframes"));

await vi.waitFor(async () => {
const after = await server!.app.request(endpoint, {
headers: { "If-None-Match": beforeTag! },
});
expect(after.status).toBe(200);
expect(after.headers.get("etag")).not.toBe(beforeTag);
});
},
);
2 changes: 1 addition & 1 deletion packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
let cachedProjectSignature: string | null = null;
watcher.addListener((changedPath) => {
if (affectsProjectSignature(projectDir, join(projectDir, changedPath))) {
if (changedPath === "." || affectsProjectSignature(projectDir, join(projectDir, changedPath))) {
cachedProjectSignature = null;
}
});
Expand Down
Loading
Loading