> ## Documentation Index
> Fetch the complete documentation index at: https://fymo.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Storage and Media

> The StorageProvider abstraction for where files live, and storage.expose for serving them over HTTP with byte-range support.

```yaml fymo.yml theme={null}
storage:
  provider: local
  root: data
```

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

```python theme={null}
from typing import Optional, Protocol, Tuple

class StorageProvider(Protocol):
    def write(self, key: str, data: bytes) -> None: ...
    def read(self, key: str, range: Optional[Tuple[int, int]] = None) -> bytes: ...
    def size(self, key: str) -> int: ...
    def url_for(self, key: str) -> Optional[str]: ...
    def exists(self, key: str) -> bool: ...
    def delete(self, key: str) -> None: ...
```

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:

```python theme={null}
from fymo.storage.base import RangeNotSatisfiable

class RangeNotSatisfiable(Exception):
    def __init__(self, size: int):
        self.size = size
```

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

```python theme={null}
from fymo.storage.providers.local import LocalStorageProvider

provider = LocalStorageProvider(project_root=project_root, root="data/uploads")
provider.write("videos/clip.webm", data)
provider.read("videos/clip.webm", range=(0, 999))
```

`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.

<Tip>
  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:

  ```python theme={null}
  provider.write("clip.webm", b"0123456789")
  provider.read("clip.webm", range=(5, 999))  # -> b"56789", clamped, not rejected
  provider.read("clip.webm", range=(20, 30))  # -> raises RangeNotSatisfiable(size=10)
  ```
</Tip>

## Registering a provider

```yaml fymo.yml theme={null}
storage:
  provider: local
  root: data/uploads
```

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:

```yaml fymo.yml theme={null}
storage:
  class: myapp.storage.S3StorageProvider
  bucket: my-bucket
```

<Tip>
  If you're using the built-in local provider with no extra options, you can skip the object form entirely:

  ```yaml fymo.yml theme={null}
  storage: local
  ```

  This roots storage at `project_root`.
</Tip>

<Warning>
  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.
</Warning>

### Writing a custom provider

```python theme={null}
# myapp/storage.py
from typing import Dict, Optional, Tuple
from fymo.storage.base import BaseStorageProvider, RangeNotSatisfiable

class EchoStorageProvider(BaseStorageProvider):
    def __init__(self) -> None:
        self._data: Dict[str, bytes] = {}

    def write(self, key: str, data: bytes) -> None:
        self._data[key] = data

    def read(self, key: str, range: Optional[Tuple[int, int]] = None) -> bytes:
        data = self._data[key]
        if range is None:
            return data
        start, end = range
        size = len(data)
        if start < 0 or start >= size or end < start:
            raise RangeNotSatisfiable(size)
        return data[start:min(end, size - 1) + 1]

    def size(self, key: str) -> int:
        return len(self._data[key])

    def url_for(self, key: str) -> Optional[str]:
        return None

    def exists(self, key: str) -> bool:
        return key in self._data

    def delete(self, key: str) -> None:
        del self._data[key]
```

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

```python theme={null}
from fymo.storage import get_storage_provider

def save_upload(data: bytes) -> None:
    get_storage_provider().write("videos/clip.webm", data)
```

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.

<Note>
  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.
</Note>

## 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.

<Note>
  `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.
</Note>

```yaml fymo.yml theme={null}
storage:
  provider: local
  root: data
  expose:
    - prefix: /media/videos/
      dir: videos
      extensions: [webm]
```

| Key          | Meaning                                                                                                                                                                                     |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefix`     | The URL path prefix requests are matched under, e.g. `/media/videos/`.                                                                                                                      |
| `dir`        | The storage-key namespace files are served from, joined onto the requested filename. Not a filesystem path, a prefix within whatever the configured storage provider resolves.              |
| `extensions` | Allowed file extensions, lowercase, no leading dot. A request for anything else gets a `400`, the same response as a path-traversal attempt, so a probing request can't tell the two apart. |

<Warning>
  A top-level `media:` key is a hard error now, at boot and in `fymo build`:

  ```
  top-level `media:` was removed, exposure now lives under `storage.expose`.
  Move each media entry under storage: unchanged (prefix/dir/extensions
  keep their meaning).
  ```

  Move each entry under `storage.expose` and the app is back to working exactly as before.
</Warning>

<Warning>
  `storage.expose` entries with no `storage.provider` (or `type`/`class`) configured are also a hard error:

  ```
  storage.expose is configured but storage itself is not: exposed entries
  serve files through the configured StorageProvider and there is no
  default, so storage must be configured. Set storage.provider
  (e.g. `storage: {provider: local, root: data}`).
  ```

  Exposed files are always served through the configured provider, so there's nothing to serve without one.
</Warning>

<Note>
  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.
</Note>

### 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:

```
GET /media/videos/clip.webm
Range: bytes=0-99

206 Partial Content
Content-Range: bytes 0-99/1024
Content-Length: 100
```

<Accordion title="More range formats, and what happens when things go wrong">
  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.
</Accordion>

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.

<CardGroup cols={2}>
  <Card title="The app/ directory" icon="folder-tree" href="/app-directory">
    Where static files, build assets, and everything else lives in a Fymo project.
  </Card>

  <Card title="Assets, fonts, and static files" icon="image" href="/assets-fonts-and-static-files">
    CSS, fonts, and images that get compiled or served verbatim.
  </Card>
</CardGroup>
