Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/open-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"chalk": "^5.3.0",
"esbuild": "0.19.2",
"express": "5.0.1",
"glob": "catalog:",
"path-to-regexp": "^6.3.0",
"urlpattern-polyfill": "^10.0.0",
"yaml": "^2.7.0"
Expand Down
4 changes: 4 additions & 0 deletions packages/open-next/src/build/createServerBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ CMD ["node", "index.mjs"]
`,
);
}

if (fnOptions.copyFiles) {
buildHelper.copyCustomFiles(fnOptions.copyFiles, outPackagePath);
}
}

function shouldGenerateDockerfile(options: FunctionOptions) {
Expand Down
10 changes: 9 additions & 1 deletion packages/open-next/src/build/edge/createEdgeBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import { openNextReplacementPlugin } from "../../plugins/replacement.js";
import { openNextResolvePlugin } from "../../plugins/resolve.js";
import { getCrossPlatformPathRegex } from "../../utils/regex.js";
import { type BuildOptions, isEdgeRuntime } from "../helper.js";
import { copyOpenNextConfig, esbuildAsync } from "../helper.js";
import {
copyCustomFiles,
copyOpenNextConfig,
esbuildAsync,
} from "../helper.js";

type Override = OverrideOptions & {
originResolver?: LazyLoadedOverride<OriginResolver> | IncludedOriginResolver;
Expand Down Expand Up @@ -221,6 +225,10 @@ export async function generateEdgeBundle(
name,
additionalPlugins,
});

if (fnOptions.copyFiles) {
copyCustomFiles(fnOptions.copyFiles, outputDir);
}
}

/**
Expand Down
56 changes: 56 additions & 0 deletions packages/open-next/src/build/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import url from "node:url";

import type { BuildOptions as ESBuildOptions } from "esbuild";
import { build as buildAsync, buildSync } from "esbuild";
import { globSync } from "glob";
import type {
CopyFile,
DefaultOverrideOptions,
OpenNextConfig,
} from "types/open-next.js";
Expand Down Expand Up @@ -440,3 +442,57 @@ export async function isEdgeRuntime(
export function getPackagePath(options: BuildOptions) {
return path.relative(options.monorepoRoot, options.appBuildOutputPath);
}

/**
* Copy files that are specified in the `copyFiles` property into the server functions output directory.
*
* @param copyFiles - Array of files to copy. Each file should have a `srcPath` and `dstPath` property.
* @param outputPath - Path to the output directory.
*/
export function copyCustomFiles(copyFiles: CopyFile[], outputPath: string) {
copyFiles.forEach(({ srcPath, dstPath }) => {
// Find all files matching the pattern
const matchedFiles = globSync(srcPath, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would that work with "relative" files?
and relative to what?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would that work with "relative" files?
and relative to what?

yeah, and by default its relative to process.cwd(). perhaps i should change that in-case you are in a monorepo.

nodir: true,
windowsPathsNoEscape: true,
});

if (matchedFiles.length === 0) {
logger.warn(`No files found for pattern: ${srcPath}`);
return;
}

if (matchedFiles.length === 1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I want to copy *.js to /some/path, will be behavior be different if there is one vs multiple files? (if yes, that does not sound right)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I want to copy *.js to /some/path, will be behavior be different if there is one vs multiple files? (if yes, that does not sound right)

yeah your correct about that one. after some more thought i should probably think about a better way to handle this. im open for suggestions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One way to handle that could be to have exclusive dstFile (error on multiple match) and dstDir options

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I am not sure what use case you are solving with this PR. Maybe having a hook accepting a function taking the buildOptions as a parameter could be good enough - that would mean the options are part of the API and must be documented and ~stable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One way to handle that could be to have exclusive dstFile (error on multiple match) and dstDir options

thats a good idea. probably something I would go for if we choose to proceed with this PR.

Also I am not sure what use case you are solving with this PR

the idea was that sometimes you need or want a file that's not there. we've had a few cases where people were missing a file in the server function's output. i.e this discord thread

however, if you guys think this wont be needed i can close it. Next does have outputFileTracingIncludes which probably might be enough for most cases anyways.

Maybe having a hook accepting a function taking the buildOptions as a parameter could be good enough - that would mean the options are part of the API and must be documented and ~stable.

that is an excellent idea. im gonna think about this until tomorrow. with more thoroughly thought this PR might not be needed for any use-cases.

// Single file match - use dstPath as it is
const srcFile = matchedFiles[0];
const fullDstPath = path.join(outputPath, dstPath);

copyFile(srcFile, fullDstPath);
} else {
// Multiple files matched, dstPath will become a directory
matchedFiles.forEach((srcFile) => {
const filename = path.basename(srcFile);
const fullDstPath = path.join(outputPath, dstPath, filename);
copyFile(srcFile, fullDstPath);
});
}
});
}
/**
* Copy a file to the destination path.
*
* @param srcFile - Path to the source file.
* @param fullDstPath - Path to the destination file.
*/
function copyFile(srcFile: string, fullDstPath: string) {
const dstDir = path.dirname(fullDstPath);

if (!fs.existsSync(dstDir)) {
fs.mkdirSync(dstDir, { recursive: true });
}
if (fs.existsSync(fullDstPath)) {
logger.warn(`File already exists: ${fullDstPath}. It will be overwritten.`);
}
fs.copyFileSync(srcFile, fullDstPath);
logger.debug(`Copied ${srcFile} to ${fullDstPath}`);
}
24 changes: 24 additions & 0 deletions packages/open-next/src/types/open-next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,15 @@ export interface DefaultFunctionOptions<
install?: InstallOptions;
}

/**
* @srcPath The path to the file to copy. Can be absolute or relative path. Can use glob pattern.
* @dstPath The relative path to the destination in the server function. Will become a directory if multiple files are found in srcPath.
*/
export interface CopyFile {
srcPath: string;
dstPath: string;
}

export interface FunctionOptions extends DefaultFunctionOptions {
/**
* Runtime used
Expand All @@ -313,6 +322,21 @@ export interface FunctionOptions extends DefaultFunctionOptions {
* @deprecated This is not supported in 14.2+
*/
experimentalBundledNextServer?: boolean;
/**
* Manually copy files into the server function after the build.
* If multiple files are found with srcPath, dstPath will become a directory.
* @copyFiles The files to copy. Is an array of objects with srcPath and dstPath.
* @example
* ```ts
* copyFiles: [
* {
* srcPath: 'relativefile.txt',
* dstPath: '/this/is/a/folder.txt',
* },
* ]
* ```
*/
copyFiles?: CopyFile[];
}

export type RouteTemplate =
Expand Down
2 changes: 2 additions & 0 deletions packages/tests-unit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
"@opennextjs/aws": "workspace:*"
},
"devDependencies": {
"@types/mock-fs": "^4.13.4",
"@types/testing-library__jest-dom": "^5.14.9",
"@vitest/coverage-v8": "^2.1.3",
"jsdom": "^22.1.0",
"mock-fs": "^5.5.0",
"vite": "5.4.9",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.1.3"
Expand Down
183 changes: 182 additions & 1 deletion packages/tests-unit/tests/build/helper.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { compareSemver } from "@opennextjs/aws/build/helper.js";
import fs from "node:fs";
import path from "node:path";

import {
compareSemver,
copyCustomFiles,
} from "@opennextjs/aws/build/helper.js";
import logger from "@opennextjs/aws/logger.js";
import mockFs from "mock-fs";
import { vi } from "vitest";

// We don't need to test canary versions, they are stripped out
describe("compareSemver", () => {
Expand Down Expand Up @@ -65,3 +74,175 @@ describe("compareSemver", () => {
expect(() => compareSemver("14.0.0", "!=" as any, "14.0.0")).toThrow();
});
});

const outputFolder = ".open-next/server-functions/default";

describe("copyFiles", () => {
beforeEach(() => {
mockFs({
"this/is/a/fake/dir": {
"some-file24214.txt": "some content",
"another-fil321313e.txt": "another content",
"empty-file321441.txt": "",
"important-js": {
"another-important.js": "console.log('important!')",
},
},
"this/is/a/real/dir": {
"randomfile.txt": "some content",
"another-dirfdsf": {
"another-filedsfdsf.txt": "another content",
},
"empty-file.txtfdsf": "",
"imporant-files": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"imporant-files": {
"important-files": {

"important.js": "console.log('important!')",
"super-important.js": "console.log('super important!')",
},
},
[`${outputFolder}/server`]: {
"index.mjs": "globalThis.process.env = {}",
},
});

vi.spyOn(fs, "copyFileSync");
vi.spyOn(fs, "mkdirSync");
vi.spyOn(fs, "readFileSync");
});

afterAll(() => {
mockFs.restore();
vi.restoreAllMocks();
});

it("should work with a glob, dstPath should become a directory", () => {
copyCustomFiles(
[
{
srcPath: "**/*.js",
dstPath: "functions",
},
],
outputFolder,
);

const dstDir = path.join(outputFolder, "functions");
expect(fs.copyFileSync).toHaveBeenCalledTimes(3);
expect(fs.mkdirSync).toHaveBeenCalledWith(dstDir, { recursive: true });
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);

expect(fs.readdirSync(dstDir)).toEqual([
"another-important.js",
"important.js",
"super-important.js",
]);

expect(
fs.readFileSync(path.join(dstDir, "important.js")).toString(),
).toMatchInlineSnapshot(`"console.log('important!')"`);
});

it("should copy a single file when srcPath matches one file", () => {
copyCustomFiles(
[
{
srcPath: "this/is/a/real/dir/randomfile.txt",
dstPath: "randomfolder/randomfile.txt",
},
],
outputFolder,
);

const dstDir = path.join(outputFolder, "randomfolder");
expect(fs.mkdirSync).toHaveBeenCalledWith(dstDir, { recursive: true });
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);

expect(fs.copyFileSync).toHaveBeenCalledTimes(1);
expect(fs.copyFileSync).toHaveBeenCalledWith(
"this/is/a/real/dir/randomfile.txt",
path.join(outputFolder, "randomfolder/randomfile.txt"),
);

expect(
fs.readFileSync(path.join(outputFolder, "randomfolder/randomfile.txt"), {
encoding: "utf-8",
}),
).toMatchInlineSnapshot(`"some content"`);
});

it("should work with a glob in a sub directory", () => {
copyCustomFiles(
[
{
srcPath: "this/is/a/real/dir/imporant-files/**/*.js",
dstPath: "super/functions",
},
],
outputFolder,
);

expect(fs.mkdirSync).toHaveBeenCalledWith(
path.join(outputFolder, "super/functions"),
{ recursive: true },
);
expect(fs.mkdirSync).toHaveBeenCalledTimes(1);

expect(fs.copyFileSync).toHaveBeenCalledTimes(2);
expect(fs.copyFileSync).toHaveBeenCalledWith(
"this/is/a/real/dir/imporant-files/important.js",
path.join(outputFolder, "super/functions/important.js"),
);
expect(fs.copyFileSync).toHaveBeenCalledWith(
"this/is/a/real/dir/imporant-files/super-important.js",
path.join(outputFolder, "super/functions/super-important.js"),
);

expect(fs.readdirSync(path.join(outputFolder, "super/functions"))).toEqual([
"important.js",
"super-important.js",
]);
expect(
fs.readFileSync(
path.join(outputFolder, "super/functions/super-important.js"),
{ encoding: "utf-8" },
),
).toMatchInlineSnapshot(`"console.log('super important!')"`);
});
it("should warn when file already exists", () => {
const logSpy = vi.spyOn(logger, "warn");

copyCustomFiles(
[
{
srcPath: "this/is/a/fake/dir/some-file24214.txt",
dstPath: "server/index.mjs",
},
],
outputFolder,
);

const fullDstPath = path.join(outputFolder, "server/index.mjs");
expect(logSpy).toHaveBeenCalledWith(
`File already exists: ${fullDstPath}. It will be overwritten.`,
);
logSpy.mockRestore();
});
it("should warn when no files are found", () => {
const logSpy = vi.spyOn(logger, "warn");
const srcPath = "path/to/dir/does-not-exist.txt";

copyCustomFiles(
[
{
srcPath: srcPath,
dstPath: "server/index.mjs",
},
],
outputFolder,
);

expect(logSpy).toHaveBeenCalledWith(
`No files found for pattern: ${srcPath}`,
);
logSpy.mockRestore();
});
});
Loading
Loading