Skip to content

Commit d5bda43

Browse files
committed
presets(vercel): hard link custom function dirs instead of copying
1 parent 6de81ee commit d5bda43

2 files changed

Lines changed: 47 additions & 7 deletions

File tree

src/presets/_utils/fs.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import fsp from "node:fs/promises";
2-
import { relative, dirname } from "pathe";
2+
import { relative, dirname, join } from "pathe";
33
import consola from "consola";
44
import { colors } from "consola/utils";
55

@@ -23,3 +23,45 @@ export async function isDirectory(path: string) {
2323
return false;
2424
}
2525
}
26+
27+
/**
28+
* Recursively recreate `src` at `dest` using hard links for files.
29+
*
30+
* Hard links share inodes with the source, so deployment targets that
31+
* read files into per-function bundles (e.g. Vercel Lambda packaging)
32+
* see them as regular files while local disk usage stays flat.
33+
*
34+
* Directories cannot be hard-linked, so the tree is recreated and each
35+
* file is linked individually. Symlinks are preserved as symlinks.
36+
* Falls back to `copyFile` when crossing filesystems (`EXDEV`) or when
37+
* the platform refuses the link operation (`EPERM`).
38+
*
39+
* Entries listed in `skip` are ignored at the top level only.
40+
*/
41+
export async function hardLinkDir(src: string, dest: string, options: { skip?: Set<string> } = {}) {
42+
await fsp.mkdir(dest, { recursive: true });
43+
const entries = await fsp.readdir(src, { withFileTypes: true });
44+
for (const entry of entries) {
45+
if (options.skip?.has(entry.name)) {
46+
continue;
47+
}
48+
const srcPath = join(src, entry.name);
49+
const destPath = join(dest, entry.name);
50+
if (entry.isDirectory()) {
51+
await hardLinkDir(srcPath, destPath);
52+
} else if (entry.isSymbolicLink()) {
53+
const target = await fsp.readlink(srcPath);
54+
await fsp.symlink(target, destPath);
55+
} else {
56+
try {
57+
await fsp.link(srcPath, destPath);
58+
} catch (err: any) {
59+
if (err?.code === "EXDEV" || err?.code === "EPERM") {
60+
await fsp.copyFile(srcPath, destPath);
61+
} else {
62+
throw err;
63+
}
64+
}
65+
}
66+
}
67+
}

src/presets/vercel/utils.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fsp from "node:fs/promises";
22
import { defu } from "defu";
3-
import { writeFile } from "../_utils/fs.ts";
3+
import { hardLinkDir, writeFile } from "../_utils/fs.ts";
44
import type { Nitro, NitroRouteRules } from "nitro/types";
55
import { dirname, relative, resolve } from "pathe";
66
import { Router } from "../../routing.ts";
@@ -636,11 +636,9 @@ async function createFunctionDirWithCustomConfig(
636636
overrides: VercelServerlessFunctionConfig,
637637
functionPath: string
638638
) {
639-
// Copy the entire server directory instead of symlinking individual
640-
// entries. Vercel's build container preserves symlinks in the Lambda
641-
// zip, but symlinks pointing outside the .func directory break at
642-
// runtime because the target path doesn't exist on Lambda.
643-
await fsp.cp(serverDir, funcDir, { recursive: true });
639+
await hardLinkDir(serverDir, funcDir, {
640+
skip: new Set([".vc-config.json"]),
641+
});
644642
const mergedConfig = defu(overrides, baseFunctionConfig);
645643
for (const [key, value] of Object.entries(overrides)) {
646644
if (Array.isArray(value)) {

0 commit comments

Comments
 (0)