Skip to content

webqit/webflo

master
Switch branches/tags
Code

Files

Permalink
Failed to load latest commit information.

Webflo

NPM version NPM downloads

Webflo is a universal web, mobile, and API backend framework built to solve for the underrated .html + .css + .js stack! This has been written specifically to draw directly on the plain old stack at the language level - to facilitate building web-native applications!

Ok, we've put all of that up for a straight read!

Note
Depending on your current framework background, the hardest part of Webflo might be having to break ties with something that isn't conventional to the .html + .css + .js stack: all of that JSX, CSS-in-JS, etc.!

Documentation

Overview

Build anything - from as basic as a static index.html page to as rich as a universal app that's either a Multi Page Application (MPA), Single Page Application (SPA), or a hybrid of these, implementing Server Side Rendering (SSR), Client Side Rendering (CSR), or a hybrid of these, offline and PWA capabilities, etc. - this time, without loosing the vanilla advantage!

Here's a glimpse of your Webflo app.

For when your application has a Server side.

  • The public directory for static files.

  • The server directory for server-side routing. (i.e. dynamic request handling on the server - in the case of Multi Page Applications, API backends, etc.)

    my-app
      ├── server/index.js
      └── public/logo.png

    And a typical index.js route handler has the following anatomy.

    /**
    server
     ├── index.js
     */
    export default function(event, context, next) {
        if (next.pathname) {
            return next();  // <--------------------------------- http://localhost:3000/logo.png (or other non-root URLs)
        }
        return { title: 'Hello from Server' };  // <------------- http://localhost:3000/ (root URL)
    }

    Above, you are handling requests for the root URL and allowing others to flow through to step handlers or to the public directory. (Details ahead.)

    Response is a JSON (API) response when handler return value is jsonfyable. (As above for the root URL.)

    Or it ends up being rendered as a page response when there is an index.html file in the public directory that pairs with the route (and when the incoming request matches text/html in its Accept header).

    my-app
      ├── server/index.js
      └── public/index.html

    And a typical index.html page has the following anatomy.

    <!--
    public
      ├── index.html
    -->
    <!DOCTYPE html>
    <html>
        <head>
            <link rel="stylesheet" href="/style.css" />   <!-- ---------------------- Application CSS -->
            <script type="module" src="/bundle.js"></script>   <!-- ----------------- Application JS bundle -->
            <template name="routes" src="/bundle.html"></template>   <!-- ------------- Reusable HTML Templates and partials (Details ahead) -->
        </head>
        <body>...</body>
    </html>

    These are regular HTML markup! And above, you're also leveraging HTML includes! (Details ahead.)

For when your application has a Client side.

  • The client directory for client-side routing. (i.e. dynamic request handling right in the browser - in the case of Single Page Applications, etc.)

  • The worker directory for, heck, Service Worker based routing! (i.e. dynamic request handling in the application Service Worker - in the case of Progressive Web Apps, etc.)

    my-app
      ├── client/index.js
      └── worker/index.js

    And in both cases, a typical index.js route handler has the following anatomy.

    /**
    [client|worker]
     ├── index.js
     */
    export default function(event, context, next) {
        if (next.pathname) {
            return next();  // <--------------------------------- http://localhost:3000/logo.png (or other non-root URLs)
        }
        return { title: 'Hello from [Browser|Worker]' };  // <--- http://localhost:3000/ (root URL)
    }

    Above, you are handling requests for the root URL and allowing others to flow through to nested handlers or to the network. (Details ahead.)

    Responses for navigation requests are rendered back into the current running page in the browser.

This and much more - ahead!

Build future-proof anything by banking more on web standards and less on abstractions! Webflo just follows where a native feature, standard, or conventional HTML, CSS or JS just works!

Here's a glimpse of the standards-based stack you get of Webflo!

For when your application involves routing:

  • The Fetch Standard, comprising of the Request, Response, and Headers interfaces, is used for all things requests and responses - across client, server, and Service Worker environments. (Details ahead)

    This paves the way to using other native APIs as-is, when handling requests and responses. For example, if you sent an instance of the native FormData, Blob, File, or ReadableStream object from the browser side of your application, you'd be getting the same instance on the server side!

  • WHATWG URL and WHATWG URLPattern are used for all things URL and URL pattern matching, respectively - across client, server, and Service Worker environments. (Details ahead)

For when your application involves pages and a UI:

  • The HTML Standard is held for all things markup - across client, server, and Service Worker environments! Webflo is all about using conventional .html-based pages and templates, valid HTML syntax, etc. You are able to get away with a "zero-JavaScript" proposition, or a Progressive Enhancement proposition that makes do with "just-enough JavaScript"!

    Your markup is also easily extendable with OOHTML - a set of new features for HTML that makes it fun to hand-author your UI! Within OOHTML are HTML Modules and HTML Imports, Reactive Scripts and more!

  • WHATWG DOM is universally available - not only on the client-side, but also on the server-side via OOHTML-SSR - for all things dynamic pages: rendering, manipulation, interactivity, etc.

    Your DOM is also easily enrichable with Custom Elements, plus Subscript Elements and The State API from OOHTML.

For when your application needs to give an app-like experience:

This and more - ahead! For building web-native apps!

Installation

Every Webflo project starts on an empty directory that you can create on your machine. The command below makes a new directory my-app from the terminal and navigates into it.

mkdir my-app
cd my-app

With npm available on your terminal, run the following command to install Webflo to your project:

System Requirements: Node.js 14.0 or later

$ npm i @webqit/webflo

The installation automatically creates a package.json file at project root, containing @webqit/webflo as a project dependency.

{
  "dependencies": {
    "@webqit/webflo": "..."
  }
}

Other important definitions like project name, package type, and aliases for common Webflo commands will now also belong here.

{
  "name": "my-app",
  "type": "module",
  "scripts": {
    "start": "webflo start::server --mode=dev",
    "generate": "webflo generate::client --compression=gz --auto-embed"
  },
  "dependencies": {
    "@webqit/webflo": "..."
  }
}

All is now set! The commands npm start and npm run generate will be coming in often during development.

"Hello World!"

To be sure that Webflo is listening, run npx webflo help on the terminal. An overview of available commands should be shown.

If you can't wait to say Hello World! 😅, you can have an HTML page say that right now!

  • Create an index.html file in a new subdirectory public.

    public
      └── index.html
    <!DOCTYPE html>
    <html>
        <head>
            <title>My App</title>
        </head>
        <body>
            <h1>Hello World!</h1>
            <p>This is <b>My App</b></p>
        </body>
    </html>
  • Start the Webflo server and visit http://localhost:3000 on your browser to see your page. 😃

    $ npm start

Concepts

Handler Functions and Layout

Whether building a server-based, browser-based, or universal application, Webflo gives you one consistent way to handle routing and navigation: using handler functions!

/**
[server|client|worker]
 ├── index.js
 */
export default function(event, context, next) {
}

Note
Other method-specific function names may be used: get, post, put, patch, del (for delete), options, head, etc.

Each function receives an event object representing details - e.g. event.request, event.url, event.session - about the current request. (Details ahead.)

For server-based applications (e.g. traditional web apps and API backends), server-side handlers go into a directory named server.

/**
server
 ├── index.js
 */
export default function(event, context, next) {
    return {
        title: 'Home | FluffyPets',
        source: 'server',
    };
}

Note
The above function responds on starting the server - npm start on your terminal - and visiting http://localhost:3000.

For browser-based applications (e.g. Single Page Apps), client-side handlers go into a directory named client.

/**
client
 ├── index.js
 */
export default function(event, context, next) {
    return {
        title: 'Home | FluffyPets',
        source: 'in-browser',
    };
}

Note
The above function is built as part of your application's JS bundle on running the npm run generate command. (It is typically bundled to the file ./public/bundle.js. And the --auto-embed flag in that command gets it automatically embedded on your ./public/index.html page as <script type="module" src="/bundle.js"></script>.) Then it responds from right in the browser on visiting http://localhost:3000.

For browser-based applications that want to support offline usage via Service-Workers (e.g Progressive Web Apps), Webflo allows us to define equivalent handlers for requests hitting the Service Worker. These worker-based handlers go into a directory named worker.

/**
worker
 ├── index.js
 */
export default function(event, context, next) {
    return {
        title: 'Home | FluffyPets',
        source: 'service-worker',
    };
}

Note
The above function is built as part of your application's Service Worker JS bundle on running the npm run generate command. (It is typically bundled to the file ./public/worker.js, and the main application bundle automatically connects to it.) Then it responds from within the Service Worker on visiting http://localhost:3000. (More details ahead.)

So, depending on what's being built, an application's handler functions may take the following form (in part or in whole as with universal applications):

client
  ├── index.js
worker
  ├── index.js
server
  ├── index.js

Static files, e.g. images, stylesheets, etc, have their place in a files directory named public.

public
  ├── logo.png

Step Functions and Workflows

Whether routing in the /client, /worker, or /server directory above, nested URLs follow the concept of Step Functions! These are parent-child layout of handlers that model an URL strucuture.

server
  ├── index.js --------------------------------- http://localhost:3000
  └── products/index.js ------------------------ http://localhost:3000/products
        └── stickers/index.js ------------------ http://localhost:3000/products/stickers

Each step calls a next() function to forward the current request to the next step (if any), is able to pass a context object along, and can recompose the return value.

/**
server
 ├── index.js
 */
export default async function(event, context, next) {
    if (next.stepname) {
        let childContext = { user: { id: 2 }, };
        let childResponse = await next( childContext );
        return { ...childResponse, title: childResponse.title + ' | FluffyPets' };
    }
    return { title: 'Home | FluffyPets' };
}
/**
server
 ├── products/index.js
 */
export default function(event, context, next) {
    if (next.stepname) {
        return next();
    }
    return { title: 'Products' };
}

This step-based workflow helps to decomplicate routing and gets us scaling horizontally as our application grows larger.

Workflows may be designed with wildcard steps using a hyphen - as step name. At runtime, a wildcard step matches any URL segment at its level in the layout! A this.stepname property could be used to see which URL segment has been matched.

/**
server
 ├── -/index.js
 */
export default function(event, context, next) {
    if (next.stepname) {
        return next();
    }
    if (this.stepname === 'products') {
        return { title: 'Products' };
    }
    return { title: 'Untitled' };
}

Additionally, workflows may be designed with as many or as few step functions as necessary; the flow control parameters next.stepname and next.pathname can be used at any point to handle the rest of an URL that have no corresponding step functions.

For example, it is possible to handle all URLs from the root handler alone.

/**
server
 ├── index.js
 */
export default function(event, context, next) {
    // For http://localhost:3000/products
    if (next.pathname === 'products') {
        return { title: 'Products' };
    }

    // For http://localhost:3000/products/stickers
    if (next.pathname === 'products/stickers') {
        return { title: 'Stickers' };
    }
    
    // Should we later support other URLs like static assets http://localhost:3000/logo.png
    if (next.pathname) {
        return next();
    }
    
    // For the root URL http://localhost:3000
    return { title: 'Home' };
}

Webflo takes a default action when next() is called at the edge of the workflow - the point where there are no more child steps - as in the return next() statement above!

For workflows in the /server directory, the default action of next()ing at the edge is to go match and return a static file in the public directory.

So, above, should our handler receive static file requests like http://localhost:3000/logo.png, the statement return next() would get Webflo to match and return the logo at public/logo.png, if any; a 404 response otherwise.

my-app
  ├── server/index.js ------------------------- http://localhost:3000, http://localhost:3000/prodcuts, http://localhost:3000/prodcuts/stickers, etc
  └── public/logo.png ------------------------- http://localhost:3000/logo.png

Note
The root handler effectively becomes the single point of entry to the application - being that it sees even requests for static files!

Now, for workflows in the /worker directory, the default action of next()ing at the edge is to send the request through the network to the server. (But Webflo will know to attempt resolving the request from the application's caching system built into the Service Worker.)

So, above, if we defined handler functions in the /worker directory, we could decide to either handle the received requests or just next() them to the server.

/**
worker
 ├── index.js
 */
export default async function(event, context, next) {
    // For http://localhost:3000/about
    if (next.pathname === 'about') {
        return {
            name: 'FluffyPets',
            version: '1.0',
        };
    }
    
    // For http://localhost:3000/logo.png
    if (next.pathname === 'logo.png') {
        let response = await next();
        console.log( 'Logo file size:', response.headers.get('Content-Length') );
        return response;
    }
    
    // For every other URL
    return next();
}

Now we get the following layout-to-URL mapping for our application:

my-app
  ├── worker/index.js ------------------------- http://localhost:3000/about, http://localhost:3000/logo.png
  ├── server/index.js ------------------------- http://localhost:3000, http://localhost:3000/prodcuts, http://localhost:3000/prodcuts/stickers, etc
  └── public/logo.png ------------------------- http://localhost:3000/logo.png

Note
Handlers in the /worker directory are only designed to see Same-Origin requests since Cross-Origin URLs like https://auth.example.com/oauth do not belong in the application's layout! These external URLs, however, benefit from the application's caching system built into the Service Worker.

For workflows in the /client directory, the default action of next()ing at the edge is to send the request through the network to the server. But where there is a Service Worker layer, then that becomes the next destination.

So, above, if we defined handler functions in the /client directory, we could decide to either handle the navigation requests in-browser or just next() them, this time, to the Service Worker layer.

/**
client
 ├── index.js
 */
export default async function(event, context, next) {
    // For http://localhost:3000/login
    if (next.pathname === 'login') {
        return {
            name: 'John Doe',
            role: 'owner',
        };
    }
    
    // For every other URL
    return next();
}

Our overall layout-to-URL mapping for this application now becomes:

my-app
  ├── client/index.js ------------------------- http://localhost:3000/login
  ├── worker/index.js ------------------------- http://localhost:3000/about, http://localhost:3000/logo.png
  ├── server/index.js ------------------------- http://localhost:3000, http://localhost:3000/prodcuts, http://localhost:3000/prodcuts/stickers, etc
  └── public/logo.png ------------------------- http://localhost:3000/logo.png

If there's anything we have now, it is the ability to break work down[i], optionally across step functions, optionally between layers!

Pages, Layout and Templating

HTML files in the public directory, just like every other public file, are served statically when accessed directly - e.g. http://localhost:3000/index.html. But index.html files, specifically, are treated as pages by Webflo. They are, therefore, also accessible with path URLs like http://localhost:3000.

my-app
  └── public/index.html ----------------------- http://localhost:3000/index.html, http://localhost:3000

But, where an index.html file pairs with a route...

my-app
  ├── server/index.js
  └── public/index.html

...the route handler determines what happens.

/**
server
 ├── index.js
 */
export default async function(event, context, next) {
    // For http://localhost:3000/index.html, etc
    if (next.pathname) {
        return next();
    }
    // For http://localhost:3000 specifically
    return { ... };
}

Now, we are able to access the data component of a route differently from its HTML component!

my-app
  └── server/index.js ------------------------- http://localhost:3000 -------------------- application/json
  └── public/index.html ----------------------- http://localhost:3000/index.html --------- text/html

But, we can also access the route in a way that gets the data rendered into the automatically-paired index.html file for a dynamic page response. We'd simply set the Accept header of the request to match text/html - e.g. text/html, text/*, */html, */*, and Webflo will automatically perform Server-Side Rendering to give a page response. (Automatic pairing works the same for nested routes! But top-level index.html files are implicitly inherited down the hierarchy.)

Note
The Accept header hint is already how browsers make requests on every page load. So, it just works!

Now, for Single Page Applications, subsequent navigations, after the initial page load, just ask for the data on destination URLs and perform Client-Side Rendering on the same running document. Navigation is sleek and instant!

Note
Unless disabled, SPA Routing is automatically built into your app's JS bundle from the npm run generate command. So, it just works!

With no extra work, your application can function as either a Multi Page App (MPA) or a Single Page App (SPA)!

Note
In a Single Page Application, all pages are based off a single index.html document. In a Multi Page Application, pages are individual index.html documents - ideally. But, Server-Side Rendering makes it possible to serve the same, but dynamically-rendered, index.html document across page loads - essentially an SPA architecture hiding on the server. But, here, lets take Multi Page Applications for an individual-page architecture.

Layout and Templating Overview

In a Multi Page Application (with an individual-page architecture), each page is its own index.html document, and it is often necessary to have certain page sections - e.g. site header, footer, and sidebar, etc. - stay consistent across pages. These sections can be defined once and imported on every page.

my-app
  └── public
      ├── about/index.html ------------------------- <!DOCTYPE html>
      ├── prodcuts/index.html ---------------------- <!DOCTYPE html>
      ├── index.html ------------------------------- <!DOCTYPE html>
      ├── header.html ------------------------------ <header></header> <!-- To appear at top of each index.html page -->
      └── footer.html ------------------------------ <footer></footer> <!-- To appear at bottom of each index.html page -->

In a Single Page Application, each page is the same index.html document, and it is often necessary to have the main page sections change on each route. These sections can be defined per-route and imported to the document on navigating to their respective URLs.

my-app
  └── public
      ├── about/main.html -------------------------- <main></main> <!-- To appear at main area of index.html -->
      ├── prodcuts/main.html ----------------------- <main></main> <!-- To appear at main area of index.html -->
      ├── main.html -------------------------------- <main></main> <!-- To appear at main area of index.html -->
      └── index.html ------------------------------- <!DOCTYPE html>

This, in both cases, is templating - the ability to define HTML partials once, and have them reused multiple times. Webflo just concerns itself with templating, and the choice of a Multi Page Application or Single Page Application becomes yours! And heck, you can even have the best of both worlds in the same application - with an architecture we'll call Multi SPA! It's all a layout thing!

Now, with pages in Webflo being DOM-based (both client-side and server-side), documents can be manipulated directly with DOM APIs, e.g. to replace or insert nodes, attributes, etc. But even better, templating in Webflo is based on the HTML Modules and HTML Imports features in OOHTML - unless disabled in config. These features provide a powerful declarative templating system on top of the standard HTML <template> element - with a module, export and import paradigm.

Here, you are able to define reusable contents in a <template> element...

<head>
    <template name="routes">
        <header exportgroup="header.html">Header Area</header>
        <main exportgroup="main.html">Main Area</main>
    </template>
</head>

...and have them imported anywhere on the root document using an <import> element:

<body>
    <import template="routes" name="header.html"></import>
    <import template="routes" name="main.html"></import>
</body>

The module element - <template name> - is able to load its contents from a remote .html file that serves as a bundle:

<!--
public
 ├── bundle.html
-->
<header exportgroup="header.html">Header Area</header>
<main exportgroup="main.html">Main Area</main>
<head>
    <template name="routes" src="/bundle.html"></template>
</head>

What we'll see shortly is how multiple standalone .html files - e.g. those header.html, footer.html, main.html files above - come together into one bundle.html file for an application.

In a Multi Page Layout

In a Multi Page layout (as above), generic contents - e.g. header and footer sections, etc. - are typically bundled into one bundle.html file that can be embedded on each page of the application.

<!--
public
 ├── index.html
-->
<!DOCTYPE html>
<html>
    <head>
        <script type="module" src="/bundle.js"></script>
        <template name="routes" src="/bundle.html"></template>
    </head>
    <body>
        <import template="routes" name="header.html"></import>
        <main>Welcome to our Home Page</main>
        <import template="routes" name="footer.html"></import>
    </body>
</html>
<!--
public/about
 ├── index.html
-->
<!DOCTYPE html>
<html>
    <head>
        <script type="module" src="/bundle.js"></script>
        <template name="routes" src="/bundle.html"></template>
    </head>
    <body>
        <import template="routes" name="header.html"></import>
        <main>Welcome to our About Page</main>
        <import template="routes" name="footer.html"></import>
    </body>
</html>
<!--
public/products
 ├── index.html
-->
<!DOCTYPE html>
<html>
    <head>
        <script type="module" src="/bundle.js"></script>
        <template name="routes" src="/bundle.html"></template>
    </head>
    <body>
        <import template="routes" name="header.html"></import>
        <main>Welcome to our Products Page</main>
        <import template="routes" name="footer.html"></import>
    </body>
</html>

Note
In this architecture, navigation is traditional - a new page loads each time. The bundle.js script comes with the appropriate OOHTML support level required for the imports to function.

In a Single Page Layout

In a Single Page layout (as seen earlier), page-specific contents - e.g. main sections - are typically bundled together into one bundle.html file that can be embedded on the document root. Nested routes end up as nested <template> elements that form the equivalent of thw application's URL structure.

<!--
public
 ├── bundle.html
-->
<template name="about">
    <main exportgroup="main.html">Welcome to our About Page</main>
</template>
<template name="products">
    <main exportgroup="main.html">Welcome to our Products Page</main>
</template>
<main exportgroup="main.html">Welcome to our Home Page</main>

Now, the <main> elements are each imported on navigating to their respective URLs. This time, Webflo takes care of setting the URL path as a global template attribute on the <body> element such that <import> elements that inherit this global attribute are resolved from its current value.

<!--
public
 ├── index.html
-->
<!DOCTYPE html>
<html>
    <head>
        <script type="module" src="/bundle.js"></script>
        <template name="routes" src="/bundle.html"></template>
    </head>
    <body template="routes/"> <!-- This "template" attribute automatically changes to routes/about or routes/products as we navigate to http://localhost:3000/about and http://localhost:3000/products respectively -->
        <header></header>
        <import name="main.html"></import> <!-- This import element omits its "template" attribute so as to inherit the global one -->
        <footer></footer>
    </body>
</html>

Note
In this architecture, navigation is instant and sleek - Webflo prevents a full page reload, obtains and sets data at document.state.data for the new URL, then sets the template attribute on the <body> element to the new URL path. The bundle.js script comes with the appropriate OOHTML support level required for the imports to function.

In a Multi SPA Layout

It's all a layout thing, so a hybrid of the two architectures above is possible in one application, to take advantage of the unique benefits of each! Here, you are able to have routes that are standalone index.html documents (MPA), which in turn, are able to act as a single document root for their subroutes (SPA).

my-app
  └── public
      ├── about/index.html ------------------------- <!DOCTYPE html> <!-- Document root 1 -->
      ├── prodcuts
      │     ├── free/main.html --------------------------- <main></main> <!-- To appear at main area of document root 2 -->
      │     ├── paid/main.html --------------------------- <main></main> <!-- To appear at main area of document root 2 -->
      │     ├── main.html -------------------------------- <main></main> <!-- To appear at main area of document root 2 -->
      │     └── index.html ------------------------------- <!DOCTYPE html> <!-- Document root 2, (doubles as an SPA) -->
      ├── index.html ------------------------------- <!DOCTYPE html> <!-- Document root 0 -->
      ├── header.html ------------------------------ <header></header> <!-- To appear at top of each document root -->
      └── footer.html ------------------------------ <footer></footer> <!-- To appear at bottom of each document root -->

The above gives us three document roots: /index.html, /about/index.html, /prodcuts/index.html. The /prodcuts route doubles as a Single Page Application such that visiting the /prodcuts route loads the document root /prodcuts/index.html and lets Webflo SPA routing determine which of /prodcuts/main.html, /prodcuts/free/main.html, /prodcuts/paid/main.html is imported on a given URL.

Webflo ensures that only the amount of JavaScript for a document root is actually loaded! So, above, a common JavaScript build is shared across the three document roots alongside an often tiny root-specific build.

<!--
public
 ├── products/index.html
-->
<!DOCTYPE html>
<html>
    <head>
        <script type="module" src="webflo.bundle.js"></script>
        <script type="module" src="/products/bundle.js"></script>
        <template name="pages" src="/bundle.html"></template>
    </head>
    <body>...</body>
</html>
<!--
public
 ├── about/index.html
-->
<!DOCTYPE html>
<html>
    <head>
        <script type="module" src="webflo.bundle.js"></script>
        <script type="module" src="/about/bundle.js"></script>
        <template name="pages" src="/bundle.html"></template>
    </head>
    <body>...</body>
</html>
<!--
public
 ├── index.html
-->
<!DOCTYPE html>
<html>
    <head>
        <script type="module" src="webflo.bundle.js"></script>
        <script type="module" src="/bundle.js"></script>
        <template name="pages" src="/bundle.html"></template>
    </head>
    <body>...</body>
</html>

The Webflo generate command automatically figures out a given architecture and generates the appropriate scripts for the application! It also factors into the generated scripts the location of each document root so that all navigations to these roots are handled as a regular page load.

Bundling

Template .html files are bundled from the filesystem into a single file using the OOHTML CLI utility. On installing this utility, you may want to add the following to your npm scripts in package.json.

"scripts": {
    "generate:templates": "oohtml bundle --recursive --auto-embed=routes"
}

The --recursive flag gets the bundler to recursively bundle subroots in a Multi SPA layout - where subdirectories with their own index.html document. (Subroots are ignored by default.)

The --auto-embed flag gets the bundler to automatically embed the generated bundle.html file on the matched index.html document. A value of routes for the flag ends up as the name of the embed template: <template name="routes" src="/bundle.html"></template>.

Note
If your HTML files are actually based off the public directory, you'll need to tell the above command to run in the public directory, either by configuring the bundler, or by rewriting the command with a prefix: cd public && oohtml bundle --recursive --auto-embed=routes.

Client and Server-Side Rendering

With pages in Webflo being DOM-based (both client-side and server-side), we are able to access and manipulate documents and elements using familiar DOM APIs - e.g. to replace or insert contents, attributes, etc. Rendering in Webflo is based on this concept!

Here, Webflo simply makes sure that the data obtained from each route is available as part of the document object, such that it is accessible to our rendering logic as a data property on the document.state object - document.state.data.

So, we could embed a script on our page and render this data on the relevant parts of the document.

<!--
public
 ├── index.html
-->
<!DOCTYPE html>
<html>
    <head>
        <title></title>
        <script>
         setTimeout(() => {
             console.log( document.state.data ); // { title: 'Home | FluffyPets' }
             let { title } = document.state.data;
             document.title = title;
         }, 0);
        </script>
    </head>
    <body></body>
</html>

Where your rendering logic is an external script, your <script> element would need to have an ssr Boolean attribute to get the rendering engine to fetch and run your script on the server.

<!--
public
 ├── index.html
-->
<!DOCTYPE html>
<html>
    <head>
        <title></title>
        <script src="app.js" ssr></script>
    </head>
    <body></body>
</html>

From here, even the most-rudimentary form of rendering (using vanilla HTML and native DOM methods) becomes possible, and this is a good thing: you get away with less tooling until you absolutely need to add up on tooling!

However, since the document objects in Webflo natively support OOHTML - unless disabled in config, we are able to write reactive UI logic! Here, OOHTML makes it possible to embed reactive <script> elements (called Subscript) right within HTML elements - where each expression automatically self-updates whenever references to data, or its properties, get an update!

 <!--
 public
  ├── index.html
 -->
 <!DOCTYPE html>
 <html>
     <head>
         <title></title>
     </head>
     <body>
         <h1></h1>
         <script type="subscript">
          let { title } = document.state.data;
          document.title = title;
          let h1Element = this.querySelector('h1');
          h1Element.innerHTML = title;
         </script>
     </body>
 </html>

Note
Now, this comes logical since logic is the whole essence of the HTML <script> element, after all! Compared to other syntax alternatives, this uniquely enables us to do all things logic in the actual language for logic - JavaScript. Then, OOHTML gives us more by extending the regular <script> element with the subscript type which gets any JavaScript code to be reactive!

Note that because these scripts are naturally reactive, we do not require any setTimeout() construct like we required earlier in the case of the classic <script> element. These expressions self-update as the values they depend on become available, removed, or updated - i.e. as document.state gets updated.

Going forward, we can get to write more succinct code! Using the Namespaced HTML feature in OOHTML, we could do without those querySelector() calls up there. Also, we could go on to use any DOM manipulation library of our choice; e.g jQuery, or even better, the jQuery-like Play UI library.

 <!--
 public
  ├── index.html
 -->
 <!DOCTYPE html>
 <html>
     <head>
         <title></title>
         <script src="/jquery.js"></script>
     </head>
     <body namespace>
         <h1 id="headline1"></h1>
         <script type="subscript">
          let { title } = document.state.data;
          document.title = title;
          let { headline1, headline2 } = this.namespace;
          $(headline1).html(title);
          if (headline2) {
              $(headline2).html(title);
          }
         </script>
     </body>
 </html>

Above, we've also referenced some currently non-existent element headline2 - ahead of when it becomes added in the DOM! This should give a glimpse of the powerful reactivity we get with having OOHTML around on our document!

setTimeout(() => {
    let headline2 = document.createElement('h2');
    headline2.id = 'headline2';
    document.body.append(headline2);
}, 1000);

Taking things further, it is possible to write class-based components that abstract away all logic! You can find a friend in Custom Elements! Plus, your Custom Elements can function reactively using SubscriptElement as base class!

Custom Render Functions

Custom render functions can be defined on a route (export function render() {}) to entirely handle, or extend, rendering.

/**
server
 ├── index.js
 */
export default async function(event, context, next) {
    return { title: 'Home | FluffyPets' };
}
export async function render(event, data, next) {
    return `
    <!DOCTYPE html>
    <html>
        <head><title>FluffyPets</title></head>
        <body>
            <h1>${ data.title }</h1>
        </body>
    </html>
    `;
}
And, custom render functions can be step functions too, nested as necessary to form a render workflow.
/**
server
 ├── index.js
 */
export async function render(event, data, next) {
    // For render callbacks at child step
    if (next.stepname) {
        return next();
    }
    return `
    <!DOCTYPE html>
    <html>
        <head><title>FluffyPets</title></head>
        <body>
            <h1>${ data.title }</h1>
        </body>
    </html>
    `;
}

Note
Typically, though, child steps do not always need to have an equivalentrender callback being that they automatically inherit rendering from their parent or ancestor.

But, custom render functions do not always need to do as much as entirely handle rendering. It is possible to get them to trigger Webflo's native rendering and simply modify the documents being rendered. Here, you would simply call the next() function to advance the render workflow into Webflo's default rendering. A window instance is returned containing the document being rendered.

/**
server
 ├── index.js
 */
export default async function(event, context, next) {
    return { title: 'Home | FluffyPets' };
}
export async function render(event, data, next) {
    let window = await next( data );
    let { document } = window;
    console.log( document.state.data ); // { title: 'Home | FluffyPets' }
    return window;
}

Custom render functions must return a value, and window objects are accepted. (Actually, any object that has a toString() method can be returned.)

The Idea of State

There often needs to be a central point in an application where things are stored and managed. You could think of it is having a global object initialized window.store = {} on which different parts of an application can store and retrieve values. This is the basic idea of state. But it also doesn't go without the idea of observability - something that lets the different parts of the application observe and respond to changes made on this object!

State and Observability in Webflo applications come down to this basic form: there is an object...

state = {}

...and there is a way to observe property changes on it...

Observer.observe(state, changes => {
    changes.forEach(change => {
        console.log(change.name, change.value);
    });
});
Observer.observe(state, propertyName, change => {
    console.log(change.name, change.value);
});

...plus, all references to the object and its properties from within embedded Subscript code are reactive.

<script type="subscript">
    // Always log the value of this property in realtime
    console.log(state.propertyName);
</script>

This way, all the moving parts of your application remain coordinated, and can easily be rendered to reflect them on the UI!

For all things application state, Webflo leverages the State API that's natively available in OOHTML-based documents - both client-side and server-side. This API exposes an application-wide document.state object and a per-element element.state object. And these are live read/write objects that can be observed for property changes using the Observer API. It comes off as the simplest approach to state and reactivity!

Note
The State API is not available when the OOHTML support level in config is switched away from full and scripting.

The document.state.data Object

This property reperesents the application data at any point in time - obtained from route handers on each navigation. Webflo simply updates this property and lets the page's rendering logic, or other parts of the application, take over.

Observer.observe(document.state, 'data', e => {
    console.log('Current page data is: ', e.value);
});
<script type="subscript">
 let { title } = document.state.data;
 document.title = title;
</script>

The document.state.url Object

This is a live object that reperesents the properties of the application URL at any point in time. The object exposes the same URL properties as with the URL API, but as live properties that can be observed as navigation happens, and modified to initiate navigation - all using the Observer API.

console.log(document.state.url) // { hash, host, hostname, href, origin, password, pathname, port, protocol, search, searchParams, username }
Observer.observe(document.state.url, 'hash', e => {
    console.log(document.state.url.hash === e.value); // true
});
// Navigates to "/login#form" as if a link was clicked
document.addEventListener('synthetic-navigation', e => {
    Observer.set(document.state.url, 'href', '/login#form');
});

// Or...
document.addEventListener('synthetic-navigation', e => {
    Observer.set(document.state.url, { pathname: '/login', hash: '#form' });
});

console.log(document.state.url.hash); // #form

There is also the convenience query property that offers the URL parameters as a live object.

// For URL: http://localhost:3000/login?as=student
console.log(document.state.url.query.as) // student

// Re-rewrite the URL and initiate navigation by simply modifying a query parameter
document.addEventListener('synthetic-navigation', e => {
    Observer.set(document.state.url.query, 'as', 'business');
});
<script type="subscript">
 let { query: { as: role } } = document.state.url;
 document.title = 'Login as ' + role;
</script>

Requests and Responses

On each request, the event object passed to route handlers exposes the incoming request as event.request. This is an instance of event.Request - an extension of the WHATWG Request class. The event object also exposes event.Response - an extension of the WHATWG Response class, for returning instance-based responses. You enjoy routing that is based on standard interfaces!

Routes in Webflo can be designed for different types of request/response scenarios. Here are some important ones:

Scenario 1: Static File Requests and Responses

Static file requests like http://localhost:3000/logo.png are expected to get a file response. These requests are automatically handled by Webflo when next()ed forward by route handlers, or where there are no route handlers.

  • On the server, Webflo serves files from the public directory. File contents along with the appropriate headers like Content-Type, Content-Length, etc. are returned as an instance of event.Response. Where a request has an Accept-Encoding header set (e.g. gzip, br) and there exists a matching compressed version of the said file on the file system (e.g. ./public/logo.png.gz, ./public/logo.png.br), the compressed version is served and the appropriate Content-Encoding response header is set.
  • On the client, Webflo serves static files from the network, or from the application cache, where available.

Scenario 2: API Requests and Responses

JSON (API) requests (requests made with an Accept header that matches application/json) are expected to get a JSON (API) response (responses with a Content-Type header of application/json). Webflo automatically responds by simply jsonfying workflow return values which are usually plain objects, or other jsonfyable types - string, number, boolean, array.

  • Routes intended to be accessed this way are expected to return a jsonfyable value (or an instance of event.Response containing same) from the workflow.
  • Workflow responses that are an instance of event.Response with a Content-Type header already set are sent as-is.

Scenario 3: Page Requests and Responses

HTML page requests (requests made to the server with an Accept header that matches text/html) are expected to get a HTML response (responses with a Content-Type header of text/html). Webflo automatically responds by rendering the workflow return value into an HTML response - via Server-Side Rendering.

  • Routes intended to be accessed this way are expected to return a plain object (or an instance of event.Response containing same) from the workflow in order to be renderable.
  • Workflow responses that are an instance of event.Response with a Content-Type header already set are sent as-is, and not rendered.

Scenario 4: Single Page Navigation Requests and Responses

In a Single Page Application layout, every navigation event (page-to-page navigation, history back and forward navigation, and form submissions) is expected to initiate a request/response flow without a full page reload, since the destination URLs are often based off the already loaded document. The Webflo client JS intercepts these navigation events and generates the equivalent request object with an Accept header of application/json, so that data can be obtained as a JSON object (scenerio 2 above) for Client-Side Rendering.

The generated request also hints the server on how to return cross-SPA redirects (redirects that will point to another origin, or to another SPA root (in a Multi SPA layout)) so that it can be handled manually by the client. The following headers are set: X-Redirect-Policy: manual-when-cross-spa, X-Redirect-Code: 200.

  • Same-SPA redirects are sent as-is, and the Webflo client JS receives and renders the final data and updates the address bar with the final URL.
  • Cross-SPA/cross-origin redirects are communicated back, as hinted, and the destination URL is opened as a fresh page load.

Scenario 5: Range Requests and Responses

In all cases, where a request specifies a Range header, Webflo automatically slices the response body to satisfy the range, and the appropriate Content-Range response header is set.

  • Workflow responses that are an instance of event.Response with a Content-Range header already set are sent as-is.

Other Requests and Responses

Workflows may return any other data type, e.g. an instance of the native FormData, Blob, File, or ReadableStream, etc., or an instance of event.Response containing same - usually on routes that do not double as a page route. Webflo tries to send these along with the appropriate response headers.

Note
The fact that all requests, even static file requests, are seen by route handlers, where defined, means that they get a chance to dynamically generate the responses that the client sees!

Custom Redirect Responses

It is possible to hint the server on how to serve redirect responses. The response code for these redirects could be substituted with a non-rediret status code so that it can be recieved as a normal response and handled manually. The following pair of headers make this possible: X-Redirect-Code, X-Redirect-Policy.

  • The X-Redirect-Code can be any valid (but preferably, 2xx) HTTP status code. This is the response code that you want Webflo to substitute the actual redirect code with.
  • The X-Redirect-Policy header can be any of manual - treat all redirects as manual, manual-if-cross-origin - treat cross-origin redirects as manual, manual-if-cross-spa - treat cross-SPA redirects (including cross-origin redirects) as manual.

Re-coded redirects have the standard Location header, and its own X-Redirect-Code response header containing the original redirect status code.

Failure Responses

Where workflows return undefined, a Not Found status is implied.

  • On the server side, a 404 HTTP response is returned.
  • On the client-side, the initiating document in the browser has its document.state.data emptied. The error is also exposed on the document.state.network.error property.

Where workflows throw an exception, an error status is implied.

  • On the server side, the error is logged and a 500 HTTP response is returned.
  • On the client-side, the initiating document in the browser has its document.state.data emptied. The error is also exposed on the document.state.network.error property.

Cookie Responses

Handlers can set response cookies via the standard Response constructor, or using the standard Headers.set() method.

let response = event.Response(data, { headers: { 'Set-Cookie': cookieString }});

response.headers.set('Set-Cookie', cookieString);

Webflo also offers a convenience method.

let response = event.Response(data, { headers: { cookies: cookieString }});

response.headers.cookies = { 'Cookie-1': cookieString, 'Cookie-2': cookie2String };
let cookieObject = { value: 'cookie-val', expires, maxAge, domain, path, secure, HttpOnly, sameSite };
let cookie2Object = { value: 'cookie2-val' };
response.headers.cookies = { 'Cookie-1': cookieObject };
response.headers.cookies = { 'Cookie-2': cookie2Object };

console.log(response.headers.cookies); // { 'Cookie-1': cookieObject, 'Cookie-2': cookie2Object };

Set cookies are accessed on the next request via request headers.

console.log(event.request.headers.get('Cookie')); // Cookie-1=cookie-val&Cookie-2=cookie2-val;

Webflo also offers a convenience method.

console.log(event.request.headers.cookies); // { 'Cookie-1': 'cookie-val', 'Cookie-2': 'cookie2-val' };

Webflo Applications

In just a few concepts, Webflo comes ready for any type of application!

Client-Side Applications

Web pages that embed the Webflo client JS bundle deliver a great user experience. It's simple: the npm run generate command does both the building and embedding of the script, or scripts, for the document root, or document roots (in a Multi Page / Multi SPA layout)!

On being loaded, the state of the application is initialized, or is restored through hydration - where Server-Side Rendering was involved to optimize for first paint, and an app-like experience kicks in! For Single-Page Applications, Client-Side Rendering is performed on each navigation.

SPA Navigation

Unless disabled in config, it is factored-in at build time for the application client JS to be able to automatially figure out when to intercept a navigation event and prevent a full page reload, and when not to. It follows the following rules:

  • When it ascertains that the destination URL is based on the current running index.html document in the browser (an SPA architecture), a full page reload is prevented for soft navigation. But where the destination URL points out of the current document root (a Multi SPA architecture), navigation is allowed as a normal page load, and a new page root is loaded.
  • If navigation is initiated with any of the following keys pressed: Meta Key, Alt Key, Shift Key, Ctrl Key, navigation is allowed to work the default way - regardless of the first rule above.
  • If navigation is initiated from a link element that has the target attribute, or the download attribute, navigation is allowed to work the default way - regardless of the first rule above.
  • If navigation is initiated from a form element that has the target attribute, navigation is allowed to work the default way - regardless of the first rule above.
Config (Default)
{ "spa_navigation": true }

File: .webqit/webflo/client.json | Command: webflo config client spa_navigation=TRUE

SPA State

On the client side of a Webflo application, the idea of state also includes the following aspects of the client-side lifecycle that can be used to provide visual cues on the UI.

The document.state.network Object

This is a live object that exposes the network activity and network state of the application.

console.log(document.state.network) // { requesting, remote, error, redirecting, connectivity, }
  • network.requesting: null|Object - This property tells when a request is ongoing, in which case it exposes the params object used to initiate the request.

    On the UI, this could be used to hide a menu drawer that may have been open.

    <menu-drawer>
        <script type="subscript">
        let { network: { requesting } } = document.state;
        if (requesting) {
            $(this).attr('open', false);
        }
        </script>
    </menu-drawer>
  • network.remote: null|String - This property tells when a remote request is ongoing - usually the same navigation requests as at network.requesting, but when not handled by any client-side route handlers, or when next()ed to this point by route handlers. The remote property also goes live when a route handler calls the special fetch() function that they recieve on their fourth parameter.

    On the UI, this could be used to show/hide a spinner, or progress bar, to provide a visual cue.

    <progress-bar>
        <script type="subscript">
        let { network: { remote } } = document.state;
        $(this).attr('hidden', !remote);
        </script>
    </progress-bar>
  • network.error: null|Error - This property tells when a request is errored in which case it contains an Error instance of the error. For requests that can be retried, the Error instance also has a custom retry() method.

    On the UI, this could be used to show/hide cute error elements.

    <nice-error>
        <script type="subscript">
        let { network: { error } } = document.state;
        $(this).attr('hidden', !error);
        </script>
    </nice-error>
  • network.redirecting: null|String - This property tells when a client-side redirect is ongoing - see Scenario 4: Single Page Navigation Requests and Responses - in which case it exposes the destination URL.

    On the UI, this could be used to prevent further interactions with the outgoing page.

    <body>
        <script type="subscript">
        let { network: { redirecting } } = document.state;
        $(this).css(redirecting ? { pointerEvents: 'none', filter: 'blur(2)' } : { pointerEvents: 'auto', filter: 'blur(0)' });
        </script>
    </body>
  • network.connectivity: String - This property tells of the browser's ability to connect to the network: online, offline.

    On the UI, this could be used to show/hide a connectivity status.

    <body>
        <script type="subscript">
        let { network: { connectivity } } = document.state;
        $(this).attr( 'connectivity', connectivity });
        </script>
    </body>

Here are some additional examples with the Observer API.

// Visualize the network state
let onlineVisualizer = changes => {
    changes.forEach(e => {
        console.log(e.name, ':', e.value);
    });
};
Observer.observe(document.state.network, onlineVisualizer);
// Or: Observer.observe(document, [ ['state', 'network'] ], onlineVisualizer, { subtree: true });
// Visualize the 'connectivity' property
let connectivityVisualizer = e => {
    console.log('You are ', e.value);
};
Observer.observe(document.state.network, 'connectivity', connectivityVisualizer);
// Or: Observer.observe(document.state, [ ['network', 'connectivity'] ], connectivityeVisualizer);
// Catch request errors; attempt a retry
Observer.observe(document.state.network, 'error', e => {
    if (!e.value) return;
    console.error(e.value.message);
    if (e.value.retry) {
        console.error('Retrying...');
        e.value.retry();
    }
});
Form Actions

When navigation occurs via form submissions, the form element and the submit button are made to go on the active state while the request is being processed. For both of these elements, the Webflo client simply sets the element.state.active to true on submission, then false, on completion.

<form method="post">
    <input name="username" placeholder="Your username..." />
    <script>
    $(this).css(this.state.active ? { pointerEvents: 'none', opacity: 'o.5' } : { pointerEvents: 'auto', opacity: '1' });
    </script>
</form>

One more thing: HTML forms can only accept two HTTP methods on their method attribute: GET, POST! The same constraint exists on the equivalent formmethod attribue in submit buttons. You are able to overcome this in Webflo by using alternative data- attributes: data-method, data-formmethod, respectively.

<form data-method="patch">
    <input name="price" placeholder="Enter new price..." />
</form>

Progressive Web Apps

Webflo client-side applications are intended to provide an app-like-first experience. So unless disabled in config, a Service Worker is built as part of your application on running the npm run generate command. You may define route handlers in the /worker directory of your application, and these will be built into the service worker to handle Same-Origin requests of the application. Where there are no worker handlers, or where these forward incoming requests, requests are fetched, either from the cache, or from the network, depending on the fetching strategy built into the Service Worker.

Config (Default)
{ "service_worker_support": true }

File: .webqit/webflo/client.json | Command: webflo config client service_worker_support=TRUE

Fetching Strategy
  • Network First - This strategy tells the Service Worker to always attempt fetching from the network first for given resources, before fetching from the cache. On every successful network fetch, a copy of the response is saved to the cache for next time. (This is good for resources that need to be fresh to the user on a "best effort" basis.) Unless changed, this is Webflo's default fetching strategy. When not the default strategy, a list of specific URLs that should be fetched this way can be configured.

    Config (Default)
    { "default_fetching_strategy": "network-first" }

    To list specific URLs...

    { "network_first_urls": [ "/logo.png" ] }

    File: .webqit/webflo/worker.json | Command: webflo config worker default_fetching_strategy=network-first

  • Cache First - This strategy tells the Service Worker to always attempt fetching from the cache first for given resources, before fetching from the network. After serving a cached response, or where not found in cache, a network fetch happens and a copy of the response is saved to the cache for next time. (This is good for resources that do not critially need to be fresh to the user.) When not the default strategy, a list of specific URLs that should be fetched this way can be configured.

    Config (Other)
    { "default_fetching_strategy": "cache-first" }

    To list specific URLs...

    { "cache_first_urls": [ "/logo.png" ] }

    File: .webqit/webflo/worker.json | Command: webflo config worker default_fetching_strategy=cache-first

  • Network Only - This strategy tells the Service Worker to always fetch given resources from the network only. They are simply not available when offline. (This is good for resources that critially need to be fresh to the user.) When not the default strategy, a list of specific URLs that should be fetched this way can be configured.

    Config (Other)
    { "default_fetching_strategy": "network-only" }

    To list specific URLs...

    { "network_only_urls": [ "/logo.png" ] }

    File: .webqit/webflo/worker.json | Command: webflo config worker default_fetching_strategy=network-only

  • Cache Only - This strategy tells the Service Worker to always fetch given resources from the cache only. (This is good for resources that do not change often.) When not the default strategy, a list of specific URLs that should be fetched this way can be configured. The listed resources are pre-cached ahead of when they'll be needed - and are served from the cache each time. (Pre-caching happens on the one-time install event of the Service Worker.)

    Config (Other)
    { "default_fetching_strategy": "cache-only" }

    To list specific URLs...

    { "cache_only_urls": [ "/logo.png" ] }

    File: .webqit/webflo/worker.json | Command: webflo config worker default_fetching_strategy=cache-only

In all cases above, the convention for specifying URLs for a strategy accepts URL patterns - against which URLs can be matched on the fly. For example, to place all files in an /image directory (and subdirectories) on the Cache First strategy, the pattern /image/* can be used. To place all .svg files in an /icons directory (including subdirectories) on the Cache Only strategy, the pattern /icons/*.svg can be used. (Specifically for the Cache Only strategy, patterns are resolved at Service Worker build-time, and each pattern must match, at least, a file.)

{ "cache_only_urls": [ "/icons/*.svg" ] }
Cross-Thread Communications

A couple APIs exists in browsers for establishing a two-way communication channel between a page and its Service Worker, for firing UI Notifications from either ends, and for implementing Push Notifications. Webflo offers to simply this with a unifying set of conventions:

  • The workport API - an object with simple methods for working with cross-thread messages, UI and Push Notifications.

    On both the client and worker side of your application, the workport object is accessible from route handlers as this.runtime.workport.

    /**
    [client|worker]
     ├── index.js
     */
    export default async function(event, context, next) {
        let { workport } = this.runtime;
        workport.messaging.post({ ... });
        return { ... };
    }

    For cross-thread messaging, both sides of the API exposes the following methods:

    • .messaging.post() - for sending arbitrary data to the other side. E.g. workport.messaging.post({ type: 'TEST' }).

    • .messaging.listen() - for listening to message event from the other side. E.g. workport.messaging.listen(event => console.log(event.data.type)). (See window: onmessage, worker: onmessage.)

    • .messaging.request() - for sending replyable messages to the other side, using the MessageChannel API.

      // On the worker side
      workport.messaging.listen(event => {
          console.log(event.data);
          if (event.ports[0]) {
              event.ports[0].postMessage({ type: 'WORKS' });
          }
      });
      // On the client side
      let response = await workport.messaging.request({ type: 'TEST' });
      console.log(response); // { type: 'WORKS' }
    • .messaging.channel() - for sending broadcast messages to the other side - including all other browsing contents that live on the same origin, using the Broadcast Channel API.

      // On the worker side
      let channelId = 'channel-1';
      workport.messaging.channel(channelId).listen(event => {
          console.log(event.data);
      });
      // On the client side
      let channelId = 'channel-1';
      workport.messaging.channel(channelId).broadcast({ type: 'TEST' });

    For UI Nofitications, both sides of the API exposes the following methods:

    • .nofitications.fire() - for firing up a UI notification. This uses the Nofitications constructor, and thus, accepts the same arguments as the constructor. But it returns a Promise that resolves when the notification is clicked or closed, but rejects when the notification encounters an error, or when the application isn't granted the notification permission.

      let title = 'Test Nofitication';
      let options = { body: '...', icon: '...', actions: [ ... ] };
      workport.nofitications.fire(title, options).then(event => {
          console.log(event.action);
      });
    • .nofitications.listen() - (in Service-Workers) for listening to notificationclick events. (Handlers are called each time a notification is clicked.)

      workport.nofitications.listen(event => {
          console.log(event.action);
      });

    For Push Nofitications, the client-side of the API exposes the following methods:

    • .push.subscribe() - the equivalent of the PushManager.subscribe() method. (But this can also take the applicationServerKey as a first argument, and other options as a second argument, in which case it automatically runs the key through an urlBase64ToUint8Array() function.)
    • .push.unsubscribe() - the equivalent of the PushSubscription.unsubscribe() method.
    • .push.getSubscription() - the equivalent of the PushManager.getSubscription() method.

    The worker-side of the API exposes the following methods:

    • .push.listen() - for listening to the push from within Service Workers. E.g. workport.push.listen(event => console.log(event.data.type)).
  • Route events - simple route events that fire when messaging and notification events happen.

    On both the client and worker side of your application, you can define an event listener alongside your root route handler. The event listener is called to handle all messaging and notification events that happen.

    /**
    [client|worker]
     ├── index.js
     */
    export default async function(event, context, next) {
        return { ... };
    }
    export async function alert(event, context, next) {
        return { ... };
    }

    The event type is given in the event.type property. This could be:

    • message - both client and worker side. For replyable messages, the event handler's return value is automatically sent back as response.
    • notificationclick - worker side.
    • push - worker side.

    The next() function could be used to delegate the handling of an event to step handlers where defined. This time, the path name must be given as a second argument to the call.

    /**
    worker
     ├── index.js
     */
    export async function alert(event, context, next) {
        if (event.type === 'push') {
            await next(context, '/services/push');
            return;
        }
        console.log(event.type);
    }

API Backends

In Webflo, an API backend is what you, in essence, come off with with your server-side routes.

/**
server
 ├── index.js
 */
export default function(event, context, next) {
    if (next.pathname) return next();
    return { ... };
}

You are always able to lay out your route handlers in the structure for a formal REST API.

server
 ├── index.js
 ├── api/v1/index.js
 └── api/v1/products/index.js

And if you will partition your backend for both page routes and a formal REST API...

server
 ├── index.js                  ──┐
 ├── cart/index.js               ├─ Page Routes
 ├── products/index.js         ──┘
 ├── api/v1/index.js           ──┐
 ├── api/v1/orders/index.js      ├─ REST API
 └── api/v1/products/index.js  ──┘

...you could get your page routes to run off your REST API by re-routing your next() calls to consume the appropriate API route.

/**
server
 ├── cart/index.js
 */
export default async function(event, context, next) {
    if (next.pathname) {
        return next();
    }
    // Items to display in cart are in the "/api/v1/orders" route
    let cartItems = await next(context, `/api/v1/orders?user_id=1`);
    return { title: 'Your Cart', ...cartItems };
}

This way, there is one source of truth for your application - both when visiting from a page and from a REST API.

Static Sites

You can build an entire static site from off the /public directory alone! It's all about placing files and HTML pages there to be served statically!

Here, static pages means pages that are not server-rendered during the request/response cycle, but served directly from files. You are free to hand-author each of them - either as standalone index.html files, or as a combination of index.html roots plus templates that can all get resolved client-side. The Pages, Layout and Templating section covers layout patterns.

On the other hand, if you have a dynamic site, you can make a static site off it! The idea is to turn on your server and crawl your dynamic site via HTTP requests, outputting static HTML representations of each page. This is called Pre-Rendering or Static-Site Generation (SSG)!

A simple tool, like staticgen, or the basic wget command (similar to curl), can get this done in an instant. On figuring out the command that works best for you, you may want to add an alias of the command to your npm scripts in package.json.

"scripts": {
    "generate:site": "wget -P public -nv -nH -r -E localhost:3000"
}

Note
Above, we used the -P flag to specify the output directory as public, the -nv flag to opt into “non-verbose” mode which outputs less information, the -r flag to get it to crawl and download recursively, and the -E flag to get it to add the .html extension to generated files.

You have a static site!

Workflow API

TODO

Webflo Config

Webflo comes convention-first! But it is entirely configurable for when you need it! The easiest way to do this is to run the command webflo config and follow the walkthrough. To simply get an overview, use the command webflo config help, and all commands and their description are shown.

Technology Stack

Webflo applications are often built on/with the following technologies.

OOHTML

OOHTML is a proposed set of new features for HTML that makes it fun to hand-author your UI! Within OOHTML are HTML Modules and HTML Imports, Reactive Scripts and more!

Webflo natively supports OOHTML in full! But it is also possible to switch this to none, or to partial support - when specific features aren't needed anywhere in your application. Server-side and client-side support for OOHTML exist independently. This is good when, for example, your application places more importance on SSR, and less on CSR, in which case a reduced support for OOHTML can reduce the overall client JS bundle size.

Config (Default)
{ "oohtml_support": "full" }

Values: full, namespacing, scripting, templating, none - See details at OOHTML SSR

File: .webqit/webflo/client.json | Command: webflo config client oohtml_support=full

File: .webqit/webflo/server.json | Command: webflo config server oohtml_support=full

OOHTML SSR

OOHTML SSR is a server-side DOM implementation with native support for OOHTML. This is internally used by Webflo as the Server-Side Rendering engine, and it it what gives Webflo its native support for OOHTML.

OOHTML CLI

OOHTML CLI is a small Command Line utility that automates certain aspects of hand-authored OOHTML-based documents.

The Observer API

The Observer API is a simple set of functions for intercepting and observing JavaScript objects and arrays. (Reflection, Interception, and Events.)

This is part of OOHTML's reactivity system, and it is made available on OOHTML-based documents as window.WebQit.Observer.

Getting Started

Your baby steps with Webflo could be a "Hello World" index.html file, optionally, next with a route handler that returns an equivalent { title: 'Hello World'} object!

You could soon be taking all your ideas to Webflo! 😃

Warning
Webflo is still evolving and some things may change quickly! Let's not go to production just yet!

Getting Involved

All forms of contributions and PR are welcome! To report bugs or request features, please submit an issue. For general discussions, ideation or community help, please join our github Discussions.

License

MIT.