# `KeenPhoenixSvelte.Apps`
[🔗](https://github.com/KeenMate/keen-phoenix-svelte/blob/v1.0.0-rc.9/lib/keen_phoenix_svelte/apps.ex#L1)

The app registry: where each island's compiled bundle is loaded from, and
**how** it is delivered to the browser.

By default apps are local — `AppsManager` imports `/apps/<name>/main.mjs` from
your own static path and nothing here is needed. Register an app here only when
its bundle lives **elsewhere** (a shared CDN, another team's deploy, a
database-driven catalogue).

## Mode of operation — `:direct` vs `:proxy`

For a registered (external) app you choose who fetches the bytes:

  * `:direct` — the browser `import()`s the configured URL straight from the
    CDN. Fewest moving parts; the CDN must send CORS headers and its origin must
    be allowed by your CSP `script-src`.
  * `:proxy` — the browser imports a **same-origin** path on your Phoenix app,
    and the server fetches the real bundle upstream (see
    `KeenPhoenixSvelte.Apps.Proxy`). No CORS, `script-src 'self'` is enough,
    and you can gate/patch/verify the bundle — the corporate-friendly default.

The mode only changes *which URL the client imports*; the client itself is
oblivious. Local apps are never affected.

## Configuration

    config :keen_phoenix_svelte,
      # your app, so local apps under priv/static/<base_path> are detected and
      # merged into the manifest (enables `preload` + server-side visibility).
      otp_app: :my_app,
      # global default mode for registered apps
      load_mode: :proxy,
      # where proxied bundles are served from (must match your router forward).
      # Defaults to the same "/apps" prefix as base_path: local bundles are
      # static files at /apps/<name>/main.mjs (served by Plug.Static, which runs
      # before the router), proxied bundles resolve at /apps/<name> via the
      # forward — the two coexist under one prefix.
      proxy_path: "/apps",
      # local apps load from here
      base_path: "/apps",
      # server-side proxy cache (see `KeenPhoenixSvelte.Apps.Proxy`)
      proxy_cache: [
        ttl: :timer.minutes(5),   # freshness fallback when the origin sends no cache directives
        respect_upstream: true,   # honor upstream Cache-Control / ETag / Last-Modified
        # the browser-facing Cache-Control for a normal (non-immutable) bundle…
        client_cache_control: "public, max-age=60, stale-while-revalidate=300",
        # …and the one emitted for a per-app `immutable: true` bundle (defaults to
        # "public, max-age=31536000, immutable" if unset).
        immutable_cache_control: "public, max-age=31536000, immutable",
        # abuse bounds for base/dir apps: cap concurrent upstream fetches, and
        # briefly remember an upstream "not found" so a flood of guaranteed-miss
        # sub-paths isn't re-fetched every request (set to 0 to disable).
        max_concurrent_fetches: 32,
        negative_ttl: :timer.seconds(10),
        # how often orphaned URLs (no longer in the registry) are swept from the
        # cache; `false` disables the sweep.
        sweep_interval: :timer.hours(1),
        # TLS options for the built-in :httpc fetcher. Defaults to verifying the
        # origin cert against the system trust store (`verify: :verify_peer` +
        # hostname check); override to pass a custom CA bundle or relax/tighten.
        ssl_options: [verify: :verify_peer]
      ],
      apps: %{
        # a plain URL uses the global load_mode:
        "org-chart" => "https://cdn.acme.com/islands/org-chart@1.4.2/main.mjs",
        # or override per app (per-app `ttl`/`immutable`/`client_cache_control`
        # tune the proxy cache; a per-app `client_cache_control:` string wins over
        # everything):
        "report" => %{url: "https://reports.internal/report/main.mjs", mode: :direct},
        "pinned" => %{url: "https://cdn.acme.com/pinned@2.0.0/main.mjs", immutable: true},
        "hourly" => %{url: "https://cdn.acme.com/hourly/main.mjs", client_cache_control: "public, max-age=3600"},
        # a multi-file bundle (JS + CSS + assets) — give a `base:` directory and
        # (optionally) the `entry:` the client imports (defaults to "main.mjs").
        # Any sub-path is proxied: `/apps/player/player.css` → `<base>/player.css`.
        # A `manifest:` (inline list, or a bundle file path to a JSON/text list)
        # restricts serving to exactly the files it names — any other sub-path is
        # a 404 with no upstream fetch.
        "player" => %{
          base: "https://cdn.acme.com/player@3/",
          entry: "player.mjs",
          manifest: "manifest.json"
        },
        # a LOCAL directory on disk (e.g. a mounted volume another process writes
        # to). `entry:` is a glob; the newest match wins, so a content-hashed
        # bundle (`bundle.a1b2c3.js`) resolves without knowing the hash. Served
        # same-origin through the proxy, cached/revalidated like any other source
        # (the file's mtime is the validator, the `:ttl` the re-scan cadence).
        # Local only — you can't glob a URL.
        "dash" => %{dir: "/srv/apps/dash", entry: "bundle.*.js", ttl: :timer.seconds(30)}
      }

The registry can just as well come from a database — build the same map at
runtime and set it with `Application.put_env/3`; it's read on every request.

# `base_path`

```elixir
@spec base_path() :: String.t()
```

Base path local (unregistered) apps load from.

# `load_mode`

```elixir
@spec load_mode() :: :direct | :proxy
```

The default delivery mode for registered apps (`:direct` or `:proxy`).

# `local_apps`

```elixir
@spec local_apps() :: [String.t()]
```

Names of **local** apps — subdirectories of the built static apps dir that
contain a `main.mjs`. Needs no per-app registration (they're discovered by
folder), but the library must be told where to look via
`config :keen_phoenix_svelte, otp_app: :my_app` (its `priv/static/<base_path>`),
or an explicit `:apps_static_path`. Returns `[]` when neither is set.

# `manifest`

```elixir
@spec manifest() :: %{optional(String.t()) =&gt; String.t()}
```

The client manifest — `%{name => url_the_browser_imports}` — merging **detected
local apps** with **registered** ones.

Emitted into the page by `<KeenPhoenixSvelte.runtime>` and read by `AppsManager`.
Two sources are collected into one map:

  * **local apps** (`local_apps/0`) — folders built to
    `priv/static/<base_path>/<name>/main.mjs`. Each maps to its convention URL
    `base_path/<name>/main.mjs` (the same URL the client would fall back to).
  * **registered apps** (`registered/0`) — the `:apps` config, whose URL points
    elsewhere (CDN `:direct`, same-origin `:proxy`, a base-path or local `dir:`).

Registered entries **override** local ones on a name clash (so registering an app
to a CDN wins over a stray same-named folder). For a registered app the URL is:
in `:proxy` mode a same-origin `proxy_path/<name>` (base-path app:
`proxy_path/<name>/<entry>`); in `:direct` mode the configured CDN URL (or
`<base>/<entry>`).

Local detection requires `config :keen_phoenix_svelte, otp_app: :my_app` so the
library can locate your static dir; without it only registered apps appear (the
client still resolves local apps by the same convention — the manifest entry only
adds them to `preload` and server-side visibility).

# `proxy_opts`

```elixir
@spec proxy_opts(String.t()) :: %{
  ttl_ms: non_neg_integer(),
  respect_upstream: boolean(),
  immutable: boolean(),
  client_cache_control: String.t() | nil,
  global_cache_control: String.t() | nil,
  immutable_cache_control: String.t() | nil,
  freshness: (map() -&gt; non_neg_integer()) | nil
}
```

The resolved proxy-cache options for `name`, merging the per-app registry entry
over the global `:proxy_cache` config. Consumed by `KeenPhoenixSvelte.Apps.Proxy`.

# `proxy_path`

```elixir
@spec proxy_path() :: String.t()
```

Same-origin base path proxied bundles are served under (matches the router forward).

# `registered`

```elixir
@spec registered() :: %{optional(String.t()) =&gt; map()}
```

The registered apps, normalized to `%{name => %{url, mode, ttl, immutable, client_cache_control}}`.

# `resolve`

```elixir
@spec resolve([String.t()]) :: {String.t(), String.t(), String.t()} | nil
```

Resolve the proxy request path (the segments after the forward prefix) to
`{name, upstream_url, sub_path}`, or `nil` if nothing matches.

  * a **base-path** app matches on its first segment; the remaining segments are
    appended to its `:base` (empty → the app's `:entry`), so a whole directory of
    files (`player.mjs`, `player.css`, fonts…) proxies through one registration.
  * a **single-file** app matches when the entire path equals its name.

Path traversal (`..`) and empty/`.`/backslash segments are rejected.

# `upstream`

```elixir
@spec upstream(String.t()) :: String.t() | nil
```

The upstream URL the proxy should fetch for a single-file app, or `nil` if unknown/not proxied.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
