Private files
Sometimes your handler needs a file: a CSV of rates, a JSON config, a text template. Put it in
assets and anyone can download it, because every file there answers at its path. Put it in
files instead. Your deploy carries it, your handler reads it, and no address ever serves it.
Which files are public
Section titled “Which files are public”The rule is one line: files in assets are public, files in files are private, and anything else
doesn’t ship.
That goes for what a bundler writes too. A framework build puts the browser’s half of each route in
assets, and a browser has to download it to run it, so it’s public. The code that runs on the
server is your handler, and no address serves that.
Declare the folder
Section titled “Declare the folder”{
"app": "letterpress",
"function": "root",
"entry": "src/index.js",
"assets": "public",
"files": "data"
}
files is a path relative to the directory you deploy from. A file keeps its path inside that
folder: data/rates.csv is carried as rates.csv, and data/templates/mail.txt as
templates/mail.txt. The CLI uploads the folder the same way it uploads assets, and sends only
the files we don’t already hold.
A deploy with private files needs a handler. Without one nothing could read them, so we turn the deploy down.
An agent deploying through the MCP endpoint declares them the same way. The
deploy_function tool takes a files list shaped like assets: each entry is a path plus either
the file’s text as contents, or a content_hash for bytes uploaded separately. A deploy that
leaves a private file out of files drops it. get_function_source lists the private files a
version carries, with their hashes, so an agent can declare them again without sending the bytes.
Read a file
Section titled “Read a file”import { readFile } from "node:fs/promises";
export default {
async fetch() {
const rates = await readFile("rates.csv", "utf8");
return new Response(rates, { headers: { "content-type": "text/csv" } });
},
};
The path is the file’s path inside your files folder, so rates.csv, ./rates.csv and
/rates.csv all name the same file. Pass an encoding such as "utf8" to get a string. Leave it out
and you get the file’s bytes as a Uint8Array.
The calls
Section titled “The calls”Everything comes from node:fs/promises, and behaves as it does in Node:
| Call | What it gives you |
|---|---|
readFile(path) |
The file’s bytes, or a string when you pass an encoding. |
readdir(path) |
The names in a folder. { withFileTypes: true } and { recursive: true } work. |
stat(path), lstat(path) |
size, isFile() and isDirectory(). |
access(path) |
Resolves if the path exists, and rejects if it doesn’t. |
A folder exists when a file sits inside it, so . is your files folder and templates exists if
templates/mail.txt does. readdir, stat and access answer from the list of files your version
carries, so they cost nothing. Only readFile loads a file.
Every call that writes, such as writeFile, mkdir, rm, rename or copyFile, rejects. The
files are read-only.
Errors
Section titled “Errors”A failed call rejects with an error whose code is Node’s:
code |
When |
|---|---|
ENOENT |
Your version carries no file or folder at that path. |
EISDIR |
You passed a folder to readFile. |
ENOTDIR |
You passed a file to readdir. |
EROFS |
You called a write, or access with constants.W_OK. |
EFBIG |
The read would take the invocation past the 8 MiB it may read. |
EACCES |
You called access on a file with constants.X_OK. Nothing here runs as a program. |
import { readFile } from "node:fs/promises";
async function readConfig() {
try {
return JSON.parse(await readFile("config.json", "utf8"));
} catch (error) {
if (error.code === "ENOENT") return {};
throw error;
}
}
There’s no sync API
Section titled “There’s no sync API”readFileSync, existsSync and the other sync calls from node:fs aren’t there. Use the call of
the same name from node:fs/promises and await it. For existsSync, use access.
Importing it
Section titled “Importing it”A static import at the top of the module works, and so does await import("node:fs/promises")
inside a handler. The CLI’s bundler leaves the import alone, and we supply the module when your Function runs.
The CLI does this for node:fs/promises and fs/promises only. Import any other node: module
and the build fails with Could not resolve, so you find out before you deploy.
A read isn’t an outbound call. It doesn’t count toward the 50 calls an invocation may make, and it isn’t egress.
We load only the files your handler reads, so a version can carry many files and a request that reads one pays for one. The first read of a file can be slower than the reads after it.
With Vite or React Router
Section titled “With Vite or React Router”The server build of Vite 6 and later keeps the import as it is, and so does React Router’s. You
don’t need to change anything. Vite 5 with ssr.target: "webworker" tries to bundle the module and
fails. Add both names to ssr.external and it leaves them alone:
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
ssr: {
external: ["node:fs/promises", "fs/promises"],
},
});
React Router builds with Vite, so the setting goes in the same place. Add it next to the ssr
options you already have:
// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
export default defineConfig(({ isSsrBuild }) => ({
plugins: [reactRouter()],
ssr: {
noExternal: isSsrBuild ? true : undefined,
external: ["node:fs/promises", "fs/promises"],
},
}));
Call readFile from a loader or an action, which run on the server. A component runs in the
browser too, and the browser has no node:fs/promises.
On your machine
Section titled “On your machine”Your tests and your dev server can read private files the way your Function does.
readFile("rates.csv") then reads rates.csv in your files folder, not in the folder you ran
the command from. A write rejects with EROFS, and a path outside files rejects with ENOENT.
The rules are the same code we run in production.
For tests, add wawesome/vitest-setup to your Vitest config, as the
CLI page shows. Templates ship it already.
For a Vite dev server, add the plugin. React Router’s dev server runs on Vite, so it goes in the same file:
// vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import { wawesomePrivateFiles } from "wawesome/vite";
export default defineConfig({
plugins: [reactRouter(), wawesomePrivateFiles()],
});
The plugin changes only the dev server. The build still leaves the import for us to supply. The dev server reads the folder on every call, so a file you add, change or delete shows up on the next request, with no restart.
Three things differ from production:
- The 8 MiB limit counts each read on its own. In production it counts every read in one invocation.
- A package in
node_modulesthat Vite or Vitest loads as it is gets Node’s ownnode:fs/promises. In production it’s bundled with your handler and gets ours. - In a test file,
node:fs/promisesis ours too, so it can’t write. To set up files on disk from a test, usenode:fs.
Limits
Section titled “Limits”One private file may be 50 MiB, and one version may carry 2,000 of them. A deploy past either is refused, and the refusal names the file or the limit. Private files count toward your storage, the same as static files.
One invocation may read 8 MiB of private files, over every read it makes. A read past that rejects
with code: "EFBIG", and your handler can catch it and answer. Limits
lists every number.
Files move with the version
Section titled “Files move with the version”Private files belong to the version, the same as the handler and the pages. A deploy that changes only a private file makes a new version. Roll back and your handler reads the files that version shipped with, so old code never runs against new data.
A private file goes wherever the version goes. An App handed to another workspace takes its private files with it, and a workspace export includes them.
npx wawesome version switch 6
A file is fixed when the version is made. Your handler can’t write to it, and nothing changes it between deploys. For data that changes, use your own database. Where your data goes covers that.
See what a version carries
Section titled “See what a version carries”The Files tab on a Function’s page lists the live version’s files in two groups. Each public file
has a button that copies its URL. The private group starts with your code, as index.js, then
lists your private files, and each one has a download button. A download uses your dashboard
sign-in. It never goes through a public address.
Private isn’t secret
Section titled “Private isn’t secret”Private means no visitor can download the file. It doesn’t mean we can’t read it: we store it the same way we store every file you deploy. Put API keys, passwords and tokens in environment variables. You can change those without a deploy, and a rollback doesn’t bring an old key back.