Skip to content

Commit 6e71c4e

Browse files
committed
feat: Plugin architecture
1 parent 9a6d997 commit 6e71c4e

File tree

7 files changed

+322
-203
lines changed

7 files changed

+322
-203
lines changed

plugins/studio/handler.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
interface HandleStudioRequestOption {
2+
username: string
3+
password: string
4+
apiKey: string
5+
}
6+
7+
function createStudioHTML(apiKey: string): string {
8+
return `<!doctype>
9+
<html>
10+
<head>
11+
<style>
12+
html, body {
13+
padding: 0;
14+
margin: 0;
15+
width: 100vw;
16+
height: 100vh;
17+
}
18+
19+
iframe {
20+
width: 100vw;
21+
height: 100vh;
22+
overflow: hidden;
23+
border: 0;
24+
}
25+
</style>
26+
<title>Your Starbase - Outerbase Studio</title>
27+
<link rel="icon" type="image/x-icon" href="https://studio.outerbase.com/icons/outerbase.ico">
28+
</head>
29+
<body>
30+
<script>
31+
function handler(e) {
32+
if (e.data.type !== "query" && e.data.type !== "transaction") return;
33+
let requestBody = e.data.type === 'transaction' ?
34+
{ transaction: e.data.statements.map(t => ({sql: t})) } :
35+
{ sql: e.data.statement };
36+
37+
fetch("/query/raw", {
38+
method: "post",
39+
headers: {
40+
"Content-Type": "application/json",
41+
"Authorization": "Bearer ${apiKey}"
42+
},
43+
body: JSON.stringify(requestBody)
44+
}).then(r => {
45+
if (!r.ok) {
46+
document.getElementById('editor').contentWindow.postMessage({
47+
id: e.data.id,
48+
type: e.data.type,
49+
error: "Something went wrong",
50+
}, "*");
51+
throw new Error("Something went wrong");
52+
}
53+
return r.json()
54+
}).then(r => {
55+
const response = {
56+
id: e.data.id,
57+
type: e.data.type,
58+
data: Array.isArray(r.result) ? r.result.map(transformRawResult) : transformRawResult(r.result),
59+
};
60+
61+
document.getElementById('editor').contentWindow.postMessage(response, "*");
62+
}).catch(console.error)
63+
}
64+
65+
function transformRawResult(raw) {
66+
const columns = raw.columns ?? [];
67+
const values = raw.rows;
68+
const headerSet = new Set();
69+
70+
const headers = columns.map((colName) => {
71+
let renameColName = colName;
72+
73+
for (let i = 0; i < 20; i++) {
74+
if (!headerSet.has(renameColName)) break;
75+
renameColName = \`__\${colName}_\${i}\`;
76+
}
77+
78+
return {
79+
name: renameColName,
80+
displayName: colName,
81+
originalType: "text",
82+
type: undefined,
83+
};
84+
});
85+
86+
const rows = values
87+
? values.map((r) =>
88+
headers.reduce((a, b, idx) => {
89+
a[b.name] = r[idx];
90+
return a;
91+
}, {})
92+
)
93+
: [];
94+
95+
return {
96+
rows,
97+
stat: {
98+
queryDurationMs: 0,
99+
rowsAffected: 0,
100+
rowsRead: raw.meta.rows_read,
101+
rowsWritten: raw.meta.rows_written,
102+
},
103+
headers,
104+
};
105+
}
106+
107+
window.addEventListener("message", handler);
108+
</script>
109+
110+
<iframe
111+
id="editor"
112+
src="https://studio.outerbase.com/embed/starbase"
113+
/>
114+
</body>
115+
</html>`
116+
}
117+
118+
export async function handleStudioRequest(
119+
request: Request,
120+
options: HandleStudioRequestOption
121+
): Promise<Response> {
122+
// Check for basic authorization
123+
const auth = request.headers.get('Authorization')
124+
125+
if (!auth || !auth.startsWith('Basic ')) {
126+
return new Response('Unauthorized', {
127+
status: 401,
128+
headers: {
129+
'WWW-Authenticate': 'Basic realm="Access to the studio"',
130+
},
131+
})
132+
}
133+
134+
// base64 auth
135+
const base64Auth = auth.split('Basic ')[1]
136+
const decodedAuth = atob(base64Auth)
137+
const [username, password] = decodedAuth.split(':')
138+
139+
if (username !== options.username || password !== options.password) {
140+
return new Response('Unauthorized', {
141+
status: 401,
142+
headers: {
143+
'WWW-Authenticate': 'Basic realm="Access to the studio"',
144+
},
145+
})
146+
}
147+
148+
// Proceed with the request
149+
return new Response(createStudioHTML(options.apiKey), {
150+
headers: { 'Content-Type': 'text/html' },
151+
})
152+
}

plugins/studio/index.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { StarbaseApp } from '../../src/handler'
2+
import { StarbasePlugin } from '../../src/plugin'
3+
import { handleStudioRequest } from './handler'
4+
5+
export class StudioPlugin extends StarbasePlugin {
6+
private username: string
7+
private password: string
8+
private apiKey: string
9+
private prefix: string
10+
11+
constructor(options: {
12+
username: string
13+
password: string
14+
apiKey: string
15+
prefix?: string
16+
}) {
17+
super('starbasedb:studio')
18+
this.username = options.username
19+
this.password = options.password
20+
this.apiKey = options.apiKey
21+
this.prefix = options.prefix || '/studio'
22+
}
23+
24+
override async register(app: StarbaseApp) {
25+
app.get(this.prefix, async (c) => {
26+
return handleStudioRequest(c.req.raw, {
27+
username: this.username,
28+
password: this.password,
29+
apiKey: this.apiKey,
30+
})
31+
})
32+
}
33+
}

plugins/websocket/index.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { StarbaseApp, StarbaseContext } from '../../src/handler'
2+
import { StarbasePlugin } from '../../src/plugin'
3+
4+
export class WebSocketPlugin extends StarbasePlugin {
5+
private prefix = '/socket'
6+
7+
constructor(opts?: { prefix?: string }) {
8+
super('starbasedb:websocket')
9+
this.prefix = opts?.prefix ?? this.prefix
10+
}
11+
12+
override async register(app: StarbaseApp) {
13+
app.all(this.prefix, (c) => {
14+
return this.upgrade(c)
15+
})
16+
}
17+
18+
private upgrade(ctx: StarbaseContext): Response {
19+
if (ctx.req.header('upgrade') !== 'websocket') {
20+
return new Response('Expected upgrade request', { status: 400 })
21+
}
22+
23+
const config = ctx.get('config')
24+
const dataSource = ctx.get('dataSource')
25+
const { executeQuery } = ctx.get('operations')
26+
27+
const webSocketPair = new WebSocketPair()
28+
const [client, server] = Object.values(webSocketPair)
29+
30+
server.accept()
31+
server.addEventListener('message', (event) => {
32+
const { sql, params, action } = JSON.parse(event.data as string)
33+
34+
if (action === 'query') {
35+
const executeQueryWrapper = async () => {
36+
const response = await executeQuery({
37+
sql,
38+
params,
39+
isRaw: false,
40+
dataSource,
41+
config,
42+
})
43+
server.send(JSON.stringify(response))
44+
}
45+
executeQueryWrapper()
46+
}
47+
})
48+
49+
return new Response(null, { status: 101, webSocket: client })
50+
}
51+
}

src/handler.ts

Lines changed: 37 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Hono } from 'hono'
1+
import { Context, Hono } from 'hono'
22
import { createMiddleware } from 'hono/factory'
33
import { validator } from 'hono/validator'
44

@@ -12,41 +12,54 @@ import { exportTableToCsvRoute } from './export/csv'
1212
import { importDumpRoute } from './import/dump'
1313
import { importTableFromJsonRoute } from './import/json'
1414
import { importTableFromCsvRoute } from './import/csv'
15-
import { handleStudioRequest } from './studio'
1615
import { corsPreflight } from './cors'
1716
import { handleApiRequest } from './api'
17+
import { StarbasePlugin, StarbasePluginRegistry } from './plugin'
1818

1919
export interface StarbaseDBConfiguration {
2020
outerbaseApiKey?: string
2121
role: 'admin' | 'client'
2222
features?: {
2323
allowlist?: boolean
2424
rls?: boolean
25-
studio?: boolean
2625
rest?: boolean
2726
websocket?: boolean
2827
export?: boolean
2928
import?: boolean
3029
}
31-
studio?: {
32-
username: string
33-
password: string
34-
apiKey: string
30+
}
31+
32+
type HonoContext = {
33+
Variables: {
34+
config: StarbaseDBConfiguration
35+
dataSource: DataSource
36+
operations: {
37+
executeQuery: typeof executeQuery
38+
executeTransaction: typeof executeTransaction
39+
}
3540
}
3641
}
3742

43+
const app = new Hono<HonoContext>()
44+
45+
export type StarbaseApp = typeof app
46+
export type StarbaseContext = Context<HonoContext>
47+
3848
export class StarbaseDB {
3949
private dataSource: DataSource
4050
private config: StarbaseDBConfiguration
4151
private liteREST: LiteREST
52+
private plugins: StarbasePlugin[]
4253

4354
constructor(options: {
4455
dataSource: DataSource
4556
config: StarbaseDBConfiguration
57+
plugins?: StarbasePlugin[]
4658
}) {
4759
this.dataSource = options.dataSource
4860
this.config = options.config
4961
this.liteREST = new LiteREST(this.dataSource, this.config)
62+
this.plugins = options.plugins || []
5063

5164
if (
5265
this.dataSource.source === 'external' &&
@@ -110,8 +123,16 @@ export class StarbaseDB {
110123
request: Request,
111124
ctx: ExecutionContext
112125
): Promise<Response> {
113-
const app = new Hono()
114-
const isUpgrade = request.headers.get('Upgrade') === 'websocket'
126+
// Add context to the request
127+
app.use('*', async (c, next) => {
128+
c.set('config', this.config)
129+
c.set('dataSource', this.dataSource)
130+
c.set('operations', {
131+
executeQuery,
132+
executeTransaction,
133+
})
134+
return next()
135+
})
115136

116137
// Non-blocking operation to remove expired cache entries from our DO
117138
ctx.waitUntil(this.expireCache())
@@ -130,22 +151,15 @@ export class StarbaseDB {
130151
)
131152
})
132153

133-
// CORS preflight handler.
134-
app.options('*', () => corsPreflight())
154+
const registry = new StarbasePluginRegistry({
155+
app,
156+
plugins: this.plugins,
157+
})
135158

136-
if (this.getFeature('studio') && this.config.studio) {
137-
app.get('/studio', async (c) => {
138-
return handleStudioRequest(request, {
139-
username: this.config.studio!.username,
140-
password: this.config.studio!.password,
141-
apiKey: this.config.studio!.apiKey,
142-
})
143-
})
144-
}
159+
await registry.init()
145160

146-
if (isUpgrade && this.getFeature('websocket')) {
147-
app.all('/socket', () => this.clientConnected())
148-
}
161+
// CORS preflight handler.
162+
app.options('*', () => corsPreflight())
149163

150164
app.post('/query/raw', async (c) => this.queryRoute(c.req.raw, true))
151165
app.post('/query', async (c) => this.queryRoute(c.req.raw, false))
@@ -310,32 +324,6 @@ export class StarbaseDB {
310324
}
311325
}
312326

313-
private clientConnected() {
314-
const webSocketPair = new WebSocketPair()
315-
const [client, server] = Object.values(webSocketPair)
316-
317-
server.accept()
318-
server.addEventListener('message', (event) => {
319-
const { sql, params, action } = JSON.parse(event.data as string)
320-
321-
if (action === 'query') {
322-
const executeQueryWrapper = async () => {
323-
const response = await executeQuery({
324-
sql,
325-
params,
326-
isRaw: false,
327-
dataSource: this.dataSource,
328-
config: this.config,
329-
})
330-
server.send(JSON.stringify(response))
331-
}
332-
executeQueryWrapper()
333-
}
334-
})
335-
336-
return new Response(null, { status: 101, webSocket: client })
337-
}
338-
339327
/**
340328
*
341329
*/

0 commit comments

Comments
 (0)