Skip to main content
fymo.yml
Anywhere Fymo needs to put or get a blob of bytes, it goes through a storage provider instead of a hardcoded filesystem path. That covers app-written uploads and generated files, and the declarative HTTP exposure described further down this page. The storage: section in fymo.yml picks which provider you’re using: the built-in local provider, or a dotted path to a class of your own. There’s exactly one active provider per app, the same as jobs and broadcasts. Every method on StorageProvider matters here, none of them are optional extras.

The provider interface

Notice that key is a namespaced relative path, like videos/foo.webm, not a filesystem path. What it resolves to is entirely up to the provider: local disk today, maybe S3 or another object store later. Subclass fymo.storage.base.BaseStorageProvider to get the right shape without writing every method from scratch. Its defaults raise NotImplementedError instead of quietly returning empty bytes or a size of zero. That way a half-finished provider fails right at the call site, instead of failing silently somewhere downstream. The one exception is url_for, which defaults to returning None. Most providers, local disk included, have nothing useful to hand back there, since callers proxy the file through the app instead. The read() method takes an optional (start, end) byte range, inclusive on both ends. If the range falls outside the object’s actual size, raise RangeNotSatisfiable(size) instead of returning garbage or an empty slice:
Carrying size on the exception saves a round trip. The caller can build the RFC 7233 416 response’s Content-Range header directly, without a separate lookup for how big the file actually is.

Local storage

LocalStorageProvider stores blobs directly on disk. Set root and it resolves that path relative to project_root. Leave root out, and it stores directly under project_root instead. Every key passes through the same containment check Fymo uses for storage.expose routes. It’s traversal-safe and symlink-safe: no .. segment, no absolute path override, and no symlink inside the root that quietly resolves somewhere outside it. A key that fails the check raises ValueError, not a 500 from some unrelated filesystem error further down.
A range read clamps end to the file’s actual size instead of treating “past EOF” as an error. Only a start at or past EOF, or an end before start, counts as unsatisfiable:

Registering a provider

fymo.yml
The object form’s selector key is provider, matching the storage: section’s own name. Other pluggable sections work differently: jobs and broadcasts use type instead. Anything else in the object, root, bucket, whatever your provider’s constructor needs, gets passed straight through as keyword arguments. Want to point at your own provider instead of the built-in one? Use a dotted class path:
fymo.yml
If you’re using the built-in local provider with no extra options, you can skip the object form entirely:
fymo.yml
This roots storage at project_root.
There’s no default storage provider. Every other pluggable subsystem in Fymo, jobs and broadcasts included, falls back to a sensible built-in when you leave it unconfigured. Storage doesn’t, on purpose.Silently writing to local disk is exactly the footgun that works fine in development and quietly loses data behind a load balancer in production. So if you configure storage.expose entries without a storage: provider, Fymo fails at startup, not at the first write.

Writing a custom provider

Point storage.class at the dotted path, and Fymo instantiates it with whatever other keys sit in that config object. There’s no extra registration step: the class just needs to satisfy the StorageProvider shape.

Using storage in app code

A remote function, a job, a controller, none of them call build_storage_provider directly. Instead they all reach the same provider that FymoApp built at startup, through a process-wide singleton accessor: get_storage_provider(). That mirrors a pattern you’ll see elsewhere in Fymo too, like the accessors for jobs and broadcasts. FymoApp calls init_storage_provider(project_root, storage_config) at startup, whenever storage is configured. A separate process, like fymo jobs-worker, isn’t part of that startup sequence. It has to call init_storage_provider itself before any job it runs can touch storage. Call get_storage_provider() before either of those has happened, and you’ll get a RuntimeError, not a silent local-disk fallback.
Keep in mind: write() takes a complete bytes payload, there’s no streaming append. Something that produces bytes incrementally, a Playwright recording running live, for instance, can’t call write() until it’s finished.Record to a scratch path first, a temp file or app/data/tmp/, then read the finished file back and call write() once with the whole thing. That pattern keeps working once storage stops being local disk, since object stores like S3 don’t offer a “keep appending to this key” operation either.

Exposing files over HTTP

Before storage.expose existed, an app that needed to stream video with seek and scrub support had to hand-write a raw WSGI handler. That meant repeating Range-header parsing, path-traversal validation, content-type mapping, and 404/400 handling in every app that needed it. A storage.expose entry turns all of that into a single declaration. Each entry becomes an HttpRoute with a handler Fymo owns, sitting alongside your app’s own routes rather than replacing that seam. Some things, webhooks, non-file responses, still want a fully custom route, and that’s fine too.
storage.expose used to be its own top-level media: section. It moved under storage: because every entry’s dir was already resolved through the configured storage provider’s root, so the two keys were never really independent: exposing an entry only made sense once storage was configured. prefix, dir, and extensions keep the exact same meaning, just nested one level deeper.
fymo.yml
A top-level media: key is a hard error now, at boot and in fymo build:
Move each entry under storage.expose and the app is back to working exactly as before.
storage.expose entries with no storage.provider (or type/class) configured are also a hard error:
Exposed files are always served through the configured provider, so there’s nothing to serve without one.
Two more checks run at startup, and neither one fails the build:
  • A prefix overlapping /dist/ or /static/, Fymo’s own reserved prefixes, only prints a warning. Fymo’s own dispatch matches those prefixes first, so an overlapping route may never be reached, but the route is still registered.
  • A dir that doesn’t exist yet under the storage root only warns too. A job might create it later; until then, requests under that prefix 404.
An entry missing prefix or dir entirely is the one exception, that’s a hard error at startup naming the bad entry.

Byte-range requests

A plain GET request, one with no Range header, returns the whole file as a 200 response. It includes Accept-Ranges: bytes and Content-Length so the client knows range requests work at all. Add a Range header, and you get back a 206 Partial Content response with a matching Content-Range header:
The suffix form (bytes=-100, the last 100 bytes) and the open-ended form (bytes=500-, from byte 500 to EOF) both work too. Only single ranges are supported, matching what video and audio scrubbing actually need. A comma-separated multi-range request isn’t supported, since that would require a multipart/byteranges body, and nothing here produces one.An unsatisfiable range, a start at or past EOF, or an end before start, gets a 416 Range Not Satisfiable response. It carries Content-Range: bytes */<size> instead of a malformed partial response or a crash from a negative slice length.A malformed Range header, one with non-numeric values, gets a 400 instead. You won’t see a raw, unhandled ValueError bubble up.
Every storage.expose route resolves its files through the configured storage provider. The same containment checks apply here as the ones LocalStorageProvider uses for its read() and exists() methods. A traversal attempt, or a symlink planted inside the exposed directory that points outside it, gets a 400. That’s the same response as any other unsafe key.

Storage vs static files

A quick way to decide where a file belongs:
  • Checked into git, served exactly as-is: put it in app/static/.
  • Created or written at runtime: write it through storage:.
  • A runtime file that also needs its own URL, video, downloads, generated PDFs: add a storage.expose entry on top of storage:.
This page covers the last two. For app/static/, app/assets/, and the rest of the build-input story, see the app directory map and the dedicated assets page.

The app/ directory

Where static files, build assets, and everything else lives in a Fymo project.

Assets, fonts, and static files

CSS, fonts, and images that get compiled or served verbatim.