├── .gitignore ├── toma.toml ├── README.md ├── tsconfig.json ├── test ├── index.html └── index.js ├── package.json ├── utils ├── subscribe.ts └── router.ts └── contracts ├── load-test-1.ts └── load-test-2.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .parcel-cache -------------------------------------------------------------------------------- /toma.toml: -------------------------------------------------------------------------------- 1 | port = 8090 2 | 3 | contracts = "./contracts/**/*.ts" 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ``` 4 | $ npm install 5 | 6 | // to start the local app 7 | $ npm run dev 8 | 9 | // to start Tomato 10 | $ npx tomato 11 | ``` 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "moduleResolution": "node", 5 | "types": ["@pubnub/tomato"], 6 | 7 | "lib": ["ES2022"] 8 | }, 9 | "include": ["contracts", "utils"] 10 | } 11 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-tomato", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "parcel test/index.html" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@ngneat/falso": "^6.2.0", 14 | "@pubnub/tomato": "^1.9.0", 15 | "find-my-way": "^7.3.1", 16 | "pubnub": "^7.2.1", 17 | "typescript": "^4.9.3" 18 | }, 19 | "prettier": { 20 | "semi": false, 21 | "singleQuote": true 22 | }, 23 | "devDependencies": { 24 | "buffer": "^5.7.1", 25 | "parcel": "^2.8.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import Pubnub from 'pubnub/dist/web/pubnub' 2 | 3 | async function main() { 4 | await fetch( 5 | 'http://localhost:8090/init?__contract__script__=loadTest&subscribeKey=demo&channel=demo&chunksPerSecond=4&messagesPerChunk=100&users=5&avatarType=0' 6 | ) 7 | 8 | const pubnub = new Pubnub({ 9 | origin: 'localhost:8090', 10 | subscribeKey: 'demo', 11 | publishKey: 'demo', 12 | userId: 'test', 13 | suppressLeaveEvents: true, 14 | }) 15 | 16 | let counter = 0 17 | let start = performance.now() 18 | 19 | pubnub.addListener({ 20 | message: (msg) => { 21 | counter++ 22 | }, 23 | status: (status) => {}, 24 | }) 25 | 26 | pubnub.subscribe({ channels: ['lmao'] }) 27 | 28 | setInterval(() => { 29 | // const now = performance.now() 30 | // console.log(counter / ((now - start) / 1000), (now - start) / 1000, counter) 31 | 32 | console.log(counter) 33 | counter = 0 34 | }, 1000) 35 | } 36 | 37 | const button = document.getElementById('start') 38 | 39 | button.addEventListener('click', main) 40 | -------------------------------------------------------------------------------- /utils/subscribe.ts: -------------------------------------------------------------------------------- 1 | export type TimetokenData = { 2 | t: string 3 | r: number 4 | } 5 | 6 | export type Envelope = { 7 | a: string 8 | b?: string 9 | c: string 10 | d: any 11 | e?: number 12 | f: number 13 | i: string 14 | k: string 15 | o?: TimetokenData 16 | p: TimetokenData 17 | u?: any 18 | } 19 | 20 | export type SubscribeResponse = { 21 | t: TimetokenData 22 | m: Envelope[] 23 | } 24 | 25 | export type SubscribeErrorResponse = { 26 | error: true 27 | status: number 28 | message: string 29 | service: 'Access Manager' 30 | payload?: { 31 | channels?: string[] 32 | 'channel-groups'?: string[] 33 | } 34 | } 35 | 36 | export type PublishResponse = [number, string, string] 37 | 38 | export const timetokenData = ( 39 | timetoken: string, 40 | region?: number 41 | ): TimetokenData => ({ 42 | t: timetoken, 43 | r: region ?? 1, 44 | }) 45 | 46 | export const envelope = (input: { 47 | shard?: string 48 | subscriptionMatch?: string 49 | channel: string 50 | payload: any 51 | messageType?: number 52 | flags?: number 53 | sender: string 54 | subKey: string 55 | metadata?: any 56 | originatingTimetoken?: TimetokenData 57 | publishingTimetoken: TimetokenData 58 | }): Envelope => ({ 59 | a: input.shard ?? '1', 60 | b: input.subscriptionMatch ?? input.channel, 61 | c: input.channel, 62 | d: input.payload, 63 | e: input.messageType ?? 0, 64 | f: input.flags ?? 0, 65 | i: input.sender, 66 | k: input.subKey, 67 | o: input.originatingTimetoken, 68 | p: input.publishingTimetoken, 69 | u: input.metadata, 70 | }) 71 | 72 | export const successfulResponse = ( 73 | timetoken: TimetokenData, 74 | envelopes: Envelope[] = [] 75 | ): SubscribeResponse => ({ 76 | t: timetoken, 77 | m: envelopes, 78 | }) 79 | -------------------------------------------------------------------------------- /utils/router.ts: -------------------------------------------------------------------------------- 1 | import * as FMWRouter from 'find-my-way' 2 | 3 | import type { ExpectInterface } from '@pubnub/tomato' 4 | 5 | type RouteHandler = ( 6 | req: { 7 | method: string 8 | body?: any 9 | headers: Record 10 | url: { path: string; query: Record } 11 | }, 12 | params?: Record 13 | ) => 14 | | Promise<{ 15 | status: number 16 | headers?: Record 17 | body?: any 18 | }> 19 | | { 20 | status: number 21 | headers?: Record 22 | body?: any 23 | } 24 | 25 | type Routes = { 26 | [k: string]: RouteHandler 27 | } 28 | 29 | export class Router { 30 | private _fwm: FMWRouter.Instance 31 | private routes: Record = {} 32 | 33 | constructor(private expect: ExpectInterface) { 34 | this._fwm = FMWRouter() 35 | } 36 | 37 | get(path: string, handler: RouteHandler) { 38 | this.routes[`GET ${path}`] = handler 39 | } 40 | 41 | post(path: string, handler: RouteHandler) { 42 | this.routes[`POST ${path}`] = handler 43 | } 44 | 45 | async run() { 46 | for (const [key, handler] of Object.entries(this.routes)) { 47 | const [method, path] = key.split(' ') 48 | 49 | this._fwm.on( 50 | method.toUpperCase() as FMWRouter.HTTPMethod, 51 | path, 52 | handler as any 53 | ) 54 | } 55 | 56 | console.log('running') 57 | 58 | while (true) { 59 | const request = await this.expect({ 60 | description: 'any request', 61 | validations: [], 62 | }) 63 | 64 | const route = this._fwm.find( 65 | request.method.toUpperCase() as FMWRouter.HTTPMethod, 66 | request.url.path 67 | ) 68 | 69 | if (!route) { 70 | await request.respond({ status: 404 }) 71 | } else { 72 | const response = await (route.handler as RouteHandler)( 73 | request, 74 | route.params 75 | ) 76 | 77 | await request.respond({ 78 | status: response?.status, 79 | headers: response?.headers, 80 | body: response?.body, 81 | }) 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /contracts/load-test-1.ts: -------------------------------------------------------------------------------- 1 | import { rand, randUuid, randDatabaseType, randFirstName } from '@ngneat/falso' 2 | import { envelope, successfulResponse, timetokenData } from '../utils/subscribe' 3 | 4 | export const name = 'loadTest' 5 | 6 | const SUB_KEY = 'demo' 7 | 8 | const MSGS_PER_SECOND = 100000 9 | const CHANNEL = 'lmao' 10 | const CONCURRENT_USERS = 4 11 | const CHUNKS_PER_SECOND = 100 12 | const MSGS_PER_CHUNK = MSGS_PER_SECOND / CHUNKS_PER_SECOND 13 | const CHUNK_DELAY_MS = (1 / CHUNKS_PER_SECOND) * 1000 14 | 15 | const users = Array.from(Array(CONCURRENT_USERS), () => ({ 16 | uuid: randUuid(), 17 | })) 18 | 19 | function generatePayload() { 20 | return { 21 | type: randDatabaseType(), 22 | content: randFirstName(), 23 | } 24 | } 25 | 26 | function generateEnvelopes(startTimetoken: string, amount: number) { 27 | const result = [] 28 | let start = BigInt(startTimetoken) 29 | 30 | for (let i = 0; i < amount; i++) { 31 | const messageTimetoken = (start++).toString() 32 | const user = rand(users) 33 | 34 | result.push( 35 | envelope({ 36 | channel: CHANNEL, 37 | sender: user.uuid, 38 | subKey: SUB_KEY, 39 | publishingTimetoken: { 40 | t: messageTimetoken, 41 | r: 0, 42 | }, 43 | payload: generatePayload(), 44 | }) 45 | ) 46 | } 47 | 48 | return result 49 | } 50 | 51 | export default async function () { 52 | let currentTimetoken = timetoken.now() 53 | 54 | const request = await expect({ 55 | description: 'subscribe with timetoken zero', 56 | validations: [], 57 | }) 58 | 59 | await request.respond({ 60 | status: 200, 61 | body: successfulResponse(timetokenData(currentTimetoken)), 62 | }) 63 | 64 | while (true) { 65 | await Promise.race([ 66 | sleep(CHUNK_DELAY_MS), 67 | expect({ 68 | description: 'subscribe next', 69 | validations: [], 70 | }).then(async (request) => { 71 | const envelopes = generateEnvelopes(currentTimetoken, MSGS_PER_CHUNK) 72 | 73 | const nextTimetoken = timetoken.now() 74 | 75 | await request.respond({ 76 | status: 200, 77 | body: successfulResponse(timetokenData(nextTimetoken), envelopes), 78 | }) 79 | 80 | currentTimetoken = nextTimetoken 81 | }), 82 | ]) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /contracts/load-test-2.ts: -------------------------------------------------------------------------------- 1 | import { Router } from '../utils/router' 2 | import { 3 | Envelope, 4 | envelope, 5 | successfulResponse, 6 | timetokenData, 7 | } from '../utils/subscribe' 8 | import { 9 | rand, 10 | randHexaDecimal, 11 | randPhrase, 12 | randRecentDate, 13 | randSkill, 14 | randUuid, 15 | } from '@ngneat/falso' 16 | 17 | let timePassed = 0 18 | let createdUsers = new Map() 19 | let occupancy = 0 20 | 21 | function addToCache(userID: String) { 22 | if (userID == undefined) { 23 | return 24 | } 25 | 26 | if (createdUsers.has(userID)) { 27 | createdUsers.set(userID, createdUsers.get(userID) + 1) 28 | } else { 29 | console.log(userID) 30 | 31 | createdUsers.set(userID, 1) 32 | occupancy++ 33 | 34 | console.log(occupancy) 35 | } 36 | } 37 | 38 | function generateUsers(amount: number, avatarType: number) { 39 | const result = [] 40 | for (let i = 1; i < amount; i++) { 41 | result.push({ 42 | name: 'user_' + i, 43 | custom: { 44 | title: randSkill(), 45 | }, 46 | email: null, 47 | eTag: randHexaDecimal({ length: 10 }).join(''), 48 | externalId: null, 49 | id: 'user_' + i, 50 | profileUrl: 51 | avatarType === 1 ? `https://i.pravatar.cc/36?u=user_${i}` : null, 52 | updated: randRecentDate(), 53 | }) 54 | } 55 | 56 | return result 57 | } 58 | 59 | function generateOccupancyPayload() { 60 | return { 61 | occupancy: occupancy, 62 | timestamp: +new Date(), 63 | state: null, 64 | uuid: 'user_0', 65 | action: 'interval', //join, leave, timeout, stateChange, interval 66 | refreshHereNow: false, 67 | } 68 | } 69 | 70 | function generatePayload(user) { 71 | const rand = randUuid() 72 | return { 73 | id: rand, 74 | type: 'default', 75 | text: randPhrase() + ' ' + user.id, 76 | sender: user, 77 | createdAt: new Date().toISOString(), 78 | } 79 | } 80 | 81 | function generateEnvelopes({ startTimetoken, amount, channel, subKey, users }) { 82 | const result: Envelope[] = [] 83 | let start = BigInt(startTimetoken) 84 | let messageTimetoken = (start++).toString() 85 | 86 | if (timePassed >= 10000) { 87 | timePassed = 0 88 | result.push( 89 | envelope({ 90 | messageType: 999, 91 | channel: 'demo-pnpres', 92 | sender: 'user_0', 93 | subKey: subKey, 94 | publishingTimetoken: { 95 | t: messageTimetoken, 96 | r: 0, 97 | }, 98 | payload: generateOccupancyPayload(), 99 | }) 100 | ) 101 | } 102 | 103 | for (let i = 0; i < amount; i++) { 104 | let user: any = rand(users) 105 | messageTimetoken = (start++).toString() 106 | addToCache(user.id) 107 | 108 | result.push( 109 | envelope({ 110 | messageType: 0, 111 | channel: channel, 112 | sender: user.id, 113 | subKey: subKey, 114 | publishingTimetoken: { 115 | t: messageTimetoken, 116 | r: 0, 117 | }, 118 | payload: generatePayload(user), 119 | }) 120 | ) 121 | } 122 | 123 | return result 124 | } 125 | 126 | export const name = 'loadTest' 127 | 128 | const router = new Router(expect) 129 | 130 | type Options = { 131 | delayBeforeStart: string 132 | users: string 133 | avatarType: string 134 | channel: string 135 | subscribeKey: string 136 | 137 | chunksPerSecond: string 138 | messagesPerChunk: string 139 | } 140 | 141 | export default async function (options: Options) { 142 | const users = generateUsers(Number(options.users), Number(options.avatarType)) 143 | 144 | router.get('/v2/subscribe/:subkey/:channel/0', async (req, params) => { 145 | if (req.url.query?.tt === '0') { 146 | return { 147 | status: 200, 148 | body: successfulResponse(timetokenData(timetoken.now())), 149 | } 150 | } 151 | 152 | await sleep(1000 / Number(options.chunksPerSecond)) 153 | 154 | const envelopes = generateEnvelopes({ 155 | startTimetoken: timetoken.now(), 156 | amount: Number(options.messagesPerChunk), 157 | channel: options.channel, 158 | subKey: options.subscribeKey, 159 | users, 160 | }) 161 | 162 | return { 163 | status: 200, 164 | body: successfulResponse(timetokenData(timetoken.now()), envelopes), 165 | } 166 | }) 167 | 168 | router.get('/v2/herenowendpoint', async (req, params) => { 169 | // do whatever you want 170 | 171 | return { status: 200, body: {} } 172 | }) 173 | 174 | await router.run() 175 | } 176 | --------------------------------------------------------------------------------