Building Real-Time Chat in Astro with Cloudflare Durable Objects and WebSockets
LaunchFast Logo LaunchFast

Building Real-Time Chat in Astro with Cloudflare Durable Objects and WebSockets

Rishi Raj Jain
Building Real-Time Chat in Astro with Cloudflare Durable Objects

Building real-time features like chat, notifications, or collaborative editing traditionally requires complex infrastructure with WebSocket servers, Redis for pub/sub, and distributed state management. Cloudflare Durable Objects changes this by providing stateful, strongly consistent objects at the edge that can handle WebSocket connections natively.

High Quality Starter Kits with built-in authentication flow (Auth.js), object uploads (AWS, Clouflare R2, Firebase Storage, Supabase Storage), integrated payments (Stripe, LemonSqueezy), email verification flow (Resend, Postmark, Sendgrid), and much more. Compatible with any database (Redis, Postgres, MongoDB, SQLite, Firestore).
Next.js Starter Kit
SvelteKit Starter Kit

In this comprehensive guide, you’ll learn how to build a real-time chat application using Astro and Cloudflare Durable Objects. We’ll cover WebSocket connections, message persistence, user presence indicators, and room-based chat - all running at the edge with zero server management.

Demo

Demo of Real-Time Chat in Astro with Cloudflare Durable Objects

Prerequisites

You’ll need the following:

Understanding Durable Objects and WebSockets

Durable Objects are Cloudflare’s solution for managing stateful applications at the edge. Unlike traditional serverless functions that are stateless, Durable Objects maintain state in memory and can handle WebSocket connections.

Key characteristics:

  • Single-threaded execution: Each Durable Object instance runs in a single location, eliminating race conditions
  • Strong consistency: State changes are immediately visible to all connections to that object
  • Built-in storage: Persistent key-value storage that survives restarts
  • WebSocket support: Native support for maintaining long-lived WebSocket connections

How Durable Objects differ from traditional WebSocket servers

Traditional WebSocket architecture:

Client → Load Balancer → WebSocket Server (Node.js) → Redis (Pub/Sub) → Database
Multiple server instances need coordination

Durable Objects architecture:

Client → Cloudflare Edge → Durable Object (single instance per chat room)
Built-in state + storage

Benefits:

  • No Redis needed: State is in-memory in the Durable Object
  • No coordination complexity: One instance = no race conditions
  • Global deployment: Runs at Cloudflare’s edge, close to users
  • Automatic scaling: Cloudflare creates instances on-demand
  • Zero server management: No servers to configure or maintain

Architecture overview

Our chat application will have this structure:

1. Astro Page (src/pages/chat/[roomId].astro)
→ Renders the chat UI
→ Includes client-side JavaScript for WebSocket
2. WebSocket API Endpoint (src/pages/api/chat/[roomId].ts)
→ Handles WebSocket upgrade requests
→ Connects clients to the appropriate Durable Object
3. Worker with ChatRoom Durable Object (src/worker.ts)
→ Exports the ChatRoom Durable Object class
→ Manages WebSocket connections for a room
→ Broadcasts messages to all connected clients
→ Persists message history
→ Tracks user presence

Each chat room gets its own Durable Object instance, identified by the room ID. All clients in that room connect to the same instance, ensuring strong consistency.

Create a new Astro application

Let’s get started by creating a new Astro project. Execute the following command:

Terminal window
npm create astro@latest realtime-chat

When prompted, choose:

  • Use minimal (empty) template when prompted on how to start the new project.
  • Yes when prompted to install dependencies.
  • Yes when prompted to initialize a git repository.

Once that’s done, you can move into the project directory and start the app:

Terminal window
cd realtime-chat
npm install wrangler
npm run dev

The app should be running on localhost:4321.

Integrate Cloudflare adapter in your Astro project

To deploy your Astro project to Cloudflare Pages and use Cloudflare KV, you need to install the Cloudflare adapter. Execute the command below:

Terminal window
npx astro add cloudflare

When prompted, choose Yes for every prompt.

This installs @astrojs/cloudflare and configures your project for server-side rendering on Cloudflare Pages.

Now update your astro.config.mjs to specify the worker entry point:

astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server',
adapter: cloudflare({
workerEntryPoint: {
path: './src/worker.ts',
namedExports: ['ChatRoom']
}
})
});

The workerEntryPoint configuration tells Astro where to find your Durable Object class. The namedExports array specifies which classes to export from the worker.

Configure wrangler.jsonc

Create a wrangler.jsonc file in the root of your project:

{
"$schema": "./node_modules/wrangler/config-schema.json",
"main": "dist/_worker.js/index.js",
"name": "realtime-chat",
"compatibility_date": "2025-12-04",
"compatibility_flags": [
"nodejs_compat",
"global_fetch_strictly_public"
],
"assets": {
"binding": "ASSETS",
"directory": "./dist"
},
"observability": {
"enabled": true
},
"durable_objects": {
"bindings": [
{
"name": "ChatRoom",
"class_name": "ChatRoom"
}
]
},
"migrations": [
{
"tag": "v1",
"new_classes": [
"ChatRoom"
]
}
]
}

The durable_objects.bindings section creates a binding named ChatRoom that your code can use to access the ChatRoom Durable Object class. The compatibility_flags ensure Node.js compatibility for features like crypto.randomUUID().

Configure environment types

Update src/env.d.ts to add TypeScript definitions for Durable Objects:

/// <reference types="astro/client" />
/// <reference types="@cloudflare/workers-types" />
type DurableObjectNamespace = import('@cloudflare/workers-types').DurableObjectNamespace
type ENV = {
ChatRoom: DurableObjectNamespace
}
type Runtime = import('@astrojs/cloudflare').Runtime<ENV>
declare namespace App {
interface Locals extends Runtime {
// Add custom locals here
}
}

This provides type safety when working with Durable Objects in your Astro application.

Create the Chat Room Durable Object

Durable Objects are defined in the worker file along with the handler exports. Create src/worker.ts:

src/worker.ts
import { handle } from '@astrojs/cloudflare/handler';
import type { SSRManifest } from 'astro';
import { App } from 'astro/app';
import { DurableObject } from 'cloudflare:workers';
interface WebSocketSession {
webSocket: WebSocket
userId: string
username: string
quit?: boolean
}
interface ChatMessage {
id: string
type: 'message' | 'join' | 'leave' | 'presence'
userId: string
username: string
content?: string
timestamp: number
}
class ChatRoom extends DurableObject<ENV> {
private sessions: Set<WebSocketSession>
private messageHistory: ChatMessage[]
constructor(ctx: DurableObjectState, env: ENV) {
super(ctx, env)
this.sessions = new Set()
this.messageHistory = []
// Load message history from storage on initialization
this.ctx.blockConcurrencyWhile(async () => {
const stored = await this.ctx.storage.get<ChatMessage[]>('messages')
if (stored) this.messageHistory = stored
})
}
/**
* Handle HTTP requests to this Durable Object
* This is called when a client wants to establish a WebSocket connection
*/
async fetch(request: Request): Promise<Response> {
// Parse the URL to get query parameters (userId and username)
const url = new URL(request.url)
const userId = url.searchParams.get('userId')
const username = url.searchParams.get('username')
if (!userId || !username)
return new Response('Missing userId or username', { status: 400 })
// Expect a WebSocket upgrade request
const upgradeHeader = request.headers.get('Upgrade')
if (upgradeHeader !== 'websocket')
return new Response('Expected WebSocket upgrade', { status: 426 })
// Create a WebSocket pair (client and server)
const pair = new WebSocketPair()
const [client, server] = Object.values(pair)
// Accept the WebSocket connection
server.accept()
// Create a session for this connection
const session: WebSocketSession = {
webSocket: server,
userId,
username,
}
// Add to active sessions
this.sessions.add(session)
// Set up event handlers for this WebSocket
server.addEventListener('message', (event) => {
this.handleMessage(session, event.data as string)
})
server.addEventListener('close', () => {
this.handleClose(session)
})
server.addEventListener('error', () => {
this.handleClose(session)
})
// Send message history to the newly connected client
this.sendMessageHistory(session)
// Broadcast join notification to all clients
this.broadcast({
id: crypto.randomUUID(),
type: 'join',
userId,
username,
timestamp: Date.now(),
})
// Send current presence to the new user
this.sendPresence(session)
// Return the client WebSocket in the response
return new Response(null, {
status: 101,
webSocket: client,
})
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(session: WebSocketSession, data: string): void {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'message' && parsed.content) {
// Create a chat message
const message: ChatMessage = {
id: crypto.randomUUID(),
type: 'message',
userId: session.userId,
username: session.username,
content: parsed.content,
timestamp: Date.now(),
}
// Add to history
this.messageHistory.push(message)
// Persist to storage (limit to last 100 messages)
if (this.messageHistory.length > 100)
this.messageHistory = this.messageHistory.slice(-100)
this.ctx.storage.put('messages', this.messageHistory)
// Broadcast to all connected clients
this.broadcast(message)
}
} catch (error) {
console.error('Error handling message:', error)
}
}
/**
* Handle WebSocket close events
*/
private handleClose(session: WebSocketSession): void {
// Remove from sessions
this.sessions.delete(session)
// Broadcast leave notification if not already quit
if (!session.quit) {
this.broadcast({
id: crypto.randomUUID(),
type: 'leave',
userId: session.userId,
username: session.username,
timestamp: Date.now(),
})
}
// Close the WebSocket if not already closed
try {
session.webSocket.close()
} catch (error) {
// Already closed
}
}
/**
* Broadcast a message to all connected clients
*/
private broadcast(message: ChatMessage): void {
const messageStr = JSON.stringify(message)
// Send to all active sessions
this.sessions.forEach((session) => {
try {
session.webSocket.send(messageStr)
} catch (error) {
// Connection might be closed, will be cleaned up by close handler
console.error('Error broadcasting to session:', error)
}
})
}
/**
* Send message history to a specific session
*/
private sendMessageHistory(session: WebSocketSession): void {
const historyMessage = JSON.stringify({
type: 'history',
messages: this.messageHistory,
})
try {
session.webSocket.send(historyMessage)
} catch (error) {
console.error('Error sending history:', error)
}
}
/**
* Send current user presence to a specific session
*/
private sendPresence(session: WebSocketSession): void {
const users = Array.from(this.sessions).map((s) => ({
userId: s.userId,
username: s.username,
}))
const presenceMessage = JSON.stringify({
type: 'presence',
users,
})
try {
session.webSocket.send(presenceMessage)
} catch (error) {
console.error('Error sending presence:', error)
}
}
/**
* Optional: Handle Durable Object alarm for cleanup tasks
*/
async alarm(): Promise<void> {
// Clean up old messages (older than 24 hours)
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000
this.messageHistory = this.messageHistory.filter((msg) => msg.timestamp > oneDayAgo)
await this.ctx.storage.put('messages', this.messageHistory)
// Schedule next cleanup in 1 hour
await this.ctx.storage.setAlarm(Date.now() + 60 * 60 * 1000)
}
}
export function createExports(manifest: SSRManifest) {
const app = new App(manifest);
return {
default: {
async fetch(request, env, ctx) {
// @ts-expect-error - request is not typed correctly
return handle(manifest, app, request, env, ctx);
}
} satisfies ExportedHandler<ENV>,
ChatRoom,
}
}

This worker file does two important things:

  1. Defines the ChatRoom Durable Object with complete chat room logic:

    • WebSocket connection handling: Accepts and manages WebSocket connections
    • Message broadcasting: Sends messages to all connected clients
    • Message persistence: Stores the last 100 messages in Durable Object storage
    • User presence: Tracks who’s currently in the room
    • Join/leave notifications: Broadcasts when users join or leave
    • Automatic cleanup: Uses alarms to clean up old messages
  2. Exports the worker and Durable Object via the createExports function:

    • The default export handles all HTTP requests via Astro’s handler
    • The ChatRoom export makes the Durable Object available to Cloudflare
    • The function receives the SSR manifest from Astro’s build process

Implement WebSocket connection handling

The Durable Object’s fetch method handles WebSocket upgrade requests. When a client connects:

  1. It validates the userId and username parameters
  2. Creates a WebSocket pair (one for the client, one for the server)
  3. Accepts the server-side WebSocket
  4. Creates a session object to track this connection
  5. Sets up event listeners for messages, close, and error events
  6. Sends the message history to the new client
  7. Broadcasts a join notification to all other clients
  8. Returns the client-side WebSocket in the response

The key insight is that each chat room is a single Durable Object instance. All clients connecting to the same room connect to the same instance, which maintains the WebSocket connections in memory.

Build the WebSocket API endpoint

Now create an API endpoint that routes WebSocket connections to the appropriate Durable Object. Create src/pages/api/chat/[roomId].ts:

src/pages/api/chat/[roomId].ts
import type { APIRoute } from 'astro'
/**
* GET /api/chat/:roomId
*
* Handles WebSocket upgrade requests and connects clients to the chat room
*/
export const GET: APIRoute = async ({ params, request, locals }) => {
const { roomId } = params
if (!roomId)
return new Response('Room ID is required', { status: 400 })
// Get the ChatRoom Durable Object namespace
const ChatRoomNamespace = locals.runtime.env.ChatRoom
if (!ChatRoomNamespace)
return new Response('Chat room binding not found', { status: 500 })
// Get a Durable Object ID for this room
// idFromName ensures the same room ID always maps to the same Durable Object
const durableObjectId = ChatRoomNamespace.idFromName(roomId)
// Get a stub (reference) to the Durable Object
const durableObjectStub = ChatRoomNamespace.get(durableObjectId)
// Forward the request to the Durable Object
// The Durable Object's fetch method will handle the WebSocket upgrade
return durableObjectStub.fetch(request)
}

This endpoint is simple but critical:

  1. It extracts the roomId from the URL
  2. Gets the ChatRoom Durable Object namespace from the environment (note: this matches the binding name in wrangler.jsonc)
  3. Creates a Durable Object ID using idFromName(roomId) - this ensures the same room ID always gets the same Durable Object instance
  4. Gets a stub (reference) to that Durable Object
  5. Forwards the entire request to the Durable Object’s fetch method

The idFromName method is important: it deterministically maps a string (the room ID) to a specific Durable Object instance. This means all clients joining “room-123” will connect to the exact same Durable Object.

Create the chat client interface

Create a chat room page at src/pages/chat/[roomId].astro:

src/pages/chat/[roomId].astro
---
const { roomId } = Astro.params;
if (!roomId) return Astro.redirect("/");
// Generate a random user ID and username (in production, use authentication)
const userId = crypto.randomUUID();
const defaultUsername = `User${Math.floor(Math.random() * 10000)}`;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chat Room: {roomId}</title>
</head>
<body>
<div id="app">
<h1>Chat Room: {roomId}</h1>
<div id="connection-status">
<span id="status-indicator">●</span>
<span id="status-text">Connecting...</span>
</div>
<div id="presence-container">
<strong>Online Users:</strong>
<div id="user-list"></div>
</div>
<div id="messages-container"></div>
<form id="message-form">
<input
type="text"
id="message-input"
placeholder="Type a message..."
autocomplete="off"
required
/>
<button type="submit">Send</button>
</form>
</div>
<script define:vars={{ roomId, userId, defaultUsername }}>
// WebSocket connection
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
let username = defaultUsername;
// DOM elements
const statusIndicator = document.getElementById("status-indicator");
const statusText = document.getElementById("status-text");
const messagesContainer =
document.getElementById("messages-container");
const messageForm = document.getElementById("message-form");
const messageInput = document.getElementById("message-input");
const userList = document.getElementById("user-list");
/**
* Connect to the WebSocket server
*/
function connect() {
// Determine WebSocket protocol (ws:// or wss://)
const protocol =
window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
// Build WebSocket URL
const wsUrl = `${protocol}//${host}/api/chat/${roomId}?userId=${userId}&username=${encodeURIComponent(username)}`;
// Create WebSocket connection
ws = new WebSocket(wsUrl);
// Connection opened
ws.addEventListener("open", () => {
console.log("WebSocket connected");
reconnectAttempts = 0;
updateConnectionStatus("connected");
});
// Listen for messages
ws.addEventListener("message", (event) => {
handleMessage(event.data);
});
// Connection closed
ws.addEventListener("close", () => {
console.log("WebSocket closed");
updateConnectionStatus("disconnected");
attemptReconnect();
});
// Connection error
ws.addEventListener("error", (error) => {
console.error("WebSocket error:", error);
updateConnectionStatus("error");
});
}
/**
* Handle incoming WebSocket messages
*/
function handleMessage(data) {
try {
const message = JSON.parse(data);
switch (message.type) {
case "history":
// Received message history
message.messages.forEach((msg) =>
displayMessage(msg),
);
break;
case "message":
// Received a new chat message
displayMessage(message);
break;
case "join":
// User joined
displaySystemMessage(
`${message.username} joined the room`,
);
break;
case "leave":
// User left
displaySystemMessage(
`${message.username} left the room`,
);
break;
case "presence":
// Update user list
updateUserList(message.users);
break;
default:
console.warn("Unknown message type:", message.type);
}
} catch (error) {
console.error("Error parsing message:", error);
}
}
/**
* Display a chat message in the UI
*/
function displayMessage(message) {
const messageEl = document.createElement("div");
messageEl.className = "message";
const isOwnMessage = message.userId === userId;
if (isOwnMessage) {
messageEl.classList.add("own-message");
}
const timestamp = new Date(
message.timestamp,
).toLocaleTimeString();
messageEl.innerHTML = `
<div class="message-header">
<strong>${escapeHtml(message.username)}</strong>
<span class="message-time">${timestamp}</span>
</div>
<div class="message-content">${escapeHtml(message.content)}</div>
`;
messagesContainer.appendChild(messageEl);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
/**
* Display a system message (join/leave notifications)
*/
function displaySystemMessage(text) {
const messageEl = document.createElement("div");
messageEl.className = "system-message";
messageEl.textContent = text;
messagesContainer.appendChild(messageEl);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
/**
* Update the connection status indicator
*/
function updateConnectionStatus(status) {
if (status === "connected") {
statusIndicator.style.color = "#10b981";
statusText.textContent = "Connected";
} else if (status === "disconnected") {
statusIndicator.style.color = "#ef4444";
statusText.textContent = "Disconnected";
} else if (status === "error") {
statusIndicator.style.color = "#f59e0b";
statusText.textContent = "Connection error";
} else {
statusIndicator.style.color = "#6b7280";
statusText.textContent = "Connecting...";
}
}
/**
* Update the user list
*/
function updateUserList(users) {
userList.innerHTML = "";
users.forEach((user) => {
const userEl = document.createElement("span");
userEl.className = "user-badge";
userEl.textContent = user.username;
userList.appendChild(userEl);
});
}
/**
* Attempt to reconnect if connection is lost
*/
function attemptReconnect() {
if (reconnectAttempts >= maxReconnectAttempts) {
displaySystemMessage(
"Failed to reconnect. Please refresh the page.",
);
return;
}
reconnectAttempts++;
const delay = Math.min(
1000 * Math.pow(2, reconnectAttempts),
10000,
);
displaySystemMessage(
`Reconnecting in ${delay / 1000} seconds... (attempt ${reconnectAttempts}/${maxReconnectAttempts})`,
);
setTimeout(() => {
connect();
}, delay);
}
/**
* Send a message
*/
function sendMessage(content) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
displaySystemMessage("Cannot send message: not connected");
return;
}
ws.send(
JSON.stringify({
type: "message",
content: content.trim(),
}),
);
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// Handle form submission
messageForm.addEventListener("submit", (e) => {
e.preventDefault();
const content = messageInput.value.trim();
if (content) {
sendMessage(content);
messageInput.value = "";
}
});
// Prompt for username on page load
const savedUsername = localStorage.getItem("chat-username");
if (savedUsername) {
username = savedUsername;
} else {
const promptedUsername = prompt(
"Enter your username:",
defaultUsername,
);
if (promptedUsername && promptedUsername.trim()) {
username = promptedUsername.trim();
localStorage.setItem("chat-username", username);
}
}
// Connect on page load
connect();
// Clean up on page unload
window.addEventListener("beforeunload", () => {
if (ws) {
ws.close();
}
});
</script>
</body>
</html>

This page provides a fully functional chat interface. It manages the WebSocket connection to the backend, handles sending and receiving messages in real time, and displays notifications for when users join or leave the chat. The interface also shows the list of users currently online, keeps track of the connection status with a visual indicator, and automatically attempts to reconnect if the connection drops, using a progressive delay strategy. To keep usernames consistent across sessions, it stores them in the browser’s localStorage. All user-generated content is carefully escaped before being displayed, which helps protect the application from cross-site scripting (XSS) attacks.

The key parts:

  • WebSocket URL construction: Uses the current hostname and the /api/chat/[roomId] endpoint
  • Message protocol: Sends/receives JSON messages with a type field
  • Reconnection strategy: Exponential backoff (1s, 2s, 4s, 8s and 10s (max))
  • Username persistence: Stores username in localStorage

Add message persistence with Durable Object storage

The message persistence is already implemented in the ChatRoom Durable Object. Here’s how it works:

src/worker.ts
// In the constructor, load message history from storage
this.ctx.blockConcurrencyWhile(async () => {
const stored = await this.ctx.storage.get<ChatMessage[]>('messages')
if (stored) {
this.messageHistory = stored
}
})
// When a new message arrives, persist it
this.messageHistory.push(message)
// Limit to last 100 messages
if (this.messageHistory.length > 100) {
this.messageHistory = this.messageHistory.slice(-100)
}
// Save to storage
this.ctx.storage.put('messages', this.messageHistory)

The blockConcurrencyWhile method ensures storage is loaded before accepting any requests. This prevents race conditions during initialization.

Implement user presence tracking

User presence is automatically handled by tracking active WebSocket sessions:

src/worker.ts
// When a user connects
this.sessions.add(session)
// Send current presence to the new user
const users = Array.from(this.sessions).map((s) => ({
userId: s.userId,
username: s.username,
}))
// When a user disconnects
this.sessions.delete(session)
// Broadcast leave notification
this.broadcast({
type: 'leave',
userId: session.userId,
username: session.username,
timestamp: Date.now(),
})

This gives real-time presence updates:

  • New users see the current user list immediately
  • All users are notified when someone joins or leaves
  • The presence is maintained in memory (no database queries needed)
  • If a connection drops, the user is automatically removed

Handle reconnection and error recovery

The client implements robust reconnection logic:

src/pages/chat/[roomId].astro
function attemptReconnect() {
if (reconnectAttempts >= maxReconnectAttempts) {
displaySystemMessage('Failed to reconnect. Please refresh the page.')
return
}
reconnectAttempts++
// Exponential backoff: 1s, 2s, 4s, 8s, 10s (max)
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000)
setTimeout(() => {
connect()
}, delay)
}

Exponential backoff helps avoid flooding the server with repeated reconnection attempts. It gives the server time to recover and helps maintain network stability. The client will automatically try to reconnect if the WebSocket closes, encounters an error, or the server restarts.

Deploy to Cloudflare Pages

Deploy your real-time chat application to production:

Terminal window
# Build the project
npm run build
# Deploy to Cloudflare Pages
npx wrangler deploy

Conclusion

You now have a real-time chat app built with Astro and Cloudflare Durable Objects, featuring instant messaging, message storage, user presence, room separation, and global low-latency with no servers to manage. This architecture scales by room, stays fast worldwide, and gives you a foundation for any real-time features like chat, notifications, or live collaboration. For production, add authentication, abuse protection, monitoring, error tracking, and test reconnection and security for a robust deployment.

If you have any questions or comments, feel free to reach out to me on Twitter.

Learn More Implementing Incremental Static Regeneration (ISR) in Astro with Cloudflare KV
Implementing Incremental Static Regeneration (ISR) in Astro with Cloudflare KV December 3, 2025
Protecting Astro from Supply Chain Attacks: Part 2 - Long-Term Security Measures
Protecting Astro from Supply Chain Attacks: Part 2 - Long-Term Security Measures December 1, 2025
Protecting Astro from Supply Chain Attacks: Part 1 - Understanding Shai-Hulud 2.0 and Immediate Response
Protecting Astro from Supply Chain Attacks: Part 1 - Understanding Shai-Hulud 2.0 and Immediate Response November 30, 2025