Serverless-first. Built for cold starts, tight memory, and one binary.Deploy guide →

Modules

A file containing import/export runs as an ES module; a plain file runs as a classic script (and ff -e code is a classic script too — no top-level await there). import is the only loader — there is no require(), no CommonJS, and no URL imports. Three specifier shapes resolve: relative (./, ../), absolute (/), and bare (lodash).

Quick look

// main.js
import { greet } from "./greet.js";
import { marked } from "marked";

console.log(greet("ada"));
console.log(marked.parse("# hi"));
// greet.js
export function greet(name) {
  return `hello, ${name}`;
}
ff imprint marked@18.0.11
ff main.js
# hello, ada
# <h1>hi</h1>

Top-level await works in every module — no async wrapper needed:

const res = await fetch("https://example.com/");
console.log(res.status);

Relative imports: ./ and ../

Joined onto the importer's directory. An extensionless specifier probes X.js, then X/index.js; anything written with an extension loads literally:

import { router } from "./router.js";     // ./router.js
import { legacy } from "./legacy";        // probes ./legacy.js, then ./legacy/index.js
import { check } from "./lib.mjs";        // .mjs works too — any literal name
import { db } from "../shared/db.js";     // up one, then down

Note: The probe only tries X.js and X/index.js — other extensions (.mjs, .json, …) must be written out. If neither probe hits, the loader error names X.js.

.. segments normalize against the importer dir (./a/../b.js → ./b.js); escaping past the filesystem root resolves to the joined remainder and fails to load.

Bare imports: packages

Anything not starting with ., /, or containing :// is bare, and walks up the directory tree through node_modules/<pkg>:

import { marked } from "marked";            // node_modules/marked/…
import Button from "@scope/ui/button.js";   // scoped: node_modules/@scope/ui/…

For each level (starting at the importer's dir, then each parent), the first node_modules/<pkg> hit wins via these rules, in order:

StepRule
1package.json exports — string form, import/default conditions, or the "." root entry for subpath maps
2package.json module field
3package.json main field
4Subpath passthrough — pkg/rest loads node_modules/pkg/rest when that file exists
5Root fallback — bare pkg loads node_modules/pkg/index.js when it exists

Then the walk continues to the parent directory. Misses everywhere and the loader prints its standard filename error for the first candidate.

Two sharp edges, both fail loud:

  • A manifest entry naming a file that isn't on disk is a broken install: could not load module filename '…' and a hard error. It never silently falls through to a higher copy (same strictness as npm).
  • Subpath imports (pkg/feature) need an exports map entry ("./feature") or a matching literal file. No map + no file = keep walking, then fail.

Install packages first — resolution needs the files:

ff imprint marked@18.0.11

Absolute paths

A leading / loads literally from the filesystem root:

import { cfg } from "/etc/myapp/config.js";

No lookup, no fallback. Missing file = load error.

Practical example: tiny multi-file API

ff init -y shop && cd shop
ff imprint marked@18.0.11
// products.js
export const products = [
  { id: 1, name: "Boots", price: 120 },
  { id: 2, name: "Hat", price: 35 },
];
// views.js
import { marked } from "marked";

export function productList(items) {
  const md = items.map((p) => `- **${p.name}** — $${p.price}`).join("\n");
  return marked.parse(md);
}
// main.js
import { products } from "./products.js";
import { productList } from "./views.js";

http.serve({ port: 3000 }, (url) => {
  const path = new URL(url, "http://localhost").pathname;
  if (path === "/") {
    return new Response(productList(products), {
      headers: { "content-type": "text/html" },
    });
  }
  if (path === "/api/products") return Response.json(products);
  return new Response("not found", { status: 404 });
});
ff start
curl http://127.0.0.1:3000/              # rendered list
curl http://127.0.0.1:3000/api/products  # JSON

Reference

SpecifierResolves to
./x.js, ../x.jsLiteral join onto importer dir
./x (extensionless)Probes ./x.js, then ./x/index.js
pkg, pkg/subnode_modules walk-up via exports → module → main → passthrough → index.js
@scope/pkg (+ /sub)Same, with scoped directory handling
/abs/path.jsLiteral filesystem path
https://… / require()Not supported — loader error

Troubleshooting

could not load module filename '…' — the specifier resolved to a path with no file. For relative imports: check the spelling and the extension. For bare imports: run ff imprint <pkg> and confirm node_modules/<pkg> exists from the importer's directory upward.

Extensionless relative import fails — the probe only tries X.js and X/index.js. For .mjs (or any other extension), write it out.

require is not defined — ESM only. Convert to import, or pick an ESM package.

Works locally, fails after move — bare resolution walks up from the importer's dir. Moving the importer without its node_modules ancestry breaks it; keep project files under the installed root.

Wrong file loaded from a package — the manifest's exports/module/main chain picked it. Inspect node_modules/<pkg>/package.json to see which entry won.