Skip to content

Commit fe4ea0c

Browse files
committed
fix(server): refresh HTML previews when loaded assets change
1 parent da8e240 commit fe4ea0c

7 files changed

Lines changed: 265 additions & 4 deletions

File tree

‎apps/server/src/assets/AssetAccess.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
3333
import { assetFileResponse } from "../http.ts";
3434
import { ASSET_ROUTE_PREFIX, issueAssetUrl, readMcpAppHtml, resolveAsset } from "./AssetAccess.ts";
3535
import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
36+
import * as WorkspacePreviewRevisions from "./WorkspacePreviewRevisions.ts";
3637
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
3738
import { fakeSourceControlDriverInstance } from "@t3tools/source-control-testing/fakeSourceControlDriver";
3839
import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";
@@ -118,6 +119,7 @@ const layerTest = Layer.mergeAll(
118119
Layer.provide(T3ProjectFileLoader.layer),
119120
),
120121
NativeAppIconResolver.layer.pipe(Layer.provide(layerConfig)),
122+
WorkspacePreviewRevisions.layer,
121123
ServerSecretStore.layer.pipe(Layer.provide(layerConfig)),
122124
).pipe(Layer.provideMerge(NodeServices.layer));
123125

@@ -846,6 +848,72 @@ describe("AssetAccess", () => {
846848
}).pipe(Effect.provide(layerTest)),
847849
);
848850

851+
it.effect.each(["workspace-file", "draft-workspace-file"] as const)(
852+
"refreshes %s HTML when loaded local assets change",
853+
(kind) =>
854+
Effect.gen(function* () {
855+
const fs = yield* FileSystem.FileSystem;
856+
const path = yield* Path.Path;
857+
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-preview-dependencies-" });
858+
const htmlPath = path.join(root, "report.html");
859+
const cssPath = path.join(root, "styles.css");
860+
const scriptPath = path.join(root, "app.js");
861+
const unrelatedPath = path.join(root, "unrelated.css");
862+
yield* fs.writeFileString(
863+
htmlPath,
864+
'<link rel="stylesheet" href="styles.css"><script src="app.js"></script>',
865+
);
866+
yield* fs.writeFileString(cssPath, "body{color:red}");
867+
yield* fs.writeFileString(scriptPath, "window.answer=1");
868+
yield* fs.writeFileString(unrelatedPath, "unrelated");
869+
const input = {
870+
resource:
871+
kind === "draft-workspace-file"
872+
? { _tag: kind, cwd: root, path: "report.html" }
873+
: { _tag: kind, threadId: ThreadId.make("thread-1"), path: htmlPath },
874+
workspaceRoot: root,
875+
};
876+
const original = yield* issueAssetUrl(input);
877+
const suffix = original.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
878+
const token = suffix.slice(0, suffix.indexOf("/"));
879+
for (const name of ["report.html", "styles.css", "app.js"]) {
880+
expect(yield* resolveAsset(token, name)).not.toBeNull();
881+
}
882+
expect(yield* resolveAsset(token, "missing.css")).toBeNull();
883+
yield* fs.writeFileString(unrelatedPath, "edited unrelated file");
884+
const unchanged = yield* issueAssetUrl(input);
885+
expect(unchanged.fileRevision).toBe(original.fileRevision);
886+
887+
yield* fs.writeFileString(cssPath, "body{color:tan}");
888+
yield* Effect.promise(() => NodeFSP.utimes(cssPath, 2_000, 2_000));
889+
const cssEdited = yield* issueAssetUrl(input);
890+
expect(cssEdited.fileRevision).not.toBe(unchanged.fileRevision);
891+
expect((yield* issueAssetUrl(input)).fileRevision).toBe(cssEdited.fileRevision);
892+
893+
yield* fs.writeFileString(scriptPath, "window.answer=2");
894+
yield* Effect.promise(() => NodeFSP.utimes(scriptPath, 3_000, 3_000));
895+
expect(yield* resolveAsset(token, "app.js")).not.toBeNull();
896+
const jsEdited = yield* issueAssetUrl(input);
897+
expect(jsEdited.fileRevision).not.toBe(cssEdited.fileRevision);
898+
899+
const replacement = path.join(root, "replacement.js");
900+
yield* fs.writeFileString(replacement, "window.answer=2");
901+
yield* Effect.promise(() => NodeFSP.utimes(replacement, 3_000, 3_000));
902+
yield* fs.rename(replacement, scriptPath);
903+
const replaced = yield* issueAssetUrl(input);
904+
expect(replaced.fileRevision).not.toBe(jsEdited.fileRevision);
905+
906+
yield* fs.remove(cssPath);
907+
const deleted = yield* issueAssetUrl(input);
908+
expect(deleted.fileRevision).not.toBe(replaced.fileRevision);
909+
expect((yield* issueAssetUrl(input)).fileRevision).toBe(deleted.fileRevision);
910+
911+
yield* fs.writeFileString(path.join(root, "missing.css"), "body{color:red}");
912+
const created = yield* issueAssetUrl(input);
913+
expect(created.fileRevision).not.toBe(deleted.fileRevision);
914+
}).pipe(Effect.provide(layerTest)),
915+
);
916+
849917
it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () =>
850918
Effect.gen(function* () {
851919
const fs = yield* FileSystem.FileSystem;

‎apps/server/src/assets/AssetAccess.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
6363
import { isWithinRoot } from "../workspace/KnownWorkspaces.ts";
6464
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
6565
import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
66+
import * as WorkspacePreviewRevisions from "./WorkspacePreviewRevisions.ts";
6667
import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts";
6768
import { extractIcnsPng } from "./icns.ts";
6869
import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
@@ -528,8 +529,16 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
528529
if (opened === null) {
529530
return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
530531
}
532+
const isHtml = /\.html?$/i.test(resolved.relativePath);
533+
const fileRevision = isHtml
534+
? yield* (yield* WorkspacePreviewRevisions.WorkspacePreviewRevisions).revision({
535+
workspaceRoot: canonicalWorkspaceRoot,
536+
baseRelativePath: path.dirname(resolved.relativePath),
537+
fileRevision: opened.fileRevision,
538+
})
539+
: opened.fileRevision;
531540
return {
532-
claims: !/\.html?$/i.test(resolved.relativePath)
541+
claims: !isHtml
533542
? {
534543
version: 1 as const,
535544
kind: "workspace-file-exact" as const,
@@ -546,7 +555,7 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
546555
},
547556
fileName: path.basename(resolved.relativePath),
548557
imageDimensions: opened.imageDimensions,
549-
fileRevision: opened.fileRevision,
558+
fileRevision,
550559
};
551560
},
552561
);
@@ -1057,5 +1066,11 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
10571066
workspaceRoot: claims.workspaceRoot,
10581067
relativePath: joinedRelativePath,
10591068
});
1069+
const revisions = yield* WorkspacePreviewRevisions.WorkspacePreviewRevisions;
1070+
yield* revisions.observe({
1071+
workspaceRoot: claims.workspaceRoot,
1072+
baseRelativePath: claims.baseRelativePath,
1073+
relativePath: joinedRelativePath,
1074+
});
10601075
return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null;
10611076
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { expect, it } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import * as Layer from "effect/Layer";
5+
6+
import * as WorkspacePreviewRevisions from "./WorkspacePreviewRevisions.ts";
7+
8+
const layerTest = WorkspacePreviewRevisions.layer.pipe(Layer.provide(NodeServices.layer));
9+
10+
it.effect("falls back to workspace refreshes when a preview has too many dependencies", () =>
11+
Effect.gen(function* () {
12+
const revisions = yield* WorkspacePreviewRevisions.WorkspacePreviewRevisions;
13+
const input = {
14+
workspaceRoot: "/missing-preview-workspace",
15+
baseRelativePath: ".",
16+
fileRevision: "entry",
17+
};
18+
const original = yield* revisions.revision(input);
19+
for (let index = 0; index < 256; index++) {
20+
yield* revisions.observe({ ...input, relativePath: `asset-${index}.css` });
21+
}
22+
expect(yield* revisions.revision(input)).toBe(original);
23+
yield* revisions.observe({ ...input, relativePath: "overflow.css" });
24+
expect(yield* revisions.revision(input)).toBeUndefined();
25+
expect(yield* revisions.revision({ ...input, baseRelativePath: "another-preview" })).toEqual(
26+
expect.any(String),
27+
);
28+
}).pipe(Effect.provide(layerTest)),
29+
);
30+
31+
it.effect("reloads a preview whose dependency metadata has been evicted", () =>
32+
Effect.gen(function* () {
33+
const revisions = yield* WorkspacePreviewRevisions.WorkspacePreviewRevisions;
34+
const input = {
35+
workspaceRoot: "/missing-preview-workspace",
36+
baseRelativePath: ".",
37+
fileRevision: "entry",
38+
};
39+
const original = yield* revisions.revision(input);
40+
for (let index = 0; index < 64; index++) {
41+
yield* revisions.revision({ ...input, baseRelativePath: `preview-${index}` });
42+
}
43+
expect(yield* revisions.revision(input)).not.toBe(original);
44+
}).pipe(Effect.provide(layerTest)),
45+
);
46+
47+
it.effect("bounds dependency metadata when asset requests overlap", () =>
48+
Effect.gen(function* () {
49+
const revisions = yield* WorkspacePreviewRevisions.WorkspacePreviewRevisions;
50+
const input = {
51+
workspaceRoot: "/missing-preview-workspace",
52+
baseRelativePath: ".",
53+
fileRevision: "entry",
54+
};
55+
yield* Effect.forEach(
56+
Array.from({ length: 257 }, (_, index) => `asset-${index}.css`),
57+
(relativePath) => revisions.observe({ ...input, relativePath }),
58+
{ concurrency: "unbounded", discard: true },
59+
);
60+
expect(yield* revisions.revision(input)).toBeUndefined();
61+
}).pipe(Effect.provide(layerTest)),
62+
);
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { isWithinRoot } from "../workspace/KnownWorkspaces.ts";
2+
import * as Cache from "effect/Cache";
3+
import * as Context from "effect/Context";
4+
import * as Crypto from "effect/Crypto";
5+
import * as Effect from "effect/Effect";
6+
import * as FileSystem from "effect/FileSystem";
7+
import * as Layer from "effect/Layer";
8+
import * as Path from "effect/Path";
9+
10+
import { openMediaFile } from "./MediaFile.ts";
11+
12+
interface PreviewDirectory {
13+
readonly workspaceRoot: string;
14+
readonly baseRelativePath: string;
15+
}
16+
17+
export class WorkspacePreviewRevisions extends Context.Service<
18+
WorkspacePreviewRevisions,
19+
{
20+
readonly observe: (
21+
input: PreviewDirectory & { readonly relativePath: string },
22+
) => Effect.Effect<void>;
23+
readonly revision: (
24+
input: PreviewDirectory & { readonly fileRevision: string },
25+
) => Effect.Effect<string | undefined>;
26+
}
27+
>()("t3/assets/WorkspacePreviewRevisions") {}
28+
29+
const make = Effect.gen(function* () {
30+
const fs = yield* FileSystem.FileSystem;
31+
const path = yield* Path.Path;
32+
const crypto = yield* Crypto.Crypto;
33+
const directories = yield* Cache.make({
34+
capacity: 64,
35+
lookup: (_key: string) =>
36+
crypto.randomUUIDv4().pipe(
37+
Effect.orDie,
38+
Effect.map((generation) => ({
39+
generation,
40+
version: 0,
41+
files: new Map<string, string | null>(),
42+
overflow: false,
43+
})),
44+
),
45+
});
46+
const readRevision = Effect.fn("WorkspacePreviewRevisions.readFile")(function* (
47+
workspaceRoot: string,
48+
relativePath: string,
49+
) {
50+
const canonical = yield* fs
51+
.realPath(path.join(workspaceRoot, relativePath))
52+
.pipe(Effect.orElseSucceed(() => null));
53+
if (canonical === null || !isWithinRoot(path, canonical, workspaceRoot)) return null;
54+
return yield* openMediaFile(canonical).pipe(
55+
Effect.map((file) =>
56+
file === null
57+
? null
58+
: `${file.info.dev}:${file.info.ino}:${file.info.size}:${file.info.mtimeNs}:${file.info.ctimeNs}`,
59+
),
60+
Effect.scoped,
61+
Effect.orElseSucceed(() => null),
62+
);
63+
});
64+
const observe = Effect.fn("WorkspacePreviewRevisions.observe")(function* (
65+
input: PreviewDirectory & { readonly relativePath: string },
66+
) {
67+
const entry = yield* Cache.get(
68+
directories,
69+
`${input.workspaceRoot}\0${input.baseRelativePath}`,
70+
);
71+
if (entry.overflow) return;
72+
const revision = yield* readRevision(input.workspaceRoot, input.relativePath);
73+
if (entry.overflow) return;
74+
if (!entry.files.has(input.relativePath) && entry.files.size >= 256) {
75+
entry.overflow = true;
76+
return;
77+
}
78+
if (entry.files.has(input.relativePath) && entry.files.get(input.relativePath) !== revision)
79+
entry.version++;
80+
entry.files.set(input.relativePath, revision);
81+
});
82+
const revision = Effect.fn("WorkspacePreviewRevisions.revision")(function* (
83+
input: PreviewDirectory & { readonly fileRevision: string },
84+
) {
85+
const entry = yield* Cache.get(
86+
directories,
87+
`${input.workspaceRoot}\0${input.baseRelativePath}`,
88+
);
89+
// Large pages keep workspace refreshes rather than retaining unbounded dependency metadata.
90+
if (entry.overflow) return undefined;
91+
yield* Effect.forEach(
92+
[...entry.files.keys()],
93+
Effect.fn("WorkspacePreviewRevisions.recheck")(function* (relativePath) {
94+
const revision = yield* readRevision(input.workspaceRoot, relativePath);
95+
if (entry.files.get(relativePath) !== revision) {
96+
entry.version++;
97+
entry.files.set(relativePath, revision);
98+
}
99+
}),
100+
{ concurrency: 4, discard: true },
101+
);
102+
return `${input.fileRevision}:${entry.generation}:${entry.version}`;
103+
});
104+
return WorkspacePreviewRevisions.of({ observe, revision });
105+
});
106+
107+
export const layer = Layer.effect(WorkspacePreviewRevisions, make);

‎apps/server/src/server.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
8989
import * as ServerSettings from "./serverSettings.ts";
9090
import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts";
9191
import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts";
92+
import * as WorkspacePreviewRevisions from "./assets/WorkspacePreviewRevisions.ts";
9293
import * as AntigravityInstallation from "./provider/AntigravityInstallation.ts";
9394
import * as CodexInstallation from "./provider/CodexInstallation.ts";
9495
import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts";
@@ -663,7 +664,13 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(
663664
),
664665
Layer.provideMerge(layerWorkspace),
665666
Layer.provideMerge(ProjectEnrichmentService.layer),
666-
Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)),
667+
Layer.provideMerge(
668+
Layer.mergeAll(
669+
NativeAppIconResolver.layer,
670+
WorkspacePreviewRevisions.layer,
671+
layerProjectFaviconResolver,
672+
),
673+
),
667674
Layer.provideMerge(layerRepositoryIdentityResolver),
668675
Layer.provideMerge(CodexManagedTokenBroker.layer),
669676
Layer.provideMerge(layerServerEnvironment),

‎apps/server/src/ws.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc";
3030
import * as SqlClient from "effect/sql/SqlClient";
3131

3232
import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
33+
import * as WorkspacePreviewRevisions from "./assets/WorkspacePreviewRevisions.ts";
3334
import * as ServerBrowser from "./preview/ServerBrowser.ts";
3435
import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator";
3536
import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
@@ -396,6 +397,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll(
396397
Layer.mock(DocumentText.DocumentText)({}),
397398
Layer.mock(ServerSecretStore.ServerSecretStore)({}),
398399
Layer.mock(WorkspacePaths.WorkspacePaths)({}),
400+
Layer.mock(WorkspacePreviewRevisions.WorkspacePreviewRevisions)({}),
399401
Layer.mock(TraceDiagnostics.TraceDiagnostics)({}),
400402
Layer.mock(ProjectFaviconResolver.ProjectFaviconResolver)({}),
401403
Layer.mock(ProjectEnrichment.ProjectEnrichmentService)({}),

‎packages/contracts/src/assets.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const AssetCreateUrlResult = Schema.Struct({
8080
TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
8181
),
8282
imageDimensions: Schema.optional(AssetImageDimensions),
83-
/** Changes only when the file's contents or identity change, unlike `relativeUrl`, which is re-signed on every mint. */
83+
/** Changes when the file, its identity, or its loaded local assets change; URL renewals alone keep it stable. */
8484
fileRevision: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(256))),
8585
});
8686
export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;

0 commit comments

Comments
 (0)