Astro JS & Strapi CMS: Basic CRUD, PWA, and WebSocket Integration using SSE (Without Using Frameworks)

abdulsamadmj

Abdul Samad M J

Posted on October 1, 2024

Astro JS & Strapi CMS: Basic CRUD, PWA, and WebSocket Integration using SSE (Without Using Frameworks)

For a recent machine test, I was tasked with creating a simple web application using Astro.JS and Strapi CMS. The requirements included basic CRUD operations, real-time updates using WebSockets (which I implemented using Server-Sent Events (SSE)), and PWA capabilities without using any additional frameworks.

This article outlines the major steps I took to accomplish the assignment requirements.

NB: This is my first time trying out both Astro.JS and Strapi CMS and the approach I have took for socket integration and related functions may not be of best performance.


Contents


Hosted Strapi CMS & Astro.js Workspace

For this project, I hosted Strapi CMS on Railway. For the Astro.JS workspace, I used Google IDX (*the workspace sharing is on beta).

You can find the full project source code here:

Note: The assignment was to integrate these for an ecommerce platform.


CRUD Operations with Strapi CMS

Creating and Managing Content Types in Strapi is actually very simple, although I found it a bit difficult to wrap my head around Component content types. where as for basic content types you can use direct database operations to manipulate data but for Component based content type, you have to use strapi.entityService functions

Components in Strapi CMS for User Cart Management

In Strapi CMS, the user cart was implemented using a component within the PluginUsersPermissionsUser collection. The cart component includes an array of products, and for each product, additional information like the image is populated.

api/user/controllers/user.js

module.exports = {
  async getCart(ctx) {
    const user = await strapi.entityService.findOne(
      "plugin::users-permissions.user",
      ctx.state.user.id,
      {
        populate: {
          cart: { populate: ["products.image"] },
        },
      }
    );
    return user.cart.products;
  },
  // Rest of the code...
};
Enter fullscreen mode Exit fullscreen mode

This allows for managing user carts seamlessly within the Strapi ecosystem, with cart items stored as a relation inside the user object.


Custom APIs in Strapi

I created custom API endpoints in Strapi to handle cart management.

Here is an example of the getCart endpoint:
api/user/routes/custom.js

module.exports = {
  routes: [
    {
      method: "GET",
      path: "/users/me/cart",
      handler: "user.getCart",
    },
    // Rest of the code...
  ],
};
Enter fullscreen mode Exit fullscreen mode

Note: In order to use the api endpoint, you should authorize the role (Strapi have default Roles and User Management) in which the api should be used.


WebSocket and SSE Integration

Socket.io Plugin in Strapi CMS

To handle real-time updates, I integrated Socket.io into Strapi using strapi-plugin-io. The configuration for listening to changes in the product content type was done via config/plugins.js.

module.exports = ({ env }) => ({
  io: {
    enabled: true,
    config: {
      contentTypes: ["api::product.product"],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

This setup emits events for product-related events (create, update, delete) and broadcasts them via WebSocket.

Socket.io integration to Astro.JS Server Side

To handle real-time updates in the application, I integrated Socket.io on the client side with Astro.js. This setup ensures that whenever a product is created, updated, or deleted on the Strapi backend, the Astro.js front-end receives these updates in real-time.

In Astro.js, I created a utility script (utils/websocket.ts) for managing WebSocket connections with Strapi. This utility file establishes the connection, handles events, and exports an EventEmitter instance for broadcasting WebSocket events, which I later used for Server-Sent Events (SSE) integration.

utils/websocket.ts

// src/scripts/websocket.js
import { io } from "socket.io-client";
import { strapi } from "./constants";
import events from "events";

const socket = io(strapi.BASE_URL, {
  transports: ["websocket"],
});
// Exporting the emitter for use in SSE
export const Emitter = new events.EventEmitter();

socket.on("connect", () => {
  console.log("WebSocket connected");
});

socket.on("connect_error", (error) => {
  console.error("WebSocket connection error:", error);
});

socket.on("disconnect", (reason) => {
  console.log("WebSocket disconnected:", reason);
});

socket.on("product:create", (data) => {
  Emitter.emit("product:create", { event: "product:create", data });
});

socket.on("product:update", (data) => {
  Emitter.emit("product:update", { event: "product:update", data });
});

socket.on("product:delete", (data) => {
  Emitter.emit("product:delete", { event: "product:delete", data });
});

export default socket;
Enter fullscreen mode Exit fullscreen mode

This setup ensures smooth and real-time communication between Strapi and the Astro.js front-end, allowing the application to stay up to date with minimal client-side processing.

SSE Integration with Astro.js

Instead of handling real-time updates on the client side using WebSocket, I opted for Server-Sent Events (SSE) in Astro.js to minimize JavaScript on the client side. The server pushes events to the client whenever there’s an update.

Here’s a sample of my SSE implementation:
pages/api/stream.ts

import { Emitter } from "../../utils/websocket"; // Import the WebSocket Emitter used to listen to events

interface ProductEventData {
  event: string;
  data: {
    data: {
      attributes: {
        pID: string;
      };
    };
  };
}

// Asynchronous GET handler function for returning a stream of events.
export async function GET() {
  console.log("stream.js> get()"); // Log to indicate the stream GET request is initiated.

  let eventsListener: (data: ProductEventData) => void; // Declare a variable to hold the event listener.

  // Create a new ReadableStream for server-sent events (SSE).
  const stream = new ReadableStream({
    // This function is called when the stream is first initialized.
    start(controller: ReadableStreamDefaultController) {
      // Define an event listener that handles WebSocket events.
      eventsListener = (data: any) => {
        console.log(
          `stream.js> Emitter event = ${data.event}, id = ${data.data.data.attributes.pID}`
        );

        // Format the event data to be sent in the SSE format.
        const eventData = `data: ${JSON.stringify(data)}\r\n\r\n`;

        // Enqueue the formatted event data to be sent to the client.
        controller.enqueue(eventData);
      };

      // Remove any previous listeners for product events (create, update, delete).
      Emitter.off("product:create", eventsListener);
      Emitter.off("product:update", eventsListener);
      Emitter.off("product:delete", eventsListener);

      // Attach listeners to the WebSocket Emitter for product events (create, update, delete).
      Emitter.on("product:create", eventsListener);
      Emitter.on("product:update", eventsListener);
      Emitter.on("product:delete", eventsListener);
    },

    // This function is called if the stream is canceled by the client.
    cancel() {
      console.log("stream.js> cancel()");

      // Remove the attached listeners for product events when the stream is canceled.
      Emitter.removeListener("product:create", eventsListener);
      Emitter.removeListener("product:update", eventsListener);
      Emitter.removeListener("product:delete", eventsListener);
    },
  });

  // Return the ReadableStream as a response to the client, sending events as SSE.
  return new Response(stream, {
    status: 200,
    headers: {
      "Content-Type": "text/event-stream", // Content type for SSE.
      Connection: "keep-alive", // Keep the connection open to send events continuously.
      "Cache-Control": "no-cache", // Prevent caching of the response.
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

This stream listens to WebSocket events and serves the updates to the client in real-time.

Note: How the client side uses these Server-Sent Events to manipulate UI is in source code.


PWA Features

Service Worker and Manifest

To make the application installable as a PWA, I implemented a Service Worker and a manifest.json. The Service Worker caches assets for offline availability and updates the cache when changes are detected.

public/sw.js

const CACHE_NAME = "astro-cache-v1";
const urlsToCache = [
  "/",
  "/manifest.json",
  "/icons/icon-192x192.png",
  "/icons/icon-512x512.png",
];

// Install event: caching necessary assets
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(urlsToCache);
    })
  );
});

// Fetch event: serve from cache, check for changes, and update cache if necessary
self.addEventListener("fetch", (event) => {
  if (event.request.method !== "GET") {
    event.respondWith(fetch(event.request));
    return;
  }

  event.respondWith(
    caches.match(event.request).then(async (cachedResponse) => {
      const fetchRequest = event.request.clone();

      // Fetch the resource from the network
      return fetch(fetchRequest)
        .then((networkResponse) => {
          // If the server returns 304, use the cached version
          if (networkResponse.status === 304 && cachedResponse) {
            return cachedResponse;
          }

          // Otherwise, clone the network response and update the cache
          const clonedResponse = networkResponse.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, clonedResponse);
          });

          // Return the network response
          return networkResponse;
        })
        .catch(() => {
          // If fetch fails (e.g., offline), return cached response if available
          if (cachedResponse) {
            return cachedResponse;
          }

          // Optionally, handle a case where neither fetch nor cache works
          return new Response("Resource not available in cache or network", {
            status: 404,
            statusText: "Not Found",
          });
        });
    })
  );
});

// Activate event: cleanup old caches
self.addEventListener("activate", (event) => {
  const cacheWhitelist = [CACHE_NAME];
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (!cacheWhitelist.includes(cacheName)) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Enter fullscreen mode Exit fullscreen mode

public/manifest.json

{
  "name": "BSO Assignment",
  "short_name": "AppName",
  "description": "A short description of your app",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Registering Service Worker and Manifest

In order to make the application a Progressive Web App (PWA), I registered a Service Worker and linked a Web App Manifest in the layout.astro file. This enables offline capabilities, caching of assets, and provides a structured way to define the app's metadata like icons, name, and theme.

layouts/layout.astro

<head>
// Rest of the code...
  <link rel="manifest" href="/manifest.json" />
  <script>
  // Register Service Worker
  if ("serviceWorker" in navigator) {
    window.addEventListener("load", () => {
      navigator.serviceWorker
        .register("/sw.js")
        .then((registration) => {
          console.log(
            "ServiceWorker registration successful with scope: ",
            registration.scope
          );
        })
        .catch((error) => {
          console.log("ServiceWorker registration failed: ", error);
        });
      });
    }
  </script>
// Rest of the Code...
</head>
Enter fullscreen mode Exit fullscreen mode

To-Do

  • Emit events directly from the Strapi backend instead of handling everything in Astro.
  • Further reduce client-side JavaScript, especially on the product page.
  • Accept Cookies Dialog for modern browser support

References


I successfully implemented the required features, hosted the Strapi backend, and created a responsive, real-time-enabled front-end application using Astro.js—all without additional frameworks. The project is still a work in progress, with plans to further improve performance and real-time capabilities.


Want to Connect? Click Here

💖 💪 🙅 🚩
abdulsamadmj
Abdul Samad M J

Posted on October 1, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related