Weavy Labs: The ACME project

This tutorial guides you through the things you need to do when adding Weavy to a real application. The code for the tutorial is available on https://github.com/weavy-labs/acme-web.

The repo contains a fully functional web application with a simple Node.js based backend with all Weavy building blocks integrated. In addition to the building blocks, we will also show you how to handle authentication, sync user data and setting up a webhook to listen for notifications and other useful things. The main goal when we created this tutorial was to have a more real life example of how Weavy is actually integrated into a web application.

Prerequisites

  1. Start by cloning the repo at https://github.com/weavy-labs/acme-web.

  2. Run npm install in the root folder.

  3. Create a Weavy account if you don't have one already.

  4. Sign in to your account and create a new environment to play with. Take note of the url, this is your WEAVY_URL.

  5. Create a new API key for the environment. You'll need it later. This is your WEAVY_APIKEY.

  6. Create a .env file in the root folder and add the the WEAVY_URL to the environment you created in step 3 and the WEAVY_APIKEY generated in step 4:

    WEAVY_URL=""     # the url to the environment you created in step 3.
    WEAVY_APIKEY=""  # the api key generated in step 4
    
  7. Run npm start to start the site.

The ACME application

So, what is this ACME website? You should think of it as the application where you want to add the functionality that Weavy offers. This could be your product, intranet or whatever web application you are building. We created the ACME website to be able to add Weavy to a "real" application. Of course, it's still a very basic example, but the important stuff here is what actually happens when a user signs in to the website and how the Weavy components are initialized and displayed.

Parts

Here's a listing of the parts of the website where Weavy is integrated in some way. Either as one of the ready-to-use building blocks from the UIKit, or as a simple request to the Web API. We want to show both.

The source for all the HTML pages can be found in the /client/ folder.

Part ACME website Weavy
Login page Authenticates a user against the ACME local db (JSON). After login to the ACME website, the user data is synced to Weavy
Top navigation Display notifications and switch locale or light/dark mode Notifications from Weavy. The Messenger building block.
Users list Display all the users in the ACME json db. User data is synced to Weavy when initializing the application.
Menu/Pages Each of the pages under Weavy Apps contains a building block; Chat, Posts and Files.
Examples Example to make a request to the Web API
Websocket A websocket connection from the Node.js server to the frontend.
Used to send a notification to the frontend when an
incoming webhook notification is delivered from the Weavy environment

UI framework

To simplify building of the ACME website we are using the Lit Web component framework to build common components, such as navigation or our custom notifications. These web components combine any javascript logic with a render function that generates the HTML to show for the component. These components sometimes also encapsulate one or several other web components in their HTML render.

All the Lit components can be found in the /client/components folder.

Users in the ACME website and Weavy

One concept that is important to understand is how the users in the host application, in this case the ACME website, and Weavy correlate. The users that you have in your application are managed by your application. But Weavy also need an user account for each host application user in order to be able to identify which user is performing any interaction with a component or the api. This is accomplished by supplying a Bearer token for each request.

When you are using UIKit Web, all of this is taken care of in the Weavy() instance that you create. What you need to supply to the Weavy instance is an async function returning a valid Bearer token for the host application user. You'll learn more about this in the Authentication section below.

Node.js backend web server

To be able to handle the users, talk to the Weavy environment and serve the application, we need a backend in running in Node.js. For this example we have chosen the Modern Web Dev Server which is suitable for development purposes. This server can be used with a generic Koa middleware for any endpoints we like to use. The Koa middleware can be used with many other Node.js web servers as well, so you could easily replace the Modern Web Dev Server with any web server of your choice as long as it supports Koa.

We have structured the Node.js server into four parts.

  • /server/web-dev-server.mjs - The configuration and startup for our Modern Web Dev Server.
  • /server/koa-middleware.mjs - The generic Koa middleware for all our API endpoints.
  • /server/web-api.mjs - Functions for exchanging data with the Weavy environment.
  • /server/db.mjs - Functions for handling the basic JSON user database.

Authentication

Let's begin with some code to illustrate the authentication process. This code snippets are taken from the /client/acme.js file.

import { Weavy } from "@weavy/uikit-web";

const tokenFactory = async (refresh) => {
  let response = await fetch(`/api/token${refresh ? "?refresh=true" : ""}`);

  if (response.ok) {
    let data = await response.json();
    return data.access_token;
  } else {
    throw new Error("Could not get access token from server!");
  }
};

const weavy = new Weavy();

(weavy.url = WEAVY_URL), (weavy.tokenFactory = tokenFactory);

/client/acme.js

So what's going on here. First, we have defined a tokenFactory function that's making a request to the Node.js server side api to get a valid Bearer token. It takes the boolean refresh argument into consideration when a fresh token is needed instead of a cached token. Remember that the request for a new Weavy token should always be made server-to-server. Never from your frontend UI! The function is set as the tokenFactory after we created the new Weavy instance.

The api endpoint in our Koa server middleware looks like this:

router.get("/api/token", async (ctx) => {
  const uid = ctx.session.uid; // get user from session
  const refresh = ctx.query.refresh && ctx.query.refresh !== "false";
  ctx.body = await getUserToken({ uid, refresh });
});

/server/koa-middleware.mjs

The endpoint gets a token from the getUserToken function in web-api.mjs, which is fetching data from the Weavy environment:

const weavyUrl = new URL(process.env.WEAVY_URL);
const apiKey = process.env.WEAVY_APIKEY;

// Token cache
const tokens = new Map();

export const getUserToken = async ({ uid, refresh = false }) => {
  // Check if token already is in cache and no fresh token is needed
  if (!refresh && tokens.has(uid)) {
    return {
      access_token: tokens.get(uid),
    };
  } else {
    // Fetch a fresh token
    let response = await fetch(new URL(`/api/users/${uid}/tokens`, weavyUrl), {
      method: "POST",
      headers: {
        "content-type": "application/json",
        Authorization: `Bearer ${apiKey}`,
      },
      body: JSON.stringify({ name: uid, expires_in: 3600 }),
    });

    if (!response.ok) {
      throw new Error("Error fetching token", { cause: response });
    }

    let data = await response.json();

    // Save the token in cache
    tokens.set(uid, data.access_token);

    return data;
  }
};

/server/web-api.mjs

The code may seem a lot, so let's break it down.

  if (!refresh && tokens.has(uid)) {
    return {
      access_token: tokens.get(uid),
    };
  } else {
    ...
  }

The web components in the UIKit will send true/false to the tokenFactory function. This indicates that a request to the Weavy api from a component failed and a new token needs to be created. This will happen when a Bearer token is expired or revoked. Checking if the token need a refresh is also convenient for you as you don't need to make a request to the Weavy environment each time asking for a new token. In the example above, we have a tokens cache containing all the fetched users tokens (in memory only). If the refresh parameter is false, we just get it from the cache, otherwise, ask the Weavy environment for a new one.

let response = await fetch(new URL(`/api/users/${uid}/tokens`, weavyUrl), {
  method: "POST",
  headers: {
    "content-type": "application/json",
    Authorization: `Bearer ${apiKey}`,
  },
  body: JSON.stringify({ name: uid, expires_in: 3600 }),
});

If we need to get a new token, a request is made to the Weavy environment api endpoint /api/users/[unique id]/tokens. Note that the Weavy API key is supplied as the Bearer token. The unique id is the important thing here. This is unique id for the signed in user that Weavy uses when creating the user. This could be the user's username, id or whatever you wish.

If the user does not exist in Weavy, the user is created. You can supply more info about the user, such as name, email etc. In the ACME website application, we don't need to do this when getting a token. Instead, we make sure the user is always synced with the correct data starting up the application. More on this in the Sync ACME user data to Weavy section below.

Sync users to Weavy

Now that you have learned how you can authenticate a user and get a token, let's check out how we can keep the ACME user's data in sync with the Weavy data. After all, when the user interacts with a Weavy component app, for example the Posts app, we want to make sure the current user profile info is displayed correctly.

In the ACME website application, we decided to do this sync in several places. First when the database is initialized, all users are synced, so that they are available for starting a chat for example. Any updated user data is synced again when the user signs in the the website, when the user updates the profile and when a user is edited/updated from the users list.

After login

The Koa middleware uses Koa user auth to handle the authentication process. It has a basic flow with a /client/login.html page together with an endpoint for signing in and an endpoint for signing out.

loginCallback: async (ctx, user) => {
  // Get uid from database
  const uid = await getUid(user.username);
  ...

  await syncUser({
    uid,
    name: user.name,
    picture: user.avatar,
    directory: "ACME",
  });
}

/server/koa-middleware.mjs

The syncUser() function in /server/web-api.mjs handles the synchronization of users to the weavy environment.

const weavyUrl = new URL(process.env.WEAVY_URL);
const apiKey = process.env.WEAVY_APIKEY;

export const syncUser = async (user) => {
  const response = await fetch(new URL(`/api/users/${user.uid}`, weavyUrl), {
    method: "PUT",
    headers: {
      "content-type": "application/json",
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify(user),
  });

  if (!response.ok) {
    throw new Error("Error fetching user", { cause: response });
  }

  return await response.json();
};

/server/web-api.mjs

To sync the user's data, we can make a request to an endpoint in the Weavy api, /api/users/[unique user id]. We supply the user data we want to update in the body. In this case we want to update the name, email and directory. The directory is optional, but in this case we want to add all the ACME users into a director called ACME. You can group users in Weavy by adding them to a specific directory.

If the user does not exist, the user is created in Weavy. So the first time an ACME user logs in to the ACME web application, the user will also be created in Weavy.

We use the same syncUser() function when a user updates its profile and when a user is updated from the Users list. So we make sure the correct user info always is in sync with Weavy.

Adding apps

Now it's time to add som Weavy component apps to the ACME website. You can check them out under the Weavy section in the left hand side menu. All of the Chat, Posts and Files pages and the Messenger panel on the right contains a Weavy component app added from the UIKit.

Contextual apps

The Chat, Posts and Files apps are so called Contextual apps. These apps are meant to belong to a specific context in your application and requires you to specify a unique id when you add them. This could for example be for a specific product page, a user, a project and so on. The unique id is something you decide what it's going to be. Let's say you you are on a project page for the project My project. This page has a unique identifier in you application called project-1. The unique id for a Chat could them for example be chat-project-1.

Before you can display a contextual app, the app must already be created in Weavy. In addition to that, the user in the host application, in this case the ACME website, must also me a member of the Weavy app. The Weavy api exposes an endpoint to handle all of this:

The Koa middleware has an endpoint we use to initialize the app.

router.get("/api/contextual/:id", async (ctx) => {
  // setup contextual app
  ctx.body = await initApp({
    uid: ctx.request.params.id,
    name: ctx.request.params.id,
    type: ctx.query.type,
    userId: ctx.session.uid,
  });
});

/server/koa-middleware.mjs

The Koa endpoint is using the initApp() function in the /server/web-api.mjs to post data to the Weavy environment.

const weavyUrl = new URL(process.env.WEAVY_URL);
const apiKey = process.env.WEAVY_APIKEY;

export const initApp = async ({ uid, name, type, userId }) => {
  if (type !== "messenger") {
    const app = { uid: uid, name: name, type: type };
    const user = { uid: `${userId}` };

    const response = await fetch(new URL(`/api/apps/init`, weavyUrl), {
      method: "POST",
      headers: {
        "content-type": "application/json",
        Authorization: `Bearer ${apiKey}`,
      },
      body: JSON.stringify({ app: app, user: user }),
    });

    if (!response.ok) {
      throw new Error("Error fetching app", { cause: response });
    }

    return await response.json();
  }
};

/server/web-api.mjs

In the body, we specify the app we want to initialize, and the user we want to add to the app as a member.

Data Type Description
app
uid string The unique identifier for the app, for example chat-project-1
name string A title for the app
type string The type of Weavy app to initialize. One of chat, posts or files
user
uid string The unique identifier of the user to add as a member

The /api/apps/init endpoint in the Weavy API will create the app if it doesn't exist, otherwise it will return the existing one. If the user isn't already a member of the app, the user will be added.

On the frontend of the ACME website, the app may be loaded in two ways. Either you can load it via javascript or via HTML.

In this example, we are loading the contextual app via HTML in the page client/chat.html. We make a request to our Koa endpoint when the page loads, to initialize the contextual app.

<head>
  ...
  <script>
    fetch("/api/contextual/acme-chat?type=chat");
  </script>
</head>
<body>
  ...
  <div class="contextual-app">
    <wy-chat uid="acme-chat"></wy-chat>
  </div>
</body>

The uid is the important thing here. This is what we set on the Chat component to load and display the correct Weavy app.

If you set an uid to the Files, Chat or Posts app components that doesn't exist in Weavy, you will get a 404 error when trying to display the app.

The Weavy Messenger

The Messenger web component is a little bit different than the Chat, Posts and Files components. The Messenger is not a contextual app. You can think of it more like a global messenger where users in a directory can create private or room conversations. The Messenger component is available in the Acme website by clicking on the Messenger icon in the top right corner in the top navigation.

The Messenger component is always available in Weavy and is not needed to be created before hand. If you take a look at the ACME Messenger component client/components/acme-messenger.js, you can see that no special initialization is done. The <wy-messenger></wy-messenger> can be used as is, without initialization.

import { LitElement, html } from 'lit'
import "@weavy/uikit-web"

class AcmeMessenger extends LitElement {
  ...

  render() {
    return html`
      <div id="messenger" class="offcanvas-end-custom settings-panel border-0 border-start show">
        <wy-messenger></wy-messenger>
      </div>
    `
  }
}
customElements.define('acme-messenger', AcmeMessenger)

Using the Web API

In Examples/Message API we show how you can use the Web API to post a chat message. When making a request to the API, you can either use an API key or an access token as the Bearer token. The API key is used when you want to make the request as the System user and the access token is used when you want to make the request as an authenticated user.

// post a message to Weavy
const response = await fetch(new URL(`/api/apps/${id}/messages`, process.env.WEAVY_URL), {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.WEAVY_APIKEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: text,
  }),
});

if (!response.ok) {
  throw new Error("Network response was not OK");
}

// Parse any fetched data
const resultData = await response.json();