Skip to content

Commit 0bb54b6

Browse files
gateway / implement databases, stats api #62
1 parent 280b04b commit 0bb54b6

File tree

6 files changed

+258
-105
lines changed

6 files changed

+258
-105
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sqlitecloud/drivers",
3-
"version": "0.0.54",
3+
"version": "0.0.55",
44
"description": "SQLiteCloud drivers for Typescript/Javascript in edge, web and node clients",
55
"main": "./lib/index.js",
66
"types": "./lib/index.d.ts",

public/index.html

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
<body class="p-4">
1010
<h1>SQLite Cloud Gateway</h1>
11-
<h2 id="status" class="pb-6 font-bold cursor-pointer">Disconnected</h2>
11+
<h2 id="status" class="font-bold cursor-pointer">Disconnected</h2>
12+
<div class="text-sm pb-6"><a id="apiDatabases" class="cursor-pointer">databases</a> | <a id="apiStats" class="cursor-pointer">stats</a></div>
1213

1314
<!-- Add the text field and button here -->
1415
<div class="pb-2">
@@ -114,6 +115,26 @@ <h2>Messages:</h2>
114115
appendMessage(`info | heapSize: ${(response.data.memory.heapSize / 1024 / 1024).toFixed(2)}mb, ${JSON.stringify(response)}`)
115116
})
116117
})
118+
119+
apiDatabases.addEventListener('click', () => {
120+
if (!socket) {
121+
socket = setupSocket()
122+
}
123+
socket.emit('v1/databases', {}, response => {
124+
console.debug('/v1/databases', response.data)
125+
appendMessage(`databases | ${JSON.stringify(response.data)}`)
126+
})
127+
})
128+
129+
apiStats.addEventListener('click', () => {
130+
if (!socket) {
131+
socket = setupSocket()
132+
}
133+
socket.emit('v1/stats', {}, response => {
134+
console.debug('/v1/stats', response.data)
135+
appendMessage(`stats | ${JSON.stringify(response.data)}`)
136+
})
137+
})
117138
</script>
118139
</body>
119140
</html>

src/gateway/api.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//
2+
// api.ts - implements various node api, eg: list databases, stats, etc
3+
//
4+
5+
import packageJson from '../../package.json'
6+
import { type ApiRequest, type ApiResponse, type SqlApiRequest, DEFAULT_PORT_HTTP, DEFAULT_PORT_SOCKET } from './shared'
7+
import { VERBOSE, connectAsync, sendCommandsAsync, log, errorResponse } from './utilities'
8+
import { SQLiteCloudBunConnection } from './connection-bun'
9+
import { heapStats } from 'bun:jsc'
10+
import { camelCase } from 'lodash'
11+
12+
const startedOn = new Date()
13+
14+
/** Server info for /v1/info endpoints */
15+
export function getServerInfo(): ApiResponse {
16+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
17+
const { objectTypeCounts, protectedObjectTypeCounts, ...memory } = heapStats()
18+
return {
19+
data: {
20+
name: '@sqlitecloud/gateway',
21+
version: packageJson.version,
22+
started: startedOn.toISOString(),
23+
uptime: `${Math.floor(process.uptime() / 3600)}h:${Math.floor((process.uptime() % 3600) / 60)}m:${Math.floor(process.uptime() % 60)}s`,
24+
bun: {
25+
version: Bun.version,
26+
path: Bun.which('bun'),
27+
main: Bun.main
28+
},
29+
memory,
30+
cpuUsage: process.cpuUsage()
31+
}
32+
}
33+
}
34+
35+
/**
36+
* Returns information on databases running on the connected node, eg: LIST DATABASES DETAILED
37+
* @see https://github.com/sqlitecloud/backend/blob/main/sqliteweb/doc/dashboard/v1/%7BprojectID%7D/databases/GET.md
38+
* @see https://github.com/sqlitecloud/backend/blob/a12cf5308c4eb09d90ec2a8f5a4f87d3200f477f/sqliteweb/dashboard/v1/%7BprojectID%7D/databases/GET.lua#L4
39+
* @param connection Database connection
40+
* @returns Information on databases available on node
41+
*/
42+
export async function getDatabases(connection: SQLiteCloudBunConnection): Promise<ApiResponse> {
43+
const results = await sendCommandsAsync(connection, 'LIST DATABASES DETAILED;')
44+
return { data: Array.isArray(results) ? results : [] }
45+
}
46+
47+
/**
48+
* Returns node stats, eg: LIST STATS NODE ? MEMORY;
49+
* @see https://github.com/sqlitecloud/backend/blob/main/sqliteweb/doc/dashboard/v1/%7BprojectID%7D/node/%7BnodeID%7D/stat/GET.md
50+
* @see https://github.com/sqlitecloud/backend/blob/a12cf5308c4eb09d90ec2a8f5a4f87d3200f477f/sqliteweb/dashboard/v1/%7BprojectID%7D/node/%7BnodeID%7D/stat/GET.lua
51+
* @param connection Database connection
52+
* @returns Information on databases available on node
53+
*/
54+
export async function getStats(connection: SQLiteCloudBunConnection): Promise<ApiResponse> {
55+
// receives list of statistics, some may be duplicates, same may be missing, open ended keys
56+
const results = await sendCommandsAsync(connection, 'LIST STATS NODE ? MEMORY;')
57+
58+
// reduce to dictionary of single most recent value for each key
59+
let data: Record<string, any> = {}
60+
if (Array.isArray(results)) {
61+
results.sort((a, b) => new Date(b.datetime).getTime() - new Date(a.datetime).getTime())
62+
data = results.reduce((acc, curr) => {
63+
const camelKey = camelCase(curr.key)
64+
if (!acc[camelKey]) {
65+
acc[camelKey] = numberIfPossible(curr.value)
66+
}
67+
return acc
68+
}, {})
69+
}
70+
71+
return { data }
72+
}
73+
74+
/** Converts CURRENT_CLIENTS to currentClients */
75+
function camelCase(str: string): string {
76+
return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase())
77+
}
78+
79+
/** Converts "42" to 42 while leaving "Mickey" alone */
80+
function numberIfPossible(value: string): number | string {
81+
const parsed = parseFloat(value)
82+
return isNaN(parsed) ? value : parsed
83+
}

src/gateway/gateway.ts

Lines changed: 86 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import packageJson from '../../package.json'
77
// bun specific driver + shared classes
88
import { SQLiteCloudBunConnection } from './connection-bun'
99
import { SQLiteCloudRowset, SQLiteCloudError, validateConfiguration } from '../index'
10-
import { type ApiRequest, type ApiResponse, type SqlApiRequest, DEFAULT_PORT_HTTP, DEFAULT_PORT_SOCKET } from './shared'
10+
import { type ApiRequest, type ApiResponse, type SqlApiRequest, DEFAULT_PORT_HTTP, DEFAULT_PORT_SOCKET, GatewayError } from './shared'
11+
import { VERBOSE, connectAsync, sendCommandsAsync, log, errorResponse } from './utilities'
12+
import { getServerInfo, getDatabases, getStats } from './api'
1113

1214
// external modules
1315
import { heapStats } from 'bun:jsc'
@@ -19,9 +21,8 @@ import http from 'http'
1921
const SOCKET_PORT = parseInt(process.env['SOCKET_PORT'] || DEFAULT_PORT_SOCKET.toString())
2022
// port where http server will listen for connections
2123
const HTTP_PORT = parseInt(process.env['HTTP_PORT'] || DEFAULT_PORT_HTTP.toString())
22-
// should we log verbose messages?
23-
const VERBOSE = process.env['VERBOSE']?.toLowerCase() === 'true'
24-
console.debug(`@sqlitecloud/gateway v${packageJson.version}`)
24+
25+
console.log(`@sqlitecloud/gateway v${packageJson.version}`)
2526

2627
//
2728
// express
@@ -65,13 +66,45 @@ io.on('connection', socket => {
6566
// handlers
6667
//
6768

69+
async function getConnection(): Promise<SQLiteCloudBunConnection> {
70+
if (!connection) {
71+
const startTime = Date.now()
72+
log('ws | connecting...')
73+
connection = await connectAsync(connectionString)
74+
log(`ws | connected in ${Date.now() - startTime}ms`)
75+
}
76+
return connection
77+
}
78+
79+
async function callbackWithApiResponse(
80+
callback: (response: ApiResponse) => void,
81+
func: (connection: SQLiteCloudBunConnection, ...args: any[]) => Promise<ApiResponse>,
82+
...args: any[]
83+
) {
84+
try {
85+
const connection = await getConnection()
86+
const response = await func(connection, ...args)
87+
callback(response)
88+
} catch (error) {
89+
callback({ error: { status: '500', title: String(error) } })
90+
}
91+
}
92+
6893
// received a sql query request from the client socket
69-
socket.on('v1/info', (_request: ApiRequest, callback: (response: ApiResponse) => void) => {
94+
socket.on('v1/info', async (_request: ApiRequest, callback: (response: ApiResponse) => void) => {
7095
const serverInfo = getServerInfo()
7196
log(`ws | info <- ${JSON.stringify(serverInfo)}`)
7297
return callback(serverInfo)
7398
})
7499

100+
socket.on('v1/databases', async (_request: ApiRequest, callback: (response: ApiResponse) => void) => {
101+
await callbackWithApiResponse(callback, getDatabases)
102+
})
103+
104+
socket.on('v1/stats', async (_request: ApiRequest, callback: (response: ApiResponse) => void) => {
105+
await callbackWithApiResponse(callback, getStats)
106+
})
107+
75108
// received a sql query request from the client socket
76109
socket.on('v1/sql', async (request: SqlApiRequest, callback: (response: ApiResponse) => void) => {
77110
if (!connectionString) {
@@ -80,14 +113,8 @@ io.on('connection', socket => {
80113
}
81114

82115
try {
83-
if (!connection) {
84-
const startTime = Date.now()
85-
log('ws | connecting...')
86-
connection = await connectAsync(connectionString)
87-
log(`ws | connected in ${Date.now() - startTime}ms`)
88-
}
89-
90116
log(`ws | sql -> ${JSON.stringify(request)}`)
117+
const connection = await getConnection()
91118
const response = await queryAsync(connection, request)
92119
log(`ws | sql <- ${JSON.stringify(response)}`)
93120
return callback(response)
@@ -121,31 +148,59 @@ app.get('/v1/info', (req, res) => {
121148
res.json(getServerInfo())
122149
})
123150

124-
app.post('/v1/sql', (req: express.Request, res: express.Response) => {
125-
void (async () => {
126-
try {
127-
log('POST /v1/sql')
128-
const response = await handleHttpSqlRequest(req, res)
129-
res.json(response)
130-
} catch (error) {
131-
log('POST /v1/sql - error', error)
132-
res.status(400).json({ error: { status: '400', title: 'Bad Request', detail: error as string } })
133-
}
134-
})
151+
app.get('/v1/databases', async (request, response) => {
152+
try {
153+
response.json(await getDatabases(await getRequestConnection(request)))
154+
} catch (error) {
155+
errorResponse(response, 500, 'Error', error)
156+
}
157+
})
158+
159+
app.get('/v1/stats', async (request, response) => {
160+
try {
161+
response.json(await getStats(await getRequestConnection(request)))
162+
} catch (error) {
163+
errorResponse(response, 500, 'Error', error)
164+
}
165+
})
166+
167+
app.post('/v1/sql', async (req: express.Request, res: express.Response) => {
168+
try {
169+
log('POST /v1/sql')
170+
const response = await handleHttpSqlRequest(req, res)
171+
res.json(response)
172+
} catch (error) {
173+
log('POST /v1/sql - error', error)
174+
res.status(400).json({ error: { status: '400', title: 'Bad Request', detail: error as string } })
175+
}
135176
})
136177

137178
//
138179
// utilities
139180
//
140181

141-
/** Handle a stateless sql query request */
142-
async function handleHttpSqlRequest(request: express.Request, response: express.Response) {
182+
/** Extract and return bearer token from request authorization headers */
183+
function getRequestToken(request: express.Request): string | null {
184+
const authorization = request.headers['authorization'] as string
185+
// console.debug(`getBearerToken - ${authorization}`, request.headers)
186+
if (authorization && authorization.startsWith('Bearer ')) {
187+
return authorization.substring(7)
188+
}
189+
return null
190+
}
191+
192+
/** Returns database connection associated with express request credentials */
193+
async function getRequestConnection(request: express.Request): Promise<SQLiteCloudBunConnection> {
143194
// bearer token is required to connect to sqlitecloud
144-
const connectionString = getBearerToken(request)
195+
const connectionString = getRequestToken(request)
145196
if (!connectionString) {
146-
return errorResponse(response, 401, 'Unauthorized')
197+
throw new GatewayError('Unauthorized', { status: 401 })
147198
}
199+
return await connectAsync(connectionString)
200+
}
148201

202+
/** Handle a stateless sql query request */
203+
async function handleHttpSqlRequest(request: express.Request, response: express.Response) {
149204
// ?sql= or json payload with sql property is required
150205
let apiRequest: SqlApiRequest
151206
try {
@@ -161,88 +216,23 @@ async function handleHttpSqlRequest(request: express.Request, response: express.
161216
return errorResponse(response, 400, 'Bad Request', 'Missing ?sql= query or json payload')
162217
}
163218

164-
let connection
219+
let connection = null
165220
try {
166221
// request is stateless so we will connect and disconnect for each request
167222
log(`http | sql -> ${JSON.stringify(apiRequest)}`)
168-
connection = await connectAsync(connectionString)
223+
connection = await getRequestConnection(request)
169224
const apiResponse = await queryAsync(connection, apiRequest)
170225
log(`http | sql <- ${JSON.stringify(apiResponse)}`)
171226
response.json(apiResponse)
172227
} catch (error) {
173228
errorResponse(response, 400, 'Bad Request', (error as Error).toString())
174229
} finally {
175-
connection?.close()
176-
}
177-
}
178-
179-
/** Server info for /v1/info endpoints */
180-
function getServerInfo() {
181-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
182-
const { objectTypeCounts, protectedObjectTypeCounts, ...memory } = heapStats()
183-
return {
184-
data: {
185-
name: '@sqlitecloud/gateway',
186-
version: packageJson.version,
187-
started: appStartedOn.toISOString(),
188-
uptime: `${Math.floor(process.uptime() / 3600)}h:${Math.floor((process.uptime() % 3600) / 60)}m:${Math.floor(process.uptime() % 60)}s`,
189-
bun: {
190-
version: Bun.version,
191-
path: Bun.which('bun'),
192-
main: Bun.main
193-
},
194-
memory,
195-
cpuUsage: process.cpuUsage()
230+
if (connection) {
231+
connection.close()
196232
}
197233
}
198234
}
199235

200-
/** Extract and return bearer token from request authorization headers */
201-
function getBearerToken(request: express.Request): string | null {
202-
const authorization = request.headers['authorization'] as string
203-
// console.debug(`getBearerToken - ${authorization}`, request.headers)
204-
if (authorization && authorization.startsWith('Bearer ')) {
205-
return authorization.substring(7)
206-
}
207-
return null
208-
}
209-
210-
/** Returns a json api compatibile error response */
211-
function errorResponse(response: express.Response, status: number, statusText: string, detail?: string) {
212-
response.status(status).json({ error: { status: status.toString(), title: statusText, detail } })
213-
}
214-
215-
/** Connects to given database asynchronously */
216-
async function connectAsync(connectionString: string): Promise<SQLiteCloudBunConnection> {
217-
return await new Promise((resolve, reject) => {
218-
const config = validateConfiguration({ connectionString })
219-
const connection = new SQLiteCloudBunConnection(config, (error: Error | null) => {
220-
if (error) {
221-
log('connectAsync | error', error)
222-
reject(error)
223-
} else {
224-
resolve(connection)
225-
}
226-
})
227-
})
228-
}
229-
230-
/** Sends given sql commands asynchronously */
231-
async function sendCommandsAsync(connection: SQLiteCloudBunConnection, sql: string): Promise<unknown> {
232-
return await new Promise((resolve, reject) => {
233-
connection.sendCommands(sql, (error: Error | null, results) => {
234-
// Explicitly type the 'error' parameter as 'Error'
235-
if (error) {
236-
log('sendCommandsAsync | error', error)
237-
reject(error)
238-
} else {
239-
// console.debug(JSON.stringify(results).substring(0, 140) + '...')
240-
resolve(results)
241-
}
242-
})
243-
})
244-
}
245-
246236
/** Runs query on given connection and returns response payload */
247237
async function queryAsync(connection: SQLiteCloudBunConnection, apiRequest: SqlApiRequest): Promise<ApiResponse> {
248238
let result: unknown = 'OK'
@@ -323,10 +313,3 @@ function generateMetadata(sql: string, result: any): ApiResponse {
323313

324314
return { data: result }
325315
}
326-
327-
/** Log only in verbose mode */
328-
function log(...args: unknown[]) {
329-
if (VERBOSE) {
330-
console.debug(...args)
331-
}
332-
}

0 commit comments

Comments
 (0)