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-form above) 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, or index.cjs — whichever exists. If you include a package.json, its main field 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. Add carrier_* to a .gitignore so 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 — a URLSearchParams built from the request's query string.
  • body — the parsed request body for POST and PUT requests (null for other methods). How it's parsed depends on the request's Content-Type:
    • application/json → a parsed JSON value
    • application/x-www-form-urlencoded, multipart/form-data, or application/form → an object of the form fields (file fields arrive as a Blob)
    • 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 200 JSON response (content-type: application/json).
  • A string is returned as a 200 text/plain response. If the string begins with redirect:, the remainder is used as a Location header and a 302 redirect is sent instead.
  • A Response is 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 500 response containing the error message.
  • Returning anything else produces a 500 response.

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 path and order.
  • File fields (image, video, audio, upload) carry their resolved url, alongside filename, sha, mime and display_type.
  • date fields 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. (UPLOADS is 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.