Phoenix LiveView without webpack

Dear Phoenix community,

Phoenix 1.5.3. LiveView 0.13.2. Is there a way to use LiveView without webpack or is it considered a bad idea ?

I do not like webpack for different reasons (opacity, too much JS, JS doing too much stuff, JS concerned with CSS or other stuff, npm with lot of modules, …) and the web part of my project being relatively small, I do not need a builder to manage dependencies and tons of JS files. I need to load CSS and JS per web page, not as one single big blob of code in app.js, because I need to use different JS/CSS depending on the web page I am currently on. I have the feeling that webpack is more suited to SPAs (single-page applications). I guess that in a bigger project webpack provides some great help and as such becomes mandatory.

I noticed that it is not possible to use --live with --no-webpack at project creation time (with mix).

In particular I have this error :

Uncaught TypeError: Failed to resolve module specifier "morphdom". Relative references must start with either "/", "./", or "../".

I know I don’t use webpack and as such the import mechanism cannot simply use module names because it needs a path (absolute, relative or an URL). I could adapt phoenix_live_view.js to add a path to import morphdom from "morphdom" but I don’t even know where morphdom is located. I did not find it in node_modules.

I have an app_module.js file which contains the content of app.js slightly modified. Note that phoenix.js and phoenix_live_view.js are imported with relative paths.

import {Socket} from "./phoenix.js"
import LiveSocket from "./phoenix_live_view.js"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
window.liveSocket = liveSocket

In my root.html.leex, I load the module using:

<script type="module" src="<%= Routes.static_path(@conn, "/js/app_module.js") %>"></script>

As a side note, I have a PoC which is working ok but without LiveView. I intend to load some modules dynamically like in the code below or simply by adding a script element through render_existing (added below too).

document.addEventListener("DOMContentLoaded", function () {
  const body_id = document.getElementsByTagName("body").item(0).getAttribute("id");
  console.log(`app_module.js. DOMContentLoaded. body id: ${body_id}`);
  if (body_id === "one") {
    (async () => {
      const module = await import('./one_module.js')
      // module.default();
      module.init();
    })();
  }
});
<%= render_existing(view_module(@conn), "css." <> view_template(@conn), assigns) %>
<%= if assigns[:live_module] do %>
  <%= render_existing @live_module, "css", assigns %>
<% end %>

and

<%= render_existing(view_module(@conn), "js." <> view_template(@conn), assigns) %>
<%= if assigns[:live_module] do %>
  <%= render_existing @live_module, "js", assigns %>
<% end %>

I don’t like Webpack either. Here’s my setup.

assets/js/app.js:

let phx = Phoenix
let phxLV = phoenix_live_view

let csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content')
let liveSocket = new phxLV.LiveSocket('/live', phx.Socket, {params: {_csrf_token: csrfToken}})

// Show progress bar on live navigation and form submits.
window.addEventListener('phx:page-loading-start', info => NProgress.start())
window.addEventListener('phx:page-loading-stop', info => NProgress.done())

// Connect if there are any LiveViews on the page.
liveSocket.connect()

// Expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000)
window.liveSocket = liveSocket

assets/Makefile for building assets:

# Make targets for assets.

css:
		@echo 'Building CSS...'
		@mkdir -p ../priv/static/css
		@cat \
			vendor/nprogress-0.2.0/nprogress.css \
			css/app.css > ../priv/static/css/app.css

js:
		@echo 'Building JS...'
		@mkdir -p ../priv/static/js
		@cat \
			../deps/phoenix/priv/static/phoenix.js \
			../deps/phoenix_html/priv/static/phoenix_html.js \
			../deps/phoenix_live_view/priv/static/phoenix_live_view.js \
			vendor/nprogress-0.2.0/nprogress.js \
			js/app.js > ../priv/static/js/app.js

static:
		@echo 'Building static...'
		@cp -R static/* ../priv/static/

clean:
		@echo 'Cleaning assets...'
		@rm -rf ../priv/static/*

build: css js static

.PHONY: css js static

root.html.eex excerpt:

    <link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
    <script src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>

I need to add JS and CSS minification, but that’s coming along later.

9 Likes

I had a somewhat similar setup that avoided webpack. If you have a limited set of files to combine it works beautifully and very quickly.

ESBuild is a very promising and fast bundling library for JS that is about ready for production: https://github.com/evanw/esbuild

If you want to combine and compact css you can use the scss binary (even if you are writing plain css). It is instantaneous and easy to run with fswatch.

5 Likes

Thanks for the interesting answers (and in particular the Makefile). I found a solution with Pika which allows me to use JS modules and completely avoid the use of a bundler (for the moment). I did not yet integrate the use of SCSS like I did with Webpack but I eventually will I guess.

import phx from "https://cdn.pika.dev/phoenix@^1.5.3";
import LiveSocket from "https://cdn.pika.dev/phoenix_live_view@^0.13.2";

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", phx.Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()

We might want to avoid the CDN completely by downloading the files and using them locally. Check https://cdn.pika.dev/phoenix_live_view@^0.13.2 and https://cdn.pika.dev/phoenix@^1.5.3 which link to the URLs used below with wget.

wget https://cdn.pika.dev/-/phoenix_live_view@v0.13.2-cnYJAsfLvfEA5wcTrE7k/dist=es2017/phoenix_live_view.js -O phoenix_live_view.js
wget https://cdn.pika.dev/-/phoenix@v1.5.3-jPQPCaxlMlUCpmNrcVZh/dist=es2017/phoenix.js -O phoenix.js

Then it is enough to write:

import phx from "./phoenix.js";
import LiveSocket from "./phoenix_live_view.js";

The main JS file named app_module.js in my case to insist on the fact that it uses JS modules is loaded in the browser with the code below. Note the use of type="module".

<script type="module" src="<%= Routes.static_path(@conn, "/js/app_module.js") %>"></script>

PS: I should add version numbers to the file names.

4 Likes

I installed Dart Sass and it is easy to let it watch my directory. I have to manually launch the watcher. Inside the directory of my web app, it is enough to do this:

/opt/dart-sass/sass --watch assets/css:priv/static/css

Source directory before :, target directory after it.

NB: if you’re pulling version 0.16.0 of LiveView you’ll need to wrap the import:

import phx from "https://cdn.pika.dev/phoenix@^1.5.12";
import { LiveSocket } from "https://cdn.pika.dev/phoenix_live_view@^0.16.0";

https://github.com/phoenixframework/phoenix_live_view/compare/v0.16.0...master#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4edR156

I downloaded them anyway so I can work offline:

$ curl https://cdn.skypack.dev/-/phoenix@v1.5.12-sxRHOaTBQMd6tvXM4o3t/dist=es2020,mode=imports/optimized/phoenix.js > phoenix@v1.5.12.js

$ curl https://cdn.skypack.dev/-/phoenix_live_view@v0.16.0-vjIoknEbnxJPXyqqhwAC/dist=es2020,mode=imports/optimized/phoenix_live_view.js > phoenix_live_view@v0.16.0.js

Eventually, I used esbuild and sass (installed globally on the system) to build my JS and CS with the following config in dev.exs (umbrella project):

config :hydro_web, HydroWeb.Endpoint,
...
  watchers: [
    {"node", [
      "build.js",
      cd: Path.expand("../apps/hydro_web/assets", __DIR__)
    ]},
    {"/opt/dart-sass/sass", [
      "--watch",
      "css:../priv/static/css",
      cd: Path.expand("../apps/hydro_web/assets", __DIR__)
    ]},

and the build.js script is:

#!/opt/node/bin/node

const esbuild    = require("esbuild");
const gzipPlugin = require("@luncheon/esbuild-plugin-gzip");

esbuild.build({
  entryPoints: ["./js/app.js"],
  outdir:    "../priv/static/js",
  minify:    true,
  sourcemap: true,
  bundle:    true,
  watch:     true,
  write:     false,
  plugins: [
    gzipPlugin({
      uncompressed: true,
      gzip: true,
      brotli: false,
    })
  ],
})

I see inside my package.json the following config:

  "devDependencies": {
    "@luncheon/esbuild-plugin-gzip": "^0.1.0",
    "esbuild": "^0.12.8",
    "sass": "^1.34.1"
  }

Thus I am wondering if I use the global version or the local one for esbuild. For SASS it should be Dart SASS which means the global one since the local one is based on JS. One may say that the config grew organically.
I keep node_modules to a minimum to avoid any problem in the space-time continuum.

I used the blog post How I Handle Static Assets in my Phoenix apps for inspiration.

2 Likes

Aha nice… so you’ll be ready for the esbuild switch in Phoenix 1.6 :slight_smile:

1 Like

For anybody who stumbles upon this thread after Phoenix 1.6 is released: this is the way.

1 Like

Which uses the esbuild Hex package from the Phoenix team. Is there a way to add plugins for e.g. gzip js files ?

The precompiled esbuild binaries are documented to not support plugins.

2 Likes

I found an interesting article about using esbuild and NPM (without the esbuild Elixir package). I use them both too but with a setup a little different than the one explained in the article.

Wrapping your head around assets in Phoenix 1.6

1 Like