KeenPhoenixSvelte.Apps (KeenPhoenixSvelte v1.0.0-rc.9)

Copy Markdown View Source

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.

Summary

Functions

Base path local (unregistered) apps load from.

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

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.

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

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

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

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

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

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

Functions

base_path()

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

Base path local (unregistered) apps load from.

load_mode()

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

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

local_apps()

@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()

@spec manifest() :: %{optional(String.t()) => 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(name)

@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() -> 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()

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

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

registered()

@spec registered() :: %{optional(String.t()) => map()}

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

resolve(segments)

@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(name)

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