Workshop: Build serverless edge applications with Cloudflare Workers and Fauna

In this workshop, you will learn how to build a distributed serverless application using Cloudflare Workers and Fauna. Traditional databases often present challenges for modern, edge-compute systems due to limitations in scalability, latency, and flexibility. Edge-compute systems require low latency and high availability, but conventional databases are often centralized, leading to increased response times and potential bottlenecks. They also require complex infrastructure to handle scaling, consistency, and performance at a global level.

To address these challenges, a distributed serverless architecture like the combination of Cloudflare Workers and Fauna is ideal. This approach allows applications to run on a globally distributed network of servers, ensuring low-latency responses regardless of user location. Fauna offers key attributes such as true serverless scalability, multi-region support, and strong consistency, making it well-suited for modern applications that demand high availability, strong consistency, and predictable performance.

Why Cloudflare Workers and Fauna?

Cloudflare workers are serverless functions that run on Cloudflare’s edge network. They are written in JavaScript and can be used to build serverless applications that run close to your users, reducing latency and improving performance.

Fauna is a globally distributed, low-latency, strongly consistent, and serverless database. It is designed to work well with serverless functions like Cloudflare Workers and provides a powerful and flexible data platform for building modern applications.

Fauna is a database delivered as an API. Fauna is globally distributed. Your data is always close to your users, reducing latency and improving performance.

By combining Cloudflare Workers and Fauna, you can build serverless applications that are fast, reliable, and scalable, with low operational overhead.

Prerequisites

  • A Cloudflare account

  • A Fauna account

  • Node.js installed on your machine

  • Some familiarity with Cloudflare Workers and Fauna

Creating the Cloudflare Worker

  1. Install Cloudflare Wrangler:

    npm install -g @cloudflare/wrangler
  2. Create a new Cloudflare Worker project:

    wrangler generate my-worker
    cd my-worker
  3. Open the newly created project in your favorite code editor:

Create a Fauna Database

Next, create a Fauna database. You can create a new database from the Fauna dashboard or using the Fauna CLI. For this workshop, we will create a new database using the Fauna CLI.

  1. Create a new Fauna database:

    fauna create-database --environment='' mydb
  2. Initialize a new Fauna project directory in the Cloudflare Worker project:

    fauna project init

    When prompted, enter:

    • A schema directory used to store .fsl files. If the directory doesn’t exist, the command creates it.

    • A default environment name. See Environments.

    • A default endpoint to use for Fauna CLI commands.

    • A default database for Fauna CLI commands.

Integrating Fauna with Cloudflare Workers

You can integrate Fauna using the Cloudflare dashboard or the Wrangler CLI. For this workshop, use the Cloudflare dashboard.

  1. Open the Cloudflare dashboard and navigate to the Workers & Pages section.

  2. Select the Worker you created earlier.

  3. Select the Integration tab.

    Configure Fauna

  4. Select Fauna and authenticate with your Fauna account.

  5. When prompted, select the Fauna database you created earlier.

  6. Select a database role. For this workshop, you can select the server role. You can also create a new role with the required permissions.

Accessing data from Fauna in Cloudflare Workers

You can either use the Fauna driver to access data from Fauna in your Cloudflare Workers. Fauna is a database that is delivered as an API. The Fauna driver is a lightweight wrapper around the Fauna API that makes it easy to interact with Fauna databases from your Cloudflare Workers.

  1. Install the Fauna driver in your Cloudflare Worker project:

    npm install fauna
  2. Add the following code to your Cloudflare Worker script:

    import { Client, fql} from "fauna";
    
    export default {
      async fetch(
        request: Request,
        env: Env,
        ctx: ExecutionContext
      ): Promise<Response> {
        // Extract the method from the request
        const { method, url } = request;
        const { pathname } = new URL(url);
        // Route the request based on path and method
        if (method === "POST" && pathname.startsWith("/dynamic/")) {
          const collectionName = pathname.split("/dynamic/")[1];
          if (!collectionName) {
            return new Response("Missing collection name", { status: 400 });
          }
          return createDynamicQuery(request, env, collectionName);
        }
        switch (method) {
          case "GET":
            return getAllProducts(request, env);
          case "POST":
            return createNewProduct(request, env);
          default:
            return new Response("Method Not Allowed", { status: 405 });
        }
      },
    };

Define data relationships with FSL

We will define one-to-many relationships between two collections using FSL. In the sample application, we will have Product and Category collections. Each product belongs to a category.

Configure Fauna

  1. Create a new folder called schema in your Cloudflare Worker project

  2. Create two files product.fsl and category.fsl in the schema folder

  3. Add the following code to the category.fsl file:

    collection Category {
      name: String
      description: String
      compute products: Set<Product> = (category => Product.byCategory(category))
    
      unique [.name]
    
      index byName {
        terms [.name]
      }
    }
  4. Add the following code to the product.fsl file:

    collection Product {
      name: String
      description: String
      // Use an Integer to represent cents.
      // This avoids floating-point precision issues.
      price: Int
      category: Ref<Category>?
      stock: Int
    
      // Use a unique constraint to ensure no two products have the same name.
      unique [.name]
      check stockIsValid (product => product.stock >= 0)
      check priceIsValid (product => product.price > 0)
    
      index byCategory {
        terms [.category]
      }
    
      index sortedByCategory {
        values [.category]
      }
    
      index byName {
        terms [.name]
      }
    
      index sortedByPriceLowToHigh {
        values [.price, .name, .description, .stock]
      }
    }
  5. Run the following command to push the schema to Fauna:

    fauna schema push

    When prompted, accept and stage the schema.

  6. Check the status of the staged schema:

    fauna schema status
  7. When the status is ready, commit the staged schema to the database:

    fauna schema commit

    The commit applies the staged schema to the database.

Document-relational model

One of Fauna’s key strengths is its flexibility to support both document and relational data patterns, making it suitable for a wide range of use cases.

In the example above, we demonstrated how to define relationships using FSL (Fauna Schema Language). You can think of the Product and Category collections as representing a typical relational model (one-to-many), where products are linked to categories.

What makes Fauna unique is its capability to perform relational-like joins within a document-based system.

For example in the Product collection, the category field is a reference to a Category document. You can query all the products in a specific category by using the category field and in the Product collection.

// Get all products in the Electronics category
Product.where(.category == Category.byName("Electronics").first())

To optimize this query we created an index byCategory() in the Product collection. You can use the byCategory() index to query all products in a specific category.

Product.byCategory(Category.byName("Electronics").first())

Fauna provides you SQL-like relational capabilities while maintaining the flexibility of a document-based database.

Learn more about data relationships in Fauna.

Adding REST endpoints

We will add REST endpoints to our Cloudflare Worker to interact with the Fauna database.

Add Fauna secret to wrangler for local development

First, create a new Fauna secret and add it to your wrangler.toml file for local development.

  1. Create a new Fauna secret:

    fauna create-key --environment='' mydb server
  2. Add the secret to your wrangler.toml file:

    [vars]
    FAUNA_SECRET = "<Your-Generated-Secret>"

Next, add the following code to your src/index.ts file:

export interface Env {
  FAUNA_SECRET: string;
}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext
  ): Promise<Response> {
    // Extract the method from the request
    const { method } = request;

		switch (method) {
			case "GET":
				return getAllProducts(request, env);
			case "POST":
				return createNewProduct(request, env);
			default:
				return new Response("Method Not Allowed", { status: 405 });
		}
	},
};

We will implement the getAllProducts and createNewProduct functions to get all products and create a new product, respectively.

GET products endpoint

Implement the following code to get all products from Fauna:

async function getAllProducts(request: Request, env: Env): Promise<Response> {
  // Custom GET logic here (e.g., fetching data from Fauna)
	const client = new Client({ secret: env.FAUNA_SECRET });
	try {
		const result = await client.query(fql`
			Product.all()
		`);
		return new Response(JSON.stringify(result.data));
	} catch (error) {
		if (error instanceof FaunaError) {
			return new Response(error.message, {status: 500});
		}
		return new Response("An error occurred", { status: 500 });
	}
}

POST products endpoint

Implement the following code to create a new product in Fauna:

async function createNewProduct(request: Request, env: Env): Promise<Response> {
  // Read and parse the request body
  const body = await request.json() as any;
	const client = new Client({ secret: env.FAUNA_SECRET });
	const {
		name,
		price,
		description,
		category,
		stock,
	} = body;

	if (!name || !price || !description || !category || !stock) {
		return new Response("Missing required fields", { status: 400 });
	}

	try {
		// Custom POST logic here (e.g., storing data to Fauna)
		const result = await client.query(fql`
			// Get the category by name. We can use .first() here because we know that the category
			// name is unique.
			let category = Category.byName(${category}).first()
			// If the category does not exist, abort the transaction.
			if (category == null) abort("Category does not exist.")
				// Create the product with the given values.
				let args = { name: ${name}, price: ${price}, stock: ${stock}, description: ${description}, category: category }
				let product: Any = Product.create(args)
				// Use projection to only return the fields you need.
				product {
					id,
					name,
					price,
					description,
					stock,
					category {
						id,
						name,
						description
					}
				}
		`);
		return new Response(JSON.stringify(result.data));
	}
	catch (error) {
		console.error(error);
		return new Response("An error occurred", { status: 500 });
	}
}

Test the application

Add records to Fauna

Before we can test the Cloudflare Worker, we need to add some records to the Fauna database.

  1. Run the following command to connect to the Fauna shell from your terminal:

    fauna shell
  2. Run the following command to enter editor mode in the Fauna shell:

    .editor
  3. Create a new category. Write the following code in the editor and press Ctrl+D to execute the code:

    Category.create({
      name: "Electronics",
      description: "Electronic products"
    })
  4. Create a new product. Write the following code in the editor and press Ctrl+D to execute the code:

    Product.create({
      name: "Laptop",
      description: "A laptop computer",
      price: 500,
      stock: 10,
      category: Category.byName("Electronics").first(),
    })
  5. Run the following command to exit the Fauna shell:

    .exit

Run the Cloudflare Worker locally

  1. Run the following command to start the Cloudflare Worker locally:

    wrangler dev
  2. Send a GET request to the /products endpoint to get all products.

    curl http://localhost:8787/products
  3. Send a POST request to the /products endpoint to create a new product.

    curl -X POST http://localhost:8787/products \
      -d '{
        "name": "Smartphone",
        "description": "A smartphone",
        "price": 300,
        "stock": 20,
        "category": "Electronics"
      }'

Dynamic Fauna queries

You can use FQL to build more complex queries that depend on dynamic values passed in the request. For example, you can use a single Cloudflare worker endpoint to perform dynamic CRUD operations on documents in any Fauna collection.

  1. Add the following code to your src/index.ts file:

    export default {
      async fetch(
        request: Request,
        env: Env,
        ctx: ExecutionContext
      ): Promise<Response> {
        // Extract the method from the request
        const { method, url } = request;
    		const { pathname } = new URL(url);
    
    		// Route the request based on path and method
        if (method === "POST" && pathname.startsWith("/dynamic/")) {
          const collectionName = pathname.split("/dynamic/")[1];
    			if (!collectionName) {
    				return new Response("Missing collection name", { status: 400 });
    			}
          return createDynamicQuery(request, env, collectionName);
        }
    
    		switch (method) {
    			case "GET":
    				return getAllProducts(request, env);
    			case "POST":
    				return createNewProduct(request, env);
    			default:
    				return new Response("Method Not Allowed", { status: 405 });
    		}
    	},
    };
  2. Next implement the createDynamicQuery function to handle dynamic queries. Add the following code to your src/index.ts file:

    // ... rest of the code
    
    // Handle dynamic FQL queries
    async function createDynamicQuery(
      request: Request,
      env: Env,
      collectionName: string
    ): Promise<Response> {
      const body = await request.json();
      const { operation } = body;
      switch (operation) {
        case "create":
          return createDocument(collectionName, body, env);
        case "update":
          return updateDocument(collectionName, body, env);
        case "delete":
          return deleteDocument(collectionName, body, env);
        default:
          return new Response("Invalid operation", { status: 400 });
      }
    }
  3. Implement functions in src/index.ts to handle create, update, and delete operations for Fauna collection documents:

    // ... rest of the code
    
    async function createDocument(
      collectionName: string,
      body: any,
      env: Env
    ): Promise<Response> {
      const client = new Client({ secret: env.FAUNA_SECRET });
      try {
        // Pass the constructed FQL string inside the `fql` template literal
        const result = await client.query(fql`
    			Collection(${collectionName}).create(${body.fields})
    		`);
        return new Response(JSON.stringify(result.data));
      } catch (error) {
        console.error(error);
        if (error instanceof FaunaError) {
          return new Response(error.message, { status: 500 });
        }
        return new Response("An error occurred", { status: 500 });
      }
    }
    async function updateDocument(
      collectionName: string,
      body: any,
      env: Env
    ): Promise<Response> {
      const client = new Client({ secret: env.FAUNA_SECRET });
      const { id, fields } = body;
      try {
        const result = await client.query(fql`
    			Collection(${collectionName}).byId(${id}).update(${fields})
    		`);
        return new Response(JSON.stringify(result.data));
      } catch (error) {
        if (error instanceof FaunaError) {
          return new Response(error.message, { status: 500 });
        }
        return new Response("An error occurred", { status: 500 });
      }
    }
    async function deleteDocument(
      collectionName: string,
      body: any,
      env: Env
    ): Promise<Response> {
      const client = new Client({ secret: env.FAUNA_SECRET });
      const { id } = body;
      try {
        const result = await client.query(fql`
    			Collection(${collectionName}).byId(${id}).delete()
    		`);
        return new Response(JSON.stringify(result.data));
      } catch (error) {
        if (error instanceof FaunaError) {
          return new Response(error.message, { status: 500 });
        }
        return new Response("An error occurred", { status: 500 });
      }
    }
  4. Run the Cloudflare Worker locally and test the dynamic queries:

    wrangler dev
  5. Send a POST request to the /dynamic/products endpoint to create a new product:

    curl -X POST http://localhost:8787/dynamic/Product \
      -d '{
        "operation": "create",
        "fields": {
          "name": "Tablet",
          "description": "A tablet computer",
          "price": 200,
          "stock": 15,
          "category": "Electronics"
        }
      }'

Using dynamic FQL (Fauna Query Language) queries is optional. Dynamic query composition can be useful for building generic CRUD endpoints that can handle any Fauna collection.

Deploy the Cloudflare Worker

  1. Deploy the Cloudflare Worker:

    wrangler publish

You can find the full source code for this workshop in the following GitHub repository.

Worker-per-resource pattern

In the previous example, we used a single Cloudflare Worker to handle all requests. This approach is suitable for small applications with a limited number of endpoints. However, as your application grows, you may want to consider using the worker-per-resource pattern.

In the worker-per-resource pattern, you create a separate Cloudflare Worker for each resource in your application. This approach allows you to scale your application more effectively and manage resources independently.

To implement the worker-per-resource pattern, you can create a new Cloudflare Worker for each resource in your application. Each worker can handle requests specific to that resource.

Let’s create CRUD endpoints for the Product and Category collections using the worker-per-resource pattern.

  1. Create a new Cloudflare Worker for the Product collection:

    wrangler generate product-worker
    cd product-worker
  2. Implement the CRUD endpoints for the Product collection in the product-worker.

    import { Client, fql, FaunaError } from "fauna";
    
    export interface Env {
      FAUNA_SECRET: string;
    }
    
    export default {
      async fetch(
        request: Request,
        env: Env,
        ctx: ExecutionContext
      ): Promise<Response> {
        // Extract the method from the request
        const { method, url } = request;
        const { pathname } = new URL(url);
    
        // Route the request based on path and method
        switch (method) {
          case "GET":
            return getAllProducts(request, env);
          case "POST":
            return createProduct(request, env);
          case "PUT":
            return updateProduct(request, env);
          case "DELETE":
            return deleteProduct(request, env);
          default:
            return new Response("Method Not Allowed", { status: 405 });
        }
      },
    };
    
    /**
     * Get all products from the database
     */
    async function getAllProducts(request: Request, env: Env): Promise<Response> {
      const client = new Client({ secret: env.FAUNA_SECRET });
      try {
        const result = await client.query(fql`
          Product.all()
        `);
        return new Response(JSON.stringify(result.data));
      } catch (error) {
        if (error instanceof FaunaError) {
          return new Response(error.message, { status: 500 });
        }
        return new Response("An error occurred", { status: 500 });
      }
    }
    
    /**
     * Create a new product in the Product collection
     */
    async function createProduct(request: Request, env: Env): Promise<Response> {
      const body = await request.json() as any;
      const client = new Client({ secret: env.FAUNA_SECRET });
      const { name, price, description, category, stock } = body;
    
      if (!name || !price || !description || !category || !stock) {
        return new Response("Missing required fields", { status: 400 });
      }
    
      try {
        const result = await client.query(fql`
          let category = Category.byName(${category}).first()
          if (category == null) abort("Category does not exist.")
          let args = { name: ${name}, price: ${price}, stock: ${stock}, description: ${description}, category: category }
          let product: Any = Product.create(args)
          product {
            id,
            name,
            price,
            description,
            stock,
            category {
              id,
              name,
              description
            }
          }
        `);
        return new Response(JSON.stringify(result.data));
      } catch (error) {
        console.error(error);
        if (error instanceof FaunaError) {
          return new Response(error.message, { status: 500 });
        }
        return new Response("An error occurred", { status: 500 });
      }
    }
    
    /**
     * Update an existing product in the Product collection
     */
    async function updateProduct(request: Request, env: Env): Promise<Response> {
      const body = await request.json() as any;
      const client = new Client({ secret: env.FAUNA_SECRET });
      const { id, fields } = body;
    
      if (!id || !fields) {
        return new Response("Missing required fields", { status: 400 });
      }
    
      try {
        const result = await client.query(fql`
          Product.byId(${id}).update(${fields})
        `);
        return new Response(JSON.stringify(result.data));
      } catch (error) {
        if (error instanceof FaunaError) {
          return new Response(error.message, { status: 500 });
        }
        return new Response("An error occurred", { status: 500 });
      }
    }
    
    /**
     * Delete an existing product in the Product collection
     */
    async function deleteProduct(request: Request, env: Env): Promise<Response> {
      const body = await request.json() as any;
      const client = new Client({ secret: env.FAUNA_SECRET });
      const { id } = body;
    
      if (!id) {
        return new Response("Missing product ID", { status: 400 });
      }
    
      try {
        const result = await client.query(fql`
          Product.byId(${id}).delete()
        `);
        return new Response(JSON.stringify(result.data));
      } catch (error) {
        if (error instanceof FaunaError) {
          return new Response(error.message, { status: 500 });
        }
        return new Response("An error occurred", { status: 500 });
      }
    }
  3. Similarly, create a new Cloudflare Worker for the Category collection and implement the CRUD endpoints for the Category collection.

  4. Deploy all the Cloudflare Workers.

Is this article helpful? 

Tell Fauna how the article can be improved:
Visit Fauna's forums or email docs@fauna.com

Thank you for your feedback!