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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "#veryfront/schemas/_test-setup.ts";
import { assertEquals } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { join } from "#veryfront/compat/path/index.ts";
import { join, toFileUrl } from "#veryfront/compat/path/index.ts";
import { makeTempDir, mkdir, remove, writeTextFile } from "#veryfront/testing/deno-compat.ts";
import {
extractAllFilePaths,
Expand Down Expand Up @@ -176,6 +176,13 @@ describe("extractAllFilePaths", () => {
assertEquals(extractAllFilePaths(code), ["/tmp/project/Button.tsx"]);
});

it("decodes encoded file URL paths before filesystem validation", () => {
const path = "/tmp/_pins/on%3Asnapshot/Button.tsx";
const code = `import a from ${JSON.stringify(toFileUrl(path).href)};`;

assertEquals(extractAllFilePaths(code), [path]);
});

it("deduplicates identical paths", () => {
const code = [
`import a from "file:///tmp/shared.js";`,
Expand Down Expand Up @@ -260,4 +267,27 @@ describe("recursive cache path extraction", () => {
await remove(tempDir, { recursive: true });
}
});

it("visits nested modules through encoded file URL paths", async () => {
const tempDir = await makeTempDir({ prefix: "vf-encoded-file-path-helper-" });
const childDir = join(tempDir, "veryfront-mdx-esm", "_pins", "on%3Asnapshot");
const childPath = join(childDir, "vfmod-child.mjs");

try {
await mkdir(childDir, { recursive: true });
await writeTextFile(
childPath,
`import markdown from "file:///app/.cache/markdown.tsx"; export default markdown;`,
);

const paths = await extractAllFilePathsRecursive(
`import child from ${JSON.stringify(toFileUrl(childPath).href)}; export default child;`,
);

assertEquals(paths.includes(childPath), true);
assertEquals(paths.includes("/app/.cache/markdown.tsx"), true);
} finally {
await remove(tempDir, { recursive: true });
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@
*/

import { createFileSystem, exists } from "#veryfront/platform/compat/fs.ts";
import { fromFileUrl } from "#veryfront/compat/path/index.ts";
import { LRUCache } from "#veryfront/utils/lru-wrapper.ts";

/** Max entries in the verified HTTP bundle paths LRU cache */
const VERIFIED_BUNDLE_CACHE_MAX_ENTRIES = 2_000;

function decodeFileUrlPath(path: string): string | null {
try {
return fromFileUrl(`file://${path}`);
} catch (_) {
return null;
}
}

/**
* Extract VF module paths (veryfront-mdx-esm/*.mjs) from code.
* These are user project modules that may import HTTP bundles.
Expand All @@ -24,12 +33,11 @@ function extractVfModulePaths(code: string): string[] {
const seen = new Set<string>();
let match;
while ((match = vfModulePattern.exec(code)) !== null) {
const path = match[1] as string;
// Strip query params for path comparison
const cleanPath = path.replace(/\?.*$/, "");
if (!seen.has(cleanPath)) {
seen.add(cleanPath);
paths.push(cleanPath);
const path = decodeFileUrlPath(match[1] as string);
if (!path) continue;
if (!seen.has(path)) {
seen.add(path);
paths.push(path);
}
}
return paths;
Expand Down Expand Up @@ -113,7 +121,7 @@ export function extractHttpBundlePaths(code: string): Array<{ path: string; hash
const absolutePattern = /file:\/\/([^"'\s]+veryfront-http-bundle\/http-([a-f0-9]+)\.mjs)/gi;
let match: RegExpExecArray | null;
while ((match = absolutePattern.exec(code)) !== null) {
const path = match[1];
const path = match[1] ? decodeFileUrlPath(match[1]) : null;
const hash = match[2];
if (!path || !hash || seen.has(hash)) continue;
seen.add(hash);
Expand Down Expand Up @@ -150,7 +158,7 @@ export function extractAllFilePaths(code: string): string[] {

let match: RegExpExecArray | null;
while ((match = allFilePathsPattern.exec(code)) !== null) {
const path = match[1]?.replace(/[?#].*$/, "");
const path = match[1] ? decodeFileUrlPath(match[1]) : null;

if (!path || !supportedPathPattern.test(path) || seen.has(path)) continue;

Expand Down
23 changes: 23 additions & 0 deletions src/platform/compat/path/url-conversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ describe("url-conversion", () => {
assertEquals(fromFileUrl("file:///home/user/my%20file.ts"), "/home/user/my file.ts");
});

it("should ignore URL search and fragment data", () => {
assertEquals(
fromFileUrl("file:///tmp/_pins/on%253Asnapshot/file.ts?v=1#module"),
"/tmp/_pins/on%3Asnapshot/file.ts",
);
});

it("should handle paths with special characters", () => {
assertEquals(
fromFileUrl("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.ts"),
Expand Down Expand Up @@ -45,6 +52,22 @@ describe("url-conversion", () => {
assertEquals(result.href.includes("spaces"), true);
});

it("should preserve percent signs as literal path characters", () => {
const path = "/tmp/_pins/on%3Asnapshot/file.ts";
const result = toFileUrl(path);

assertEquals(result.href, "file:///tmp/_pins/on%253Asnapshot/file.ts");
assertEquals(fromFileUrl(result), path);
});

it("should preserve query and fragment delimiters as literal path characters", () => {
const path = "/tmp/cache?variant#module.ts";
const result = toFileUrl(path);

assertEquals(result.href, "file:///tmp/cache%3Fvariant%23module.ts");
assertEquals(fromFileUrl(result), path);
});

it("should handle relative path by resolving", () => {
const result = toFileUrl("relative/path.ts");
assertEquals(result.protocol, "file:");
Expand Down
29 changes: 18 additions & 11 deletions src/platform/compat/path/url-conversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,38 @@ function getFileURLToPath(): ((url: string | URL) => string) | null {
}

export function fromFileUrl(url: string | URL): string {
const fileURLToPath = getFileURLToPath();
if (fileURLToPath) return fileURLToPath(url);
const parsedUrl = typeof url === "string" ? new URL(url) : url;
if (parsedUrl.protocol !== "file:") {
throw new TypeError("Must be a file URL");
}

const urlString = typeof url === "string" ? url : url.toString();
const fileURLToPath = getFileURLToPath();
if (fileURLToPath) return fileURLToPath(parsedUrl);

if (isDeno) {
const g = globalThis as GlobalWithRequire;
const hasCwd = Boolean(g.Deno?.cwd);
const isWindows = g.Deno?.build?.os === "windows";

if (hasCwd && isWindows) {
return decodeURIComponent(urlString.slice(8).replace(/\//g, "\\"));
return decodeURIComponent(parsedUrl.pathname)
.replace(/^\/([A-Za-z]:)/, "$1")
.replace(/\//g, "\\");
}

return decodeURIComponent(urlString.slice(7));
}

if (!urlString.startsWith("file://")) {
throw new TypeError("Must be a file URL");
return decodeURIComponent(parsedUrl.pathname);
}

return decodeURIComponent(urlString.slice(7));
return decodeURIComponent(parsedUrl.pathname);
}

export function toFileUrl(path: string): URL {
const absolute = hasNodePath ? path : isAbsolute(path) ? path : resolve(path);
return new URL(`file://${absolute}`);
// Preserve filesystem characters that URL parsing would otherwise decode or
// interpret as fragment/query delimiters.
const encodedPath = absolute
.replaceAll("%", "%25")
.replaceAll("#", "%23")
.replaceAll("?", "%3F");
return new URL(`file://${encodedPath}`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,25 @@ describe("module-loader/dependency-resolver", () => {
assertStringIncludes(rewritten, `from "file:///tmp/lib/value.def.js"`);
});

it("preserves encoded dependency-pin directory names in file URLs", () => {
const source = `import { value } from "../lib/value";`;
const rewritten = rewriteResolvedDependencyImports(source, [
withStaticSpan(source, {
full: `from "../lib/value"`,
path: "../lib/value",
relativePath: "../lib/value",
depFilePath: "/project/lib/value.ts",
depTempPath: "/tmp/_pins/on%3Asnapshot/lib/value.abc.js",
isLocalLib: false,
}),
]);

assertStringIncludes(
rewritten,
`from "file:///tmp/_pins/on%253Asnapshot/lib/value.abc.js"`,
);
});

it("rewrites the matched import instead of the same text in an earlier comment", async () => {
await withDependencyFixture(
{
Expand Down
21 changes: 12 additions & 9 deletions src/rendering/orchestrator/module-loader/dependency-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { dirname, join, normalize } from "#veryfront/compat/path/index.ts";
import { dirname, join, normalize, toFileUrl } from "#veryfront/compat/path/index.ts";
import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts";
import { parallelMap, rendererLogger } from "#veryfront/utils";
import {
Expand Down Expand Up @@ -192,13 +192,16 @@ export function rewriteResolvedDependencyImports(
fileContent: string,
transformedDeps: TransformedModuleDependency[],
): string {
const replacements: SourceSpanReplacement[] = transformedDeps.map((dep) => ({
start: dep.start,
end: dep.end,
expected: dep.full,
// A dynamic span covers only the quoted specifier; a static one covers the
// whole `from "…"` clause.
replacement: dep.isDynamic ? `"file://${dep.depTempPath}"` : `from "file://${dep.depTempPath}"`,
}));
const replacements: SourceSpanReplacement[] = transformedDeps.map((dep) => {
const moduleUrl = toFileUrl(dep.depTempPath).href;
return {
start: dep.start,
end: dep.end,
expected: dep.full,
// A dynamic span covers only the quoted specifier; a static one covers the
// whole `from "…"` clause.
replacement: dep.isDynamic ? `"${moduleUrl}"` : `from "${moduleUrl}"`,
};
});
return replaceSourceSpans(fileContent, replacements);
}
49 changes: 48 additions & 1 deletion src/rendering/orchestrator/module-loader/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
type ModuleLoaderConfig,
transformModuleWithDeps,
} from "./index.ts";
import { getModuleCacheKey } from "./module-cache-lookup.ts";
import { buildModuleTransformCacheVariant, getModuleCacheKey } from "./module-cache-lookup.ts";
import { isBuildFailure } from "./build-failure.ts";

async function withModuleLoaderFixture<T>(
Expand Down Expand Up @@ -338,6 +338,53 @@ describe("module-loader/loadModule build-failure tagging", () => {
});

describe("module-loader/loadModule", () => {
it("loads a module from an encoded dependency-pin cache directory", async () => {
await withModuleLoaderFixture(
{ "app/page.ts": `export const value = "pinned";` },
async ({ projectDir, tmpDir, config }) => {
const filePath = join(projectDir, "app/page.ts");
const dependencyPinningCacheKey = "on:z7bg3qnfgtcb";
const moduleServerOrigin = "https://preview.example.test";
const cacheVariant = buildModuleTransformCacheVariant(
dependencyPinningCacheKey,
moduleServerOrigin,
);
assert(cacheVariant);
const artifactPath = join(
tmpDir,
"_pins",
encodeURIComponent(cacheVariant),
"app/page.pinned.mjs",
);
await Deno.mkdir(dirname(artifactPath), { recursive: true });
await Deno.writeTextFile(artifactPath, `export const value = "pinned";`);
config.moduleCache.set(
getModuleCacheKey(
filePath,
undefined,
projectDir,
undefined,
undefined,
"development",
dependencyPinningCacheKey,
moduleServerOrigin,
),
artifactPath,
);

await runWithCacheDir(tmpDir, async () => {
const loaded = await loadModule(filePath, {
...config,
dependencyPinningCacheKey,
moduleServerOrigin,
});

assertEquals(loaded.value, "pinned");
});
},
);
});

it("reuses the content-addressed module identity across repeated loads", async () => {
await withModuleLoaderFixture(
{ "app/page.ts": `export const value = "stable";` },
Expand Down
8 changes: 4 additions & 4 deletions src/rendering/orchestrator/module-loader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts";
import { getLocalAdapter } from "#veryfront/platform/adapters/registry.ts";
import { getProjectTmpDir } from "#veryfront/modules/react-loader/index.ts";
import { getHttpBundleCacheDir, getMdxEsmCacheDir } from "#veryfront/utils/cache-dir.ts";
import { join } from "#veryfront/compat/path/index.ts";
import { join, toFileUrl } from "#veryfront/compat/path/index.ts";
import { invalidateMdxEsmModule } from "#veryfront/transforms/mdx/esm-module-loader/cache/index.ts";
import {
resolveModuleDependencies,
Expand Down Expand Up @@ -325,7 +325,7 @@ export async function loadModule(
throw markBuildFailure(error);
}

const moduleUrl = `file://${tempFilePath}`;
const moduleUrl = toFileUrl(tempFilePath).href;
markModuleLoadProgress(config, "module:import-start", filePath);

try {
Expand Down Expand Up @@ -354,7 +354,7 @@ export async function loadModule(

if (recovered) {
logger.info("HTTP bundle recovered, retrying import", { hash });
return await import(`file://${tempFilePath}?t=${Date.now()}&retry=1`);
return await import(`${toFileUrl(tempFilePath).href}?t=${Date.now()}&retry=1`);
}
}

Expand Down Expand Up @@ -403,7 +403,7 @@ export async function loadModule(
throw markBuildFailure(rebuildError);
}

return await import(`file://${rebuiltPath}?t=${Date.now()}&rebuilt=1`);
return await import(`${toFileUrl(rebuiltPath).href}?t=${Date.now()}&rebuilt=1`);
}

logger.error("Failed to import module:", {
Expand Down
Loading