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.
- `:plugs` — list of additional `Plug` modules inserted into the HTTP pipeline (e.g. auth).
- `: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, [])
session_store = Keyword.get(opts, :session_store, :memory)
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 __session_store__, do: unquote(session_store)
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