Full-stack development with Play Framework (Java) and React, covering non-blocking REST APIs, real-time WebSockets with Akka Streams, Redux state management, and deployment on AWS EC2 and Firebase.
Table of Contents
- Course Overview
- Application Architecture
- Module 1 — Getting Started with Play Framework
- Module 2 — WebSocket and Akka Streams
- Module 3 — Frontend React + WebSocket + Redux
- Module 4 — Deployment
- Architecture Diagrams
- Reference Tables
- Key Points and Best Practices
1. Course Overview
This course builds a complete full-stack application with three main features:
| Feature | Main technology | Communication type |
|---|---|---|
| Real-time chat | Play WebSocket + Akka Streams | Bidirectional WebSocket |
| Collaborative document editor | Play WebSocket + React SimpleMDE | Bidirectional WebSocket |
| Streaming log viewer | Akka Streams + SSE | HTTP Streaming (Server-Sent Events) |
Full technology stack
| Layer | Technology | Version |
|---|---|---|
| Backend framework | Play Framework | 2.8.22 |
| Backend language | Java | 11 |
| Build tool | SBT (Scala Build Tool) | 1.11.x |
| Streaming | Akka Streams | 2.6.20 |
| ORM | Hibernate + JPA | 5.6.15 |
| Database | H2 (in-memory) | 2.2.220 |
| Dependency injection | Guice | (via Play) |
| Frontend framework | React | 18+ |
| Frontend build | Vite | latest |
| State management | Redux Toolkit | latest |
| Navigation | React Router DOM | latest |
| Styles | Bootstrap | latest |
| Backend hosting | AWS EC2 (Ubuntu) | — |
| Frontend hosting | Firebase Hosting | — |
2. Application Architecture
Overall full-stack architecture
graph TB
subgraph "Frontend — Firebase Hosting"
A[React App<br/>Vite + Bootstrap]
B[Redux Store]
C[React Router]
end
subgraph "Backend — AWS EC2 / localhost:9000"
D[Play Framework 2.8.x]
E[Akka Streams<br/>Akka HTTP Server]
F[ChatController<br/>WebSocket]
G[DocumentWebSocketController<br/>WebSocket]
H[DocumentController<br/>REST API]
I[LogStreamController<br/>SSE Streaming]
J[JPAApi<br/>Hibernate]
end
subgraph "Database"
K[(H2 In-Memory DB)]
end
subgraph "Security"
L[Cloudflare Tunnel<br/>HTTPS / WSS]
end
A -- "WS ws://host/ws/chat" --> F
A -- "WS ws://host/ws/documents" --> G
A -- "HTTP GET/POST /documents" --> H
A -- "HTTP GET /api/stream/logs" --> I
D --> E
F --> E
G --> E
H --> J
J --> K
L -- "HTTPS / WSS" --> D
B --> A
C --> A
Play Framework MVC Flow
sequenceDiagram
participant Client as Browser / Client
participant Routes as routes (conf/routes)
participant Controller as Controller (Java)
participant JPA as JPAApi / Hibernate
participant DB as H2 Database
Client->>Routes: HTTP Request (GET/POST /documents)
Routes->>Controller: Dispatch to the corresponding method
Controller->>JPA: withTransaction(em -> em.persist(doc))
JPA->>DB: SQL INSERT / SELECT
DB-->>JPA: Result
JPA-->>Controller: Object or list
Controller-->>Client: HTTP Response (JSON, 200/201)
WebSocket Flow with Akka Streams
sequenceDiagram
participant C1 as Client 1 (John)
participant C2 as Client 2 (Emma)
participant Play as Play WebSocket<br/>(Akka Streams)
participant Sink as Sink (read)
participant Queue as SourceQueue<br/>(queue)
C1->>Play: WebSocket connection
Play->>Queue: Create SourceQueue for C1
C2->>Play: WebSocket connection
Play->>Queue: Create SourceQueue for C2
C1->>Sink: JSON { type:"chat", message:"Hello there!" }
Sink->>Queue: offer(message) → C1's queue
Sink->>Queue: offer(message) → C2's queue
Queue-->>C1: Message broadcast
Queue-->>C2: Message broadcast
React state management with Redux
flowchart LR
Comp["React Component<br/>(Documents.jsx)"] -- "dispatch(saveDocument(doc))" --> Action
Action["Action<br/>{ type: 'SAVE_DOCUMENT',<br/>payload: doc }"] --> Reducer
Reducer["Reducer<br/>documentReducer"] -- "New state" --> Store
Store["Redux Store<br/>{ documents: [...] }"] -- "useSelector(selectDocument)" --> Comp
3. Module 1 — Getting Started with Play Framework
3.1 Prerequisites and setup
| Tool | Recommended version | Notes |
|---|---|---|
| Java JDK | 11 | Play 2.8.x is officially tested with Java 11 |
| SBT | 1.11.x | Download via MSI on Windows |
| IDE | IntelliJ Ultimate or VS Code | Plugins: Scala, Play 2 Routes |
Recommended IntelliJ plugins:
- Scala — Play is built on Scala, and SBT is Scala-based
- Play 2 Routes — Syntax highlighting and navigation in the
routesfile - Scala (Metals) for VS Code
Startup commands:
# Clone the starter project
git clone <repo-url>
cd colab-flow-backend-play-framework
# Clean and compile (downloads all dependencies)
sbt clean compile
# Start the Play server (localhost:9000)
sbt run
3.2 Play project structure
colab-flow-backend-play-framework/
├── app/
│ ├── controllers/
│ │ ├── HomeController.java
│ │ ├── DocumentController.java ← REST API (GET/POST /documents)
│ │ ├── ChatController.java ← WebSocket /ws/chat
│ │ ├── DocumentWebSocketController.java ← WebSocket /ws/documents
│ │ └── LogStreamController.java ← SSE /api/stream/logs
│ ├── models/
│ │ └── Document.java ← JPA Entity
│ ├── filters/
│ │ └── DefaultHttpFilters.java ← CORS
│ ├── utility/
│ │ └── LogQueue.java ← Thread-safe log queue
│ └── views/ ← Not used (React replaces templates)
├── conf/
│ ├── application.conf ← Main configuration
│ ├── prod.conf ← Production configuration (EC2)
│ └── routes ← Endpoint definitions
├── project/
│ └── plugins.sbt ← Play Framework plugin
├── build.sbt ← Dependencies and project configuration
└── public/ ← Static assets
Note: The
views/folder is auto-created by Play for Scala HTML templates, but is not used in this course as the frontend is entirely managed by React.
3.3 Configuration (application.conf)
# H2 in-memory database
db.default.driver = org.h2.Driver
db.default.url = "jdbc:h2:mem:play;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false"
db.default.username = "sa"
db.default.password = ""
# JPA / Hibernate
jpa.default = defaultPersistenceUnit
play.jpa.default = "defaultPersistenceUnit"
# Evolutions — automatic JPA table creation
play.evolutions.enabled = true
play.evolutions.autoApply = true
# CORS filter — allow Vite frontend (dev)
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.cors {
allowedOrigins = ["http://localhost:5173"]
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Origin", "Content-Type", "Authorization"]
allowCredentials = true
}
Important points:
DB_CLOSE_DELAY=-1: prevents H2 from closing the database when the connection closesDATABASE_TO_UPPER=false: prevents H2 from uppercasing column and table names- Evolutions configured with
autoApply = trueautomatically create tables from@EntityJPA entities OPTIONSinallowedHttpMethodshandles browser preflight requests before actual CORS requests
3.4 JPA Entity — Document
package models;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
@Entity
public class Document {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
@NotNull
public String title;
public String content;
public String username;
// No-argument constructor required by JPA
public Document() {}
public Document(Long id, String title, String content, String username) {
this.id = id;
this.title = title;
this.content = content;
this.username = username;
}
}
| Annotation | Role |
|---|---|
@Entity | Tells JPA this class is a database table |
@Id | Primary key |
@GeneratedValue(IDENTITY) | Auto-increment primary key |
@NotNull | Validation constraint — title is required |
3.5 Non-blocking REST APIs
Why non-blocking APIs?
flowchart LR
subgraph "Blocking approach"
R1[Request] --> T1[Allocated thread]
T1 -- "Waits 40ms" --> DB1[(DB)]
DB1 --> T1
T1 --> Resp1[Response]
note1["500 requests = 500 threads waiting 🚫"]
end
subgraph "Non-blocking approach"
R2[Request] --> T2[Main thread]
T2 -- "delegates to ForkJoinPool" --> BG[Background Thread]
T2 -- "released immediately ✅" --> Others[Other requests]
BG --> DB2[(DB)]
DB2 --> BG
BG --> Resp2[Response]
end
POST API — Create a document
public CompletableFuture<Result> createDocument(Http.Request request) {
LogQueue.logs.add("Creating a new document");
return CompletableFuture.supplyAsync(() -> {
JsonNode body = request.body().asJson();
Document doc = Json.fromJson(body, Document.class);
jpaApi.withTransaction(em -> {
em.persist(doc);
});
LogQueue.logs.add("Document created successfully");
return created(Json.toJson(doc)); // HTTP 201
});
}
GET API — Retrieve all documents
public CompletableFuture<Result> getAllDocuments() {
LogQueue.logs.add("Getting all documents");
return CompletableFuture.supplyAsync(() ->
jpaApi.withTransaction(em -> {
List<Document> docs = em.createQuery("FROM Document", Document.class)
.getResultList();
return ok(Json.toJson(docs)); // HTTP 200
})
);
}
CompletableFuture.supplyAsync mechanism:
- Uses Java’s internal
ForkJoinPoolto delegate work to a background thread - The main thread is released immediately to handle other requests
- JPA operations must be inside
withTransaction()for transaction management
3.6 routes file
# Routes — conf/routes
GET / controllers.HomeController.index()
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
# REST API Documents
GET /documents controllers.DocumentController.getAllDocuments
POST /documents controllers.DocumentController.createDocument(request: Request)
# WebSocket Chat
GET /ws/chat controllers.ChatController.chatSocket
# WebSocket Documents (collaboration)
GET /ws/documents controllers.DocumentWebSocketController.documentSocket
# SSE Streaming
GET /api/stream/logs controllers.LogStreamController.streamLogs
Note: Methods receiving a
request(JSON body) must haverequest: Requestin theroutesdeclaration. The signature must match the Java controller method.
3.7 Build — build.sbt
name := """play-colab-tool"""
organization := "com.example"
version := "1.0-SNAPSHOT"
scalaVersion := "2.13.16"
lazy val root = (project in file("."))
.enablePlugins(PlayJava)
libraryDependencies ++= Seq(
// Streaming and WebSocket
"com.typesafe.akka" %% "akka-stream" % "2.6.20",
// Akka HTTP server (more stable for WS than Netty)
"com.typesafe.play" %% "play-akka-http-server" % "2.8.22",
// H2 in-memory database
"com.h2database" % "h2" % "2.2.220",
// Hibernate ORM
"org.hibernate" % "hibernate-core" % "5.6.15.Final",
// JPA API
"javax.persistence" % "javax.persistence-api" % "2.2",
// JPA integration in Play
"com.typesafe.play" %% "play-java-jpa" % "2.8.22",
// Guice dependency injection
"com.typesafe.play" %% "play-guice" % "2.8.22",
// CORS, CSRF, security headers
"com.typesafe.play" %% "filters-helpers" % "2.8.22"
)
4. Module 2 — WebSocket and Akka Streams
4.1 Microphone/Headset analogy
To understand the WebSocket architecture with Akka Streams:
| Akka concept | Analogy | Role |
|---|---|---|
| Sink | Microphone | Reads incoming messages from the client |
| Source / SourceQueueWithComplete | Headset/Earphones | Sends messages to the client |
| Flow | Zoom connection | Combines Sink + Source into a bidirectional stream |
| Materializer | Physical wiring | Instantiates and executes the stream graph |
| OverflowStrategy.dropHead | Bounded FIFO queue | If buffer is full, drops the oldest message |
4.2 Sink, Source, and Flow
flowchart LR
Client["Client Browser"] -- "incoming messages" --> Sink
Sink["Sink.foreach(msg → broadcast)"] -- "offer(msg)" --> Queue1["Queue Client 1"]
Sink -- "offer(msg)" --> Queue2["Queue Client 2"]
Sink -- "offer(msg)" --> QueueN["Queue Client N"]
Queue1 -- "stream" --> Source1["clientSource 1"]
Queue2 -- "stream" --> Source2["clientSource 2"]
QueueN -- "stream" --> SourceN["clientSource N"]
Source1 -- "WebSocket push" --> C1["Client 1"]
Source2 -- "WebSocket push" --> C2["Client 2"]
SourceN -- "WebSocket push" --> CN["Client N"]
Key parameters of Source.queue:
| Parameter | Value | Meaning |
|---|---|---|
bufferSize | 10 | Maximum number of messages in the queue |
OverflowStrategy.dropHead | drop oldest | If queue is full, drops the oldest message |
Production use case: For payment apps or critical notifications,
dropHeadis unsuitable. You need to cache messages and resend them when the client is ready. For a real-time location tracker (data per second),dropHeadis acceptable as only the latest position matters.
4.3 ChatController — chat WebSocket
package controllers;
import akka.NotUsed;
import akka.stream.Materializer;
import akka.stream.OverflowStrategy;
import akka.stream.javadsl.*;
import play.libs.F;
import play.mvc.Controller;
import play.mvc.WebSocket;
import akka.Done;
import akka.japi.Pair;
import javax.inject.Inject;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CompletionStage;
public class ChatController extends Controller {
private final Materializer materializer;
// CopyOnWriteArrayList = thread-safe for concurrent access
private final List<SourceQueueWithComplete<String>> chatClients =
new CopyOnWriteArrayList<>();
@Inject
public ChatController(Materializer materializer) {
this.materializer = materializer;
}
public WebSocket chatSocket() {
return WebSocket.Text.acceptOrResult(request -> {
// 1. Create a Source with buffer size 10
Source<String, SourceQueueWithComplete<String>> source =
Source.queue(10, OverflowStrategy.dropHead());
// 2. Materialize the Source → get (queue, source)
Pair<SourceQueueWithComplete<String>, Source<String, NotUsed>> pair =
source.preMaterialize(materializer);
SourceQueueWithComplete<String> clientQueue = pair.first();
Source<String, NotUsed> clientSource = pair.second();
// 3. Add queue to list of connected clients
chatClients.add(clientQueue);
// Automatically remove when connection closes
clientQueue.watchCompletion().thenRun(() -> chatClients.remove(clientQueue));
// 4. Sink: read incoming messages and broadcast to all
Sink<String, CompletionStage<Done>> sink = Sink.foreach(rawMsg -> {
for (SourceQueueWithComplete<String> q : chatClients) {
q.offer(rawMsg); // Send to each connected client
}
});
// 5. Combine Sink + Source into a Flow
Flow<String, String, NotUsed> flow = Flow.fromSinkAndSource(sink, clientSource);
return CompletableFuture.completedFuture(F.Either.Right(flow));
});
}
}
Chat JSON protocol:
// Chat message
{ "type": "chat", "username": "john@techcorp.com", "message": "Hello Emma!" }
// Typing indicator
{ "type": "typing", "username": "john@techcorp.com" }
4.4 DocumentWebSocketController
package controllers;
import akka.NotUsed;
import akka.stream.Materializer;
import akka.stream.OverflowStrategy;
import akka.stream.javadsl.*;
import play.libs.F;
import play.mvc.Controller;
import play.mvc.WebSocket;
import akka.Done;
import akka.japi.Pair;
import javax.inject.Inject;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CompletionStage;
public class DocumentWebSocketController extends Controller {
private final Materializer materializer;
private final List<SourceQueueWithComplete<String>> allClients =
new CopyOnWriteArrayList<>();
@Inject
public DocumentWebSocketController(Materializer materializer) {
this.materializer = materializer;
}
public WebSocket documentSocket() {
return WebSocket.Text.acceptOrResult(request -> {
Source<String, SourceQueueWithComplete<String>> source =
Source.queue(10, OverflowStrategy.dropHead());
Pair<SourceQueueWithComplete<String>, Source<String, NotUsed>> pair =
source.preMaterialize(materializer);
SourceQueueWithComplete<String> clientQueue = pair.first();
Source<String, NotUsed> clientSource = pair.second();
allClients.add(clientQueue);
clientQueue.watchCompletion().thenRun(() -> allClients.remove(clientQueue));
Sink<String, CompletionStage<Done>> sink = Sink.foreach((String rawMsg) -> {
try {
// Broadcast as-is — frontend handles message types
for (SourceQueueWithComplete<String> q : allClients) {
q.offer(rawMsg);
}
} catch (Exception e) {
System.out.println("Invalid message: " + rawMsg);
}
});
Flow<String, String, NotUsed> flow = Flow.fromSinkAndSource(sink, clientSource);
return CompletableFuture.completedFuture(F.Either.Right(flow));
});
}
}
Document JSON protocol:
type | Trigger | Client-side action |
|---|---|---|
"doc-init" | Creating a new document | Show notification to other users |
"doc-update" | Content modification | Synchronize the Markdown editor in real time |
"typing" | Typing in progress | Show “X is typing…“ |
4.5 LogStreamController — SSE Streaming
package controllers;
import akka.stream.javadsl.Source;
import akka.util.ByteString;
import play.mvc.Controller;
import play.mvc.Result;
import utility.LogQueue;
import java.time.Duration;
public class LogStreamController extends Controller {
public Result streamLogs() {
Source<ByteString, ?> source =
Source.tick(
Duration.ofSeconds(0), // Start immediately
Duration.ofSeconds(1), // Tick every second
"tick"
)
.map(tick -> {
StringBuilder sb = new StringBuilder();
while (!LogQueue.logs.isEmpty()) {
sb.append("data: ").append(LogQueue.logs.poll()).append("\n\n");
}
String output = sb.toString();
if (output.isEmpty()) {
// Send "..." to prevent the browser from closing the connection
return ByteString.fromString("data: ...\n\n");
}
return ByteString.fromString(output);
});
// text/event-stream = SSE format expected by browsers
return ok().chunked(source).as("text/event-stream");
}
}
SSE (Server-Sent Events) format:
data: Creating a new document\n\n
data: Document created successfully\n\n
data: Getting all documents\n\n
data: ...\n\n
Important: The double
\n\nis the SSE event delimiter. Without it, the browser does not recognize the end of an event. If no data is sent for a few minutes, some browsers close the connection — that’s why we continuously send....
5. Module 3 — Frontend React + WebSocket + Redux
5.1 Creating the project with Vite
# Create the React project with Vite
npm create vite@latest colab-flow-ui -- --template react
# Navigate to the folder
cd colab-flow-ui
# Install base dependencies
npm install
# Install additional dependencies
npm install react-router-dom bootstrap
npm install react-redux @reduxjs/toolkit
npm install react-simplemde-editor easymde
# Start the development server (hot reload)
npm run dev
# → http://localhost:5173
UI project structure:
UI/
├── index.html
├── vite.config.js
├── firebase.json ← Firebase Hosting config
├── package.json
└── src/
├── main.jsx ← React entry point + Redux Provider
├── App.jsx ← Root component + React Router
├── config.js ← Backend URLs (dev/prod)
├── users.js ← Demo users
├── components/
│ ├── Login.jsx ← Login form
│ ├── Dashboard.jsx ← Main layout with Outlet
│ ├── Navbar.jsx ← Bootstrap navigation
│ ├── Chat.jsx ← Real-time chat (WebSocket)
│ ├── Documents.jsx ← Collaborative editor (WebSocket + Redux)
│ └── DocumentList.jsx ← Saved documents list
└── store/
├── store.js ← Redux store configuration
└── document/
├── documentActions.js ← Redux actions
├── documentReducer.js ← Pure reducer
└── documentSelectors.js ← State selectors
5.2 Endpoint configuration
// src/config.js
export const endpoints = {
dev: {
apiBase: 'http://localhost:9000',
wsBase: 'ws://localhost:9000',
},
prod: {
apiBase: 'https://your-cloudflare-tunnel.trycloudflare.com',
wsBase: 'wss://your-cloudflare-tunnel.trycloudflare.com',
},
};
// Switch between dev and prod here
export const API_BASE = endpoints.dev.apiBase;
export const WS_BASE = endpoints.dev.wsBase;
Production security note: Replace hardcoded URLs with environment variables (
import.meta.env.VITE_API_BASE) in a real context.
5.3 Routing and main components
// src/App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import Chat from './components/Chat';
import Documents from './components/Documents';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />}>
{/* Default route: Chat */}
<Route index element={<Chat />} />
<Route path="chat" element={<Chat />} />
<Route path="document" element={<Documents />} />
</Route>
</Routes>
</BrowserRouter>
);
}
// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store/store';
import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
);
5.4 Login component
Demo users:
// src/users.js
const users = [
{
name: 'John Doe',
username: 'john@techcorp.com',
password: 'john@123',
},
{
name: 'Emma Jones',
username: 'emma@techcorp.com',
password: 'emma@123',
}
];
export default users;
// src/components/Login.jsx (main logic)
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import users from '../users';
const Login = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const navigate = useNavigate();
const onLogin = (e) => {
e.preventDefault(); // Prevent page reload
const user = users.find(
u => u.username === username && u.password === password
);
if (user) {
localStorage.setItem('username', username);
navigate('/dashboard');
} else {
alert("Invalid credentials");
}
};
return (
<form onSubmit={onLogin}>
<input value={username} onChange={e => setUsername(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button type="submit">Login</button>
</form>
);
};
Production warning: This implementation uses in-memory login to simulate a multi-user environment. In production, secure authentication is required (JWT, OAuth2, server-side sessions).
5.5 Chat component with WebSocket
// src/components/Chat.jsx
import { useEffect, useRef, useState } from "react";
import { WS_BASE } from "../config";
const Chat = () => {
const ws = useRef(null); // useRef avoids reconnections on every re-render
const [chatText, setChatText] = useState("");
const [typingStatus, setTypingStatus] = useState("");
const [messages, setMessages] = useState([]);
const [username] = useState(localStorage.getItem('username'));
useEffect(() => {
// Connect WebSocket on component mount
ws.current = new WebSocket(`${WS_BASE}/ws/chat`);
ws.current.onopen = () => console.log("WebSocket connected.");
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "chat") {
// Display all messages (including own)
setMessages(prev => [...prev, `${data.username}: ${data.message}`]);
} else if (data.type === "typing" && data.username !== username) {
// Typing indicator only for other users
setTypingStatus(`${data.username} is typing...`);
setTimeout(() => setTypingStatus(""), 3000);
}
} catch (err) {
console.error("Invalid message", err);
}
};
// Cleanup: close connection on unmount
return () => ws.current?.close();
}, [username]);
// Send a JSON payload via WebSocket
const send = (payload) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(payload));
}
};
const handleSendChat = () => {
send({ type: "chat", username, message: chatText });
setChatText("");
};
const handleTyping = () => {
send({ type: "typing", username });
};
// ... JSX with Bootstrap Cards
};
Why useRef for the WebSocket?
flowchart TB
subgraph "Without useRef (problem)"
R1[Re-render] --> NW1[new WebSocket created]
NW1 --> DC1[Duplicate connections]
DC1 --> ERR[Multiple active event handlers ❌]
end
subgraph "With useRef (solution)"
R2[Re-render] --> REF[ws.current unchanged]
REF --> SAME[Same WebSocket connection ✅]
SAME --> OK[Single event handler]
end
5.6 Documents component — real-time collaboration
// src/components/Documents.jsx (main logic)
import { useEffect, useRef, useState } from "react";
import { useDispatch } from "react-redux";
import { API_BASE, WS_BASE } from "../config";
import { saveDocument } from "../store/document/documentActions";
const Documents = () => {
const [username] = useState(localStorage.getItem('username'));
const ws = useRef(null);
const [title, setTitle] = useState("");
const [value, setValue] = useState(""); // Markdown content
const [docOwner, setDocOwner] = useState(username);
const [isDocOpen, setIsDocOpen] = useState(false);
const [docNotification, setDocNotification] = useState(null);
const [typingStatus, setTypingStatus] = useState("");
const dispatch = useDispatch();
useEffect(() => {
ws.current = new WebSocket(`${WS_BASE}/ws/documents`);
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "doc-init" && data.username !== username) {
// Notification for other users
setDocNotification({
title: data.title,
content: data.message,
username: data.username,
});
} else if (data.type === "typing" && data.username !== username) {
setTypingStatus(`${data.username} is typing...`);
setTimeout(() => setTypingStatus(""), 2000);
} else if (data.type === "doc-update") {
// Real-time content synchronization
setValue(data.message);
}
} catch (err) {
console.error("Invalid message", err);
}
};
return () => ws.current?.close();
}, []);
const send = (payload) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(payload));
}
};
// Send the initial document to all clients
const handleSendDoc = () => {
if (!title.trim()) return alert("Please enter a title.");
send({ type: "doc-init", username, title: title.trim(), message: value });
setDocOwner(username);
setIsDocOpen(true);
};
// Broadcast content updates
const handleUpdateDoc = () => {
send({ type: "doc-update", username, message: value });
};
// Save via REST API — only the owner
const handleSaveDoc = async () => {
if (docOwner && docOwner !== username) return; // Only owner saves
try {
const res = await fetch(`${API_BASE}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), content: value, username }),
});
if (!res.ok) return alert("Failed to save document.");
const saved = await res.json();
// Dispatch to Redux store
dispatch(saveDocument({
...saved,
createdAt: new Date().toLocaleString("en-US")
}));
} catch (err) {
console.error("Save failed", err);
}
};
};
5.7 Redux — Global state management
Actions
// src/store/document/documentActions.js
export const saveDocument = (document) => ({
type: "SAVE_DOCUMENT",
payload: document
});
export const setDocuments = (documents) => ({
type: "SET_DOCUMENTS",
payload: documents
});
Reducer
// src/store/document/documentReducer.js
const initialState = {
documents: []
};
const documentReducer = (state = initialState, action) => {
switch (action.type) {
case "SAVE_DOCUMENT":
return {
...state,
// Spread operator = clone state (Redux immutability)
documents: [...state.documents, action.payload],
};
case "SET_DOCUMENTS":
return {
...state,
documents: action.payload
};
default:
return state;
}
};
export default documentReducer;
Selector
// src/store/document/documentSelectors.js
export const selectDocument = (state) => state.document;
Store
// src/store/store.js
import { configureStore } from "@reduxjs/toolkit";
import documentReducer from "./document/documentReducer";
const store = configureStore({
reducer: {
document: documentReducer
}
});
export default store;
Key Redux principles:
| Concept | Definition | Rule |
|---|---|---|
| Action | Object { type, payload } describing what should change | Does not modify the store directly |
| Reducer | Pure function (state, action) → newState | No side effects |
| Store | Central database of the app | Single source of truth |
| Selector | Function that extracts a part of the store | Declared in documentSelectors.js |
| dispatch | Sends an action to the reducer | Called via useDispatch() hook |
| Immutability | Never mutate state directly | Always use spread {...state} |
6. Module 4 — Deployment
6.1 Deploying the backend on AWS EC2
flowchart LR
A[Local Machine] -- "sbt clean dist" --> B[target/universal/*.zip]
B -- "scp via PEM" --> C[AWS EC2 Ubuntu]
C -- "unzip + run" --> D[Play App :9000]
Backend deployment steps:
# 1. Generate a secret key (KeyGen.java class with SecureRandom)
# 2. Create prod.conf with the secret key
# 3. Generate the distribution package
sbt clean dist
# → creates target/universal/play-colab-tool-1.0-SNAPSHOT.zip
# 4. On EC2 — set PEM file permissions
chmod 400 your-key.pem
# 5. Connect to EC2 instance
ssh -i your-key.pem ubuntu@<ec2-public-ip>
# 6. Transfer and extract the zip
scp -i your-key.pem target/universal/*.zip ubuntu@<ec2-ip>:~/
unzip *.zip
# 7. Launch the Play app in production
./bin/play-colab-tool -Dconfig.file=conf/prod.conf
# 8. Launch in background with nohup
nohup ./bin/play-colab-tool -Dconfig.file=conf/prod.conf &
conf/prod.conf:
include "application.conf"
# Secret key generated by SecureRandom
play.http.secret.key = "${APPLICATION_SECRET}"
# Allow the Cloudflare tunnel domain
play.filters.hosts {
allowed = [".trycloudflare.com", "localhost:9000"]
}
6.2 Deploying the frontend on Firebase
# 1. Create a Firebase project at console.firebase.google.com
# 2. Install Firebase CLI and log in
npx firebase-tools login
# 3. Initialize Firebase Hosting
npx firebase-tools init hosting
# → Select the created Firebase project
# → Single page app: Yes
# → GitHub auto-builds: No (for this course)
# 4. Edit firebase.json — change "public" → "dist"
# {
# "hosting": {
# "public": "dist", ← here
# "rewrites": [{ "source": "**", "destination": "/index.html" }]
# }
# }
# 5. Production build
npm run build
# → creates the dist/ folder
# 6. Delete the automatically generated public/ folder
rm -rf public/
# 7. Deploy
npx firebase-tools deploy
# → Provides the Firebase Hosting URL of the app
6.3 SSL with Cloudflare Tunnel
Firebase requires HTTPS/WSS for connections. A Cloudflare tunnel provides temporary SSL for testing:
# On the EC2 instance
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
dpkg -i cloudflared-linux-amd64.deb
# Open tunnel to the Play app
cloudflared tunnel --url http://localhost:9000
# → Returns a temporary URL: https://xxx.trycloudflare.com
// Update config.js with the Cloudflare domain
export const endpoints = {
prod: {
apiBase: 'https://xxx.trycloudflare.com',
wsBase: 'wss://xxx.trycloudflare.com',
},
};
Note: For permanent deployment, register a domain with a proper SSL certificate (Let’s Encrypt, AWS Certificate Manager). The Cloudflare tunnel is for development testing only.
7. Architecture Diagrams
Complete deployment architecture
graph TB
subgraph "Developer"
D[Local Machine<br/>localhost:9000 + :5173]
end
subgraph "Production"
subgraph "AWS EC2"
EC2[Ubuntu Instance]
PLAY[Play App :9000<br/>Java 11 + Akka]
CF[Cloudflare Tunnel<br/>HTTPS / WSS]
end
subgraph "Firebase"
FB[Firebase Hosting<br/>HTTPS]
REACT[React Bundle<br/>dist/]
end
end
subgraph "Users"
U1[Browser John]
U2[Browser Emma]
end
PLAY --- EC2
CF --- PLAY
REACT --- FB
U1 -- "HTTPS" --> FB
U2 -- "HTTPS" --> FB
U1 -- "WSS / HTTPS" --> CF
U2 -- "WSS / HTTPS" --> CF
Chat message lifecycle
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connected : new WebSocket(url)
Connected --> Sending : handleSendChat()
Sending --> Connected : JSON sent via ws.send()
Connected --> Receiving : ws.onmessage
Receiving --> Connected : state updated (setMessages)
Connected --> Disconnected : ws.close() (useEffect cleanup)
Document collaboration flow
sequenceDiagram
participant John as John (Browser)
participant Play as Play Backend<br/>(DocumentWebSocketController)
participant Emma as Emma (Browser)
John->>Play: WS Connect /ws/documents
Emma->>Play: WS Connect /ws/documents
John->>Play: { type:"doc-init", title:"API Design", username:"john" }
Play->>Emma: Broadcast { type:"doc-init", title:"API Design", username:"john" }
Note over Emma: Notification: John created a document
Emma->>Play: handleOpenDocument() — opens editor
John->>Play: { type:"typing", username:"john" }
Play->>Emma: Broadcast { type:"typing", username:"john" }
Note over Emma: "john is typing..."
John->>Play: { type:"doc-update", message:"# Introduction\n..." }
Play->>Emma: Broadcast { type:"doc-update", message:"..." }
Note over Emma: Markdown editor synchronized in real time
John->>Play: POST /documents (fetch API)
Play->>Play: JPA persist() in H2
Play-->>John: HTTP 201 { id:1, title:"API Design", ... }
Note over John: dispatch(saveDocument(...)) → Redux Store
8. Reference Tables
Play backend REST APIs
| Method | Endpoint | Controller | Description |
|---|---|---|---|
GET | /documents | DocumentController.getAllDocuments | Retrieves all documents (JPA) |
POST | /documents | DocumentController.createDocument | Creates a document (JSON body) |
GET | /ws/chat | ChatController.chatSocket | Chat WebSocket connection |
GET | /ws/documents | DocumentWebSocketController.documentSocket | Documents WebSocket connection |
GET | /api/stream/logs | LogStreamController.streamLogs | SSE log stream |
React hooks used
| Hook | Usage in the course | File |
|---|---|---|
useState | Manage local states (messages, title, etc.) | Chat.jsx, Documents.jsx, Login.jsx |
useEffect | WebSocket connection on mount, cleanup on unmount | Chat.jsx, Documents.jsx |
useRef | Store WebSocket instance without triggering re-render | Chat.jsx, Documents.jsx |
useNavigate | Redirect after login | Login.jsx |
useDispatch | Dispatch Redux actions | Documents.jsx |
useSelector | Read the Redux store | DocumentList.jsx |
WebSocket message types
type | Direction | Payload | Effect |
|---|---|---|---|
"chat" | Client → Broadcast | { username, message } | Display in chat feed |
"typing" | Client → Broadcast | { username } | Show “X is typing…” |
"doc-init" | Client → Broadcast | { username, title, message } | Notification + initialization |
"doc-update" | Client → Broadcast | { username, message } | Synchronize Markdown editor |
Common SBT commands
| Command | Description |
|---|---|
sbt clean compile | Clean and compile (downloads dependencies) |
sbt run | Start Play server in development mode |
sbt test | Run tests |
sbt clean dist | Generate distribution package (.zip) |
sbt ~compile | Auto-recompile on each change |
Firebase commands
| Command | Description |
|---|---|
npx firebase-tools login | Firebase authentication |
npx firebase-tools init hosting | Initialize Firebase Hosting |
npm run build | Generate production bundle (dist/) |
npx firebase-tools deploy | Deploy to Firebase Hosting |
9. Key Points and Best Practices
Non-blocking design with CompletableFuture
Always wrap I/O operations (database, network calls) in
CompletableFuture.supplyAsync()to free Play’s main thread and maintain high concurrency.
Akka Streams back-pressure management
Back-pressure occurs when a client consumes messages more slowly than the server produces them. Management strategies:
| Strategy | OverflowStrategy | Use case |
|---|---|---|
| Drop the oldest | dropHead | GPS tracker, non-critical real-time data |
| Drop the newest | dropTail | Rare |
| Queue | backpressure | Critical apps (payments) |
| Cache and resend | Custom logic | Transactional notifications |
CORS security in production
# application.conf — DO NOT use "*" in production
play.filters.cors {
allowedOrigins = ["https://your-firebase-app.web.app"]
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Origin", "Content-Type", "Authorization"]
allowCredentials = true
}
Redux state immutability
// ❌ INCORRECT — direct state mutation
case "SAVE_DOCUMENT":
state.documents.push(action.payload); // Mutation ❌
return state;
// ✅ CORRECT — immutable new state
case "SAVE_DOCUMENT":
return {
...state,
documents: [...state.documents, action.payload] // Clone ✅
};
WebSocket resource cleanup (useEffect cleanup)
useEffect(() => {
ws.current = new WebSocket(url);
// ... handlers
// Close connection when component unmounts
return () => ws.current?.close();
}, []);
Without the cleanup function, WebSocket connections remain open after navigating to another page, causing memory leaks and message duplications.
Play deployment — Secret key
// utility/KeyGen.java
import java.security.SecureRandom;
import java.util.Base64;
public class KeyGen {
public static void main(String[] args) {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
System.out.println(Base64.getEncoder().encodeToString(bytes));
}
}
In production, the Play secret key must never be hardcoded in the source code. Use an environment variable:
play.http.secret.key = ${APPLICATION_SECRET}.
Architecture recommendation — Separation of concerns
Play Backend (lightweight broadcast server)
├── WebSocket Controllers → Broadcast JSON without parsing (delegate to frontend)
├── REST Controllers → Non-blocking CRUD with CompletableFuture
└── Streaming Controllers → SSE with Akka Source.tick
React Frontend (message business logic)
├── useRef(WebSocket) → Single connection per component
├── useEffect (cleanup) → Close connection on unmount
├── JSON type field → Discriminate message types
└── Redux Store → Global state of saved documents
Search Terms
full-stack · java · development · play · framework · react · backend · architecture · web · websocket · flow · redux · akka · apis · chat · component · deployment · document · management · non-blocking · state · streams · api · collaboration