How-to

Single-page application

Serve the build output of a single-page application (React, Vue, Svelte, …) as static files, with precompressed assets and client-side routes falling back to index.html.

Prerequisites

  • ScalWS installed, with include: sites.d/*.yaml in /etc/scalws/scalws.yaml.
  • The built application in a directory outside /home, for example /srv/spa/dist, readable by group scalws.

Precompressed files

With static.precompressed ScalWS serves <file>.br or <file>.gz instead of <file> to clients that accept that encoding, with Vary: Accept-Encoding. The compressed file must not be older than the original. Create them after every build, for example:

cd /srv/spa/dist
find . -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) \
    -exec brotli -kf {} \; -exec gzip -kf9 {} \;

Many bundlers can write the .br and .gz files themselves.

The history fallback

The static runtime has no “fall back to index.html” setting: a request for /settings/profile looks for that file and answers 404. Client-side routes are sent to index.html with an .htaccess file in the document root, which ScalWS reads with htaccess: true (.htaccess support).

/srv/spa/dist/.htaccess:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]

Existing files and directories are served as they are; every other path is rewritten internally to /index.html. .htaccess itself is never served (dotfiles answer 404). Many build tools copy files from a public/ folder into the output, so you can keep the .htaccess there.

The site file

sudo scalwsctl site add spa.example.com --root /srv/spa/dist --htaccess

Then add precompressed:

# /etc/scalws/sites.d/spa.example.com.yaml
domain: spa.example.com
root: /srv/spa/dist
static: {precompressed: true}
htaccess: true
https_redirect: true

The .htaccess files are looked up below the site’s root, so root must be the build directory (or a directory above it that holds the .htaccess). Static sites work under both scalws-controller and scalwsd.service.

An API on the same domain can be added as a route; see reverse proxy:

domain: spa.example.com
root: /srv/spa/dist
static: {precompressed: true}
htaccess: true
routes:
  - path: /api
    runtime: {type: proxy, upstream: "http://127.0.0.1:4000"}

.htaccess rules run for every request of the site before the route is chosen, and a rewritten path selects the route again. The fallback rule above would therefore rewrite /api/users (no such file) to /index.html. Exclude the API path:

RewriteEngine On
RewriteCond %{REQUEST_URI} !^/api(/|$)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]

Check it works

curl -sI -H 'Host: spa.example.com' http://127.0.0.1/                    # 200, index.html
curl -sI -H 'Host: spa.example.com' http://127.0.0.1/settings/profile    # 200, index.html
curl -sI -H 'Host: spa.example.com' -H 'Accept-Encoding: br' \
     http://127.0.0.1/assets/app.js | grep -iE '^(content-encoding|vary)'  # br, Accept-Encoding

Common problems

  • Client-side routes answer 404: htaccess: true is missing, the .htaccess is not below root, or it is not readable by scalws. Edits are picked up within 2 seconds.
  • No Content-Encoding: precompressed is not set, the client did not send a matching Accept-Encoding, or the .br/.gz file is older than the original (rebuild, then compress again).
  • A missing asset returns index.html with 200: that is the fallback rule at work for every path that is not a file.
  • API requests get index.html: the fallback rule rewrote them before routing; add the RewriteCond %{REQUEST_URI} !^/api(/|$) line shown above.
  • Only GET and HEAD work: the static runtime serves nothing else; send other methods to an API route.

See also: applications: static, static SPA example.