mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): avoid preview reloads for temporary and excluded files (#4553)
* fix(studio): avoid preview reloads for temporary and excluded files * docs: capture public preview reload reproduction * fix(studio-server): normalize watch paths in linear time * fix(studio): register dynamically opened preview projects
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -79,6 +79,20 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"watchIgnore": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": "Project-relative files or directories that should not trigger preview reloads (literal paths, not glob patterns). Directory entries include their descendants. Does not exclude files from rendering or serving. hyperframes.json always triggers a reload."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,3 +57,28 @@ For lower-level failures, continue to [general troubleshooting](/guides/troubles
|
||||
- [Work with source and an agent](/studio/source)
|
||||
- [Finish, render, and share](/guides/export-and-share)
|
||||
- [Diagnose a project or CLI problem](/guides/troubleshooting)
|
||||
|
||||
## Preview keeps restarting while tools write reports
|
||||
|
||||
Preview watches project files so source and asset edits appear automatically.
|
||||
If an agent or check command writes notes and reports inside your project, exclude
|
||||
those outputs from live reload in `hyperframes.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"preview": {
|
||||
"watchIgnore": ["docs", "references/check-results.json"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Entries are literal project-relative file or directory paths, not globs. A directory
|
||||
entry includes its descendants, but not similarly named siblings. Only exclude
|
||||
files the composition does not use: ignored files remain available to the server
|
||||
and renderer, but edits to them will not automatically reload the player or its
|
||||
thumbnails. Changes to this setting take effect without restarting the server;
|
||||
`hyperframes.json` itself always triggers a reload.
|
||||
|
||||
Atomic-save temporary files (`.tmp`, `.tmp.<pid>.<hex>`, `.swp`, `.swo`, and trailing
|
||||
`~`) do not trigger preview reloads. Renaming them to the final source filename
|
||||
still does.
|
||||
|
||||
@@ -671,6 +671,27 @@ describe("Studio file-change SSE", () => {
|
||||
const encodedVersion = (content: string): string =>
|
||||
fileContentVersion(content).replaceAll('"', '\\"');
|
||||
|
||||
it("does not deliver report or temporary-file changes, but delivers the final source save", async () => {
|
||||
const projectDir = tmpProject();
|
||||
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
|
||||
writeFileSync(
|
||||
join(projectDir, "hyperframes.json"),
|
||||
JSON.stringify({ preview: { watchIgnore: ["docs"] } }),
|
||||
);
|
||||
server = createStudioServer({ projectDir });
|
||||
const streams = await subscribe(2);
|
||||
mockWatcher.emit("change", "change", "docs/report.json");
|
||||
mockWatcher.emit("change", "rename", "index.html.tmp.97896.1d3e1712ad2c");
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
writeFileSync(join(projectDir, "index.html"), "<html>after</html>");
|
||||
mockWatcher.emit("change", "rename", "index.html");
|
||||
for (const payload of await Promise.all(streams.map(nextEvent))) {
|
||||
expect(payload).toContain('"path":"index.html"');
|
||||
expect(payload).not.toContain("report.json");
|
||||
expect(payload).not.toContain(".tmp");
|
||||
}
|
||||
});
|
||||
|
||||
it("labels a Studio write for every open subscriber, not just the first", async () => {
|
||||
const projectDir = tmpProject();
|
||||
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
getMimeType,
|
||||
affectsProjectSignature,
|
||||
compositionsAffectedBy,
|
||||
shouldReloadPreview,
|
||||
type PreviewApiAdapter,
|
||||
PREVIEW_BUNDLE_OPTIONS,
|
||||
createPreviewDocumentStore,
|
||||
@@ -896,7 +897,9 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// Re-applied here because the watcher now also emits the signature
|
||||
// manifest files, which must not trigger a browser reload.
|
||||
const wrappedListener = (changedPath: string) => {
|
||||
if (shouldWatchProjectFile(changedPath)) listener(changedPath);
|
||||
if (shouldWatchProjectFile(changedPath) && shouldReloadPreview(projectDir, changedPath)) {
|
||||
listener(changedPath);
|
||||
}
|
||||
};
|
||||
watcher.addListener(wrappedListener);
|
||||
stream.onAbort(() => watcher.removeListener(wrappedListener));
|
||||
|
||||
@@ -50,6 +50,23 @@ describe("projectConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves preview watch exclusions through config writes and authoring updates", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
writeProjectConfig(dir, {
|
||||
...DEFAULT_PROJECT_CONFIG,
|
||||
preview: { watchIgnore: ["./docs/", "reports/check.json"] },
|
||||
});
|
||||
expect(readProjectConfig(dir)?.preview).toEqual({
|
||||
watchIgnore: ["docs", "reports/check.json"],
|
||||
});
|
||||
seedProjectAuthoringSkill(dir, "music-to-video");
|
||||
expect(readProjectConfig(dir)?.preview?.watchIgnore).toEqual(["docs", "reports/check.json"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("normalizeConfig", () => {
|
||||
it("fills in defaults for missing fields", () => {
|
||||
const result = normalizeConfig({ registry: "https://alt.example.com" });
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* point at custom registries or reshape their project layout.
|
||||
*/
|
||||
|
||||
import { normalizePreviewWatchIgnore } from "@hyperframes/studio-server";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { DEFAULT_REGISTRY_URL } from "../registry/index.js";
|
||||
@@ -59,6 +60,7 @@ export interface ProjectConfig {
|
||||
paths: ProjectConfigPaths;
|
||||
/** Media handling options (e.g. auto-proxying of browser-hostile codecs). */
|
||||
media?: ProjectConfigMedia;
|
||||
preview?: { watchIgnore?: string[] };
|
||||
/**
|
||||
* Owning authoring-workflow skill slug (e.g. "product-launch-video"). Stamped
|
||||
* by `hyperframes init --skill` or seeded from the first `hyperframes render
|
||||
@@ -151,6 +153,9 @@ export function normalizeConfig(partial: Partial<ProjectConfig>): ProjectConfig
|
||||
? partial.media.autoProxy
|
||||
: DEFAULT_PROJECT_CONFIG.media?.autoProxy,
|
||||
},
|
||||
preview: partial.preview
|
||||
? { watchIgnore: normalizePreviewWatchIgnore(partial.preview.watchIgnore) }
|
||||
: undefined,
|
||||
// Slug-gate on read so a hand-edited or corrupt value never reaches the
|
||||
// telemetry stream; an invalid slug simply drops the attribution.
|
||||
authoringSkill: normalizeSkillSlug(partial.authoringSkill),
|
||||
|
||||
@@ -43,6 +43,7 @@ const EVERY_WRITTEN_KEY: Required<ProjectConfig> = {
|
||||
registry: "https://example.test/registry",
|
||||
paths: DEFAULT_PROJECT_CONFIG.paths,
|
||||
media: { autoProxy: true },
|
||||
preview: { watchIgnore: ["docs", "references/check-results.json"] },
|
||||
authoringSkill: "product-launch-video",
|
||||
registryItems: [EVERY_RECORD_FIELD],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { normalizePreviewWatchIgnore, shouldReloadPreview } from "./previewWatchIgnore";
|
||||
|
||||
const directories: string[] = [];
|
||||
function project(config: unknown = {}): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-watch-ignore-"));
|
||||
directories.push(dir);
|
||||
writeFileSync(join(dir, "hyperframes.json"), JSON.stringify(config));
|
||||
return dir;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("preview reload filtering", () => {
|
||||
it("defaults to reloading arbitrary assets, data, and source files", () => {
|
||||
const dir = project();
|
||||
for (const path of [
|
||||
"index.html",
|
||||
"assets/photo.jpg",
|
||||
"data.json",
|
||||
"docs/data.md",
|
||||
"scene.tsx",
|
||||
]) {
|
||||
expect(shouldReloadPreview(dir, path)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores literal paths and directory descendants without matching siblings", () => {
|
||||
const dir = project({ preview: { watchIgnore: ["./docs/", "reports\\checks.json"] } });
|
||||
for (const path of ["docs", "docs/a.json", "docs\\a.md", "reports/checks.json"]) {
|
||||
expect(shouldReloadPreview(dir, path)).toBe(false);
|
||||
}
|
||||
for (const path of ["docs-other/a.json", "reports/other.json", "index.html"]) {
|
||||
expect(shouldReloadPreview(dir, path)).toBe(true);
|
||||
}
|
||||
expect(shouldReloadPreview(dir, join(dir, "docs", "report.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("always reloads config and reads changed settings without restarting", () => {
|
||||
const dir = project({ preview: { watchIgnore: ["docs", "hyperframes.json"] } });
|
||||
expect(shouldReloadPreview(dir, "hyperframes.json")).toBe(true);
|
||||
expect(shouldReloadPreview(dir, "docs/report.json")).toBe(false);
|
||||
writeFileSync(join(dir, "hyperframes.json"), "{}");
|
||||
expect(shouldReloadPreview(dir, "docs/report.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores atomic-save artifacts while retaining the final source and ordinary names", () => {
|
||||
const dir = project();
|
||||
for (const path of [
|
||||
"index.html.tmp",
|
||||
"docs/edit.md.tmp.97896.1d3e1712ad2c",
|
||||
".index.html.swp",
|
||||
"index.html.swo",
|
||||
"index.html~",
|
||||
]) {
|
||||
expect(shouldReloadPreview(dir, path)).toBe(false);
|
||||
}
|
||||
for (const path of ["index.html", "templates/a.html", "photo.tmp.jpg"]) {
|
||||
expect(shouldReloadPreview(dir, path)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails open on malformed or missing configuration", () => {
|
||||
for (const config of [null, [], { preview: null }, { preview: { watchIgnore: "docs" } }]) {
|
||||
expect(shouldReloadPreview(project(config), "docs/report.json")).toBe(true);
|
||||
}
|
||||
const dir = project();
|
||||
writeFileSync(join(dir, "hyperframes.json"), "{");
|
||||
expect(shouldReloadPreview(dir, "index.html")).toBe(true);
|
||||
rmSync(join(dir, "hyperframes.json"));
|
||||
expect(shouldReloadPreview(dir, "index.html")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles long runs of separators in untrusted settings", () => {
|
||||
const separators = "/".repeat(100_000);
|
||||
expect(normalizePreviewWatchIgnore([`docs${separators}`])).toEqual(["docs"]);
|
||||
expect(normalizePreviewWatchIgnore([`docs${separators}report.json`])).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects exclusions that could hide the whole project or escape its root", () => {
|
||||
expect(
|
||||
normalizePreviewWatchIgnore([
|
||||
"",
|
||||
".",
|
||||
"./",
|
||||
"..",
|
||||
"../a",
|
||||
"/tmp",
|
||||
"C:\\tmp",
|
||||
"a/../b",
|
||||
"**",
|
||||
null,
|
||||
5,
|
||||
"docs",
|
||||
]),
|
||||
).toEqual(["docs"]);
|
||||
const dir = project();
|
||||
expect(shouldReloadPreview(dir, dir)).toBe(false);
|
||||
expect(shouldReloadPreview(dir, join(dir, ".."))).toBe(false);
|
||||
expect(shouldReloadPreview(dir, join(dir, "..", "other.html"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
const CONFIG_FILE = "hyperframes.json";
|
||||
|
||||
export function normalizePreviewWatchIgnore(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry: unknown) => {
|
||||
if (typeof entry !== "string") return [];
|
||||
const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
let end = normalized.length;
|
||||
while (end > 0 && normalized[end - 1] === "/") end -= 1;
|
||||
const path = normalized.slice(0, end);
|
||||
if (
|
||||
!path ||
|
||||
path.startsWith("/") ||
|
||||
/[:*?[\]]/.test(path) ||
|
||||
path.split("/").some((part) => !part || part === "." || part === "..")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [path];
|
||||
});
|
||||
}
|
||||
|
||||
function readIgnoredPaths(projectDir: string): string[] {
|
||||
try {
|
||||
const config: unknown = JSON.parse(readFileSync(resolve(projectDir, CONFIG_FILE), "utf8"));
|
||||
if (!config || typeof config !== "object" || !("preview" in config)) return [];
|
||||
const preview = config.preview;
|
||||
if (!preview || typeof preview !== "object" || !("watchIgnore" in preview)) return [];
|
||||
return normalizePreviewWatchIgnore(preview.watchIgnore);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldReloadPreview(projectDir: string, changedPath: string): boolean {
|
||||
const path = relative(resolve(projectDir), resolve(projectDir, changedPath)).replace(/\\/g, "/");
|
||||
if (!path || path === ".." || path.startsWith("../") || isAbsolute(path)) return false;
|
||||
if (path === CONFIG_FILE) return true;
|
||||
const name = path.split("/").at(-1) ?? "";
|
||||
if (/\.tmp(?:\.\d+\.[a-f\d]+)?$/i.test(name) || /\.(?:swp|swo)$|~$/.test(name)) return false;
|
||||
return !readIgnoredPaths(projectDir).some(
|
||||
(ignored) => path === ignored || path.startsWith(`${ignored}/`),
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { shouldReloadPreview, normalizePreviewWatchIgnore } from "./helpers/previewWatchIgnore.js";
|
||||
export { createStudioApi } from "./createStudioApi.js";
|
||||
export { createProjectSignature, affectsProjectSignature } from "./helpers/projectSignature.js";
|
||||
export { compositionsAffectedBy } from "./helpers/compositionInputs.js";
|
||||
|
||||
@@ -17,6 +17,8 @@ import { createStudioApi, type ProjectHistory } from "@hyperframes/studio-server
|
||||
import type { ViteDevServer } from "vite";
|
||||
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
|
||||
|
||||
import { previewChangeOwner } from "./vite.preview-watch";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
@@ -175,3 +177,35 @@ describe("Vite project resolution boundary", () => {
|
||||
expect(adapter.resolveProject("alias")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dynamic preview ownership", () => {
|
||||
it.each([false, true])("registers a project opened after startup (symlink: %s)", (linked) => {
|
||||
const { root, data, sessions } = fixture();
|
||||
const owners = new Map<string, string>();
|
||||
const watched: string[] = [];
|
||||
const cache = createProjectSignatureCache({
|
||||
compute: () => "signature",
|
||||
watch: (dir) => {
|
||||
// Ownership must exist before newly watched files can emit events.
|
||||
expect(previewChangeOwner(owners, join(dir, "index.html"))).toBe("new-project");
|
||||
watched.push(dir);
|
||||
},
|
||||
});
|
||||
const adapter = createViteAdapter(data, {} as ViteDevServer, cache, {
|
||||
onResolveProject: (project) => owners.set(project.dir, project.id),
|
||||
});
|
||||
expect(adapter.resolveProject("new-project")).toBeNull();
|
||||
expect(owners.size).toBe(0);
|
||||
const dir = linked ? join(root, "different-target-name") : join(data, "new-project");
|
||||
mkdirSync(dir);
|
||||
if (linked) symlinkSync(dir, join(data, "new-project"), "junction");
|
||||
writeFileSync(join(dir, "index.html"), "before");
|
||||
writeFileSync(join(sessions, "alias.json"), JSON.stringify({ projectId: "new-project" }));
|
||||
const project = adapter.resolveProject(linked ? "alias" : "new-project")!;
|
||||
cache.get(project.dir);
|
||||
writeFileSync(join(dir, "index.html"), "after");
|
||||
expect(watched).toEqual([realpathSync(dir)]);
|
||||
expect(previewChangeOwner(owners, join(realpathSync(dir), "index.html"))).toBe("new-project");
|
||||
expect(previewChangeOwner(owners, join(realpathSync(dir), "index.html.tmp"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +107,12 @@ export function createViteAdapter(
|
||||
{
|
||||
historyRoot = DEFAULT_HISTORY_ROOT,
|
||||
openHistory = openProjectHistory,
|
||||
}: { historyRoot?: string; openHistory?: typeof openProjectHistory } = {},
|
||||
onResolveProject,
|
||||
}: {
|
||||
historyRoot?: string;
|
||||
openHistory?: typeof openProjectHistory;
|
||||
onResolveProject?: (project: ResolvedProject) => void;
|
||||
} = {},
|
||||
): StudioApiAdapter {
|
||||
const histories = historyCache((projectDir) =>
|
||||
openHistory({ projectDir, historyRoot }).catch((error: unknown) => {
|
||||
@@ -240,11 +245,13 @@ export function createViteAdapter(
|
||||
projectDir = resolve(dataDir, session.projectId);
|
||||
if (!isPathWithin(dataDir, projectDir)) return null;
|
||||
if (existsSync(projectDir)) {
|
||||
return {
|
||||
const project = {
|
||||
id: session.projectId,
|
||||
dir: realpathSync(projectDir),
|
||||
title: session.title,
|
||||
};
|
||||
onResolveProject?.(project);
|
||||
return project;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -253,7 +260,9 @@ export function createViteAdapter(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return { id, dir: realpathSync(projectDir) };
|
||||
const project = { id, dir: realpathSync(projectDir) };
|
||||
onResolveProject?.(project);
|
||||
return project;
|
||||
},
|
||||
|
||||
async bundle(dir, options) {
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
} from "node:fs";
|
||||
import { join, relative, resolve, sep } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { readNodeRequestBody } from "./vite.request-body.js";
|
||||
import { watch } from "chokidar";
|
||||
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
|
||||
import { previewConfigPayload } from "./vite.preview-config";
|
||||
import { loadStudioServerDevModule } from "./vite.studio-server-module";
|
||||
import type { openProjectHistory } from "@hyperframes/studio-server";
|
||||
import { previewChangeOwner } from "./vite.preview-watch";
|
||||
|
||||
async function loadRuntimeSourceForDev(
|
||||
server: import("vite").ViteDevServer,
|
||||
@@ -102,12 +103,15 @@ function devProjectApi(): Plugin {
|
||||
// ignore them (see `server.watch.ignored`), because it answers an html
|
||||
// change with a full page reload; this one only announces the change and
|
||||
// lets Studio decide what to do with it.
|
||||
const realProjectPaths: string[] = [];
|
||||
const watchedProjects = new Map<string, string>();
|
||||
try {
|
||||
for (const entry of readdirSync(dataDir, { withFileTypes: true })) {
|
||||
const full = join(dataDir, entry.name);
|
||||
try {
|
||||
realProjectPaths.push(lstatSync(full).isSymbolicLink() ? realpathSync(full) : full);
|
||||
watchedProjects.set(
|
||||
lstatSync(full).isSymbolicLink() ? realpathSync(full) : full,
|
||||
entry.name,
|
||||
);
|
||||
} catch {
|
||||
/* skip broken symlinks */
|
||||
}
|
||||
@@ -116,7 +120,7 @@ function devProjectApi(): Plugin {
|
||||
/* dataDir doesn't exist yet */
|
||||
}
|
||||
|
||||
const projectWatcher = watch(realProjectPaths, {
|
||||
const projectWatcher = watch([...watchedProjects.keys()], {
|
||||
ignoreInitial: true,
|
||||
// A project write is a whole-file replace; wait for it to settle so a
|
||||
// half-written composition is never announced.
|
||||
@@ -172,6 +176,9 @@ function devProjectApi(): Plugin {
|
||||
// The engine records its write receipts in this module, where the watcher below reads them.
|
||||
const adapter = createViteAdapter(dataDir, server, signatureCache, {
|
||||
openHistory: mod.openProjectHistory,
|
||||
// Projects can be created or imported after startup. Keep the canonical
|
||||
// id and real root before the signature cache starts watching them.
|
||||
onResolveProject: (project) => watchedProjects.set(project.dir, project.id),
|
||||
});
|
||||
_api = mod.createStudioApi(adapter);
|
||||
}
|
||||
@@ -247,6 +254,8 @@ function devProjectApi(): Plugin {
|
||||
});
|
||||
|
||||
projectWatcher.on("change", (filePath: string) => {
|
||||
const owner = previewChangeOwner(watchedProjects, filePath);
|
||||
if (!owner) return;
|
||||
if (
|
||||
!filePath.endsWith(".html") &&
|
||||
!filePath.endsWith(".css") &&
|
||||
@@ -269,13 +278,7 @@ function devProjectApi(): Plugin {
|
||||
const receipt = studioServer
|
||||
? studioServer.identifyFileWrite(filePath, version ?? studioServer.DELETED_VERSION)
|
||||
: null;
|
||||
// First path segment under `dataDir` is the project id (`data/projects/<id>/...`).
|
||||
// Mirrors the CLI host's `project.id` field on the same event — see its
|
||||
// doc comment for why a stale tab needs this to ignore another
|
||||
// project's saves on a shared connection. This host is multi-project
|
||||
// (any dir under `dataDir` resolves), so unlike the CLI host it can't
|
||||
// assume one fixed id.
|
||||
const projectId = relative(dataDir, filePath).split(sep)[0];
|
||||
const projectId = owner;
|
||||
server.ws.send({
|
||||
type: "custom",
|
||||
event: "hf:file-change",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { previewChangeOwner } from "./vite.preview-watch";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("Vite preview change ownership", () => {
|
||||
it("uses a symlink target's project id and config without leaking into sibling projects", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-vite-watch-"));
|
||||
dirs.push(root);
|
||||
const a = join(root, "external");
|
||||
const b = join(root, "external-other");
|
||||
for (const path of [a, b]) mkdirSync(path);
|
||||
writeFileSync(
|
||||
join(a, "hyperframes.json"),
|
||||
JSON.stringify({ preview: { watchIgnore: ["docs"] } }),
|
||||
);
|
||||
const projects = new Map([
|
||||
[a, "symlink-project"],
|
||||
[b, "other-project"],
|
||||
]);
|
||||
expect(previewChangeOwner(projects, join(a, "index.html"))).toBe("symlink-project");
|
||||
expect(previewChangeOwner(projects, join(a, "docs", "report.json"))).toBeNull();
|
||||
expect(previewChangeOwner(projects, join(a, "index.html.tmp"))).toBeNull();
|
||||
expect(previewChangeOwner(projects, join(b, "docs", "report.json"))).toBe("other-project");
|
||||
expect(previewChangeOwner(projects, join(root, "outside.html"))).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the nearest project when watched directories are nested", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-vite-watch-"));
|
||||
dirs.push(root);
|
||||
const nested = join(root, "nested");
|
||||
mkdirSync(nested);
|
||||
expect(
|
||||
previewChangeOwner(
|
||||
new Map([
|
||||
[root, "root"],
|
||||
[nested, "nested"],
|
||||
]),
|
||||
join(nested, "index.html"),
|
||||
),
|
||||
).toBe("nested");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { sep } from "node:path";
|
||||
import { shouldReloadPreview } from "@hyperframes/studio-server";
|
||||
|
||||
export function previewChangeOwner(
|
||||
watchedProjects: ReadonlyMap<string, string>,
|
||||
filePath: string,
|
||||
): string | null {
|
||||
const owner = [...watchedProjects]
|
||||
.sort(([left], [right]) => right.length - left.length)
|
||||
.find(([dir]) => filePath.startsWith(dir + sep));
|
||||
if (!owner || !shouldReloadPreview(owner[0], filePath)) return null;
|
||||
return owner[1];
|
||||
}
|
||||
Reference in New Issue
Block a user