Astro JS & Strapi CMS: Basic CRUD, PWA, and WebSocket Integration using SSE (Without Using Frameworks)
Abdul Samad M J
Posted on October 1, 2024
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
- CRUD Operations with Strapi CMS
- WebSocket and SSE Integration
- PWA (Progressive Web App) Features
- To-Do
- References
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:
- Astro.JS: astro-machine-test
- Strapi: strapi-machine-test
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...
};
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...
],
};
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"],
},
},
});
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;
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.
},
});
}
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);
}
})
);
})
);
});
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"
}
]
}
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>
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
- Astro.js Documentation
- Strapi CMS Documentation
- Strapi Plugin IO Documentation
- Reddit - Establishing WebSocket Connection in Astro
- GitHub - SSE Counter Example
- Flowbite Components: For UI
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
Posted on October 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.