Svelte frontend (render modes)

Copy Markdown View Source

mix caravela.gen.live MyApp.Domains.Library generates a working Svelte frontend for every entity in the domain. Each entity picks one of two render modes via its DSL declaration:

  • frontend: :live (default) — Phoenix LiveView + WebSocket.
  • frontend: :rest — Phoenix controller + Inertia-style HTTP.

Both modes mount Svelte components through caravela_svelte, so the same BookIndex.svelte works under either transport without changes — the author barely notices which mode is in play.

Setup

Add caravela_svelte to the consumer app:

# mix.exs
{:caravela, "~> 0.13"},
{:caravela_svelte, "~> 0.1"}

Wire the client runtime into assets/js/app.js following the caravela_svelte docs.

Generated files

lib/my_app_web/live/library/book_live/{index,show,form}.ex   # :live mode
lib/my_app_web/controllers/book_controller.ex                # :rest mode
assets/svelte/library/{BookIndex,BookShow,BookForm}.svelte   # both modes
assets/svelte/library/{BookIndex,BookShow,BookForm}.test.ts  # Vitest smoke tests
assets/svelte/types/library.ts                               # shared TS
test/my_app_web/live/library/book_live_test.exs              # :live tests
test/my_app_web/controllers/book_controller_test.exs         # :rest tests

The trio of Svelte components is identical between modes. Mode is a server-side decision; the Svelte component receives the same props either way.

Registering routes

Don't paste route snippets. Caravela.Router exposes a compile-time macro that expands to the full route list based on each entity's frontend / realtime? declaration:

defmodule MyAppWeb.Router do
  use Phoenix.Router
  use Caravela.Router
  import CaravelaSvelte.Router  # needed only when any entity is :rest

  scope "/", MyAppWeb.Library do
    pipe_through :browser
    caravela_routes MyApp.Domains.Library
  end
end

caravela_routes/1 expands into live routes for :live entities and caravela_rest routes for :rest entities, with realtime: true appended automatically when the entity opts in. Versioned domains (version "v1") insert the version segment into the module aliases so Phoenix's scope alias resolution continues to match the generated MyAppWeb.V1.Library.BookLive.Index layout.

caravela_routes MyApp.Domains.Library,
  session: [on_mount: {MyAppWeb.Auth, :require_user}]

Passing :session wraps the :live routes in a live_session/3 block so a group of entities can share an on_mount hook.

:live mode — what the generated LiveView looks like

defmodule MyAppWeb.Library.BookLive.Index do
  use MyAppWeb, :live_view
  alias MyApp.Library

  def mount(_params, _session, socket) do
    context = build_context(socket)

    socket =
      socket
      |> assign(:context, context)
      |> assign(:field_access, Library.field_access(:books, context))
      |> assign(:actions, Library.action_access(:books, context))
      |> assign(:books, Library.list_books(context))
      |> assign(:loading, false)
      |> assign(:flash_message, nil)

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <CaravelaSvelte.svelte
      name="library/BookIndex"
      props={%{
        books: @books,
        loading: @loading,
        flash_message: @flash_message,
        field_access: @field_access,
        actions: @actions
      }}
      socket={@socket}
    />
    """
  end
end

The LiveView is standard Phoenix — plain mount/3, explicit handle_event clauses, the context module aliased at the top. No Caravela runtime coupling in the default output.

:rest mode — what the generated controller looks like

defmodule MyAppWeb.BookController do
  use Phoenix.Controller, formats: [:html]

  alias MyApp.Library
  alias Caravela.ChangesetTranslator
  alias CaravelaSvelte.Caravela, as: CS

  def index(conn, _params) do
    context = build_context(conn)
    items = Library.list_books(context)
    field_access = field_access(context)
    actions = Library.action_access(:books, context)

    conn
    |> CS.put_field_access(field_access)
    |> CaravelaSvelte.render("library/BookIndex", %{
      books: items,
      field_access: field_access,
      actions: actions
    })
  end

  # … show / new / edit / create / update / delete …
end

CaravelaSvelte.render/3 produces an Inertia-compatible HTTP response (JSON for SPA navigation, full HTML on first load). Structured changeset errors flow through Caravela.ChangesetTranslator (see validation).

realtime: true for :rest entities

Declaring realtime: true on a :rest entity:

entity :books, frontend: :rest, realtime: true do
  field :title, :string, required: true
end

… adds three things to the generated controller:

  1. CS.broadcast_patch/3 on create / update / delete, keyed by the conventional per-actor topic "caravela:<entity>:actor:<id>".
  2. JSON-Patch operations matching the list mutation (add / replace / remove on /<plural>/<id>).
  3. A matching caravela_rest "/books", BookController, realtime: true line in the router expansion, which CaravelaSvelte.Router wires into the SSE endpoint clients subscribe through.

:live entities already have LiveView's WebSocket for real-time, so realtime: true on a :live entity is rejected at compile time with a clear error.

Version + multi-tenant flow-through

  • Versioned domain → MyAppWeb.V1.Library.BookLive.Index under lib/my_app_web/live/v1/…, mounting v1/library/BookIndex.svelte. The Router macro inserts the V1. segment automatically.
  • Multi-tenant domain → generated build_context/1 picks up socket.assigns[:tenant] / conn.assigns[:tenant] automatically, so tenant scoping in the context Just Works.

The --with-domain flag (:live mode only)

For an onramp to the more ambitious Live runtime, pass --with-domain:

mix caravela.gen.live --with-domain MyApp.Domains.Library

This emits one extra file per :live entity — a Caravela.Live.Domain-backed companion module — and regenerates form.ex to use Caravela.Live.Template. Index and show stay plain. The Template-backed form is ~30% shorter and makes the Updater model concrete. :rest entities are unaffected.

Other flags

  • --frontend rest|live — blanket override. Treats every entity in the domain as the given mode, ignoring DSL declarations. Useful for previewing generator output.
  • --no-tests — skip emitting the ExUnit + Vitest test skeletons (they're emitted by default — see testing).
  • --dry-run, --force, --output DIR — standard across all generators.

Client events (:live mode)

Svelte components receive live as a prop and call live.pushEvent to dispatch events back. The generated components use these event names:

ComponentEventParamsWhat the server does
Indexnew{}push_navigate to /new
Indexedit{id}push_navigate to /<id>/edit
Indexdelete{id}Library.delete_book/2 + refetch list
Showedit{id}push_navigate to /<id>/edit
Showback{}push_navigate to index
Formvalidate{field, value}Rebuild changeset, update :errors
Formsave{}create_* or update_*, navigate
Formcancel{}push_navigate to index

Feel free to add your own — generated handle_event clauses live above the # --- CUSTOM --- marker; anything you add below it survives regeneration (see regeneration).

Client navigation (:rest mode)

:rest pages use Inertia-style SPA navigation through caravela_svelte's client helpers (navigate, useForm). See the caravela_svelte docs for the full client API.

Props your Svelte component sees

For BookIndex.svelte:

let {
  books = [],
  loading = false,
  flash_message = null,
  field_access = { title: true, isbn: true /* … */ },
  actions = { create: true, update: true, delete: true },
  live  // :live mode only; absent under :rest
}: {
  books?: Book[];
  loading?: boolean;
  flash_message?: string | null;
  field_access?: BookFieldAccess;
  actions?: BookActions;
  live?: LiveHandle;
} = $props();

For BookForm.svelte:

let {
  book = {},
  errors = {},
  saving = false,
  field_access = { /* … */ },
  actions = { create: true, update: true, delete: true },
  live
}: {
  book?: Partial<Book>;
  // Structured error shape from Caravela.ChangesetTranslator.
  // See docs/validation.md.
  errors?: Record<string, Array<{ code: string; params: object; message: string }>>;
  saving?: boolean;
  field_access?: BookFieldAccess;
  actions?: BookActions;
  live?: LiveHandle;
} = $props();

The TypeScript interfaces are regenerated from the domain IR. Changes to field :title, :string, required: true in Elixir immediately flow to title: string (no ?) in TypeScript on the next mix caravela.gen.live. See policies for how BookFieldAccess and BookActions relate to the policy block.

SSR caveats

Both modes render components server-side via caravela_svelte's Node bridge by default. A few Svelte libraries load code lazily at runtime in a way the Node bridge can't resolve:

  • Shiki (syntax highlighter) — dynamically imports per-language grammar chunks. Under Node SSR the dynamic imports fail and the render crashes. Workaround: disable SSR in your app config:

    # config/config.exs
    config :caravela_svelte, :ssr, false

    Client-side hydration still works; only the initial server render falls back to a blank placeholder until the Svelte runtime takes over.

If you hit a similar "works in the browser, crashes in SSR" pattern with another library, the same switch applies.