defmodule Dialup do @moduledoc """ Dialup is a WebSocket-first, file-based routing framework for Elixir. Each browser tab gets its own Elixir process (an internal `UserSessionProcess`), and all user interactions flow through that process via WebSocket. The server renders HTML and sends it to the client, where [idiomorph](https://github.com/bigskysoftware/idiomorph) morphs the DOM efficiently. ## Setup Add `use Dialup` to your Application module: defmodule MyApp do use Application use Dialup, app_dir: __DIR__ <> "/app", title: "My App", lang: "en" @impl Application def start(_type, _args) do children = [{Dialup, app: __MODULE__, port: 4000}] Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor) end end ## Options for `use Dialup` - `:app_dir` — **(required)** path to the directory containing `page.ex` / `layout.ex` files. Typically `__DIR__ <> "/app"`. - `:title` — default `` value used when `page_title/1` returns `nil`. Defaults to `"Dialup App"`. - `:lang` — value for the `html lang` attribute. Defaults to `"en"`. - `:check_origin` — WebSocket origin validation. `:conn` (default) validates against the HTTP host, `false` disables validation, or a list of allowed hosts. - `:agent_session` — when `true`, enables `POST /_dialup/agent-session` for headless MCP sessions. Defaults to `false`; opt in explicitly and protect with auth plugs in production. - `:secure_cookies` — sets the `Secure` flag on `dialup_session` cookies. `:auto` (default) enables it when the request is HTTPS (`conn.scheme` or `X-Forwarded-Proto: https`); `true` or `false` force the flag on or off. - `:plugs` — list of additional `Plug` modules inserted into the HTTP pipeline (e.g. auth). - `:auth_accounts` — module implementing `Dialup.Auth.Accounts` for user resolution. - `:session_store` — `:memory` (default) or `:ets`. Use `:ets` to persist session state across process restarts. ## Child spec `Dialup` implements `child_spec/1` and `start_link/1`, so you can add it directly to a supervision tree: children = [{Dialup, app: MyApp, port: 4000}] ## File-based routing Files placed under `app_dir` are automatically compiled into a route table at build time: app/ ├── layout.ex # root layout ├── page.ex # route: / ├── error.ex # error page └── blog/ ├── layout.ex # nested layout for /blog/* └── [slug]/ └── page.ex # route: /blog/:slug (dynamic) No `router.ex` registration is required. """ defmacro __using__(opts) do app_dir = Keyword.fetch!(opts, :app_dir) title = Keyword.get(opts, :title, "Dialup App") lang = Keyword.get(opts, :lang, "en") plugs = Keyword.get(opts, :plugs, []) auth_accounts = Keyword.get(opts, :auth_accounts) session_store = Keyword.get(opts, :session_store, :memory) agent_session = Keyword.get(opts, :agent_session, false) secure_cookies = Keyword.get(opts, :secure_cookies, :auto) unless secure_cookies in [:auto, true, false] do raise ArgumentError, "invalid :secure_cookies option #{inspect(secure_cookies)}; expected :auto, true, or false" end quote do use Dialup.Router, app_dir: unquote(app_dir) # app_dir は __DIR__ <> "/app" のため quote 内で評価(ユーザモジュールのコンパイル時に __DIR__ が展開される) @_dialup_static_dir unquote(app_dir) |> Path.dirname() |> Path.dirname() |> Path.join("priv/static") for f <- ~w[dialup.js idiomorph.js] do if File.exists?(Path.join(@_dialup_static_dir, f)) do IO.warn( "priv/static/#{f} shadows the Dialup framework file. Remove it unless intentional.", [] ) end end @_dialup_shell_path unquote(app_dir) |> Path.dirname() |> Path.join("root.html.heex") @external_resource @_dialup_shell_path @before_compile Dialup.Shell def __shell_opts__, do: %{title: unquote(title), lang: unquote(lang)} def __static_dir__, do: @_dialup_static_dir def __plugs__, do: unquote(Macro.escape(plugs)) def __auth_accounts__, do: unquote(auth_accounts) def __session_store__, do: unquote(session_store) def __agent_session__, do: unquote(agent_session) def __secure_cookies__, do: unquote(secure_cookies) def __check_origin__, do: unquote(Keyword.get(opts, :check_origin, :conn)) end end def child_spec(opts) do %{ id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :supervisor } end def start_link(opts) do app_module = Keyword.fetch!(opts, :app) port = Keyword.get(opts, :port, 4000) session_store = if function_exported?(app_module, :__session_store__, 0), do: app_module.__session_store__(), else: :memory store_children = if session_store == :ets, do: [Dialup.SessionStore], else: [] base_children = store_children ++ [ {Registry, keys: :unique, name: Dialup.SessionRegistry}, {DynamicSupervisor, name: Dialup.SessionSupervisor, strategy: :one_for_one}, {Bandit, plug: {Dialup.Server, app: app_module}, port: port} ] children = if Code.ensure_loaded?(Mix) and Mix.env() == :dev do [{Dialup.Reloader, [Path.join(File.cwd!(), "lib")]} | base_children] else base_children end Supervisor.start_link(children, strategy: :one_for_one, name: __MODULE__) end end