├── example ├── wrangler.template.toml ├── package.json └── index.template.js ├── .gitignore ├── rollup.config.js ├── .github └── workflows │ ├── release.yml │ └── build.yml ├── LICENSE ├── package.json ├── QUICKSTART.md ├── README.md ├── bin └── quickstart └── src └── index.ts /example/wrangler.template.toml: -------------------------------------------------------------------------------- 1 | name = "db-connect-quickstart" 2 | type = "webpack" 3 | workers_dev = true 4 | account_id = "$ACCOUNT" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | example/dist/ 5 | example/node_modules/ 6 | example/worker/ 7 | example/index.js 8 | example/wrangler.toml 9 | example/package-lock.json 10 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db-connect-example", 3 | "version": "0.0.6", 4 | "description": "A template for quickstarting Cloudflare Workers and SQL", 5 | "main": "index.js", 6 | "author": "Ashcon Partovi ", 7 | "dependencies": { 8 | "@cloudflare/db-connect": "0.0.6", 9 | "ua-parser-js": "^0.7.20" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import pkg from './package.json' 2 | import typescript from 'rollup-plugin-typescript' 3 | 4 | export default { 5 | input: 'src/index.ts', 6 | output: [ 7 | { format: 'es', file: pkg.module }, 8 | { format: 'cjs', file: pkg.main }, 9 | { format: 'umd', file: pkg.browser, name: pkg.name }, 10 | ], 11 | plugins: [ 12 | typescript({lib: ['es5', 'es6', 'dom'], target: 'es5'}) 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - package*.json 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npm ci 20 | - run: npm publish 21 | env: 22 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ${{matrix.os}} 14 | strategy: 15 | matrix: 16 | node_version: [8, 10, 12] 17 | os: [ubuntu-latest, windows-latest, macOS-latest] 18 | steps: 19 | - uses: actions/checkout@v1 20 | - uses: actions/setup-node@v1 21 | with: 22 | version: ${{matrix.node_version}} 23 | - run: npm ci 24 | - run: npm test 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2019, Cloudflare, Inc. 2 | Copyright (C) 2019, Ashcon Partovi 3 | 4 | Permission is hereby granted, free of charge, to any 5 | person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the 7 | Software without restriction, including without 8 | limitation the rights to use, copy, modify, merge, 9 | publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software 11 | is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice 15 | shall be included in all copies or substantial portions 16 | of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 19 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 20 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 21 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 22 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 23 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 24 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 25 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 26 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudflare/db-connect", 3 | "version": "0.0.6", 4 | "description": "Connect your SQL database to Cloudflare Workers.", 5 | "repository": "github:cloudflare/db-connect", 6 | "homepage": "https://github.com/cloudflare/db-connect#readme", 7 | "author": "Ashcon Partovi ", 8 | "license": "MIT", 9 | "main": "dist/db-connect.cjs.js", 10 | "module": "dist/db-connect.esm.js", 11 | "browser": "dist/db-connect.umd.js", 12 | "types": "src/index.ts", 13 | "bin": { 14 | "@cloudflare/db-connect-quickstart": "bin/quickstart", 15 | "db-connect-quickstart": "bin/quickstart" 16 | }, 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "devDependencies": { 21 | "email-validator": "^2.0.4", 22 | "enquirer": "^2.3.1", 23 | "node-fetch": "^2.6.0", 24 | "rollup": "^0.68.0", 25 | "rollup-plugin-typescript": "^1.0.1", 26 | "tslib": "^1.10.0", 27 | "typescript": "^3.5.3" 28 | }, 29 | "scripts": { 30 | "build": "rollup -c", 31 | "prepare": "npm run build" 32 | }, 33 | "files": [ 34 | "dist/", 35 | "bin/", 36 | "example/" 37 | ], 38 | "keywords": [ 39 | "sql", 40 | "postgres", 41 | "mysql", 42 | "database", 43 | "workers", 44 | "cloudflare" 45 | ], 46 | "dependencies": {} 47 | } 48 | -------------------------------------------------------------------------------- /QUICKSTART.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | ## 1. [Access](https://developers.cloudflare.com/access/service-auth/service-token/) 4 | 5 | Go to your Cloudflare dashboard under "Access" and generate a new service token. 6 | 7 | ![](https://developers.cloudflare.com/access/static/srv-generate.png) 8 | 9 | Copy and save the `Client ID` and `Client Secret`, you will need this later to connect to the database. 10 | 11 | ![](https://developers.cloudflare.com/access/static/srv-secret.png) 12 | 13 | Create an access policy for your specified `hostname` using the service token. 14 | 15 | ![](https://developers.cloudflare.com/access/static/srv-tokenname.png) 16 | 17 | ## 2. [Argo Tunnel](https://developers.cloudflare.com/argo-tunnel/quickstart/) 18 | 19 | Install `cloudflared` on the server where your database is running. If you are using a managed database, you can install it on a nearby VM. 20 | 21 | ```bash 22 | brew install cloudflare/cloudflare/cloudflared 23 | ``` 24 | 25 | Start the tunnel in `db-connect` mode, providing a hostname and your database connection URL. 26 | 27 | ```bash 28 | cloudflared db-connect --hostname db.myzone.com --url postgres://user:pass@localhost?sslmode=disable --insecure 29 | ``` 30 | 31 | If you want to deploy using Docker or Kubernetes, see our guide [here](https://developers.cloudflare.com/argo-tunnel/reference/sidecar/). You can alternatively specify the following environment variables: `TUNNEL_HOSTNAME` and `TUNNEL_URL`. 32 | 33 | 34 | ## 3. Code 35 | 36 | Import the `db-connect` library in your Cloudflare Workers or browser project. 37 | 38 | ```bash 39 | npm i @cloudflare/db-connect 40 | ``` 41 | 42 | Now initalize the client and start coding! 43 | 44 | ```js 45 | import { DbConnect } from '@cloudflare/db-connect' 46 | 47 | const db = new DbConnect({ 48 | host: 'db.myzone.com', 49 | clientId: 'xxx', 50 | clientSecret: 'xxx' 51 | }) 52 | 53 | async function doPing() { 54 | const resp = await db.ping() 55 | } 56 | ``` 57 | -------------------------------------------------------------------------------- /example/index.template.js: -------------------------------------------------------------------------------- 1 | import { DbConnect } from '@cloudflare/db-connect' 2 | import { UAParser } from 'ua-parser-js' 3 | 4 | addEventListener('fetch', event => { 5 | const colo = (event.request.cf || { colo: 'Unknown' }).colo 6 | const headers = event.request.headers 7 | const ray = headers.get('Cf-ray') 8 | const agent = new UAParser(headers.get('User-agent')) 9 | 10 | var device = agent.getDevice() 11 | if(!device.vendor) device.vendor = agent.getOS().name 12 | if(!device.model) device.model = agent.getBrowser().name 13 | 14 | event.respondWith(handleSql(ray, colo, `${device.vendor} ${device.model}`.trim())) 15 | }) 16 | 17 | const db = $CODE 18 | 19 | async function handleSql(ray, colo, device) { 20 | 21 | const insert = await db.submit({ 22 | mode: 'exec', 23 | isolation: 'serializable', 24 | arguments: [ ray, colo, device ], 25 | cacheTtl: -1, 26 | statement: ` 27 | CREATE TABLE IF NOT EXISTS quickstart 28 | (ray TEXT, colo TEXT, device TEXT); 29 | INSERT OR IGNORE INTO quickstart VALUES (?, ?, ?);`}) 30 | 31 | if(!insert.ok) { 32 | return insert 33 | } 34 | 35 | const [queryC, queryD] = await Promise.all([ 36 | db.submit({ 37 | mode: 'query', 38 | cacheTtl: -1, 39 | statement: ` 40 | SELECT colo, COUNT(*) AS views FROM quickstart 41 | GROUP BY colo ORDER BY views DESC;`}), 42 | db.submit({ 43 | mode: 'query', 44 | cacheTtl: -1, 45 | statement: ` 46 | SELECT device, COUNT(*) AS views FROM quickstart 47 | GROUP BY device ORDER BY views DESC;`}) 48 | ]) 49 | 50 | for(const query of [queryC, queryD]) { 51 | if(!query.ok) { 52 | return query 53 | } 54 | } 55 | 56 | const colos = (await queryC.json()) 57 | .map(row => `\t${row.colo}: ${row.views}`) 58 | .join('\n') 59 | 60 | const devices = (await queryD.json()) 61 | .map(row => `\t${row.device}: ${row.views}`) 62 | .join('\n') 63 | 64 | return new Response(` 65 | === Quickstart for db-connect === 66 | Number of Views by Colo:\n${colos} 67 | Number of Views by Device:\n${devices} 68 | `) 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # db-connect 2 | 3 | ### This is an experimental project and does not have official support yet. 4 | 5 | Connect your SQL database to [Cloudflare Workers](https://workers.cloudflare.com/). Import this lightweight Javascript library to execute commands or cache queries from a database through an [Argo Tunnel](https://developers.cloudflare.com/argo-tunnel/quickstart/). Although designed for Workers, this library can be used in any environment that has access to the [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Syntax) and [SubtleCrypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#Syntax) APIs. 6 | 7 | # Installation 8 | 9 | ```bash 10 | npm i -s @cloudflare/db-connect 11 | ``` 12 | 13 | # Example 14 | 15 | ```js 16 | import { DbConnect } from '@cloudflare/db-connect' 17 | 18 | const db = new DbConnect({ 19 | host: 'sql.mysite.com', // Hostname of an Argo Tunnel 20 | clientId: 'xxx', // Client ID of an Access service token 21 | clientSecret: 'xxx' // Client Secret of an Access service token 22 | }) 23 | 24 | async function findBirthday(name) { 25 | const resp = await db.submit({ 26 | statement: 'SELECT * FROM users WHERE name = ? LIMIT 1', 27 | arguments: [ name ], 28 | cacheTtl: 60 29 | }) 30 | 31 | if(!resp.ok) { 32 | return new Error('oops! could not find user.') 33 | } 34 | 35 | const users = await resp.json() 36 | // [ { "id": 1111, 37 | // "name": "Matthew", 38 | // "birthday": "2009-07-01" } ] 39 | 40 | return users[0].birthday 41 | } 42 | 43 | findBirthday('Matthew').then(bday => console.log(bday)) 44 | ``` 45 | 46 | # Quickstart 47 | 48 | `db-connect` requires that you setup Cloudflare Access, Argo Tunnel, and Workers. You can use the quickstart command below or read the [`quickstart`](QUICKSTART.md) file for details on how to set this up yourself. 49 | 50 | ``` 51 | npm i -g @cloudflare/db-connect 52 | db-connect-quickstart 53 | ``` 54 | 55 | # Databases 56 | 57 | `db-connect` supports the following database drivers out-of-the-box. If your database is not explicitly on the list it may still be supported. For instance, MariaDB uses the MySQL protocol and CockroachDB uses the PostgreSQL protocol. 58 | 59 | * [PostgreSQL](https://github.com/lib/pq) 60 | * [MySQL](https://github.com/go-sql-driver/mysql) 61 | * [MSSQL](https://github.com/denisenkom/go-mssqldb) 62 | * [Clickhouse](https://github.com/kshvakov/clickhouse) 63 | * [SQLite3](https://github.com/mattn/go-sqlite3) 64 | 65 | In the future, we may consider adding support for more databases such as Oracle, MongoDB, and Redis. If you want to contribute you can track the code in the [`cloudflared`](https://github.com/cloudflare/cloudflared/tree/master/dbconnect) repository. 66 | 67 | CouchDB and PouchDB use HTTP endpoints, therefore these databases can connect directly to Argo Tunnel not requiring the use of `db-connect`. 68 | 69 | # Documentation 70 | 71 | ## `new DbConnect(options)` 72 | 73 | ```js 74 | import { DbConnect } from '@cloudflare/db-connect' 75 | 76 | const db = new DbConnect({ 77 | host, // required, hostname of your Argo Tunnel running in db-connect mode. 78 | clientId, // recommended, client id from your Access service token. 79 | clientSecret, // recommended, client secret from your Access service token. 80 | }) 81 | ``` 82 | 83 | ## `Promise db.ping()` 84 | 85 | ```js 86 | import { DbConnect } from '@cloudflare/db-connect' 87 | 88 | const db = new DbConnect({...}) 89 | 90 | async function myPing() { 91 | const resp = await db.ping() 92 | if(resp.ok) { 93 | return true 94 | } 95 | throw new Error(await resp.text()) 96 | } 97 | ``` 98 | 99 | ## `new Command(options)` 100 | 101 | ```js 102 | import { Command } from '@cloudflare/db-connect' 103 | 104 | const cmd = new Command({ 105 | statement, // required, the database statement to submit. 106 | arguments, // optional, either an array or object of arguments. 107 | mode, // optional, type of command as either 'query' or 'exec'. 108 | isolation, // optional, type of transaction isolation, defaults to 'none' for no transactions. 109 | timeout, // optional, number of seconds before a timeout, defaults to infinite. 110 | cacheTtl, // optional, number of seconds to cache responses, defaults to -1. 111 | staleTtl, // optional, after cacheTtl expires, number of seconds to serve stale, defaults to -1. 112 | }) 113 | ``` 114 | 115 | ## `Promise db.submit(command)` 116 | 117 | ```js 118 | import { DbConnect, Command } from '@cloudflare/db-connect' 119 | 120 | const db = new DbConnect({...}) 121 | 122 | const cmd = new Command({ 123 | statement: 'SELECT COUNT(*) AS n FROM books', 124 | cacheTtl: 60 125 | }) 126 | 127 | async function mySubmit() { 128 | const resp = await db.submit(cmd) 129 | if(resp.ok) { 130 | return await resp.json() // [ { "n": 1234 } ] 131 | } 132 | throw new Error(await resp.text()) 133 | } 134 | ``` 135 | 136 | # Testing 137 | 138 | If you want to test `db-connect` without a database you can use the following command to create an in-memory SQLite3 database: 139 | ```bash 140 | cloudflared db-connect --playground 141 | ``` 142 | 143 | # Beta Access 144 | 145 | We are looking for beta testers who want to create applications using `db-connect` using Cloudflare Workers. If you have a use-case or an idea, [reach out](mailto:ashcon@cloudflare.com) to us and we'll consider giving you with special access! 146 | -------------------------------------------------------------------------------- /bin/quickstart: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fetch = require('node-fetch') 4 | const fs = require('fs') 5 | const path = require('path') 6 | const { prompt } = require('enquirer') 7 | const { validate } = require('email-validator') 8 | const { bold, green } = require('ansi-colors') 9 | 10 | const name = 'db-connect-quickstart' 11 | const headers = { 12 | 'User-Agent': 'db-connect/quickstart', 13 | 'Content-Type': 'application/json' 14 | } 15 | 16 | async function entrypoint() { 17 | console.log(bold.whiteBright('Starting db-connect quickstart...')) 18 | 19 | const user = await getUser() 20 | const zone = await getZone() 21 | const account = await getAccount(zone ? zone.account.id : null) 22 | 23 | var command = getCloudflaredCommand() 24 | var code = getCodeSnippet('xxx-replace-me-xxx.trycloudflare.com') 25 | if(zone) { 26 | const argo = await hasArgo(zone.id) 27 | if(argo) { 28 | const org = await getAccessOrganization(zone.account.id) 29 | if(org) { 30 | const app = await getAccessApplication(zone.id) 31 | const token = await getAccessToken(zone.account.id) 32 | const group = await getAccessGroup(zone.account.id, token.id) 33 | const policy = await getAccessPolicy(zone.id, app.id, group.id) 34 | command = getCloudflaredCommand(app.domain, org.auth_domain, app.aud) 35 | code = getCodeSnippet(app.domain, token.client_id, token.client_secret) 36 | } 37 | } 38 | } 39 | 40 | writeTemplate(account.id, code) 41 | 42 | console.log(bold.whiteBright('\n1. Run the following command to start db-connect:\n')) 43 | console.log(green(command)) 44 | console.log(bold.whiteBright('\n2. Put the following code in a Worker to submit queries:\n')) 45 | console.log(green(code)) 46 | console.log(bold.whiteBright('\n3. Run \"wrangler publish\" in the example/ directory for an example!')) 47 | console.log() 48 | 49 | return '' 50 | } 51 | 52 | async function getUser() { 53 | headers['X-Auth-Email'] = await ask('Cloudflare email?', (_ => true), { validate }) 54 | var user = null 55 | await ask('Cloudflare global api key?', async key => { 56 | headers['X-Auth-Key'] = key 57 | return fetchCf('/user').then(res => { user = res }) 58 | }, { type: 'password' }) 59 | return user 60 | } 61 | 62 | async function getAccount(accountId = null) { 63 | if(accountId) { 64 | return fetchCf(`/accounts/${accountId}`) 65 | } 66 | const accounts = await fetchCf('/accounts?page=1&per_page=21&direction=desc') 67 | if(accounts.length < 1) { 68 | return null 69 | } 70 | if(accounts.length > 20) { 71 | return ask('Cloudflare account id?', getAccount) 72 | } 73 | const accountMap = new Map(accounts.map(account => [account.name, account.id])) 74 | return askList('Cloudflare account?', accountMap).then(getAccount) 75 | } 76 | 77 | async function getZone(zoneId = null) { 78 | if(zoneId === 'try') { 79 | return null 80 | } 81 | if(zoneId) { 82 | return fetchCf(`/zones/${zoneId}`) 83 | } 84 | const zones = await fetchCf('/zones?status=active&page=1&per_page=21&direction=desc') 85 | if(zones.length < 1) { 86 | return null 87 | } 88 | if(zones.length > 20) { 89 | return ask('Cloudflare zone id?', getZone) 90 | } 91 | const zoneMap = new Map(zones.map(zone => [zone.name, zone.id])) 92 | zoneMap.set('trycloudflare.com', 'try') 93 | return askList('Cloudflare zone?', zoneMap).then(getZone) 94 | } 95 | 96 | async function hasArgo(zoneId = null) { 97 | if(!zoneId) { 98 | return false 99 | } 100 | const res = await fetchCf(`/zones/${zoneId}/argo/smart_routing`) 101 | if(res.editable) { 102 | if(res.value === 'on') { 103 | return true 104 | } 105 | const ok = await askOk('Argo Smart Routing is required, is it okay to enable?') 106 | if(ok) { 107 | return fetchCf(`/zones/${zoneId}/argo/smart_routing`, 'PATCH', { value: 'on' }).then(() => ok) 108 | } 109 | } 110 | return false 111 | } 112 | 113 | async function getAccessOrganization(accountId) { 114 | return fetchCf(`/accounts/${accountId}/access/organizations`) 115 | .catch(async err => { 116 | const ok = await askOk('Cloudflare Access is required, is it okay to enable?') 117 | if(!ok) return null 118 | return ask('Cloudflare Access subdomain name?', name => { 119 | name = `${name}.cloudflareaccess.com` 120 | return fetchCf(`/accounts/${accountId}/access/organizations`, 'POST', { name, auth_domain: name }) 121 | }) 122 | }) 123 | } 124 | 125 | async function getAccessApplication(zoneId, hostname = null) { 126 | if(hostname) { 127 | return fetchCf(`/zones/${zoneId}/access/apps`, 'POST', { 128 | name, 129 | domain: hostname, 130 | session_duration: '30m' 131 | }).catch(err => { 132 | if(err.message.includes('already_exists')) { 133 | return fetchCf(`/zones/${zoneId}/access/apps`) 134 | .then(apps => apps.filter(app => app.domain.includes(hostname))[0]) 135 | } 136 | throw err 137 | }) 138 | } 139 | return ask('Cloudflare Argo Tunnel hostname?', async hostname => { 140 | return getAccessApplication(zoneId, hostname) 141 | }) 142 | } 143 | 144 | async function getAccessGroup(accountId, tokenId) { 145 | return fetchCf(`/accounts/${accountId}/access/groups`, 'POST', { 146 | name, 147 | include: [ { service_token: { token_id: tokenId } } ] 148 | }).catch(err => { 149 | if(err.message.includes('already_exists')) { 150 | return fetchCf(`/accounts/${accountId}/access/groups`) 151 | .then(groups => groups.filter(group => group.name === name)[0]) 152 | } 153 | throw err 154 | }) 155 | } 156 | 157 | async function getAccessPolicy(zoneId, accessId, groupId) { 158 | return fetchCf(`/zones/${zoneId}/access/apps/${accessId}/policies`, 'POST', { 159 | name, 160 | decision: 'non_identity', 161 | include: [ { group: { id: groupId } } ] 162 | }) 163 | } 164 | 165 | async function getAccessToken(accountId) { 166 | return fetchCf(`/accounts/${accountId}/access/service_tokens`, 'POST', { name }) 167 | } 168 | 169 | function writeTemplate(accountId, codeSnippet) { 170 | const dir = file => path.resolve(__dirname, '..', 'example', file) 171 | try { 172 | var index = fs.readFileSync(dir('index.template.js'), 'utf8').replace('\$CODE', codeSnippet) 173 | var wrangler = fs.readFileSync(dir('wrangler.template.toml'), 'utf8').replace('\$ACCOUNT', accountId) 174 | 175 | fs.writeFileSync(dir('index.js'), index) 176 | fs.writeFileSync(dir('wrangler.toml'), wrangler) 177 | } catch(err) {} 178 | } 179 | 180 | function getCloudflaredCommand(hostname, domain, aud) { 181 | var command = 'cloudflared update\ncloudflared db-connect --playground' 182 | if(hostname) { 183 | command = `${command} --hostname ${hostname} --auth-domain ${domain} --application-aud ${aud}` 184 | } 185 | return command 186 | } 187 | 188 | function getCodeSnippet(hostname, clientId, clientSecret) { 189 | if(clientId && clientSecret) { 190 | return `new DbConnect({ host: '${hostname}', clientId: '${clientId}', clientSecret: '${clientSecret}' })` 191 | } 192 | return `new DbConnect({ host: '${hostname}' })` 193 | } 194 | 195 | async function ask(question, check = (_ => true), options = {}) { 196 | var value = null 197 | const res = await prompt(Object.assign({ 198 | name: 'value', 199 | type: 'input', 200 | message: question, 201 | validate: async input => { 202 | try { 203 | value = await check(input) 204 | return true 205 | } catch(err) { 206 | return err.message 207 | } 208 | } 209 | }, options)) 210 | return value || res['value'] 211 | } 212 | 213 | async function askOk(question, check = (_ => true)) { 214 | return ask(question, check, { type: 'confirm' }) 215 | } 216 | 217 | async function askList(question, items = new Map(), limit = 20) { 218 | const res = await ask(question, (_ => true), { 219 | type: 'autocomplete', 220 | choices: new Array(...items.keys()), 221 | limit, 222 | suggest: (input, choices) => { 223 | return choices.filter(choice => choice.message.includes(input)) 224 | }, 225 | validate: (_ => true) 226 | }) 227 | return items.get(res) 228 | } 229 | 230 | async function fetchCf(path, method = 'GET', body = null) { 231 | const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, { 232 | method, 233 | headers, 234 | body: body ? JSON.stringify(body) : null 235 | }) 236 | const json = await res.json() 237 | if(res.ok) { 238 | return json.result 239 | } 240 | throw new Error(json.errors[0].message) 241 | } 242 | 243 | entrypoint() 244 | .then(console.log) 245 | .catch(console.error) 246 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * DbConnect, access your SQL database from Cloudflare Workers or the browser. 3 | * 4 | * @example 5 | * const db = new DbConnect({ 6 | * host: 'sql.mysite.com', 7 | * clientId: '', 8 | * clientSecret: '' }) 9 | * 10 | * db.ping() 11 | * 12 | * db.submit({ 13 | * statement: 'CREATE TABLE firewall VALUES (ip INT)' }) 14 | * 15 | * db.submit({ 16 | * mode: 'exec', 17 | * statement: 'INSERT INTO firewall VALUES (?), (?)', 18 | * arguments: [1111, 1001] }) 19 | * 20 | * db.submit({ 21 | * mode: 'query', 22 | * statement: 'SELECT COUNT(*) FROM firewall', 23 | * cacheTtl: 60 }) 24 | * 25 | * async function do() { 26 | * const resp = await db.query({ statement: 'SELECT * FROM firewall' }) 27 | * if(resp.ok) { 28 | * const rows = await resp.json() 29 | * // [ { "ip": 1111 }, { "ip": 1001 } ] 30 | * } 31 | * } 32 | * 33 | * @author Ashcon Partovi 34 | * @copyright Cloudflare, Inc. 35 | */ 36 | export class DbConnect { 37 | private readonly httpClient: HttpClient 38 | 39 | /** 40 | * Creates a DbConnect instance with host and credentials. 41 | * 42 | * @param host required, hostname or url of your Argo Tunnel running in db-connect mode. 43 | * @param clientId recommended, client id of the Access policy for your host. 44 | * @param clientSecret recommended, client secret of the Access policy for your host. 45 | */ 46 | constructor(parameters: DbConnectInit | object) { 47 | const init = new DbConnectInit(parameters) 48 | 49 | const url = new URL(init.host) 50 | const headers = new Headers() 51 | if(init.clientId) { 52 | headers.set('Cf-access-client-id', init.clientId) 53 | headers.set('Cf-access-client-secret', init.clientSecret) 54 | } 55 | 56 | this.httpClient = new HttpClient(url, { 57 | headers: headers, 58 | keepalive: true, 59 | cf: { 60 | cacheEverything: true 61 | } 62 | }) 63 | } 64 | 65 | /** 66 | * Ping tests the connection to the database. 67 | * 68 | * To reduce latency, pings will be served stale for up to 1 second. 69 | * 70 | * @example 71 | * const db = new DbConnect({ ... }) 72 | * 73 | * async function doPing() { 74 | * const resp = await db.ping() 75 | * if(resp.ok) { 76 | * return true 77 | * } 78 | * throw new Error(await resp.text()) 79 | * } 80 | */ 81 | public async ping(): Promise { 82 | return this.httpClient.fetch('ping', {method: 'GET'}, 0, 1) 83 | } 84 | 85 | /** 86 | * Submit sends a Command to the database and fetches a Response. 87 | * 88 | * @example 89 | * const db = new DbConnect({ ... }) 90 | * 91 | * async function doSubmit() { 92 | * const cmd = new Command({ 93 | * statement: 'SELECT * FROM users WHERE name = ? AND age > ?', 94 | * arguments: ['matthew', 21] }) 95 | * const resp = await db.submit(cmd) 96 | * if(resp.ok) { 97 | * return await resp.json() 98 | * } 99 | * throw new Error(await resp.text()) 100 | * } 101 | * 102 | * @param command required, the command to submit. 103 | */ 104 | public async submit(command: Command | object): Promise { 105 | if(!(command instanceof Command)) command = new Command(command) 106 | const cmd = command 107 | 108 | const init = { 109 | method: 'POST', 110 | body: JSON.stringify(cmd), 111 | headers: {'Content-type': 'application/json'} 112 | } 113 | 114 | return this.httpClient.fetch('submit', init, cmd.cacheTtl, cmd.staleTtl) 115 | } 116 | 117 | } 118 | 119 | /** 120 | * Initializer for DbConnect with host and credentials. 121 | * 122 | * @see DbConnect 123 | */ 124 | class DbConnectInit { 125 | host: string 126 | clientId?: string 127 | clientSecret?: string 128 | 129 | constructor(parameters: object) { 130 | Object.assign(this, parameters) 131 | 132 | if(!this.host) throw new TypeError('host is a required argument') 133 | if(!this.host.startsWith('http')) this.host = `https://${this.host}` 134 | if(!this.clientId != !this.clientSecret) throw new TypeError('both clientId and clientSecret must be specified') 135 | } 136 | } 137 | 138 | /** 139 | * Command is a standard, non-vendor format for submitting database commands. 140 | */ 141 | export class Command { 142 | readonly statement: string 143 | readonly arguments: any 144 | readonly mode: Mode 145 | readonly isolation: Isolation 146 | readonly timeout: number 147 | readonly cacheTtl: number 148 | readonly staleTtl: number 149 | 150 | /** 151 | * Creates a new database Command. 152 | * 153 | * @param statement required, statement of the command. 154 | * @param args an array or map of arguments, defaults to an empty array. 155 | * @param mode mode of the command, defaults to 'query'. 156 | * @param isolation isolation of the command, defaults to 'default'. 157 | * @param timeout timeout in seconds of the command, defaults to indefinite. 158 | * @param cacheTtl number of seconds to cache responses, defaults to -1. 159 | * @param staleTtl after cacheTtl expires, number of seconds to serve stale responses. 160 | */ 161 | constructor(parameters: CommandInit | object) { 162 | const init = new CommandInit(parameters) 163 | 164 | Object.assign(this, init) 165 | } 166 | } 167 | 168 | /** 169 | * Initializer for Command with statement and options. 170 | * 171 | * @see Command 172 | */ 173 | class CommandInit { 174 | statement: string 175 | arguments?: any 176 | mode?: Mode 177 | isolation?: Isolation 178 | timeout?: number 179 | cacheTtl?: number 180 | staleTtl?: number 181 | 182 | constructor(parameters: object) { 183 | Object.assign(this, parameters) 184 | 185 | if(!this.statement) throw new TypeError('statement is a required argument') 186 | if(!this.arguments) this.arguments = [] 187 | if(!this.mode) this.mode = Mode.query 188 | if(!this.timeout) this.timeout = 0 189 | if(!this.isolation) this.isolation = Isolation.none 190 | if(!this.cacheTtl) this.cacheTtl = -1 191 | if(!this.staleTtl) this.staleTtl = this.cacheTtl 192 | } 193 | } 194 | 195 | /** 196 | * Mode is a kind of Command. 197 | * * query, a request for a set of rows or objects. 198 | * * exec, an execution that returns a single result. 199 | * 200 | * @link https://golang.org/pkg/database/sql/#DB.Exec 201 | */ 202 | export enum Mode { 203 | query = "query", 204 | exec = "exec" 205 | } 206 | 207 | /** 208 | * Isolation is a transaction type when executing a Command. 209 | * 210 | * @link https://golang.org/pkg/database/sql/#IsolationLevel 211 | */ 212 | export enum Isolation { 213 | none = "none", 214 | default = "default", 215 | readUncommitted = "read_uncommitted", 216 | readCommitted = "read_committed", 217 | writeCommitted = "write_committed", 218 | repeatableRead = "repeatable_read", 219 | snapshot = "snapshot", 220 | serializable = "serializable", 221 | linearizable = "linearizable" 222 | } 223 | 224 | /** 225 | * HttpClient is a convience wrapper for doing common transforms, 226 | * such as injecting authentication headers, to fetch requests. 227 | */ 228 | class HttpClient { 229 | private readonly url: URL 230 | private readonly init: RequestInit 231 | private readonly cache: Cache 232 | 233 | /** 234 | * Creates a new HttpClient. 235 | * 236 | * @param url required, the base url of all requests. 237 | * @param init initializer for requests, defaults to empty. 238 | * @param cache cache storage for requests, defaults to global. 239 | */ 240 | constructor(url: URL, init?: RequestInit, cache?: Cache) { 241 | if(!url) throw new TypeError('url is a required argument') 242 | this.url = url 243 | 244 | this.init = init || {} 245 | if(!this.init.headers) this.init.headers = {} 246 | 247 | this.cache = cache || ( caches).default 248 | } 249 | 250 | /** 251 | * Fetch a path from the origin or cache. 252 | * 253 | * @param path required, the path to fetch, joined by the client url. 254 | * @param init initializer for the request, recursively merges with client initializer. 255 | * @param cacheTtl required, number of seconds to cache the response. 256 | * @param staleTtl required, number of seconds to serve the response stale. 257 | */ 258 | public async fetch(path: string, init?: RequestInit, cacheTtl?: number, staleTtl?: number): Promise { 259 | const key = await this.cacheKey(path, init) 260 | 261 | if(cacheTtl < 0 && staleTtl < 0) { 262 | return this.fetchOrigin(path, init) 263 | } 264 | 265 | var response = await this.cache.match(key, {ignoreMethod: true}) 266 | if(!response) { 267 | response = await this.fetchOrigin(path, init) 268 | response.headers.set('Cache-control', this.cacheHeader(cacheTtl, staleTtl)) 269 | 270 | await this.cache.put(key, response.clone()) 271 | } 272 | 273 | return response 274 | } 275 | 276 | /** 277 | * Fetch a path directly from the origin. 278 | * 279 | * @param path required, the path to fetch, joined by the client url. 280 | * @param init initializer for the request, recursively merges with client initializer. 281 | */ 282 | private async fetchOrigin(path: string, init?: RequestInit): Promise { 283 | path = new URL(path, this.url).toString() 284 | init = this.initMerge(init) 285 | 286 | var response = await fetch(path, init) 287 | 288 | // FIXME: access sometimes redirects to a 200 login page when client credentials are invalid. 289 | if(response.redirected && new URL(response.url).hostname.endsWith('cloudflareaccess.com')) { 290 | return new Response('client credentials rejected by cloudflare access', response) 291 | } 292 | 293 | return new Response(response.body, response) 294 | } 295 | 296 | /** 297 | * Creates a new RequestInit for requests. 298 | * 299 | * @param init the initializer to merge into the client initializer. 300 | */ 301 | private initMerge(init?: RequestInit): RequestInit { 302 | init = Object.assign({headers: {}}, init || {}) 303 | 304 | for(var kv of Object.entries(this.init.headers)) { 305 | init.headers[kv[0]] = [1] 306 | } 307 | 308 | return Object.assign(init, this.init) 309 | } 310 | 311 | /** 312 | * Creates a cache key for a Request. 313 | * 314 | * @param path required, the resource path of the request. 315 | * @param init the initializer for the request, defaults to empty. 316 | */ 317 | private async cacheKey(path: string, init?: RequestInit): Promise { 318 | path = new URL(path, this.url).toString() 319 | init = this.initMerge(init) 320 | 321 | if(init.method != 'POST') return new Request(path, init) 322 | 323 | const hash = await sha256(init.body) 324 | return new Request(`${path}/_/${hash}`, {method: 'GET', headers: init.headers}) 325 | } 326 | 327 | /** 328 | * Creates a Cache-control header for a Response. 329 | * 330 | * @param cacheTtl required, number of seconds to cache the response. 331 | * @param staleTtl required, number of seconds to serve the response stale. 332 | */ 333 | private cacheHeader(cacheTtl?: number, staleTtl?: number): string { 334 | var cache = 'public' 335 | 336 | if(cacheTtl < 0 && staleTtl < 0) cache = 'private no-store no-cache' 337 | if(cacheTtl >= 0) cache += `, max-age=${cacheTtl}` 338 | if(staleTtl >= 0) cache += `, stale-while-revalidate=${staleTtl}` 339 | 340 | return cache 341 | } 342 | } 343 | 344 | /** 345 | * Generate a SHA-256 hash of any object. 346 | * 347 | * @param object the object to generate a hash. 348 | */ 349 | async function sha256(object: any): Promise { 350 | const buffer = new TextEncoder().encode(JSON.stringify(object)) 351 | const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) 352 | const hashArray = Array.from(new Uint8Array(hashBuffer)) 353 | 354 | return hashArray.map(b => ('00' + b.toString(16)).slice(-2)).join('') 355 | } 356 | --------------------------------------------------------------------------------