├── .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 |
--------------------------------------------------------------------------------