Profile
Back to NewsBack
GitHub Trending 35 min
Reader Mode
joelmoss/proscenium: Modern client-side development for Rails

joelmoss/proscenium: Modern client-side development for Rails

14 hours ago

Proscenium - Integrated Frontend Development for Rails

🗣️ prow · see · nee · uhm
> _noun_: proscenium
> - _the part of a theatre stage in front of the curtain._

_Proscenium_ treats your frontend and client-side code as first class citizens of your Rails app, and assumes a "fast by default" internet. It bundles and minifies JavaScript (+JSX), TypeScript (+TSX) and CSS in real time, on demand, and with zero configuration.

The highlights:

  • Fast, real-time bundling, tree-shaking, code-splitting and minification of Javascript (.js,.jsx), Typescript (.ts,.tsx) and CSS (.css).
  • NO JavaScript runtime needed (eg. Node) - just the browser!
  • NO build step or pre-compilation.
  • NO additional process or server - Just run rails server!
  • Transforms newer JavaScript and CSS syntax to older syntax for older browsers.
  • Deep integration with Rails.
  • Automatically side-load JS and CSS for your layouts, views, and partials.
  • Import from NPM, URL's, and locally.
  • CSS Modules & mixins.
  • Source maps.

Table of Contents

- Local Imports - Tree Shaking - Code Splitting - __filename and __dirname - JavaScript Caveats - Importing CSS from JavaScript - CSS Modules - CSS Mixins - CSS Caveats - Typescript Caveats

Getting Started

Getting started obviously depends on whether you are adding Proscenium to an existing Rails app, or creating a new one. So choose the appropriate guide below:

- Migrate from Sprockets

Installation

Add this line to your Rails application's Gemfile, and you're good to go:

gem 'proscenium'

Please note that Proscenium is designed solely for use with Rails.

Supported platforms

Proscenium is a Ruby gem wrapped around a Go library, so it ships a precompiled gem per platform. Bundler picks the right one for you.

| Platform | Gem | | --- | --- | | macOS, Apple silicon | arm64-darwin | | macOS, Intel | x86_64-darwin | | Linux glibc, arm64 | aarch64-linux-gnu | | Linux glibc, x86-64 | x86_64-linux-gnu | | Windows, x64 (RubyInstaller) | x64-mingw-ucrt |

The Linux gems need RubyGems 3.3.22 or newer, which is the first version able to tell -gnu from -musl. Older versions cannot, so they refuse the gem rather than installing one your machine cannot load. Ruby 3.4 already ships a newer RubyGems than this, so you are almost certainly fine; if not, gem update --system. Bundler 2.5.6 or newer resolves the platform names without needing anything else from you.

Installing on a platform not listed here gets you the platform-less gem, which carries no compiled library, and Proscenium will say so by name when it loads rather than failing somewhere inside FFI. If your platform should be supported, please open an issue.

The Linux gems name their libc. Earlier releases shipped bare x86_64-linux and aarch64-linux names, which RubyGems matches on glibc and musl alike. If your Gemfile.lock is pinned to either, add the matching -gnu platform:

bundle lock --add-platform x86_64-linux-gnu   # or aarch64-linux-gnu, on ARM

Adding it is enough. There is no need to remove the old platform.

On Windows, with a Gemfile.lock written on another machine: a plain bundle install adds the platform itself, but in frozen or deployment mode Bundler will not, so add it once.

bundle lock --add-platform x64-mingw-ucrt

Alpine and other musl Linux distributions are not supported

Use a glibc base image, such as ruby:3.4-slim rather than ruby:3.4-alpine.

This is not an oversight. Proscenium's Go library is built with -buildmode=c-shared and loaded at runtime through Ruby's FFI, which uses dlopen. Go emits initial-exec TLS relocations into such libraries, and musl refuses to dlopen anything carrying them, by design:

Error relocating ...: free: initial-exec TLS resolves to dynamic definition

The library builds and links against musl perfectly well; it just cannot be loaded the way Proscenium needs to load it. This is golang/go#54805, open since 2022, and the linker flag that resolves it has not shipped as of Go 1.27. Musl gems will follow when it does.

Now if you start your Rails app, you can open any front end code (JS, CSS, etc.). For example, a file at app/assets/stylesheets/application.css can be accessed at https://localhost:3000/app/assets/stylesheets/application.css, which will be transformed, bundled, and minified [in production] in real time.

Client-Side Code Anywhere

Proscenium believes that your frontend code is just as important as your backend code, and is not an afterthought - they should be first class citizens of your Rails app. So instead of having to throw all your JS and CSS into a "app/assets" directory, and then requiring a separate process to compile or bundle, you can simply put them wherever you want within your app, and just run Rails!

For example, if you have some JS that is required by your app/views/users/index.html.erb view, just create a JS file alongside it at app/views/users/index.js. Or if you have some CSS that is used by your entire application, put it in app/views/layouts/application.css and load it alongside your layout. Maybe you have a few JS utility functions, so put them in lib/utils.js.

Simply put your JS(X) and CSS anywhere you want, and they will be served by your Rails app from the same location where you placed them.

Using the examples above...

  • app/views/users/index.js => https://localhost:3000/app/views/users/index.js
  • app/views/layouts/application.css => https://localhost:3000/app/views/layouts/application.css
  • lib/utils.js => https://localhost:3000/lib/utils.js
  • app/components/menu_component.jsx => https://localhost:3000/app/components/menu_component.jsx
  • config/properties.css => https://localhost:3000/config/properties.css

Side Loading

Proscenium is best experienced when your assets are automatically side loaded.

The Problem

With Rails you would typically load your JavaScript and CSS assets declaratively using the javascript_include_tag and stylesheet_link_tag helpers.

For example, you may have top-level "application" styles located in a file at /app/assets/stylesheets/application.css. Likewise, you may have some global JavaScript located in a file at /app/javascript/application.js.

You would manually and declaratively include those two files in each of your layouts, something like this:

<%# /app/views/layouts/application.html.erb %>

<!DOCTYPE html> <html> <head> <title>Hello World</title> <%= stylesheet_link_tag 'application' %> <!-- << Your app CSS --> </head> <body> <%= yield %> <%= javascript_include_tag 'application' %> <!-- << Your app JS --> </body> </html>

Now, you may have some CSS and JavaScript that is only required by a specific view and partial, so you would load that in your view (or layout), something like this:

<%# /app/views/users/index.html.erb %>

<%= stylesheet_link_tag 'users' %> <%= javascript_include_tag 'users' %>

<%# needed by the users/_user.html.erb partial %> <%= javascript_include_tag '_user' %>

<% render @users %>

The main problem is that you have to keep track of all these assets, and make sure each is loaded by all the views that require them, but also avoid loading them when not needed. This can be a real pain, especially when you have a lot of views.

The Solution

When side loading your JavaScript, Typescript and CSS with Proscenium, they are automatically included alongside your views, partials, layouts, and components, and only when needed.

Side loading works by looking for a JS/TS/CSS file with the same name as your view, partial, layout or component. For example, if you have a view at app/views/users/index.html.erb, then Proscenium will look for a JS and CSS file at app/views/users/index.js (or TypeScript with a .ts extension) and app/views/users/index.css. If it finds one, it will automatically include it in the HTML for that view. And only for that view.

This allows you to keep your assets organized alongside the views, partials, and components that use them, without having to manually track and include them. It also means only the assets that are needed are included.

JSX is also supported for JavaScript and Typescript. Simply use the .jsx or .tsx extension instead of .js or .ts.

Usage

Simply create a JS and/or CSS file with the same name as any view, partial or layout.

Let's continue with our problem example above, where we have the following assets

  • /app/assets/application.css
  • /app/assets/application.js
  • /app/assets/users.css
  • /app/assets/users.js
  • /app/assets/user.js
Your application layout is at /app/views/layouts/application.html.erb, and the view that needs the users assets is at /app/views/users/index.html.erb, so move your assets JS and CSS alongside them:
  • /app/views/layouts/application.css
  • /app/views/layouts/application.js
  • /app/views/users/index.css
  • /app/views/users/index.js
  • /app/views/users/_user.js (partial)
Now, in your layout and view, replace the javascript_include_tag and stylesheet_link_tag helpers with the include_asset helper from Proscenium. Something like this:
<!DOCTYPE html>
<html>
  <head>
    <title>Hello World</title>
    <%= include_assets # <-- %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

On each page request, Proscenium will check if any of your views, layouts and partials have a JS/TS/CSS file of the same name, and then include them wherever you placed the include_assets helper.

Now you never have to remember to include your assets again. Just create them alongside your views, partials and layouts, and Proscenium will take care of the rest.

Side loading is enabled by default, but you can disable it by setting config.proscenium.side_load to false in your /config/application.rb.

There are also include_stylesheets and include_javascripts helpers to allow you to control where the CSS and JS assets are included in the HTML. These helpers should be used instead of include_assets if you want to control exactly where the assets are included.

Controlling side loading

sideload_assets turns side loading off, or sets attributes on the tags it adds. In a controller, it applies to every view, layout and partial that controller renders. Each of these is an alternative:

class UsersController < ApplicationController
  sideload_assets false                  # side load nothing
  sideload_assets css: false             # side load JS only
  sideload_assets js: { defer: true }    # add attributes to each script tag
  sideload_assets proc { !request.xhr? } # evaluated against the controller, on every request
end

In a view, layout or partial, it applies to that template, for that render only:

<% sideload_assets false %>

Call it outside any cache block. A cache hit skips the block, and the call with it, so that render side loads the template's assets as though it had not been called.

A partial rendered as a collection is side loaded once for the whole collection, so a sideload_assets call in any item applies to every item. With cached: true, a cache hit renders no items, so the call never runs. To control a cached collection partial, call sideload_assets in the controller instead.

In a partial rendered with a block, as in render 'box' do ... end, a call in the partial applies to the partial, and a call inside the block applies to the template that passed it.

Bundling

To bundle a file means to inline any imported dependencies into the file itself. This process is recursive so dependencies of dependencies (and so on) will also be inlined.

Proscenium will bundle by default, and in real time. So there is no separate build step or pre-compilation.

Proscenium supports importing JS, JSX, TS, TSX, CSS and SVG from NPM, by URL, your local app, and even from other Ruby Gems.

Both static (import) and dynamic (import()) imports are supported for JavaScript and TypeScript, and can be used to import JS, TS, JSX, TSX, JSON, CSS and SVG files.

The @import CSS at-rule is supported for CSS.

Non-analyzable imports

Import paths are currently only bundled if they are a string literal or a glob pattern. Other forms of import paths are not bundled, and are instead preserved verbatim in the generated output. This is because bundling is a compile-time operation and Proscenium doesn't support all forms of run-time path resolution.

Here are some examples:

// Analyzable imports (will be bundled)
import "pkg";
import("pkg");
import(./locale-${foo}.json);

// Non-analyzable imports (will not be bundled) import(pkg/${foo});

The way to work around non-analyzable imports is to mark the package containing this problematic code as unbundled so that it's not included in the bundle. You will then need to ensure that a copy of the external package is available to your bundled code at run-time.

Import from NPM (node_modules)

Bare imports (imports not beginning with ./, /, https://, http://) are fully supported, and will use your package manager of choice (eg, NPM, Yarn, pnpm) via the package.json file located at the root of your Rails app.

Install the package you want to import using your package manager of choice...

npm install react

...and then import it as you would any other package.

import React from "react";

Local Imports

And of course you can import your own code, using relative or absolute paths (file extension is optional, and absolute paths use your Rails root as the base):

import utils from "/lib/utils";
import constants from "./constants";
import Header from "/app/components/header";
@import "/lib/reset";

Unbundling

Sometimes you don't want to bundle an import. For example, you want to ensure that only one instance of React is loaded. In these cases, you can use the unbundle import attribute:

import React from "react" with { unbundle: 'true' };

You can also unbundle entries in aliases using an unbundle: prefix, which ensures that all imports of a particular path are always unbundled:

config.proscenium.aliases = {
  "react": "unbundle:react"
}

Then just import as normal:

import React from "react";

Or if you don't want any bundling at all, simply turn it off application-wide:

config.proscenium.bundle = false

This will mean every asset and import will be loaded independently.

Source Maps

Source maps can make it easier to debug your code. They encode the information necessary to translate from a line/column offset in a generated output file back to a line/column offset in the corresponding original input file. This is useful if your generated code is sufficiently different from your original code (e.g. your original code is TypeScript or you enabled minification). This is also useful if you prefer looking at individual files in your browser's developer tools instead of one big bundled file.

Source map output is supported for both JavaScript and CSS. Each file is appended with the link to the source map. For example:

//# sourceMappingURL=/app/views/layouts/application.js.map

Your browsers dev tools should pick this up and automatically load the source map when and where needed.

SVG

You can import SVG from JS(X), which will bundle the SVG source code. Additionally, if importing from JSX or TSX, the SVG source code will be rendered as a JSX/TSX component.

That component is compiled with esbuild's default JSX runtime, React, even when a tsconfig.json or jsconfig.json names another jsxImportSource, so importing an SVG from JSX needs react installed.

When bundling, the SVG is read as markup, never as code, whether it is local or imported by URL. Its text and attribute values are kept as plain strings, so an expression such as {proscenium.env.API_KEY} inside an SVG stays literal text, and anything after the root element is ignored. The SVG is still rendered into the page as-is, so only import SVGs from sources you trust.

Environment Variables

You can define and access any environment variable from your JavaScript and Typescript under the proscenium.env namespace.

For performance and security reasons you must declare the environment variable names that you wish to expose in your config/application.rb file.

config.proscenium.env_vars = Set['APP_VERSION', 'PUBLIC_API_URL']
config.proscenium.env_vars << 'SENTRY_DSN'
[!WARNING]
A declared variable's value is written as a plain string into any JavaScript that references it, both in what Proscenium serves and in what assets:precompile writes under public/. Anyone who loads the page can read it. Never declare a secret, such as an API key, password or token.

This assumes that the environment variable of the same name has already been defined. If not, you will need to define it yourself either in your code using Ruby's ENV object, or in your shell.

These declared environment variables will be replaced with constant expressions, allowing you to use this like this:

console.log(proscenium.env.RAILS_ENV); // console.log("development")
console.log(proscenium.env.RAILS_ENV === "development"); // console.log(true)

The RAILS_ENV and NODE_ENV environment variables will always automatically be declared for you.

In addition to this, Proscenium also provides a process.env.NODE_ENV variable, which is set to the same value as proscenium.env.RAILS_ENV. It is provided to support the community's existing tooling, which often relies on this variable.

Environment variables are particularly powerful in aiding tree shaking.

function start() {
  console.log("start");
}
function doSomethingDangerous() {
  console.log("resetDatabase");
}

proscenium.env.RAILS_ENV === "development" && doSomethingDangerous();

start();

In any environment other than development, such as production, the above code will be transformed into the following code, discarding the definition of, and call to, doSomethingDangerous().

function start() {
  console.log("start");
}
start();

Please note that for security reasons environment variables are not replaced in URL imports.

An undefined environment variable will be replaced with undefined.

console.log(proscenium.env.UNKNOWN); // console.log((void 0).UNKNOWN)

This means that code that relies on this will not be tree shaken. You can work around this by using the optional chaining operator:

if (typeof proscenium.env?.UNKNOWN !== "undefined") {
  // do something if UNKNOWN is defined
}

i18n

Support is provided for importing your Rails locale files from config/locales/*.yml, exporting them as JSON.

import translations from "proscenium/i18n";
// translations.en.*

If you have multiple locale files, they will be merged together. into one json object.

An app with no config/locales directory exports an empty object. A directory that exists but cannot be read - wrong permissions, an I/O error - fails the build instead, because the alternative is an app that silently ships with every translation missing.

Note that because it is assumed that you will be consuming these translations in the browser, all keys are converted to camelCase, as per the JavaScript conventions.

Javascript

By default, Proscenium's output will take advantage of all modern JS features from the ES2022 spec and earlier. For example, a !== void 0 && a !== null ? a : b will become a ?? b when minifying (enabled by default in production), which makes use of syntax from the ES2020 version of JavaScript. Any syntax feature that is not supported by ES2020 will be transformed into older JavaScript syntax that is more widely supported.

Tree Shaking

Tree shaking is the term the JavaScript community uses for dead code elimination, a common compiler optimization that automatically removes unreachable code. Tree shaking is enabled by default in Proscenium.

function one() {
  console.log("one");
}
function two() {
  console.log("two");
}
one();

The above code will be transformed to the following code, discarding two(), as it is never called.

function one() {
  console.log("one");
}
one();

Code Splitting

Side loaded assets are automatically code split. This means that if you have a file that is imported and used imported several times, and by different files, it will be split off into a separate file.

As an example:

// /lib/son.js
import father from "./father";

father() + " and Son";

// /lib/daughter.js
import father from "./father";

father() + " and Daughter";

// /lib/father.js
export default () => "Father";

Both son.js and daughter.js import father.js, so both son and daughter would usually include a copy of father, resulting in duplicated code and larger bundle sizes.

If these files are side loaded, then father.js will be split off into a separate file or chunk, and only downloaded once.

  • Code shared between multiple entry points is split off into a separate shared file that both entry points import. That way if the user first browses to one page and then to another page, they don't have to download all of the JavaScript for the second page from scratch if the shared part has already been downloaded and cached by their browser.
  • Code referenced through an asynchronous import() expression will be split off into a separate file and only loaded when that expression is evaluated. This allows you to improve the initial download time of your app by only downloading the code you need at startup, and then lazily downloading additional code if needed later.
  • Without code splitting, an import() expression becomes Promise.resolve().then(() => require()) instead. This still preserves the asynchronous semantics of the expression but it means the imported code is included in the same bundle instead of being split off into a separate file.
Code splitting is enabled by default. You can disable it by setting the code_splitting configuration option to false in your application's /config/application.rb:
config.proscenium.code_splitting = false

__filename and __dirname

Proscenium provides Node.js-style __filename and __dirname constants in your JavaScript and TypeScript files. These are replaced at build time with the root-relative path of the current file and its directory respectively.

// /app/views/users/index.js
console.log(__filename); // "/app/views/users/index.js"
console.log(__dirname); // "/app/views/users"

Files inside Ruby gems resolve to their @rubygems/ scoped path:

// Inside the "mygem" gem
console.log(__filename); // "@rubygems/mygem/lib/mygem/component.js"
console.log(__dirname); // "@rubygems/mygem/lib/mygem"

Note that __filename and __dirname are not injected into files within node_modules.

JavaScript Caveats

There are a few important caveats as far as JavaScript is concerned. These are detailed on the esbuild site.

CSS

CSS is a first-class content type in Proscenium, which means it can bundle CSS files directly without needing to import your CSS from JavaScript code. You can @import other CSS files and reference image and font files with url() and Proscenium will bundle everything together.

Note that by default, Proscenium's output will take advantage of all modern CSS features. For example, color: rgba(255, 0, 0, 0.4) will become color: #f006 after minifying in production, which makes use of syntax from CSS Color Module Level 4.

The new CSS nesting syntax is supported, and transformed into non-nested CSS for older browsers.

Proscenium will also automatically insert vendor prefixes so that your CSS will work in older browsers.

Importing CSS from JavaScript

You can also import CSS from JavaScript. When you do this, Proscenium will automatically append each stylesheet to the document's head as a