@prsm/realtime 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +49 -0
- package/src/adapters/postgres.js +139 -0
- package/src/adapters/sqlite.js +177 -0
- package/src/client/client.js +441 -0
- package/src/client/connection.js +178 -0
- package/src/client/ids.js +26 -0
- package/src/client/index.js +5 -0
- package/src/client/queue.js +44 -0
- package/src/client/subscriptions/channels.js +79 -0
- package/src/client/subscriptions/collections.js +96 -0
- package/src/client/subscriptions/presence.js +98 -0
- package/src/client/subscriptions/records.js +123 -0
- package/src/client/subscriptions/rooms.js +69 -0
- package/src/devtools/client/dist/assets/index-CGm1NqOQ.css +1 -0
- package/src/devtools/client/dist/assets/index-w2FI7RvC.js +168 -0
- package/src/devtools/client/dist/index.html +16 -0
- package/src/devtools/client/index.html +15 -0
- package/src/devtools/client/package.json +17 -0
- package/src/devtools/client/src/App.vue +173 -0
- package/src/devtools/client/src/components/ConnectionPicker.vue +38 -0
- package/src/devtools/client/src/components/JsonView.vue +18 -0
- package/src/devtools/client/src/composables/useApi.js +71 -0
- package/src/devtools/client/src/composables/useHighlight.js +57 -0
- package/src/devtools/client/src/main.js +5 -0
- package/src/devtools/client/src/style.css +440 -0
- package/src/devtools/client/src/views/ChannelsView.vue +27 -0
- package/src/devtools/client/src/views/CollectionsView.vue +61 -0
- package/src/devtools/client/src/views/MetadataView.vue +108 -0
- package/src/devtools/client/src/views/RecordsView.vue +30 -0
- package/src/devtools/client/src/views/RoomsView.vue +39 -0
- package/src/devtools/client/vite.config.js +17 -0
- package/src/devtools/demo/server.js +144 -0
- package/src/devtools/index.js +186 -0
- package/src/index.js +9 -0
- package/src/server/connection.js +116 -0
- package/src/server/context.js +22 -0
- package/src/server/managers/broadcast.js +94 -0
- package/src/server/managers/channels.js +118 -0
- package/src/server/managers/collections.js +127 -0
- package/src/server/managers/commands.js +55 -0
- package/src/server/managers/connections.js +111 -0
- package/src/server/managers/instance.js +125 -0
- package/src/server/managers/persistence.js +371 -0
- package/src/server/managers/presence.js +217 -0
- package/src/server/managers/pubsub.js +242 -0
- package/src/server/managers/record-subscriptions.js +123 -0
- package/src/server/managers/records.js +110 -0
- package/src/server/managers/redis.js +61 -0
- package/src/server/managers/rooms.js +129 -0
- package/src/server/message-stream.js +20 -0
- package/src/server/server.js +878 -0
- package/src/server/utils/constants.js +4 -0
- package/src/server/utils/ids.js +5 -0
- package/src/server/utils/pattern-conversion.js +14 -0
- package/src/shared/errors.js +7 -0
- package/src/shared/index.js +5 -0
- package/src/shared/logger.js +53 -0
- package/src/shared/merge.js +17 -0
- package/src/shared/message.js +17 -0
- package/src/shared/status.js +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prsm/realtime",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Distributed WebSocket framework with Redis-backed rooms, records, presence, channels, collections, and persistence",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./src/client/index.js",
|
|
10
|
+
"./sqlite": "./src/adapters/sqlite.js",
|
|
11
|
+
"./postgres": "./src/adapters/postgres.js",
|
|
12
|
+
"./devtools": "./src/devtools/index.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "vitest run --reporter verbose"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"eventemitter3": "^5.0.1",
|
|
25
|
+
"fast-json-patch": "^3.1.1",
|
|
26
|
+
"ioredis": "^5.6.1",
|
|
27
|
+
"ws": "^8.18.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"express": "^4.0.0 || ^5.0.0",
|
|
31
|
+
"pg": "^8.0.0",
|
|
32
|
+
"sqlite3": "^5.0.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"express": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"sqlite3": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"pg": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"ioredis": "^5.6.1",
|
|
47
|
+
"vitest": "^3.2.4"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { convertToSqlPattern } from "../server/utils/pattern-conversion.js"
|
|
2
|
+
import { serverLogger } from "../shared/index.js"
|
|
3
|
+
|
|
4
|
+
export function createPostgresAdapter(options = {}) {
|
|
5
|
+
const opts = {
|
|
6
|
+
host: "localhost",
|
|
7
|
+
port: 5432,
|
|
8
|
+
database: "mesh_test",
|
|
9
|
+
user: "mesh",
|
|
10
|
+
password: "mesh_password",
|
|
11
|
+
max: 10,
|
|
12
|
+
...options,
|
|
13
|
+
}
|
|
14
|
+
let pool = null
|
|
15
|
+
let initialized = false
|
|
16
|
+
|
|
17
|
+
async function createTables() {
|
|
18
|
+
if (!pool) throw new Error("Database not initialized")
|
|
19
|
+
const client = await pool.connect()
|
|
20
|
+
try {
|
|
21
|
+
await client.query(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS channel_messages (
|
|
23
|
+
id TEXT PRIMARY KEY, channel TEXT NOT NULL, message TEXT NOT NULL,
|
|
24
|
+
instance_id TEXT NOT NULL, timestamp BIGINT NOT NULL, metadata JSONB
|
|
25
|
+
)
|
|
26
|
+
`)
|
|
27
|
+
await client.query(`CREATE INDEX IF NOT EXISTS idx_channel_timestamp ON channel_messages (channel, timestamp)`)
|
|
28
|
+
await client.query(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
30
|
+
record_id TEXT PRIMARY KEY, version INTEGER NOT NULL,
|
|
31
|
+
value JSONB NOT NULL, timestamp BIGINT NOT NULL
|
|
32
|
+
)
|
|
33
|
+
`)
|
|
34
|
+
await client.query(`CREATE INDEX IF NOT EXISTS idx_records_timestamp ON records (timestamp)`)
|
|
35
|
+
} finally { client.release() }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
async initialize() {
|
|
40
|
+
if (initialized) return
|
|
41
|
+
try {
|
|
42
|
+
const pg = await import("pg")
|
|
43
|
+
const Pool = pg.default?.Pool || pg.Pool
|
|
44
|
+
pool = new Pool(
|
|
45
|
+
opts.connectionString
|
|
46
|
+
? { connectionString: opts.connectionString, max: opts.max }
|
|
47
|
+
: { host: opts.host, port: opts.port, database: opts.database, user: opts.user, password: opts.password, ssl: opts.ssl, max: opts.max }
|
|
48
|
+
)
|
|
49
|
+
await createTables()
|
|
50
|
+
initialized = true
|
|
51
|
+
} catch (err) {
|
|
52
|
+
serverLogger.error("Error initializing PostgreSQL database:", err)
|
|
53
|
+
throw err
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async storeMessages(messages) {
|
|
58
|
+
if (!pool) throw new Error("Database not initialized")
|
|
59
|
+
if (messages.length === 0) return
|
|
60
|
+
const client = await pool.connect()
|
|
61
|
+
try {
|
|
62
|
+
await client.query("BEGIN")
|
|
63
|
+
for (const msg of messages) {
|
|
64
|
+
await client.query(
|
|
65
|
+
`INSERT INTO channel_messages (id, channel, message, instance_id, timestamp, metadata) VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
66
|
+
[msg.id, msg.channel, msg.message, msg.instanceId, msg.timestamp, msg.metadata || null]
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
await client.query("COMMIT")
|
|
70
|
+
} catch (err) { await client.query("ROLLBACK"); throw err }
|
|
71
|
+
finally { client.release() }
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async getMessages(channel, since, limit = 50) {
|
|
75
|
+
if (!pool) throw new Error("Database not initialized")
|
|
76
|
+
let query = "SELECT * FROM channel_messages WHERE channel = $1"
|
|
77
|
+
const params = [channel]
|
|
78
|
+
let paramIndex = 2
|
|
79
|
+
if (since !== undefined) {
|
|
80
|
+
if (typeof since === "number") {
|
|
81
|
+
query += ` AND timestamp > $${paramIndex}`
|
|
82
|
+
params.push(since)
|
|
83
|
+
paramIndex++
|
|
84
|
+
} else {
|
|
85
|
+
const timestampResult = await pool.query("SELECT timestamp FROM channel_messages WHERE id = $1", [since])
|
|
86
|
+
const timestamp = timestampResult.rows[0]?.timestamp || 0
|
|
87
|
+
query += ` AND timestamp > $${paramIndex}`
|
|
88
|
+
params.push(timestamp)
|
|
89
|
+
paramIndex++
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
query += ` ORDER BY timestamp ASC LIMIT $${paramIndex}`
|
|
93
|
+
params.push(limit)
|
|
94
|
+
const result = await pool.query(query, params)
|
|
95
|
+
return result.rows.map((row) => ({
|
|
96
|
+
id: row.id, channel: row.channel, message: row.message,
|
|
97
|
+
instanceId: row.instance_id, timestamp: parseInt(row.timestamp), metadata: row.metadata,
|
|
98
|
+
}))
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async storeRecords(records) {
|
|
102
|
+
if (!pool) throw new Error("Database not initialized")
|
|
103
|
+
if (records.length === 0) return
|
|
104
|
+
const client = await pool.connect()
|
|
105
|
+
try {
|
|
106
|
+
await client.query("BEGIN")
|
|
107
|
+
for (const record of records) {
|
|
108
|
+
await client.query(
|
|
109
|
+
`INSERT INTO records (record_id, version, value, timestamp) VALUES ($1, $2, $3, $4)
|
|
110
|
+
ON CONFLICT (record_id) DO UPDATE SET version = $2, value = $3, timestamp = $4`,
|
|
111
|
+
[record.recordId, record.version, record.value, record.timestamp]
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
await client.query("COMMIT")
|
|
115
|
+
} catch (err) { await client.query("ROLLBACK"); throw err }
|
|
116
|
+
finally { client.release() }
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async getRecords(pattern) {
|
|
120
|
+
if (!pool) throw new Error("Database not initialized")
|
|
121
|
+
const sqlPattern = convertToSqlPattern(pattern)
|
|
122
|
+
const result = await pool.query(
|
|
123
|
+
`SELECT record_id, version, value, timestamp FROM records WHERE record_id LIKE $1 ORDER BY timestamp DESC`,
|
|
124
|
+
[sqlPattern]
|
|
125
|
+
)
|
|
126
|
+
return result.rows.map((row) => ({
|
|
127
|
+
recordId: row.record_id, version: row.version,
|
|
128
|
+
value: row.value, timestamp: parseInt(row.timestamp),
|
|
129
|
+
}))
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async close() {
|
|
133
|
+
if (!pool) return
|
|
134
|
+
await pool.end()
|
|
135
|
+
pool = null
|
|
136
|
+
initialized = false
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { convertToSqlPattern } from "../server/utils/pattern-conversion.js"
|
|
2
|
+
import { serverLogger } from "../shared/index.js"
|
|
3
|
+
|
|
4
|
+
export function createSqliteAdapter(options = {}) {
|
|
5
|
+
const opts = { filename: ":memory:", ...options }
|
|
6
|
+
let db = null
|
|
7
|
+
let initialized = false
|
|
8
|
+
|
|
9
|
+
async function createTables() {
|
|
10
|
+
if (!db) throw new Error("Database not initialized")
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
db.run(
|
|
13
|
+
`CREATE TABLE IF NOT EXISTS channel_messages (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
channel TEXT NOT NULL,
|
|
16
|
+
message TEXT NOT NULL,
|
|
17
|
+
instance_id TEXT NOT NULL,
|
|
18
|
+
timestamp INTEGER NOT NULL,
|
|
19
|
+
metadata TEXT
|
|
20
|
+
)`,
|
|
21
|
+
(err) => {
|
|
22
|
+
if (err) return reject(err)
|
|
23
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_channel_timestamp ON channel_messages (channel, timestamp)", (err) => {
|
|
24
|
+
if (err) return reject(err)
|
|
25
|
+
db.run(
|
|
26
|
+
`CREATE TABLE IF NOT EXISTS records (
|
|
27
|
+
record_id TEXT PRIMARY KEY,
|
|
28
|
+
version INTEGER NOT NULL,
|
|
29
|
+
value TEXT NOT NULL,
|
|
30
|
+
timestamp INTEGER NOT NULL
|
|
31
|
+
)`,
|
|
32
|
+
(err) => {
|
|
33
|
+
if (err) return reject(err)
|
|
34
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_records_timestamp ON records (timestamp)", (err) => {
|
|
35
|
+
if (err) return reject(err)
|
|
36
|
+
resolve()
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
async initialize() {
|
|
48
|
+
if (initialized) return
|
|
49
|
+
const sqlite3 = await import("sqlite3")
|
|
50
|
+
const { Database } = sqlite3.default || sqlite3
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
try {
|
|
53
|
+
db = new Database(opts.filename, async (err) => {
|
|
54
|
+
if (err) return reject(err)
|
|
55
|
+
try {
|
|
56
|
+
await createTables()
|
|
57
|
+
initialized = true
|
|
58
|
+
resolve()
|
|
59
|
+
} catch (e) { reject(e) }
|
|
60
|
+
})
|
|
61
|
+
} catch (err) { reject(err) }
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async storeMessages(messages) {
|
|
66
|
+
if (!db) throw new Error("Database not initialized")
|
|
67
|
+
if (messages.length === 0) return
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
db.serialize(() => {
|
|
70
|
+
db.run("BEGIN TRANSACTION")
|
|
71
|
+
const stmt = db.prepare(
|
|
72
|
+
`INSERT INTO channel_messages (id, channel, message, instance_id, timestamp, metadata) VALUES (?, ?, ?, ?, ?, ?)`
|
|
73
|
+
)
|
|
74
|
+
try {
|
|
75
|
+
for (const msg of messages) {
|
|
76
|
+
const metadata = msg.metadata ? JSON.stringify(msg.metadata) : null
|
|
77
|
+
stmt.run(msg.id, msg.channel, msg.message, msg.instanceId, msg.timestamp, metadata)
|
|
78
|
+
}
|
|
79
|
+
stmt.finalize()
|
|
80
|
+
db.run("COMMIT", (err) => { if (err) reject(err); else resolve() })
|
|
81
|
+
} catch (err) { db.run("ROLLBACK"); reject(err) }
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async getMessages(channel, since, limit = 50) {
|
|
87
|
+
if (!db) throw new Error("Database not initialized")
|
|
88
|
+
let query = "SELECT * FROM channel_messages WHERE channel = ?"
|
|
89
|
+
const params = [channel]
|
|
90
|
+
if (since !== undefined) {
|
|
91
|
+
if (typeof since === "number") {
|
|
92
|
+
query += " AND timestamp > ?"
|
|
93
|
+
params.push(since)
|
|
94
|
+
} else {
|
|
95
|
+
const timestampQuery = await new Promise((resolve, reject) => {
|
|
96
|
+
db.get("SELECT timestamp FROM channel_messages WHERE id = ?", [since], (err, row) => {
|
|
97
|
+
if (err) reject(err)
|
|
98
|
+
else resolve(row ? row.timestamp : 0)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
query += " AND timestamp > ?"
|
|
102
|
+
params.push(timestampQuery)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
query += " ORDER BY timestamp ASC LIMIT ?"
|
|
106
|
+
params.push(limit)
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
db.all(query, params, (err, rows) => {
|
|
109
|
+
if (err) return reject(err)
|
|
110
|
+
resolve(rows.map((row) => ({
|
|
111
|
+
id: row.id,
|
|
112
|
+
channel: row.channel,
|
|
113
|
+
message: row.message,
|
|
114
|
+
instanceId: row.instance_id,
|
|
115
|
+
timestamp: row.timestamp,
|
|
116
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
117
|
+
})))
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async storeRecords(records) {
|
|
123
|
+
if (!db) throw new Error("Database not initialized")
|
|
124
|
+
if (records.length === 0) return
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
db.serialize(() => {
|
|
127
|
+
db.run("BEGIN TRANSACTION")
|
|
128
|
+
const stmt = db.prepare(
|
|
129
|
+
`INSERT OR REPLACE INTO records (record_id, version, value, timestamp) VALUES (?, ?, ?, ?)`
|
|
130
|
+
)
|
|
131
|
+
try {
|
|
132
|
+
for (const record of records) {
|
|
133
|
+
stmt.run(record.recordId, record.version, record.value, record.timestamp)
|
|
134
|
+
}
|
|
135
|
+
stmt.finalize()
|
|
136
|
+
db.run("COMMIT", (err) => {
|
|
137
|
+
if (err) { db.run("ROLLBACK"); reject(err) }
|
|
138
|
+
else resolve()
|
|
139
|
+
})
|
|
140
|
+
} catch (err) { db.run("ROLLBACK"); reject(err) }
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async getRecords(pattern) {
|
|
146
|
+
if (!db) throw new Error("Database not initialized")
|
|
147
|
+
const sqlPattern = convertToSqlPattern(pattern)
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
db.all(
|
|
150
|
+
`SELECT record_id, version, value, timestamp FROM records WHERE record_id LIKE ? ORDER BY timestamp DESC`,
|
|
151
|
+
[sqlPattern],
|
|
152
|
+
(err, rows) => {
|
|
153
|
+
if (err) return reject(err)
|
|
154
|
+
resolve(rows.map((row) => ({
|
|
155
|
+
recordId: row.record_id,
|
|
156
|
+
version: row.version,
|
|
157
|
+
value: row.value,
|
|
158
|
+
timestamp: row.timestamp,
|
|
159
|
+
})))
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async close() {
|
|
166
|
+
if (!db) return
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
db.close((err) => {
|
|
169
|
+
if (err) return reject(err)
|
|
170
|
+
db = null
|
|
171
|
+
initialized = false
|
|
172
|
+
resolve()
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|