Skip to content

Commit be3d7f0

Browse files
committedApr 7, 2022
docs(examples): add TODO example with Postgres and Node.js cluster
1 parent d12aab2 commit be3d7f0

File tree

10 files changed

+403
-0
lines changed

10 files changed

+403
-0
lines changed
 

‎examples/basic-crud-application/README.md

+7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
Please read the related [guide](https://socket.io/get-started/basic-crud-application/).
44

5+
This repository contains several implementations of the server:
6+
7+
| Directory | Language | Database | Cluster? |
8+
|----------------------------|------------|------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
9+
| `server/` | TypeScript | in-memory | No |
10+
| `server-postgres-cluster/` | JavaScript | Postgres, with the [Postgres adapter](https://socket.io/docs/v4/postgres-adapter/) | Yes, with the [`@socket.io/sticky`](https://github.com/socketio/socket.io-sticky) module) |
11+
512
## Running the frontend
613

714
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
A basic TODO project.
3+
4+
| Characteristic | |
5+
|----------------|-------------------------------------------------------------------------------------------|
6+
| Language | plain JavaScript |
7+
| Database | Postgres, with the [Postgres adapter](https://socket.io/docs/v4/postgres-adapter/) |
8+
| Cluster? | Yes, with the [`@socket.io/sticky`](https://github.com/socketio/socket.io-sticky) module) |
9+
10+
## Usage
11+
12+
```
13+
$ docker-compose up -d
14+
$ npm install
15+
$ npm start
16+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
version: "3"
2+
3+
services:
4+
postgres:
5+
image: postgres:12
6+
ports:
7+
- "5432:5432"
8+
environment:
9+
POSTGRES_PASSWORD: "changeit"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Server } from "socket.io";
2+
import createTodoHandlers from "./todo-management/todo.handlers.js";
3+
import { setupWorker } from "@socket.io/sticky";
4+
import { createAdapter } from "@socket.io/postgres-adapter";
5+
6+
export function createApplication(httpServer, components, serverOptions = {}) {
7+
const io = new Server(httpServer, serverOptions);
8+
9+
const { createTodo, readTodo, updateTodo, deleteTodo, listTodo } =
10+
createTodoHandlers(components);
11+
12+
io.on("connection", (socket) => {
13+
socket.on("todo:create", createTodo);
14+
socket.on("todo:read", readTodo);
15+
socket.on("todo:update", updateTodo);
16+
socket.on("todo:delete", deleteTodo);
17+
socket.on("todo:list", listTodo);
18+
});
19+
20+
// enable sticky session in the cluster (to remove in standalone mode)
21+
setupWorker(io);
22+
23+
io.adapter(createAdapter(components.connectionPool));
24+
25+
return io;
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import cluster from "cluster";
2+
import { createServer } from "http";
3+
import { setupMaster } from "@socket.io/sticky";
4+
import { cpus } from "os";
5+
6+
if (cluster.isMaster) {
7+
console.log(`Master ${process.pid} is running`);
8+
const httpServer = createServer();
9+
10+
setupMaster(httpServer, {
11+
loadBalancingMethod: "least-connection",
12+
});
13+
14+
httpServer.listen(3000);
15+
16+
for (let i = 0; i < cpus().length; i++) {
17+
cluster.fork();
18+
}
19+
20+
cluster.on("exit", (worker) => {
21+
console.log(`Worker ${worker.process.pid} died`);
22+
cluster.fork();
23+
});
24+
} else {
25+
console.log(`Worker ${process.pid} started`);
26+
27+
import("./index.js");
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createServer } from "http";
2+
import { createApplication } from "./app.js";
3+
import { Sequelize } from "sequelize";
4+
import pg from "pg";
5+
import { PostgresTodoRepository } from "./todo-management/todo.repository.js";
6+
7+
const httpServer = createServer();
8+
9+
const sequelize = new Sequelize("postgres", "postgres", "changeit", {
10+
dialect: "postgres",
11+
});
12+
13+
const connectionPool = new pg.Pool({
14+
user: "postgres",
15+
host: "localhost",
16+
database: "postgres",
17+
password: "changeit",
18+
port: 5432,
19+
});
20+
21+
createApplication(
22+
httpServer,
23+
{
24+
connectionPool,
25+
todoRepository: new PostgresTodoRepository(sequelize),
26+
},
27+
{
28+
cors: {
29+
origin: ["http://localhost:4200"],
30+
},
31+
}
32+
);
33+
34+
const main = async () => {
35+
// create the tables if they do not exist already
36+
await sequelize.sync();
37+
38+
// create the table needed by the postgres adapter
39+
await connectionPool.query(`
40+
CREATE TABLE IF NOT EXISTS socket_io_attachments (
41+
id bigserial UNIQUE,
42+
created_at timestamptz DEFAULT NOW(),
43+
payload bytea
44+
);
45+
`);
46+
47+
// uncomment when running in standalone mode
48+
// httpServer.listen(3000);
49+
};
50+
51+
main();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { Errors, mapErrorDetails, sanitizeErrorMessage } from "../util.js";
2+
import { v4 as uuid } from "uuid";
3+
import Joi from "joi";
4+
5+
const idSchema = Joi.string().guid({
6+
version: "uuidv4",
7+
});
8+
9+
const todoSchema = Joi.object({
10+
id: idSchema.alter({
11+
create: (schema) => schema.forbidden(),
12+
update: (schema) => schema.required(),
13+
}),
14+
title: Joi.string().max(256).required(),
15+
completed: Joi.boolean().required(),
16+
});
17+
18+
export default function (components) {
19+
const { todoRepository } = components;
20+
return {
21+
createTodo: async function (payload, callback) {
22+
const socket = this;
23+
24+
// validate the payload
25+
const { error, value } = todoSchema.tailor("create").validate(payload, {
26+
abortEarly: false,
27+
stripUnknown: true,
28+
});
29+
30+
if (error) {
31+
return callback({
32+
error: Errors.INVALID_PAYLOAD,
33+
errorDetails: mapErrorDetails(error.details),
34+
});
35+
}
36+
37+
value.id = uuid();
38+
39+
// persist the entity
40+
try {
41+
await todoRepository.save(value);
42+
} catch (e) {
43+
return callback({
44+
error: sanitizeErrorMessage(e),
45+
});
46+
}
47+
48+
// acknowledge the creation
49+
callback({
50+
data: value.id,
51+
});
52+
53+
// notify the other users
54+
socket.broadcast.emit("todo:created", value);
55+
},
56+
57+
readTodo: async function (id, callback) {
58+
const { error } = idSchema.validate(id);
59+
60+
if (error) {
61+
return callback({
62+
error: Errors.ENTITY_NOT_FOUND,
63+
});
64+
}
65+
66+
try {
67+
const todo = await todoRepository.findById(id);
68+
callback({
69+
data: todo,
70+
});
71+
} catch (e) {
72+
callback({
73+
error: sanitizeErrorMessage(e),
74+
});
75+
}
76+
},
77+
78+
updateTodo: async function (payload, callback) {
79+
const socket = this;
80+
81+
const { error, value } = todoSchema.tailor("update").validate(payload, {
82+
abortEarly: false,
83+
stripUnknown: true,
84+
});
85+
86+
if (error) {
87+
return callback({
88+
error: Errors.INVALID_PAYLOAD,
89+
errorDetails: mapErrorDetails(error.details),
90+
});
91+
}
92+
93+
try {
94+
await todoRepository.save(value);
95+
} catch (e) {
96+
return callback({
97+
error: sanitizeErrorMessage(e),
98+
});
99+
}
100+
101+
callback();
102+
socket.broadcast.emit("todo:updated", value);
103+
},
104+
105+
deleteTodo: async function (id, callback) {
106+
const socket = this;
107+
108+
const { error } = idSchema.validate(id);
109+
110+
if (error) {
111+
return callback({
112+
error: Errors.ENTITY_NOT_FOUND,
113+
});
114+
}
115+
116+
try {
117+
await todoRepository.deleteById(id);
118+
} catch (e) {
119+
return callback({
120+
error: sanitizeErrorMessage(e),
121+
});
122+
}
123+
124+
callback();
125+
socket.broadcast.emit("todo:deleted", id);
126+
},
127+
128+
listTodo: async function (callback) {
129+
try {
130+
callback({
131+
data: await todoRepository.findAll(),
132+
});
133+
} catch (e) {
134+
callback({
135+
error: sanitizeErrorMessage(e),
136+
});
137+
}
138+
},
139+
};
140+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Errors } from "../util.js";
2+
import { Model, DataTypes } from "sequelize";
3+
4+
class CrudRepository {
5+
findAll() {}
6+
findById(id) {}
7+
save(entity) {}
8+
deleteById(id) {}
9+
}
10+
11+
export class TodoRepository extends CrudRepository {}
12+
13+
class Todo extends Model {}
14+
15+
export class PostgresTodoRepository extends TodoRepository {
16+
constructor(sequelize) {
17+
super();
18+
this.sequelize = sequelize;
19+
20+
Todo.init(
21+
{
22+
id: {
23+
type: DataTypes.STRING,
24+
primaryKey: true,
25+
allowNull: false,
26+
},
27+
title: {
28+
type: DataTypes.STRING,
29+
},
30+
completed: {
31+
type: DataTypes.BOOLEAN,
32+
},
33+
},
34+
{
35+
sequelize,
36+
tableName: "todos",
37+
}
38+
);
39+
}
40+
41+
findAll() {
42+
return this.sequelize.transaction((transaction) => {
43+
return Todo.findAll({ transaction });
44+
});
45+
}
46+
47+
async findById(id) {
48+
return this.sequelize.transaction(async (transaction) => {
49+
const todo = await Todo.findByPk(id, { transaction });
50+
51+
if (!todo) {
52+
throw Errors.ENTITY_NOT_FOUND;
53+
}
54+
55+
return todo;
56+
});
57+
}
58+
59+
save(entity) {
60+
return this.sequelize.transaction((transaction) => {
61+
return Todo.upsert(entity, { transaction });
62+
});
63+
}
64+
65+
async deleteById(id) {
66+
return this.sequelize.transaction(async (transaction) => {
67+
const count = await Todo.destroy({ where: { id }, transaction });
68+
69+
if (count === 0) {
70+
throw Errors.ENTITY_NOT_FOUND;
71+
}
72+
});
73+
}
74+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const Errors = {
2+
ENTITY_NOT_FOUND: "entity not found",
3+
INVALID_PAYLOAD: "invalid payload",
4+
};
5+
6+
const errorValues = Object.values(Errors);
7+
8+
export function sanitizeErrorMessage(message) {
9+
if (typeof message === "string" && errorValues.includes(message)) {
10+
return message;
11+
} else {
12+
return "an unknown error has occurred";
13+
}
14+
}
15+
16+
export function mapErrorDetails(details) {
17+
return details.map((item) => ({
18+
message: item.message,
19+
path: item.path,
20+
type: item.type,
21+
}));
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "basic-crud-server",
3+
"version": "0.0.1",
4+
"description": "Server for the Basic CRUD Socket.IO example (with Postgres and multiple Socket.IO servers)",
5+
"main": "lib/cluster.js",
6+
"type": "module",
7+
"scripts": {
8+
"start": "node lib/cluster.js"
9+
},
10+
"repository": {
11+
"type": "git",
12+
"url": "git+https://github.com/socketio/socket.io.git"
13+
},
14+
"author": "Damien Arrachequesne <damien.arrachequesne@gmail.com>",
15+
"license": "MIT",
16+
"bugs": {
17+
"url": "https://github.com/socketio/socket.io/issues"
18+
},
19+
"homepage": "https://github.com/socketio/socket.io#readme",
20+
"dependencies": {
21+
"@socket.io/postgres-adapter": "^0.2.0",
22+
"@socket.io/sticky": "^1.0.1",
23+
"joi": "^17.4.0",
24+
"pg": "^8.7.3",
25+
"pg-hstore": "^2.3.4",
26+
"sequelize": "^6.18.0",
27+
"socket.io": "^4.0.1",
28+
"uuid": "^8.3.2"
29+
}
30+
}

0 commit comments

Comments
 (0)
Please sign in to comment.