A small Phoenix LiveView
app to showcase:
- navigation with tabs.
- code splitting,
- prefetching data on image hovering.
- include a
SolidJS
component that renders a table with the WebSocket connection to an endpoint to preprend data in realtime. This component sends the data to the server via anElixir.Channel
where it is saved into anSQLite
database. - include a
SolidJS
component that uses the lightweight Javascript chartingApexCharts
. We visualize data sent from a WebSocket client (a server module powered by Fresh). He set a PubSub between the Fresh server and anElixir.Channel
, and then push via the Channel to the browser. - compare image classification with pre-trained models, BLIP server-side, mediaPipe & ML5 client-side based on RESNET.
We are going to use Esbuild
plugins. This is explained in the documentation.
Besides bringing in esbuild
and tailwind
, we use solidjs
and apexcharts
.
At the time of writting, you have:
pnpm init
pnpm add -D @tailwindcss/forms esbuild esbuild-plugin-solid tailwindcss fs
pnpm add solid-js @solid-primitives/ref apexcharts solid-apexcharts ../deps/phoenix ../deps/phoenix_html ../deps/phoenix_live_view
You should have (at the time of writing):
"type": "module",
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"esbuild": "^0.23.0",
"esbuild-plugin-solid": "^0.6.0",
"tailwindcss": "^3.4.6",
"fs": "0.0.1-security",
},
"dependencies": {
"@solid-primitives/refs": "^1.0.8",
"@solidjs/router": "^0.14.1",
"apexcharts": "^3.51.0",
"phoenix": "link:../deps/phoenix",
"phoenix_html": "link:../deps/phoenix_html",
"phoenix_live_view": "link:../deps/phoenix_live_view",
"solid-apexcharts": "^0.3.4",
"solid-js": "^1.8.18",
"topbar": "^3.0.0"
}
SolidJS uses JSX for templating, we have to be sure Esbuild compiles the JSX files for SolidJS.
Phoenix compiles and bundles all js
and jsx
files into the "priv/static/assets" folder.
We also evaluate bundle size mapping.
Build.js file
// new file: /assets/build.js
import { context, build } from "esbuild";
import { solidPlugin } from "esbuild-plugin-solid";
import fs from "fs";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
const deploy = args.includes("--deploy");
// Define esbuild options
let opts = {
entryPoints: ["js/app.js"],
bundle: true,
logLevel: "info",
target: "es2022",
outdir: "../priv/static/assets",
external: ["*.css", "fonts/*", "images/*"],
loader: { ".js": "jsx", ".svg": "file" },
plugins: [solidPlugin()],
nodePaths: ["../deps"],
format: "esm",
};
if (deploy) {
opts = {
...opts,
minify: true,
splitting: true,
};
let result = await build(opts);
fs.writeFileSync("meta.json", JSON.stringify(result.metafile, null, 2));
}
if (watch) {
opts = {
...opts,
sourcemap: "inline",
};
context(opts)
.then((ctx) => (watch ? ctx.watch() : build(opts)))
.catch((error) => {
console.log(`Build error: ${error}`);
process.exit(1);
});
}
In "config/dev/exs", add:
# config/devs.exs
config :solidjs, SolidjsWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "aaiv+mgJIO4sLLpE7GUxg45/HQeETt98/a8ff6zlCwPEd4mOYSzDU7UEWoLyuzzv",
watchers: [
node: ["build.js", "--watch", cd: Path.expand("../assets", __DIR__)],
^^^
tailwind: {Tailwind, :install_and_run, [:solidjs, ~w(--watch)]}
]
and remove the config for "esbuild" (as node
will run esbuild
).
The Mix
tasks in the "mix.exs" file are:
# mix.exs
defp aliases do
[
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs", "cmd --cd assets pnpm install"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.deploy": [
"tailwind solidjs --minify",
"cmd --cd assets node build.js --deploy",
"phx.digest"
]
]
end
- In the
handle_params/3
callback, parse the "uri" argument and add alive_action
assign accordingly. For example, we want to navigate to "/chart", we addlive_action: :chart
. - In the router, add the routes corresponding to the tabs, such as:
live "/chart", MainLive, :chart
- add a
render/1
with a guard clause to render the appropriate markup.
def render(assigns) when assigns.live_action == :chart do
~H"""
.....
"""
end
The navigation action is triggered by a link that looks like:
<.link
replace
phx-click={JS.patch(uri)}
class="..."
>
Go the chart
</.link>
We used the "context" pattern: you parametrize a function that returns a Component.
The pattern is:
export const component = (ctx) => {
// get stuff from the context
const {state, setState} = ctx
[...]
return Component(props) {
do stuff...;
return HTMLComponent
}
}
To use it, do:
import context from "./context";
import {componen}t from "./component.jsx"
const Component = component(context);
You want to design a component as stateless as possible. You centralize everything related to the configuration, styles.
Local state is still possible though if it only belongs to this component where reactivity is need.
For example, userSocket
and useChannel
are declared in the "context". We can use them in any component.
To establish the "user socket" and channel, we firstly build the userSocket client endpoint
- connect client-side:
❗note that we did not pass a "user-token" as an object to the "params" key in the new Socket
constructor, but this should be the case.
import { Socket } from "phoenix";
const userSocket = new Socket("/socket", {});
userSocket.connect();
export default userSocket;
- define the "userSocket" server-side:
# add to endpoint.ex
socket "/socket", SolidjsWeb.UserSocket,
websocket: true,
- define the server-side handler:
❗ we should receive the "user-token" in the connect "params" and check it.
# create user_socket.ex
defmodule SolidjsWeb.UserSocket do
use Phoenix.Socket
channel "currency:*", SolidjsWeb.CurrencyChannel
@impl true
def connect(_params, socket) do
{:ok, socket}
end
@impl true
def id(_socket), do: nil
end
Note that we already declared the channels we will use in this module. For example, CurrencyChannel
with the topics "currecny:*".
To establish channels on top of the "user socket", we define a generic useChannel
client-side function:
// userChannel.js
export default function useChannel(socket, topic) {
if (!socket) return null;
const channel = socket.channel(topic, {});
channel
.join()
.receive("ok", () => {
console.log(`Joined successfully: ${topic}`);
})
.receive("error", (resp) => {
console.log(`Unable to join ${topic}`, resp.reason);
});
return channel;
}
To use it, we add the topic client-side which matches the one declared in "user_socket.ex".
import userSocket from "./context";
const currencyChannel = useChannel(userSocket, "currency:bitcoin");
It remains to build the channel "CurrencyChannel" with this topic server-side. An example here
defmodule SolidjsWeb.CurrencyChannel do
use Phoenix.Channel
@impl true
def join("currency:"<>type, _params, socket) do
topic = "streamer:#{type}"
:ok = SolidjsWeb.Endpoint.subscribe(topic)
{:ok, assign(socket, :currency, type)}
end
# received FROM the browser, to be saved in the database
@impl true
def handle_in("currency:"<>currency, payload, socket)
when socket.assigns.currency == currency do
save_to_db(payload)
{:noreply, socket}
end
@impl true
# received as we subscribed to a PubSub topic
# brodcasted FROM the WebSocket client Solidjs.Streamer, then forward TO the browser chartHook via the channel
def handle_info(%{topic: "streamer:"<>currency, event: "update", payload: payload}, socket)
when currency == socket.assigns.currency do
broadcast!(socket, "update", payload)
{:noreply, socket}
end
defp save_to_db(payload) do
Solidjs.Repo.save(payload)
end
end
LiveView
shadows the SolidJS
method onCleanup
.
To properly stop a channel and a websocket connection when you leave a tab that runs these features, we may need to pass a reference from the Javascript hook, and use the "ref.current" in the destroyed
hook lifecycle (check "table.jsx" and "tableHook.js")
const componentHook = {
channelRef: { current: null },
socketRef: { current: null },
mounted() {
Component(channelRef, socketRef);
},
destroyed() {
this.channelRref.leave();
this.socketRef.close();
},
};
The "build.js" will produce a metafile when running mix assets.deploy
.
Analyse it:
https://esbuild.github.io/analyze/
This displays: