Skip to content

Commit 3c797ee

Browse files
committed
Implement SSR adapter system
Now the only way of running JS code on Elixir is using NodeJS call but there are other ways of executing JS like for example using Bun runtime or in development rendering the inertia app using VideJS dev server. This changes allow users to hook into inertia's ssr system and use their own adapters.
1 parent d86448b commit 3c797ee

File tree

13 files changed

+240
-51
lines changed

13 files changed

+240
-51
lines changed

.circleci/config.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ jobs:
66
version:
77
type: string
88

9-
# The resource_class feature allows configuring CPU and RAM resources for each job.
10-
# Different resource classes are available for different executors.
9+
# The resource_class feature allows configuring CPU and RAM resources for each job.
10+
# Different resource classes are available for different executors.
1111
# https://circleci.com/docs/2.0/configuration-reference/#resourceclass
1212
resource_class: "large"
1313

@@ -21,6 +21,8 @@ jobs:
2121
steps:
2222
- checkout
2323
- run: mix --version
24+
- run: node -v
25+
- run: npm -v
2426
- restore_cache:
2527
keys:
2628
- elixir-build-<< parameters.version >>-{{ checksum "mix.lock" }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# The directory Mix will write compiled artifacts to.
22
/_build/
33

4+
# nvim ELP plugin output
5+
.elixir_ls/
6+
47
# If you run "mix test --cover", coverage assets end up here.
58
/cover/
69

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ config :inertia,
8484
# see instructions below). Defaults to `false`.
8585
ssr: false,
8686

87+
# By default the server side rendering is done by executing nodejs.
88+
# you can use your own adapter following the spec.
89+
ssr_adapter: MyAdapter
90+
8791
# Whether to raise an exception when server-side rendering fails (only applies
8892
# when SSR is enabled). Defaults to `true`.
8993
#
@@ -534,7 +538,6 @@ conn
534538

535539
The `Inertia.Testing` module includes helpers for testing your Inertia controller responses, such as the `inertia_component/1` and `inertia_props/1` functions.
536540

537-
538541
```elixir
539542
use MyAppWeb.ConnCase
540543

@@ -672,6 +675,7 @@ Add the `ssr` build to the watchers in your dev environment, alongside the other
672675
]
673676
```
674677

678+
### Build and deploy with SSR
675679
Add the `ssr` build step to the asset build and deploy scripts.
676680

677681
```diff
@@ -771,6 +775,20 @@ Then, update your config to enable SSR (if you'd like to enable it globally).
771775
raise_on_ssr_failure: config_env() != :prod
772776
```
773777

778+
### Using ESM (EcmaScript Modules) on SSR entrypoint
779+
780+
By default this library uses CommonJS modules for SSR. If you want to use ESM (EcmaScript Modules) set `esm: true` in your config.
781+
782+
```elixir
783+
{Inertia.SSR, path: Path.join([Application.app_dir(:my_app), "priv"]), esm: true},
784+
```
785+
786+
### Custom SSR adapter
787+
788+
You can setup your own SSR adapter. This is a list of third party adapters.
789+
790+
- [vitex](https://github.com/andresgutgon/vitex) is a package that helps with ViteJS development in Phoenix apps. It has a custom SSR adapter for this package (inertia-phoenix) so SSR can be handle in development through Vite Dev server instead of calling a NodeJS process like we do in production. You can see [how it's configured here](https://github.com/andresgutgon/vitex?tab=readme-ov-file#configuring-vitejs-in-your-phoenix-app)
791+
774792
### Installing Node.js in your production
775793

776794
You need to have Node.js installed in your production server environment, so that we can call the SSR script when serving pages. These steps assume you are deploying your application using a Dockerfile and releases.

lib/inertia/ssr.ex

Lines changed: 8 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,24 @@
11
defmodule Inertia.SSR do
2+
alias Inertia.SSR.{Config, Adapter}
3+
alias Inertia.SSR.Adapters.{Bootstrap, NodeJS}
4+
25
@moduledoc """
3-
A supervisor that provides SSR support for Inertia views. This module is
4-
responsible for starting a pool of Node.js processes that can run the SSR
5-
rendering function for your application.
6+
Supervisor for SSR support in Inertia views.
67
"""
7-
88
use Supervisor
99

10-
require Logger
11-
12-
alias Inertia.SSR.Config
13-
14-
@default_pool_size 4
15-
@default_module "ssr"
16-
17-
@doc """
18-
Starts the SSR supervisor and accompanying Node.js workers.
19-
20-
## Options
21-
22-
- `:path` - (required) the path to the directory where your `ssr.js` file lives.
23-
- `:module` - (optional) the name of the Node.js module file. Defaults to "#{@default_module}".
24-
- `:pool_size` - (optional) the number of Node.js workers. Defaults to #{@default_pool_size}.
25-
"""
2610
def start_link(init_arg) do
2711
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
2812
end
2913

30-
@impl true
31-
@doc false
3214
def init(opts) do
33-
path = Keyword.fetch!(opts, :path)
34-
module = Keyword.get(opts, :module, @default_module)
35-
pool_size = Keyword.get(opts, :pool_size, @default_pool_size)
36-
37-
children = [
38-
{Config, module: module},
39-
{NodeJS.Supervisor, name: supervisor_name(), path: path, pool_size: pool_size}
40-
]
41-
15+
{adapter, config} = Bootstrap.fetch_adapter(opts: opts, default_adapter: NodeJS)
16+
children = [{Config, adapter: adapter, config: config}] ++ adapter.children(config)
4217
Supervisor.init(children, strategy: :one_for_one)
4318
end
4419

45-
@doc false
20+
@spec call(Adapter.page()) :: Adapter.ssr_result()
4621
def call(page) do
47-
module = GenServer.call(Config, :module)
48-
NodeJS.call({module, :render}, [page], name: supervisor_name(), binary: true)
49-
end
50-
51-
defp supervisor_name do
52-
Module.concat(__MODULE__, Supervisor)
22+
Config.call(page)
5323
end
5424
end

lib/inertia/ssr/adapter.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
defmodule Inertia.SSR.Adapter do
2+
@moduledoc false
3+
4+
@type adapter_config :: struct()
5+
@type page :: %{
6+
required(:component) => String.t(),
7+
required(:props) => map(),
8+
required(:url) => String.t(),
9+
optional(:version) => String.t(),
10+
optional(:encryptHistory) => boolean(),
11+
optional(:clearHistory) => boolean(),
12+
optional(:mergeProps) => list(String.t()),
13+
optional(:deferredProps) => map()
14+
}
15+
@type ssr_result :: {:ok, map()} | {:error, String.t()}
16+
17+
@callback init(opts :: keyword()) :: adapter_config()
18+
@callback children(adapter_config()) :: [{module(), keyword()}]
19+
@callback call(page(), adapter_config()) :: ssr_result()
20+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
defmodule Inertia.SSR.Adapters.Bootstrap do
2+
@moduledoc false
3+
4+
alias Inertia.SSR.Adapter
5+
6+
@spec fetch_adapter(
7+
opts: keyword(),
8+
default_adapter: module()
9+
) :: {module(), Adapter.adapter_config()}
10+
def fetch_adapter(opts: opts, default_adapter: default_adapter) do
11+
custom_adapter = Keyword.get(opts, :ssr_adapter, nil)
12+
adapter = resolve_adapter(default_adapter, custom_adapter)
13+
config = adapter.init(opts)
14+
{adapter, config}
15+
end
16+
17+
@spec resolve_adapter(module(), module() | nil) :: module()
18+
defp resolve_adapter(default_adapter, custom_adapter) do
19+
if is_atom(custom_adapter) and
20+
Code.ensure_loaded?(custom_adapter) and
21+
function_exported?(custom_adapter, :init, 1) do
22+
custom_adapter
23+
else
24+
default_adapter
25+
end
26+
end
27+
end

lib/inertia/ssr/adapters/config.ex

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
defmodule Inertia.SSR.Adapters.Config do
2+
@moduledoc false
3+
4+
defmacro __using__(opts) do
5+
name = Keyword.fetch!(opts, :name)
6+
config_module = Keyword.fetch!(opts, :config)
7+
8+
quote do
9+
@behaviour Inertia.SSR.Adapter
10+
@name unquote(name)
11+
12+
def init(opts) do
13+
config = unquote(config_module).build(opts)
14+
config
15+
end
16+
end
17+
end
18+
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
defmodule Inertia.SSR.Adapters.NodeJS do
2+
require Logger
3+
alias Inertia.SSR.Supervisor, as: SSRSupervisor
4+
alias Inertia.SSR.Adapters.NodeJS.Config
5+
6+
@moduledoc """
7+
## Options
8+
9+
- `:path` - (required) the path to the directory where your `ssr.js` file lives.
10+
- `:module` - (optional) the name of the Node.js module file
11+
- `:esm` - (optional) Use ESM for the generated ssr.js file
12+
- `:pool_size` - (optional) the number of Node.js workers
13+
14+
SSR adapter using NodeJS invoked from Elixir.
15+
"""
16+
17+
use Inertia.SSR.Adapters.Config, name: :nodejs, config: Config
18+
19+
@impl true
20+
def children(%Config{path: path, pool_size: pool_size}) do
21+
[
22+
{NodeJS.Supervisor, name: SSRSupervisor, path: path, pool_size: pool_size}
23+
]
24+
end
25+
26+
@impl true
27+
def call(page, %Config{module: module, esm: esm}) when is_map(page) do
28+
# ESM module needs the `.js` extension
29+
module = if(esm, do: "#{module}.js", else: module)
30+
31+
NodeJS.call({module, :render}, [page],
32+
name: SSRSupervisor,
33+
binary: true,
34+
esm: esm
35+
)
36+
end
37+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
defmodule Inertia.SSR.Adapters.NodeJS.Config do
2+
@moduledoc false
3+
4+
@enforce_keys [:path]
5+
defstruct [:path, :module, :esm, :pool_size]
6+
7+
@default_module "ssr"
8+
@default_esm false
9+
@default_pool_size 4
10+
11+
@type t :: %__MODULE__{
12+
path: String.t(),
13+
module: String.t(),
14+
esm: boolean(),
15+
pool_size: pos_integer()
16+
}
17+
18+
@spec build(keyword()) :: t()
19+
def build(opts) do
20+
%__MODULE__{
21+
path: Keyword.fetch!(opts, :path),
22+
module: Keyword.get(opts, :module, @default_module),
23+
esm: Keyword.get(opts, :esm, @default_esm),
24+
pool_size: Keyword.get(opts, :pool_size, @default_pool_size)
25+
}
26+
end
27+
end

lib/inertia/ssr/config.ex

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,52 @@
11
defmodule Inertia.SSR.Config do
22
@moduledoc false
33

4+
alias Inertia.SSR.Adapter
5+
46
use GenServer
57

6-
# Client
8+
@type state :: %{adapter: module(), config: struct()}
9+
10+
def start_link(opts) do
11+
adapter = Keyword.fetch!(opts, :adapter)
12+
config = Keyword.fetch!(opts, :config)
713

8-
def start_link(init_arg) do
9-
GenServer.start_link(__MODULE__, init_arg, name: __MODULE__)
14+
GenServer.start_link(
15+
__MODULE__,
16+
%{
17+
adapter: adapter,
18+
config: config
19+
},
20+
name: __MODULE__
21+
)
1022
end
1123

12-
def module(pid) do
13-
GenServer.call(pid, :module)
24+
@doc "Stores the adapter module and its config"
25+
def set_adapter(adapter_module, config) do
26+
GenServer.call(__MODULE__, {:set_adapter, adapter_module, config})
1427
end
1528

16-
# Server (callbacks)
29+
@doc "Forwards the page call to the adapter"
30+
def call(page) do
31+
GenServer.call(__MODULE__, {:call, page})
32+
end
33+
34+
@impl true
35+
@spec init(state()) :: {:ok, state()}
36+
def init(state), do: {:ok, state}
1737

1838
@impl true
19-
def init(state) do
20-
{:ok, state}
39+
@spec handle_call(
40+
{:call, Adapter.page()},
41+
GenServer.from(),
42+
state()
43+
) :: {:reply, Adapter.ssr_result(), state()}
44+
def handle_call({:call, page}, _from, %{adapter: adapter, config: config} = state) do
45+
{:reply, adapter.call(page, config), state}
2146
end
2247

2348
@impl true
24-
def handle_call(:module, _from, state) do
25-
{:reply, state[:module], state}
49+
def handle_call({:set_adapter, adapter_module, config}, _from, _state) do
50+
{:reply, :ok, %{adapter: adapter_module, config: config}}
2651
end
2752
end

0 commit comments

Comments
 (0)