Carriers
Occasionally your website will need to do something using privileged keys (e.g. submit a form, call third party APIs, etc) - carriers are small javascript or typescript functions that can be used to abstract these calls so you don't leak your keys. They "carry information" to your website, should you need to use information not available at build time or via simple scripting.
Carriers are serverless, stateless, and are deployed automatically whenever your site builds.
Once deployed, a carrier is reachable at /carriers/<carrier-name> on your site. For example, a carrier in carriers/submit-form/ is served at https://yoursite.com/carriers/submit-form.
Folder structure
Carriers live in a top-level carriers/ directory in your repo. Each subdirectory is a single carrier, and the directory name becomes the carrier's URL path:
carriers/
submit-form/
package.json # optional — "main" selects the entry file, "scripts.build" runs at deploy
package-lock.json # optional — used for a reproducible install
index.ts # the carrier's entry file
archival-objects.d.ts # generated by npx archival-carrier-types — commit this
.gitignore # ignores generated carrier_* build files
- The directory name (
submit-formabove) is the carrier name and the URL segment:/carriers/submit-form. - By default the entry file is
index.ts,index.mts,index.cts,index.js,index.mjs, orindex.cjs— whichever exists. If you include apackage.json, itsmainfield selects the entry file instead (e.g."main": "submit.ts"). - During a build, archival writes generated wrapper and config files named
carrier_*into the carrier directory. Addcarrier_*to a.gitignoreso these build artifacts aren't committed.
TypeScript carriers
A carrier may be written in TypeScript or JavaScript. TypeScript is bundled directly at deploy time, so there is no separate compile step to configure — name your entry file index.ts and it just works.
Because npm init writes "main": "index.js" by default, a main that points at a file which was never emitted is resolved against every supported extension before giving up. In other words, "main": "index.js" next to an index.ts finds the TypeScript file rather than failing the deploy.
To get types for the carrier signature itself, add @archival/carrier as a dev dependency:
cd carriers/submit-form
npm install --save-dev @archival/carrier
import type { Carrier } from "@archival/carrier";
const carrier: Carrier = async (params, body, objects) => ({
hello: params.get("name"),
site: objects.SITE_URL,
});
export default carrier;
The package exports the whole contract, so you can name any part of it explicitly:
| Type | |
|---|---|
Carrier<Objects> |
The handler itself — the default export of your entry file. The type parameter is an escape hatch if you'd rather name your objects type than rely on the generated augmentation. |
CarrierParams |
The first argument: a URLSearchParams. |
CarrierBody |
The second argument: parsed JSON, a form body, text, or null. |
CarrierObjects / SiteObjects |
The third argument: your site's objects merged with the vars archival injects. |
CarrierEnv |
Just the injected vars — SITE_URL and UPLOADS. |
CarrierUploads |
The UPLOADS var: list() and get(). |
CarrierUpload |
One upload's body and metadata, as get() resolves it. |
CarrierUploadEntry |
A { sha, filename } pair, as list() returns them. |
CarrierFormBody |
A parsed form submission — { [key: string]: string | Blob }. |
CarrierJsonValue |
Any value that survives a JSON round trip. |
CarrierResponse |
What a carrier may return. |
The package is types-only at runtime, so it belongs in devDependencies — nothing from it is bundled into the deployed carrier.
Dependencies and build scripts
If a package.json is present, dependencies are installed at deploy time — npm ci when a package-lock.json (or npm-shrinkwrap.json) exists, otherwise npm install. This means a carrier can depend on npm packages.
After the install, if your package.json declares a build script, archival runs npm run build in the carrier directory:
{
"main": "dist/carrier.js",
"scripts": {
"build": "tsc"
},
"devDependencies": {
"@archival/carrier": "^0.0.1",
"typescript": "^5.0.0"
}
}
A failing build fails the deploy, with the script's output in the build logs.
The entry file is resolved after the install and build steps run, so a build script may generate the file that main points at — which is what makes the example above work. You only need a build script if you want one: plain TypeScript carriers are bundled without it.
Carrier request signature
A carrier's entry file must export default an async function that receives three arguments:
export default async function (params, body, objects) {
// ...your logic
}
params— aURLSearchParamsbuilt from the request's query string.body— the parsed request body forPOSTandPUTrequests (nullfor other methods). How it's parsed depends on the request'sContent-Type:application/json→ a parsed JSON valueapplication/x-www-form-urlencoded,multipart/form-data, orapplication/form→ an object of the form fields (file fields arrive as aBlob)- anything else → the raw request text
objects— your site's objects, plus the vars archival injects (see below).
The function's return value determines the HTTP response:
- An object is serialized to a
200JSON response (content-type: application/json). - A string is returned as a
200text/plainresponse. If the string begins withredirect:, the remainder is used as aLocationheader and a302redirect is sent instead. - A
Responseis sent as-is, for when you need to set your own status, headers, or body — serving a file, for instance. - Throwing an error produces a
500response containing the error message. - Returning anything else produces a
500response.
A minimal carrier that validates a form post and redirects back to the site:
export default async function (params, body, objects) {
if (!body) {
throw new Error("Method Not Allowed");
}
// ...do something with the submitted fields...
return "redirect:" + objects.SITE_URL + "?submitStatus=ok";
}
The objects argument
Carriers used to receive a plain env object. They now receive your site's whole object tree, with archival's own vars merged in — so a carrier can read the same content your templates render without refetching or duplicating it.
export default async function (params, body, objects) {
return {
titles: objects.posts.map((post) => post.title),
contact: objects.settings.contact,
hero: objects.posts[0].hero?.url,
};
}
The values match what a liquid template sees:
- Objects backed by a directory (
objects/posts/*.toml) arrive as an array, sorted the way archival sorts them. Objects backed by a single file (objects/settings.toml) arrive as a single value. An object with no files on disk is an empty array. - Unset fields are
null, and child objects default to[]. - Every object read from its own file also carries
pathandorder. - File fields (
image,video,audio,upload) carry their resolvedurl, alongsidefilename,sha,mimeanddisplay_type. datefields are ISO 8601 strings, since JSON has no date type.
Archival's own vars are merged over your objects, so they win if a site object shares their name:
SITE_URL— the full URL of your site (e.g.https://yoursite.com). Use it to build absolute URLs for redirects and links rather than hard-coding your domain.UPLOADS— the files uploaded to your site, readable at request time. See Reading uploads.
A few things worth knowing:
- Objects are embedded at deploy time, so a carrier sees a snapshot from the build it was deployed with, not live content. Publishing your site redeploys its carriers with fresh values. (
UPLOADSis the exception — it reads your files when the request runs.) - The tree is deeply frozen, so one request can't mutate what the next request sees.
- Because they're embedded, there's an upper bound on how large a site's objects can be (5MB serialized). A site over that fails its carrier deploy rather than silently shipping a carrier with missing data.
Reading uploads
objects.UPLOADS reads the files you've uploaded to your site. Unlike the rest of objects it isn't a snapshot — it reads at request time, so a carrier sees files uploaded since it was deployed. Reads are scoped to your own site, and there is no way to write.
This is what lets a carrier put a file behind a check your site can't make on its own — a password, a signed link, a purchase — or serve one under a name that isn't known until the request arrives.
list() gives every file, as the name it was uploaded with and the content hash it's stored under:
await objects.UPLOADS.list();
// [{ sha: "31f4725e…", filename: "cover.png" }, …]
get() reads one, by name, and resolves to null when nothing matches:
const upload = await objects.UPLOADS.get("cover.png");
Names aren't unique — the same name can be uploaded more than once, under different hashes — so looking one up by name alone throws when it's ambiguous rather than guessing. Pass the hash to say which you meant, or pass a file field straight off objects, which already carries its own:
await objects.UPLOADS.get("cover.png", "31f4725e…");
await objects.UPLOADS.get(objects.settings.menu);
What you get back is the file's body and metadata: body, size, etag, httpEtag, arrayBuffer(), text(), json(), blob(), and writeHttpMetadata(headers), which copies the stored content type onto a Headers you're building.
Returning a Response is how you serve one back:
const carrier: Carrier = async (params, body, objects) => {
if (params.get("password") !== objects.settings.download_password) {
return "redirect:/login";
}
const upload = await objects.UPLOADS.get("private-menu.pdf");
if (!upload) {
return "redirect:/404";
}
const headers = new Headers();
upload.writeHttpMetadata(headers);
headers.set("etag", upload.httpEtag);
return new Response(upload.body, { headers });
};
Bear in mind that a file which is already referenced by a published object is served from the CDN too, at its own url — reading it through a carrier doesn't hide it. Gating only means something for files nothing on your site links to.
Typing your site's objects
Every site's objects are different, so the types for them are generated from your objects.toml. From a carrier directory that has @archival/carrier installed:
npx archival-carrier-types
That runs archival types and writes an archival-objects.d.ts next to each carrier — a self-contained module declaring an ArchivalObjects interface, followed by the one block that wires it into the carrier signature:
declare module "@archival/carrier" {
interface SiteObjects extends Omit<ArchivalObjects, keyof CarrierEnv> {}
}
Commit those files. They contain no object values, only your schema, and committing them means your editor and CI work on a fresh clone with no extra setup.
After generating, objects is fully typed:
const carrier: Carrier = async (params, body, objects) => ({
titles: objects.posts.map((post) => post.title), // (string | null)[]
contact: objects.settings.contact,
hero: objects.posts[0].hero?.url,
});
Until you generate, reading anything but the injected vars off objects is a compile error — deliberately, so a carrier can't silently read an object that isn't there.
The generator needs the archival binary on your PATH or in your node_modules (npm install --save-dev archival, or cargo install archival).
| Flag | |
|---|---|
--check |
Don't write; exit non-zero if anything is out of date. For CI. |
--carrier <name> |
Only generate for one carrier. |
--carriers-dir <path> |
Carriers directory, if not carriers. |
--site <path> |
Site root, if not an ancestor of the working directory. |
--out <path> |
Write a single file here instead of one per carrier. |
--archival <path> |
Path to the archival binary. |
The output is deterministic, so it's safe to keep honest in CI:
npx archival-carrier-types --check
Reading secrets
Fields declared secret are stripped from template contexts, but carriers run on the server and do receive their values — that's the point of the type. They're typed as string | null like any other string field.
const carrier: Carrier = async (params, body, objects) => {
const response = await fetch("https://api.example.com/send", {
headers: { authorization: `Bearer ${objects.settings.api_key}` },
});
return { ok: response.ok };
};
Remember that a secret is stored as plain text in your repo — it keeps a value out of your built site, not out of your git history. See secret for the full picture.