Introduction
What is Ramber?
Ramber is a framework for building extremely high-performance web apps. You're looking at one right now! There are two basic concepts:
- Each page of your app is a Hamber component
- You create pages by adding files to the
src/routes
directory of your project. These will be server-rendered so that a user's first visit to your app is as fast as possible, then a client-side app takes over
Building an app with all the modern best practices — code-splitting, offline support, server-rendered views with client-side hydration — is fiendishly complicated. Ramber does all the boring stuff for you so that you can get on with the creative part.
You don't need to know Hamber to understand the rest of this guide, but it will help. In short, it's a UI framework that compiles your components to highly optimized vanilla JavaScript.
Why the name?
In war, the soldiers who build bridges, repair roads, clear minefields and conduct demolitions — all under combat conditions — are known as rambers.
For web developers, the stakes are generally lower than for combat engineers. But we face our own hostile environment: underpowered devices, poor network connections, and the complexity inherent in front-end engineering. Ramber, which is short for Svelte app maker, is your courageous and dutiful ally.
Comparison with Next.js
Next.js is a React framework from Zeit, and is the inspiration for Ramber. There are a few notable differences, however:
- Ramber is powered by Hamber instead of React, so it's faster and your apps are smaller
- Instead of route masking, we encode route parameters in filenames (see the routing section below)
- As well as pages, you can create server routes in your
src/routes
directory. This makes it very easy to, for example, add a JSON API such as the one powering this very page (try visiting /docs.json) - Links are just
<a>
elements, rather than framework-specific<Link>
components. That means, for example, that this link right here, despite being inside a blob of markdown, works with the router as you'd expect
Getting started
The easiest way to start building a Ramber app is to clone the ramber-template repo
npx degit hamberjs/ramber-template#rollup my-app
# or: npx degit hamberjs/ramber-template#webpack my-app
cd my-app
npm install
npm run dev
This will scaffold a new project in the my-app
directory, install its dependencies, and start a server on localhost:3000. Try editing the files to get a feel for how everything works – you may not need to bother reading the rest of this guide!
Ramber app structure
This section is a reference for the curious. We recommend you play around with the project template first, and come back here when you've got a feel for how things fit together.
If you take a look inside the ramber-template repo, you'll see some files that Ramber expects to find:
├ package.json
├ src
│ ├ routes
│ │ ├ # your routes here
│ │ ├ _error.hamber
│ │ └ index.hamber
│ ├ client.js
│ ├ server.js
│ ├ service-worker.js
│ └ template.html
├ static
│ ├ # your files here
└ rollup.config.js / webpack.config.js
When you first run Ramber, it will create an additional __ramber__
directory containing generated files.
You can create these files from scratch, but it's much better to use the template. See getting started for instructions on how to easily clone it
package.json
Your package.json contains your app's dependencies and defines a number of scripts:
npm run dev
— start the app in development mode, and watch source files for changesnpm run build
— build the app in production modenpm run export
— bake out a static version, if applicable (see exporting)npm start
— start the app in production mode after you've built it
src
This contains the three entry points for your app — src/client.js
, src/server.js
and (optionally) src/service-worker.js
— along with a src/template.html
file.
src/client.js
This must import, and call, the start
function from the generated @ramber/app
module:
import * as ramber from '@ramber/app';
ramber.start({
target: document.querySelector('#ramber')
});
In many cases, that's the entirety of your entry module, though you can do as much or as little here as you wish. See the client API section for more information on functions you can import.
src/server.js
This is a normal Express (or Polka, etc) app, with three requirements:
- it should serve the contents of the
static
folder, using for example sirv - it should call
app.use(ramber.middleware())
at the end, whereramber
is imported from@ramber/server
- it must listen on
process.env.PORT
Beyond that, you can write the server however you like.
src/service-worker.js
Service workers act as proxy servers that give you fine-grained control over how to respond to network requests. For example, when the browser requests /goats.jpg
, the service worker can respond with a file it previously cached, or it can pass the request on to the server, or it could even respond with something completely different, such as a picture of llamas.
Among other things, this makes it possible to build applications that work offline.
Because every app needs a slightly different service worker (sometimes it's appropriate to always serve from the cache, sometimes that should only be a last resort in case of no connectivity), Ramber doesn't attempt to control the service worker. Instead, you write the logic in service-worker.js
. You can import any of the following from @ramber/service-worker
:
files
— an array of files found in thestatic
directoryshell
— the client-side JavaScript generated by the bundler (Rollup or webpack)routes
— an array of{ pattern: RegExp }
objects you can use to determine whether a Ramber-controlled page is being requestedtimestamp
— the time the service worker was generated (useful for generating unique cache names)
src/template.html
This file is a template for responses from the server. Ramber will inject content that replaces the following tags:
%ramber.base%
— a<base>
element (see base URLs)%ramber.styles%
— critical CSS for the page being requested%ramber.head%
— HTML representing page-specific<head>
contents, like<title>
%ramber.html%
— HTML representing the body of the page being rendered%ramber.scripts%
— script tags for the client-side app
src/routes
This is the meat of your app — the pages and server routes. See the section on routing for the juicy details.
static
This is a place to put any files that your app uses — fonts, images and so on. For example static/favicon.png
will be served as /favicon.png
.
Ramber doesn't serve these files — you'd typically use sirv or serve-static for that — but it will read the contents of the static
folder so that you can easily generate a cache manifest for offline support (see service-worker.js).
rollup.config.js / webpack.config.js
Ramber can use Rollup or webpack to bundle your app. You probably won't need to change the config, but if you want to (for example to add new loaders or plugins), you can.
Routing
As we've seen, there are two types of route in Ramber — pages, and server routes.
Pages
Pages are Hamber components written in .hamber
files. When a user first visits the application, they will be served a server-rendered version of the route in question, plus some JavaScript that 'hydrates' the page and initialises a client-side router. From that point forward, navigating to other pages is handled entirely on the client for a fast, app-like feel.
The filename determines the route. For example, src/routes/index.hamber
is the root of your site:
<!-- src/routes/index.hamber -->
<hamber:head>
<title>Welcome</title>
</hamber:head>
<h1>Hello and welcome to my site!</h1>
A file called either src/routes/about.hamber
or src/routes/about/index.hamber
would correspond to the /about
route:
<!-- src/routes/about.hamber -->
<hamber:head>
<title>About</title>
</hamber:head>
<h1>About this site</h1>
<p>TODO...</p>
Dynamic parameters are encoded using [brackets]
. For example, here's how you could create a page that renders a blog post:
<!-- src/routes/blog/[slug].hamber -->
<script context="module">
// the (optional) preload function takes a
// `{ path, params, query }` object and turns it into
// the data we need to render the page
export async function preload(page, session) {
// the `slug` parameter is available because this file
// is called [slug].hamber
const { slug } = page.params;
// `this.fetch` is a wrapper around `fetch` that allows
// you to make credentialled requests on both
// server and client
const res = await this.fetch(`blog/${slug}.json`);
const article = await res.json();
return { article };
}
</script>
<script>
export let article;
</script>
<hamber:head>
<title>{article.title}</title>
</hamber:head>
<h1>{article.title}</h1>
<div class='content'>
{@html article.html}
</div>
See the section on preloading for more info about
preload
andthis.fetch
Server routes
Server routes are modules written in .js
files that export functions corresponding to HTTP methods. Each function receives HTTP request
and response
objects as arguments, plus a next
function. This is useful for creating a JSON API. For example, here's how you could create an endpoint that served the blog page above:
// routes/blog/[slug].json.js
import db from './_database.js'; // the underscore tells Ramber this isn't a route
export async function get(req, res, next) {
// the `slug` parameter is available because this file
// is called [slug].json.js
const { slug } = req.params;
const article = await db.get(slug);
if (article !== null) {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(article));
} else {
next();
}
}
delete
is a reserved word in JavaScript. To handle DELETE requests, export a function calleddel
instead.
File naming rules
There are three simple rules for naming the files that define your routes:
- A file called
src/routes/about.hamber
corresponds to the/about
route. A file calledsrc/routes/blog/[slug].hamber
corresponds to the/blog/:slug
route, in which caseparams.slug
is available topreload
- The file
src/routes/index.hamber
corresponds to the root of your app.src/routes/about/index.hamber
is treated the same assrc/routes/about.hamber
. - Files and directories with a leading underscore do not create routes. This allows you to colocate helper modules and components with the routes that depend on them — for example you could have a file called
src/routes/_helpers/datetime.js
and it would not create a/_helpers/datetime
route
Error page
In addition to regular pages, there is a 'special' page that Ramber expects to find — src/routes/_error.hamber
. This will be shown when an error occurs while rendering a page.
The error
object is made available to the template along with the HTTP status
code.
Regexes in routes
You can use a subset of regular expressions to qualify route parameters, by placing them in parentheses after the parameter name.
For example, src/routes/items/[id([0-9]+)].hamber
would only match numeric IDs — /items/123
would match, but /items/xyz
would not.
Because of technical limitations, the following characters cannot be used: /
, \
, ?
, :
, (
and )
.
Client API
The @ramber/app
module, which is generated by Ramber based on the shape of your app, contains functions for controlling Ramber programmatically and responding to events.
start({ target })
target
— an element to render pages to
This configures the router and starts the application — listens for clicks on <a>
elements, interacts with the history
API, and renders and updates your Hamber components.
Returns a Promise
that resolves when the initial page has been hydrated.
import * as ramber from '@ramber/app';
ramber.start({
target: document.querySelector('#ramber')
}).then(() => {
console.log('client-side app has started');
});
goto(href, options?)
href
— the page to go tooptions
— can include areplaceState
property, which determines whether to usehistory.pushState
(the default) orhistory.replaceState
). Not required
Programmatically navigates to the given href
. If the destination is a Ramber route, Ramber will handle the navigation, otherwise the page will be reloaded with the new href
. (In other words, the behaviour is as though the user clicked on a link with this href
.)
prefetch(href)
href
— the page to prefetch
Programmatically prefetches the given page, which means a) ensuring that the code for the page is loaded, and b) calling the page's preload
method with the appropriate options. This is the same behaviour that Ramber triggers when the user taps or mouses over an <a>
element with rel=prefetch.
prefetchRoutes(routes?)
routes
— an optional array of strings representing routes to prefetch
Programmatically prefetches the code for routes that haven't yet been fetched. Typically, you might call this after ramber.start()
is complete, to speed up subsequent navigation (this is the 'L' of the PRPL pattern). Omitting arguments will cause all routes to be fetched, or you can specify routes by any matching pathname such as /about
(to match src/routes/about.hamber
) or /blog/*
(to match src/routes/blog/[slug].hamber
). Unlike prefetch
, this won't call preload
for individual pages.
Preloading
As seen in the routing section, page components can have an optional preload
function that will load some data that the page depends on. This is similar to getInitialProps
in Next.js or asyncData
in Dorayaki.js.
<script context="module">
export async function preload(page, session) {
const { slug } = page.params;
const res = await this.fetch(`blog/${slug}.json`);
const article = await res.json();
return { article };
}
</script>
It lives in a context="module"
script — see the tutorial — because it's not part of the component instance itself; instead, it runs before the component is created, allowing you to avoid flashes while data is fetched.
Argument
The preload
function receives two arguments — page
and session
.
page
is a { path, params, query }
object where path
is the URL's pathname, params
is derived from path
and the route filename, and query
is an object of values in the query string.
So if the example above was src/routes/blog/[slug].hamber
and the URL was /blog/some-post?foo=bar&baz
, the following would be true:
page.path === '/blog/some-post'
page.params.slug === 'some-post'
page.query.foo === 'bar'
page.query.baz === true
session
is generated on the server by the session
option passed to ramber.middleware
(TODO this needs further documentation. Perhaps a server API section?)
Return value
If you return a Promise from preload
, the page will delay rendering until the promise resolves. You can also return a plain object.
When Ramber renders a page on the server, it will attempt to serialize the resolved value and include it on the page, so that the client doesn't also need to call preload
upon initialization. Serialization will fail if the value includes functions or custom classes (cyclical and repeated references are fine, as are built-ins like Date
, Map
, Set
and RegExp
).
Context
Inside preload
, you have access to three methods:
this.fetch(url, options)
this.error(statusCode, error)
this.redirect(statusCode, location)
this.fetch
In browsers, you can use fetch
to make AJAX requests, for getting data from your server routes (among other things). On the server it's a little trickier — you can make HTTP requests, but you must specify an origin, and you don't have access to cookies. This means that it's impossible to request data based on the user's session, such as data that requires you to be logged in.
To fix this, Ramber provides this.fetch
, which works on the server as well as in the client:
<script context="module">
export async function preload() {
const res = await this.fetch(`secret-data.json`, {
credentials: 'include'
});
// ...
}
</script>
Note that you will need to use session middleware such as express-session in your app/server.js
in order to maintain user sessions or do anything involving authentication.
this.error
If the user navigated to /blog/some-invalid-slug
, we would want to render a 404 Not Found page. We can do that with this.error
:
<script context="module">
export async function preload({ params, query }) {
const { slug } = params;
const res = await this.fetch(`blog/${slug}.json`);
if (res.status === 200) {
const article = await res.json();
return { article };
}
this.error(404, 'Not found');
}
</script>
The same applies to other error codes you might encounter.
this.redirect
You can abort rendering and redirect to a different location with this.redirect
:
<script context="module">
export async function preload(page, session) {
const { user } = session;
if (!user) {
return this.redirect(302, 'login');
}
return { user };
}
</script>
Layouts
So far, we've treated pages as entirely standalone components — upon navigation, the existing component will be destroyed, and a new one will take its place.
But in many apps, there are elements that should be visible on every page, such as top-level navigation or a footer. Instead of repeating them in every page, we can use layout components.
To create a layout component that applies to every page, make a file called src/routes/_layout.hamber
. The default layout component (the one that Ramber uses if you don't bring your own) looks like this...
<slot></slot>
...but we can add whatever markup, styles and behaviour we want. For example, let's add a nav bar:
<!-- src/routes/_layout.hamber -->
<nav>
<a href=".">Home</a>
<a href="about">About</a>
<a href="settings">Settings</a>
</nav>
<slot></slot>
If we create pages for /
, /about
and /settings
...
<!-- src/routes/index.hamber -->
<h1>Home</h1>
<!-- src/routes/about.hamber -->
<h1>About</h1>
<!-- src/routes/settings.hamber -->
<h1>Settings</h1>
...the nav will always be visible, and clicking between the three pages will only result in the <h1>
being replaced.
Nested routes
Suppose we don't just have a single /settings
page, but instead have nested pages like /settings/profile
and /settings/notifications
with a shared submenu (for an real-life example, see github.com/settings).
We can create a layout that only applies to pages below /settings
(while inheriting the root layout with the top-level nav):
<!-- src/routes/settings/_layout.hamber -->
<h1>Settings</h1>
<div class="submenu">
<a href="settings/profile">Profile</a>
<a href="settings/notifications">Notifications</a>
</div>
<slot></slot>
Layout components receive a segment
property which is useful for things like styling:
+<script>
+ export let segment;
+</script>
+
<div class="submenu">
- <a href="settings/profile">Profile</a>
- <a href="settings/notifications">Notifications</a>
+ <a
+ class:selected={segment === "profile"}
+ href="settings/profile"
+ >Profile</a>
+
+ <a
+ class:selected={segment === "notifications"}
+ href="settings/notifications"
+ >Notifications</a>
</div>
Server-side rendering
Ramber, by default, renders server-side first (SSR), and then re-mounts any dynamic elements on the client. Hamber provides excellent support for this. This has benefits in performance and search engine indexing, among others, but comes with its own set of complexities.
Making a component SSR compatible
Ramber works well with most third-party libraries you are likely to come across. However, sometimes, a third-party library comes bundled in a way which allows it to work with multiple different module loaders. Sometimes, this code creates a dependency on window
, such as checking for the existence of window.global
might do.
Since there is no window
in a server-side environment like Ramber's, the action of simply importing such a module can cause the import to fail, and terminate the Ramber's server with an error such as:
ReferenceError: window is not defined
The way to get around this is to use a dynamic import for your component, from within the onMount
function (which is only called on the client), so that your import code is never called on the server.
<script>
import { onMount } from 'hamber';
let MyComponent;
onMount(async () => {
const module = await import('my-non-ssr-component');
MyComponent = module.default;
});
</script>
<hamber:component this={MyComponent} foo="bar"/>
Stores
The page
and session
values passed to preload
functions are available to components as stores, along with preloading
.
Inside a component, get references to the stores like so:
<script>
import { stores } from '@ramber/app';
const { preloading, page, session } = stores();
</script>
preloading
contains a readonly boolean value, indicating whether or not a navigation is pendingpage
contains a readonly{ path, params, query }
object, identical to that passed topreload
functionssession
contains whatever data was seeded on the server. It is a writable store, meaning you can update it with new data (for example, after the user logs in) and your app will be refreshed
Seeding session data
On the server, you can populate session
by passing an option to ramber.middleware
:
// src/server.js
express() // or Polka, or a similar framework
.use(
serve('assets'),
authenticationMiddleware(),
ramber.middleware({
session: (req, res) => ({
user: req.user
})
})
)
.listen(process.env.PORT);
Session data must be serializable — no functions or custom classes, just built-in JavaScript data types
Prefetching
Ramber uses code splitting to break your app into small chunks (one per route), ensuring fast startup times.
For dynamic routes, such as our src/routes/blog/[slug].hamber
example, that's not enough. In order to render the blog post, we need to fetch the data for it, and we can't do that until we know what slug
is. In the worst case, that could cause lag as the browser waits for the data to come back from the server.
rel=prefetch
We can mitigate that by prefetching the data. Adding a rel=prefetch
attribute to a link...
<a rel=prefetch href='blog/what-is-ramber'>What is Ramber?</a>
...will cause Ramber to run the page's preload
function as soon as the user hovers over the link (on a desktop) or touches it (on mobile), rather than waiting for the click
event to trigger navigation. Typically, this buys us an extra couple of hundred milliseconds, which is the difference between a user interface that feels laggy, and one that feels snappy.
rel=prefetch
is a Ramber idiom, not a standard attribute for<a>
elements
Building
Up until now we've been using ramber dev
to build our application and run a development server. But when it comes to production, we want to create a self-contained optimized build.
ramber build
This command packages up your application into the __ramber__/build
directory. (You can change this to a custom directory, as well as controlling various other options — do ramber build --help
for more information.)
The output is a Node app that you can run from the project root:
node __ramber__/build
Exporting
Many sites are effectively static, which is to say they don't actually need an Express server backing them. Instead, they can be hosted and served as static files, which allows them to be deployed to more hosting environments (such as Netlify or GitHub Pages). Static sites are generally cheaper to operate and have better performance characteristics.
Ramber allows you to export a static site with a single zero-config ramber export
command. In fact, you're looking at an exported site right now!
Static doesn't mean non-interactive — your Hamber components work exactly as they do normally, and you still get all the benefits of client-side routing and prefetching.
ramber export
Inside your Ramber project, try this:
# npx allows you to use locally-installed dependencies
npx ramber export
This will create a __ramber__/export
folder with a production-ready build of your site. You can launch it like so:
npx serve __ramber__/export
Navigate to localhost:5000 (or whatever port serve
picked), and verify that your site works as expected.
You can also add a script to your package.json...
{
"scripts": {
...
"export": "ranber export"
}
}
...allowing you to npm run export
your app.
How it works
When you run ranber export
, Ramber first builds a production version of your app, as though you had run ranber build
, and copies the contents of your assets
folder to the destination. It then starts the server, and navigates to the root of your app. From there, it follows any <a>
elements it finds, and captures any data served by the app.
Because of this, any pages you want to be included in the exported site must be reachable by <a>
elements. Additionally, any non-page routes should be requested in preload
, not in onMount
or elsewhere.
When not to export
The basic rule is this: for an app to be exportable, any two users hitting the same page of your app must get the same content from the server. In other words, any app that involves user sessions or authentication is not a candidate for ranber export
.
Note that you can still export apps with dynamic routes, like our src/routes/blog/[slug].hamber
example from earlier. ranber export
will intercept fetch
requests made inside preload
, so the data served from src/routes/blog/[slug].json.js
will also be captured.
Route conflicts
Because ranber export
writes to the filesystem, it isn't possible to have two server routes that would cause a directory and a file to have the same name. For example, src/routes/foo/index.js
and src/routes/foo/bar.js
would try to create export/foo
and export/foo/bar
, which is impossible.
The solution is to rename one of the routes to avoid conflict — for example, src/routes/foo-bar.js
. (Note that you would also need to update any code that fetches data from /foo/bar
to reference /foo-bar
instead.)
For pages, we skirt around this problem by writing export/foo/index.html
instead of export/foo
.
Deployment
Ramber apps run anywhere that supports Node 8 or higher.
Deploying to Now
This section relates to Now 1, not Now 2
We can very easily deploy our apps to Now:
npm install -g now
now
This will upload the source code to Now, whereupon it will do npm run build
and npm start
and give you a URL for the deployed app.
For other hosting environments, you may need to do npm run build
yourself.
Deploying service workers
Ramber makes the Service Worker file (service-worker.js
) unique by including a timestamp in the source code (calculated using Date.now()
).
In environments where the app is deployed to multiple servers (such as Now), it is advisable to use a consistent timestamp for all deployments. Otherwise, users may run into issues where the Service Worker updates unexpectedly because the app hits server 1, then server 2, and they have slightly different timestamps.
To override Ramber's timestamp, you can use an environment variable (e.g. RAMBER_TIMESTAMP
) and then modify the service-worker.js
:
const timestamp = process.env.RAMBER_TIMESTAMP; // instead of `import { timestamp }`
const ASSETS = `cache${timestamp}`;
export default {
/* ... */
plugins: [
/* ... */
replace({
/* ... */
'process.env.RAMBER_TIMESTAMP': process.env.RAMBER_TIMESTAMP || Date.now()
})
]
}
Then you can set it using the environment variable, e.g.:
RAMBER_TIMESTAMP=$(date +%s%3N) npm run build
When deploying to Now, you can pass the environment variable into Now itself:
now -e RAMBER_TIMESTAMP=$(date +%s%3N)
Security
By default, Ramber does not add security headers to your app, but you may add them yourself using middleware such as Helmet.
Content Security Policy (CSP)
Ramber generates inline <script>
s, which can fail to execute if Content Security Policy (CSP) headers disallow arbitrary script execution (unsafe-inline
).
To work around this, Ramber can inject a nonce which can be configured with middleware to emit the proper CSP headers. Here is an example using Express and Helmet:
// server.js
import uuidv4 from 'uuid/v4';
import helmet from 'helmet';
app.use((req, res, next) => {
res.locals.nonce = uuidv4();
next();
});
app.use(helmet({
contentSecurityPolicy: {
directives: {
scriptSrc: [
"'self'",
(req, res) => `'nonce-${res.locals.nonce}'`
]
}
}
}));
app.use(ramber.middleware());
Using res.locals.nonce
in this way follows the convention set by Helmet's CSP docs.
Base URLs
Ordinarily, the root of your Ramber app is served at /
. But in some cases, your app may need to be served from a different base path — for example, if Ramber only controls part of your domain, or if you have multiple Ramber apps living side-by-side.
This can be done like so:
// app/server.js
express() // or Polka, or a similar framework
.use(
'/my-base-path', // <!-- add this line
compression({ threshold: 0 }),
serve('assets'),
ramber.middleware()
)
.listen(process.env.PORT);
Ramber will detect the base path and configure both the server-side and client-side routers accordingly.
If you're exporting your app, you will need to tell the exporter where to begin crawling:
ramber export --basepath my-base-path
Debugging
Debugging your server code is particularly easy with ndb. Install it globally...
npm install -g ndb
...then run Ramber:
ndb npm run dev
This assumes that
npm run dev
runsramber dev
. You can also run Ramber via npx, as inndb npx ramber dev
.
Note that you may not see any terminal output for a few seconds while ndb starts up.