UNPKG

@outerbase/browsable-durable-object

Version:

A browsable SQL query interface for Cloudflare Durable Objects

361 lines (350 loc) 11.6 kB
import { DurableObject } from "cloudflare:workers"; export const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PATCH, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Starbase-Source, X-Data-Source', 'Access-Control-Max-Age': '86400', }; export function corsPreflight() { return new Response(null, { status: 204, headers: corsHeaders, }); } export function createResponse(result, error, status) { return new Response(JSON.stringify({ result, error }), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json', }, }); } export class BrowsableHandler { constructor(sql) { this.supportedRoutes = ['/query/raw']; this.sql = sql; } async fetch(request) { const url = new URL(request.url); const path = url.pathname; // Check if this is a supported route that we should handle in our browsable // class. If no matches are found we call up to super and as a last resort // return a 404 to let the user know this inheritance class did not find a // request to match on. if (this.supportedRoutes.includes(path)) { // Handle CORS preflight, at the moment this acts as a very permissive // acceptance of requests. Expecting the users to add in their own // version of authentication to protect against users misusing these // endpoints. if (request.method === 'OPTIONS') { return corsPreflight(); } if (path === '/query/raw' && request.method === 'POST') { const { sql, params, transaction } = (await request.json()); let data = await this.executeTransaction({ queries: transaction ?? [{ sql, params }] }); return createResponse(data, undefined, 200); } } return new Response('Not found', { status: 404 }); } async executeTransaction(opts) { const { queries } = opts; const results = []; for (const query of queries) { let result = await this.executeQuery({ sql: query.sql, params: query.params ?? [], isRaw: true }); if (!result) { console.error('Returning empty array.'); return []; } results.push(result); } return results; } async executeRawQuery(opts) { const { sql, params } = opts; try { let cursor; if (params && params.length) { cursor = this.sql?.exec(sql, ...params); } else { cursor = this.sql?.exec(sql); } return cursor; } catch (error) { console.error('SQL Execution Error:', error); throw error; } } async executeQuery(opts) { const cursor = await this.executeRawQuery(opts); if (!cursor) return []; if (opts.isRaw) { return { columns: cursor.columnNames, rows: Array.from(cursor.raw()), meta: { rows_read: cursor.rowsRead, rows_written: cursor.rowsWritten, }, }; } return cursor.toArray(); } } export function Browsable() { return function (constructor) { return class extends constructor { async fetch(request) { // Initialize handler if not already done if (!this._bdoHandler) { this._bdoHandler = new BrowsableHandler(this.sql); } // Try browsable handler first const browsableResponse = await this._bdoHandler.fetch(request); // If browsable handler returns 404, try the parent class's fetch if (browsableResponse.status === 404) { return super.fetch(request); } return browsableResponse; } async __studio(cmd) { const storage = this.ctx.storage; const sql = storage.sql; if (cmd.type === 'query') { return executeQuery(sql, cmd.statement); } else if (cmd.type === 'transaction') { return storage.transactionSync(() => { const results = []; for (const statement of cmd.statements) { results.push(executeQuery(sql, statement)); } return results; }); } } }; }; } export class BrowsableDurableObject extends DurableObject { constructor(state, env) { super(state, env); this.sql = undefined; } async fetch(request) { this._bdoHandler = new BrowsableHandler(this.sql); return this._bdoHandler.fetch(request); } } function executeQuery(sql, statement) { const cursor = sql.exec(statement); const columnSet = new Set(); const columnNames = cursor.columnNames.map((colName) => { let renameColName = colName; for (let i = 0; i < 20; i++) { if (!columnSet.has(renameColName)) break; renameColName = '__' + colName + '_' + i; } return { name: renameColName, displayName: colName, originalType: 'text', type: undefined, }; }); return { headers: columnNames, rows: Array.from(cursor.raw()).map((r) => columnNames.reduce((a, b, idx) => { a[b.name] = r[idx]; return a; }, {})), stat: { queryDurationMs: 0, rowsAffected: 0, rowsRead: cursor.rowsRead, rowsWritten: cursor.rowsWritten, }, }; } function createHomepageInterface() { return `<!DOCTYPE > <html> <title>Outerbase Studio</title> <style> html, body { font-size: 20px; font-family: monospace; padding: 1rem; } #name, #submit { font-size: 1rem; padding: 0.2rem 0.5rem; outline: none; font-family: monospace; } h1 { font-size: 1.5rem; } p { padding: 0; margin: 10px 0; } </style> </html> <body> <h1>Outerbase Studio</h1> <form method='get' action=''> <p>env.MY_DURABLE_OBJECT.idFromName(</p> <div style="padding-left: 20px"> <input id='name' name='id' placeholder='name' required></input> <button id='submit'>View</button> </div> <p>)</p> </form> </body> </html>`; } function createStudioInterface(stubId) { return `<!DOCTYPE > <html> <head> <style> html, body { padding: 0; margin: 0; width: 100vw; height: 100vh; } iframe { width: 100vw; height: 100vh; overflow: hidden; border: 0; } </style> <title>Your Starbase - Outerbase Studio</title> <link rel="icon" type="image/x-icon" href="https://studio.outerbase.com/icons/outerbase.ico" /> </head> <body> <script> function handler(e) { if (e.data.type !== "query" && e.data.type !== "transaction") return; fetch(window.location.pathname, { method: "post", body: JSON.stringify({ ...e.data, id: ${JSON.stringify(stubId)} }), }) .then((r) => { if (!r.ok) { document.getElementById("editor").contentWindow.postMessage( { id: e.data.id, type: e.data.type, error: "Something went wrong", }, "*" ); throw new Error("Something went wrong"); } return r.json(); }) .then((r) => { if (r.error) { document.getElementById("editor").contentWindow.postMessage( { id: e.data.id, type: e.data.type, error: r.error, }, "*" ) } const response = { id: e.data.id, type: e.data.type, data: r.result }; document .getElementById("editor") .contentWindow.postMessage(response, "*"); }) .catch(console.error); } window.addEventListener("message", handler); </script> <iframe id="editor" allow="clipboard-read; clipboard-write" src="https://studio.outerbase.com/embed/starbase" ></iframe> </body> </html>`; } export async function studio(request, doNamespace, options) { // Protecting if (options?.basicAuth) { const authHeader = request.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Basic ')) { return new Response('Authentication required', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="Secure Area"', }, }); } const encoded = authHeader.split(' ')[1]; const decoded = atob(encoded); const [username, password] = decoded.split(':'); if (username !== options.basicAuth.username || password !== options.basicAuth.password) { return new Response('Invalid credentials', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="Secure Area"', }, }); } } // We run on a single endpoint, we will make use the METHOD to determine what to do if (request.method === 'GET') { // This is where we render the interface const url = new URL(request.url); const stubId = url.searchParams.get('id'); if (!stubId) { return new Response(createHomepageInterface(), { headers: { 'Content-Type': 'text/html' } }); } return new Response(createStudioInterface(stubId), { headers: { 'Content-Type': 'text/html' } }); } else if (request.method === 'POST') { const body = (await request.json()); if (body.type === 'query' || body.type === 'transaction') { const stubId = doNamespace.idFromName(body.id); const stub = doNamespace.get(stubId); try { // @ts-ignore - accessing __studio method that we know exists const result = await stub.__studio(body); return Response.json({ result }); } catch (e) { if (e instanceof Error) { return Response.json({ error: e.message }); } return Response.json({ error: 'Unknown error' }); } } return Response.json({ error: 'Invalid request' }); } return new Response('Method not allowed'); }