Skip to content

Commit 6804fa7

Browse files
authored
Merge pull request #99 from supabase-community/feat/db-sharing
feat: database live share
2 parents e4016cd + 8390bfb commit 6804fa7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+5376
-3566
lines changed

.vscode/settings.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
{
22
"deno.enablePaths": ["supabase/functions"],
33
"deno.lint": true,
4-
"deno.unstable": true
4+
"deno.unstable": true,
5+
"[javascript]": {
6+
"editor.defaultFormatter": "esbenp.prettier-vscode"
7+
},
8+
"[json]": {
9+
"editor.defaultFormatter": "esbenp.prettier-vscode"
10+
},
11+
"[jsonc]": {
12+
"editor.defaultFormatter": "esbenp.prettier-vscode"
13+
},
14+
"[typescript]": {
15+
"editor.defaultFormatter": "esbenp.prettier-vscode"
16+
},
17+
"[typescriptreact]": {
18+
"editor.defaultFormatter": "esbenp.prettier-vscode"
19+
}
520
}

apps/browser-proxy/.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
AWS_ACCESS_KEY_ID="<aws-access-key-id>"
2+
AWS_ENDPOINT_URL_S3="<aws-endpoint-url-s3>"
3+
AWS_S3_BUCKET=storage
4+
AWS_SECRET_ACCESS_KEY="<aws-secret-access-key>"
5+
AWS_REGION=us-east-1
6+
LOGFLARE_SOURCE_URL="<logflare-source-url>"
7+
# enable PROXY protocol support
8+
#PROXIED=true
9+
SUPABASE_URL="<supabase-url>"
10+
SUPABASE_ANON_KEY="<supabase-anon-key>"
11+
WILDCARD_DOMAIN=browser.staging.db.build

apps/browser-proxy/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
tls

apps/browser-proxy/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM node:22-alpine
2+
3+
WORKDIR /app
4+
5+
COPY --link package.json ./
6+
COPY --link src/ ./src/
7+
8+
RUN npm install
9+
10+
EXPOSE 443
11+
EXPOSE 5432
12+
13+
CMD ["node", "--experimental-strip-types", "src/index.ts"]

apps/browser-proxy/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Browser Proxy
2+
3+
This app is a proxy that sits between the browser and a PostgreSQL client.
4+
5+
It is using a WebSocket server and a TCP server to make the communication between the PGlite instance in the browser and a standard PostgreSQL client possible.
6+
7+
## Development
8+
9+
Copy the `.env.example` file to `.env` and set the correct environment variables.
10+
11+
Install dependencies:
12+
13+
```sh
14+
npm install
15+
```
16+
17+
Start the proxy in development mode:
18+
19+
```sh
20+
npm run dev
21+
```
22+
23+
## Deployment
24+
25+
Create a new app on Fly.io, for example `database-build-browser-proxy`.
26+
27+
Fill the app's secrets with the correct environment variables based on the `.env.example` file.
28+
29+
Deploy the app:
30+
31+
```sh
32+
fly deploy --app database-build-browser-proxy
33+
```

apps/browser-proxy/fly.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
primary_region = 'iad'
2+
3+
[[services]]
4+
internal_port = 5432
5+
protocol = "tcp"
6+
[[services.ports]]
7+
handlers = ["proxy_proto"]
8+
port = 5432
9+
10+
[[services]]
11+
internal_port = 443
12+
protocol = "tcp"
13+
[[services.ports]]
14+
port = 443
15+
16+
[[restart]]
17+
policy = "always"
18+
retries = 10
19+
20+
[[vm]]
21+
memory = '512mb'
22+
cpu_kind = 'shared'
23+
cpus = 1

apps/browser-proxy/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@database.build/browser-proxy",
3+
"type": "module",
4+
"scripts": {
5+
"start": "node --env-file=.env --experimental-strip-types src/index.ts",
6+
"dev": "node --watch --env-file=.env --experimental-strip-types src/index.ts",
7+
"type-check": "tsc"
8+
},
9+
"dependencies": {
10+
"@aws-sdk/client-s3": "^3.645.0",
11+
"@supabase/supabase-js": "^2.45.4",
12+
"debug": "^4.3.7",
13+
"expiry-map": "^2.0.0",
14+
"findhit-proxywrap": "^0.3.13",
15+
"nanoid": "^5.0.7",
16+
"p-memoize": "^7.1.1",
17+
"pg-gateway": "^0.3.0-beta.3",
18+
"ws": "^8.18.0"
19+
},
20+
"devDependencies": {
21+
"@total-typescript/tsconfig": "^1.0.4",
22+
"@types/debug": "^4.1.12",
23+
"@types/node": "^22.5.4",
24+
"typescript": "^5.5.4"
25+
}
26+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { PostgresConnection } from 'pg-gateway'
2+
import type { WebSocket } from 'ws'
3+
4+
type DatabaseId = string
5+
type ConnectionId = string
6+
7+
class ConnectionManager {
8+
private socketsByDatabase: Map<DatabaseId, ConnectionId> = new Map()
9+
private sockets: Map<ConnectionId, PostgresConnection> = new Map()
10+
private websockets: Map<DatabaseId, WebSocket> = new Map()
11+
12+
constructor() {}
13+
14+
public hasSocketForDatabase(databaseId: DatabaseId) {
15+
return this.socketsByDatabase.has(databaseId)
16+
}
17+
18+
public getSocket(connectionId: ConnectionId) {
19+
return this.sockets.get(connectionId)
20+
}
21+
22+
public getSocketForDatabase(databaseId: DatabaseId) {
23+
const connectionId = this.socketsByDatabase.get(databaseId)
24+
return connectionId ? this.sockets.get(connectionId) : undefined
25+
}
26+
27+
public setSocket(databaseId: DatabaseId, connectionId: ConnectionId, socket: PostgresConnection) {
28+
this.sockets.set(connectionId, socket)
29+
this.socketsByDatabase.set(databaseId, connectionId)
30+
}
31+
32+
public deleteSocketForDatabase(databaseId: DatabaseId) {
33+
const connectionId = this.socketsByDatabase.get(databaseId)
34+
this.socketsByDatabase.delete(databaseId)
35+
if (connectionId) {
36+
this.sockets.delete(connectionId)
37+
}
38+
}
39+
40+
public hasWebsocket(databaseId: DatabaseId) {
41+
return this.websockets.has(databaseId)
42+
}
43+
44+
public getWebsocket(databaseId: DatabaseId) {
45+
return this.websockets.get(databaseId)
46+
}
47+
48+
public setWebsocket(databaseId: DatabaseId, websocket: WebSocket) {
49+
this.websockets.set(databaseId, websocket)
50+
}
51+
52+
public deleteWebsocket(databaseId: DatabaseId) {
53+
this.websockets.delete(databaseId)
54+
this.deleteSocketForDatabase(databaseId)
55+
}
56+
}
57+
58+
export const connectionManager = new ConnectionManager()
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export function createStartupMessage(
2+
user: string,
3+
database: string,
4+
additionalParams: Record<string, string> = {}
5+
): Uint8Array {
6+
const encoder = new TextEncoder()
7+
8+
// Protocol version number (3.0)
9+
const protocolVersion = 196608
10+
11+
// Combine required and additional parameters
12+
const params = {
13+
user,
14+
database,
15+
...additionalParams,
16+
}
17+
18+
// Calculate total message length
19+
let messageLength = 4 // Protocol version
20+
for (const [key, value] of Object.entries(params)) {
21+
messageLength += key.length + 1 + value.length + 1
22+
}
23+
messageLength += 1 // Null terminator
24+
25+
const uint8Array = new Uint8Array(4 + messageLength)
26+
const view = new DataView(uint8Array.buffer)
27+
28+
let offset = 0
29+
view.setInt32(offset, messageLength + 4, false) // Total message length (including itself)
30+
offset += 4
31+
view.setInt32(offset, protocolVersion, false) // Protocol version number
32+
offset += 4
33+
34+
// Write key-value pairs
35+
for (const [key, value] of Object.entries(params)) {
36+
uint8Array.set(encoder.encode(key), offset)
37+
offset += key.length
38+
uint8Array.set([0], offset++) // Null terminator for key
39+
uint8Array.set(encoder.encode(value), offset)
40+
offset += value.length
41+
uint8Array.set([0], offset++) // Null terminator for value
42+
}
43+
44+
uint8Array.set([0], offset) // Final null terminator
45+
46+
return uint8Array
47+
}
48+
49+
export function createTerminateMessage(): Uint8Array {
50+
const uint8Array = new Uint8Array(5)
51+
const view = new DataView(uint8Array.buffer)
52+
view.setUint8(0, 'X'.charCodeAt(0))
53+
view.setUint32(1, 4, false)
54+
return uint8Array
55+
}

apps/browser-proxy/src/debug.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import createDebug from 'debug'
2+
3+
createDebug.formatters.e = (fn) => fn()
4+
5+
export const debug = createDebug('browser-proxy')

apps/browser-proxy/src/extract-ip.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { isIPv4 } from 'node:net'
2+
3+
export function extractIP(address: string): string {
4+
if (isIPv4(address)) {
5+
return address
6+
}
7+
8+
// Check if it's an IPv4-mapped IPv6 address
9+
const ipv4 = address.match(/::ffff:(\d+\.\d+\.\d+\.\d+)/)
10+
if (ipv4) {
11+
return ipv4[1]!
12+
}
13+
14+
// We assume it's an IPv6 address
15+
return address
16+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module 'findhit-proxywrap' {
2+
const module = {
3+
proxy: (net: typeof import('node:net')) => typeof net,
4+
}
5+
export default module
6+
}

apps/browser-proxy/src/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { httpsServer } from './websocket-server.ts'
2+
import { tcpServer } from './tcp-server.ts'
3+
4+
process.on('unhandledRejection', (reason, promise) => {
5+
console.error({ location: 'unhandledRejection', reason, promise })
6+
})
7+
8+
process.on('uncaughtException', (error) => {
9+
console.error({ location: 'uncaughtException', error })
10+
})
11+
12+
httpsServer.listen(443, () => {
13+
console.log('websocket server listening on port 443')
14+
})
15+
16+
tcpServer.listen(5432, () => {
17+
console.log('tcp server listening on port 5432')
18+
})
19+
20+
const shutdown = async () => {
21+
await Promise.allSettled([
22+
new Promise<void>((res) =>
23+
httpsServer.close(() => {
24+
res()
25+
})
26+
),
27+
new Promise<void>((res) =>
28+
tcpServer.close(() => {
29+
res()
30+
})
31+
),
32+
])
33+
process.exit(0)
34+
}
35+
36+
process.on('SIGTERM', shutdown)
37+
process.on('SIGINT', shutdown)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const VECTOR_OID = 99999
2+
export const FIRST_NORMAL_OID = 16384

0 commit comments

Comments
 (0)