Skip to content

Commit

Permalink
docs(examples): add TODO example with Postgres and Node.js cluster
Browse files Browse the repository at this point in the history
  • Loading branch information
darrachequesne committed Apr 7, 2022
1 parent d12aab2 commit be3d7f0
Show file tree
Hide file tree
Showing 10 changed files with 403 additions and 0 deletions.
7 changes: 7 additions & 0 deletions examples/basic-crud-application/README.md
Expand Up @@ -2,6 +2,13 @@

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

This repository contains several implementations of the server:

| Directory | Language | Database | Cluster? |
|----------------------------|------------|------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| `server/` | TypeScript | in-memory | No |
| `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) |

## Running the frontend

```
Expand Down
16 changes: 16 additions & 0 deletions examples/basic-crud-application/server-postgres-cluster/README.md
@@ -0,0 +1,16 @@

A basic TODO project.

| Characteristic | |
|----------------|-------------------------------------------------------------------------------------------|
| Language | plain JavaScript |
| Database | Postgres, with the [Postgres adapter](https://socket.io/docs/v4/postgres-adapter/) |
| Cluster? | Yes, with the [`@socket.io/sticky`](https://github.com/socketio/socket.io-sticky) module) |

## Usage

```
$ docker-compose up -d
$ npm install
$ npm start
```
@@ -0,0 +1,9 @@
version: "3"

services:
postgres:
image: postgres:12
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: "changeit"
26 changes: 26 additions & 0 deletions examples/basic-crud-application/server-postgres-cluster/lib/app.js
@@ -0,0 +1,26 @@
import { Server } from "socket.io";
import createTodoHandlers from "./todo-management/todo.handlers.js";
import { setupWorker } from "@socket.io/sticky";
import { createAdapter } from "@socket.io/postgres-adapter";

export function createApplication(httpServer, components, serverOptions = {}) {
const io = new Server(httpServer, serverOptions);

const { createTodo, readTodo, updateTodo, deleteTodo, listTodo } =
createTodoHandlers(components);

io.on("connection", (socket) => {
socket.on("todo:create", createTodo);
socket.on("todo:read", readTodo);
socket.on("todo:update", updateTodo);
socket.on("todo:delete", deleteTodo);
socket.on("todo:list", listTodo);
});

// enable sticky session in the cluster (to remove in standalone mode)
setupWorker(io);

io.adapter(createAdapter(components.connectionPool));

return io;
}
@@ -0,0 +1,28 @@
import cluster from "cluster";
import { createServer } from "http";
import { setupMaster } from "@socket.io/sticky";
import { cpus } from "os";

if (cluster.isMaster) {
console.log(`Master ${process.pid} is running`);
const httpServer = createServer();

setupMaster(httpServer, {
loadBalancingMethod: "least-connection",
});

httpServer.listen(3000);

for (let i = 0; i < cpus().length; i++) {
cluster.fork();
}

cluster.on("exit", (worker) => {
console.log(`Worker ${worker.process.pid} died`);
cluster.fork();
});
} else {
console.log(`Worker ${process.pid} started`);

import("./index.js");
}
@@ -0,0 +1,51 @@
import { createServer } from "http";
import { createApplication } from "./app.js";
import { Sequelize } from "sequelize";
import pg from "pg";
import { PostgresTodoRepository } from "./todo-management/todo.repository.js";

const httpServer = createServer();

const sequelize = new Sequelize("postgres", "postgres", "changeit", {
dialect: "postgres",
});

const connectionPool = new pg.Pool({
user: "postgres",
host: "localhost",
database: "postgres",
password: "changeit",
port: 5432,
});

createApplication(
httpServer,
{
connectionPool,
todoRepository: new PostgresTodoRepository(sequelize),
},
{
cors: {
origin: ["http://localhost:4200"],
},
}
);

const main = async () => {
// create the tables if they do not exist already
await sequelize.sync();

// create the table needed by the postgres adapter
await connectionPool.query(`
CREATE TABLE IF NOT EXISTS socket_io_attachments (
id bigserial UNIQUE,
created_at timestamptz DEFAULT NOW(),
payload bytea
);
`);

// uncomment when running in standalone mode
// httpServer.listen(3000);
};

main();
@@ -0,0 +1,140 @@
import { Errors, mapErrorDetails, sanitizeErrorMessage } from "../util.js";
import { v4 as uuid } from "uuid";
import Joi from "joi";

const idSchema = Joi.string().guid({
version: "uuidv4",
});

const todoSchema = Joi.object({
id: idSchema.alter({
create: (schema) => schema.forbidden(),
update: (schema) => schema.required(),
}),
title: Joi.string().max(256).required(),
completed: Joi.boolean().required(),
});

export default function (components) {
const { todoRepository } = components;
return {
createTodo: async function (payload, callback) {
const socket = this;

// validate the payload
const { error, value } = todoSchema.tailor("create").validate(payload, {
abortEarly: false,
stripUnknown: true,
});

if (error) {
return callback({
error: Errors.INVALID_PAYLOAD,
errorDetails: mapErrorDetails(error.details),
});
}

value.id = uuid();

// persist the entity
try {
await todoRepository.save(value);
} catch (e) {
return callback({
error: sanitizeErrorMessage(e),
});
}

// acknowledge the creation
callback({
data: value.id,
});

// notify the other users
socket.broadcast.emit("todo:created", value);
},

readTodo: async function (id, callback) {
const { error } = idSchema.validate(id);

if (error) {
return callback({
error: Errors.ENTITY_NOT_FOUND,
});
}

try {
const todo = await todoRepository.findById(id);
callback({
data: todo,
});
} catch (e) {
callback({
error: sanitizeErrorMessage(e),
});
}
},

updateTodo: async function (payload, callback) {
const socket = this;

const { error, value } = todoSchema.tailor("update").validate(payload, {
abortEarly: false,
stripUnknown: true,
});

if (error) {
return callback({
error: Errors.INVALID_PAYLOAD,
errorDetails: mapErrorDetails(error.details),
});
}

try {
await todoRepository.save(value);
} catch (e) {
return callback({
error: sanitizeErrorMessage(e),
});
}

callback();
socket.broadcast.emit("todo:updated", value);
},

deleteTodo: async function (id, callback) {
const socket = this;

const { error } = idSchema.validate(id);

if (error) {
return callback({
error: Errors.ENTITY_NOT_FOUND,
});
}

try {
await todoRepository.deleteById(id);
} catch (e) {
return callback({
error: sanitizeErrorMessage(e),
});
}

callback();
socket.broadcast.emit("todo:deleted", id);
},

listTodo: async function (callback) {
try {
callback({
data: await todoRepository.findAll(),
});
} catch (e) {
callback({
error: sanitizeErrorMessage(e),
});
}
},
};
}
@@ -0,0 +1,74 @@
import { Errors } from "../util.js";
import { Model, DataTypes } from "sequelize";

class CrudRepository {
findAll() {}
findById(id) {}
save(entity) {}
deleteById(id) {}
}

export class TodoRepository extends CrudRepository {}

class Todo extends Model {}

export class PostgresTodoRepository extends TodoRepository {
constructor(sequelize) {
super();
this.sequelize = sequelize;

Todo.init(
{
id: {
type: DataTypes.STRING,
primaryKey: true,
allowNull: false,
},
title: {
type: DataTypes.STRING,
},
completed: {
type: DataTypes.BOOLEAN,
},
},
{
sequelize,
tableName: "todos",
}
);
}

findAll() {
return this.sequelize.transaction((transaction) => {
return Todo.findAll({ transaction });
});
}

async findById(id) {
return this.sequelize.transaction(async (transaction) => {
const todo = await Todo.findByPk(id, { transaction });

if (!todo) {
throw Errors.ENTITY_NOT_FOUND;
}

return todo;
});
}

save(entity) {
return this.sequelize.transaction((transaction) => {
return Todo.upsert(entity, { transaction });
});
}

async deleteById(id) {
return this.sequelize.transaction(async (transaction) => {
const count = await Todo.destroy({ where: { id }, transaction });

if (count === 0) {
throw Errors.ENTITY_NOT_FOUND;
}
});
}
}
@@ -0,0 +1,22 @@
export const Errors = {
ENTITY_NOT_FOUND: "entity not found",
INVALID_PAYLOAD: "invalid payload",
};

const errorValues = Object.values(Errors);

export function sanitizeErrorMessage(message) {
if (typeof message === "string" && errorValues.includes(message)) {
return message;
} else {
return "an unknown error has occurred";
}
}

export function mapErrorDetails(details) {
return details.map((item) => ({
message: item.message,
path: item.path,
type: item.type,
}));
}

0 comments on commit be3d7f0

Please sign in to comment.