backend integration

This commit is contained in:
nicwands 2026-07-23 15:27:12 -04:00
parent 777b7f9a6d
commit bbe9d7e889
66 changed files with 2518 additions and 115 deletions

11
.env Normal file
View file

@ -0,0 +1,11 @@
# Backend Environment Variables
# Production URL (for CORS in production)
PRODUCTION_URL=https://braindrops.nicwands.com
# Optional: Custom backend port (default: 3001)
# PORT=3001
# Add your API keys and secrets here
# OPENAI_API_KEY=your-api-key
# SOME_SERVICE_KEY=your-key

11
.env.example Normal file
View file

@ -0,0 +1,11 @@
# Backend Environment Variables
# Production URL (for CORS in production)
PRODUCTION_URL=https://yourdomain.com
# Optional: Custom backend port (default: 3001)
# PORT=3001
# Add your API keys and secrets here
# OPENAI_API_KEY=your-api-key
# SOME_SERVICE_KEY=your-key

496
BACKEND_GUIDE.md Normal file
View file

@ -0,0 +1,496 @@
# Backend Integration Guide
This guide explains how to use the backend system in Braindrops.
## Overview
Braindrops now includes an optional self-hosted backend powered by Bun. Each drop can optionally have:
- **REST API endpoints** - For data persistence and external API calls
- **WebSocket handlers** - For real-time communication
- **SQLite database** - Shared across all drops with namespaced tables
## Quick Start
### 1. Develop a Drop with Backend
```bash
npm run dev:drops image-melody
```
This automatically starts:
- Drop frontend on `http://localhost:3000`
- CMS on `http://localhost:8787`
- Backend API on `http://localhost:3001/api/image-melody`
- WebSocket on `ws://localhost:3001/ws/image-melody`
### 2. Add Backend to a Drop
Create a `backend/` folder in your drop:
```bash
drops/my-drop/
├── src/ # Frontend
├── backend/ # Backend (NEW)
│ ├── api.js # REST endpoints
│ ├── schema.sql # Database schema
│ └── websocket.js # WebSocket handler (optional)
└── public/
```
### 3. Use the Template
New drops created with `npm run create` will ask if you want to include a backend. The template provides:
- Example CRUD API
- Sample database schema
- WebSocket handler
- README with documentation
## Creating a Backend
### Step 1: Define Your Database Schema
Create `backend/schema.sql`:
```sql
-- Use naming convention: <drop-name>_<table-name>
CREATE TABLE IF NOT EXISTS my_drop_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
data TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_my_drop_items_created
ON my_drop_items(created_at);
```
### Step 2: Create Your API
Create `backend/api.js`:
```javascript
import { dbHelpers } from '../../../backend/src/db.js'
const { query, queryOne, run } = dbHelpers
export const routes = {
// GET /api/my-drop/items
'GET /items': async (req) => {
const items = query('SELECT * FROM my_drop_items ORDER BY created_at DESC')
return Response.json({ items, count: items.length })
},
// GET /api/my-drop/items/:id
'GET /items/:id': async (req, params) => {
const item = queryOne('SELECT * FROM my_drop_items WHERE id = ?', [params.id])
if (!item) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(item)
},
// POST /api/my-drop/items
'POST /items': async (req) => {
const body = await req.json()
if (!body.name || !body.data) {
return Response.json({ error: 'Missing fields' }, { status: 400 })
}
const result = run(
'INSERT INTO my_drop_items (name, data) VALUES (?, ?)',
[body.name, JSON.stringify(body.data)]
)
return Response.json({ id: result.lastInsertRowid, success: true }, { status: 201 })
},
// DELETE /api/my-drop/items/:id
'DELETE /items/:id': async (req, params) => {
const result = run('DELETE FROM my_drop_items WHERE id = ?', [params.id])
if (result.changes === 0) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json({ success: true })
}
}
```
### Step 3: Add WebSocket (Optional)
Create `backend/websocket.js`:
```javascript
export const websocket = {
open(ws) {
console.log('Client connected')
ws.send(JSON.stringify({ type: 'connected', message: 'Welcome!' }))
},
message(ws, message) {
const data = JSON.parse(message)
// Broadcast to all connected clients
ws.publish('my-drop', JSON.stringify({
type: 'broadcast',
data: data
}))
},
close(ws) {
console.log('Client disconnected')
}
}
```
## Using the Backend in Your Drop
### REST API
From your drop's frontend (`src/App.vue` or any component):
```javascript
// GET request
async function loadItems() {
const response = await fetch('/api/my-drop/items')
const data = await response.json()
console.log(data.items)
}
// POST request
async function createItem(name, data) {
const response = await fetch('/api/my-drop/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, data })
})
const result = await response.json()
console.log('Created ID:', result.id)
}
// DELETE request
async function deleteItem(id) {
const response = await fetch(`/api/my-drop/items/${id}`, {
method: 'DELETE'
})
const result = await response.json()
console.log('Deleted:', result.success)
}
```
### WebSocket
```javascript
import { ref, onMounted, onUnmounted } from 'vue'
export default {
setup() {
const ws = ref(null)
const messages = ref([])
onMounted(() => {
// Connect to WebSocket
ws.value = new WebSocket('ws://localhost:3001/ws/my-drop')
ws.value.onopen = () => {
console.log('Connected to WebSocket')
}
ws.value.onmessage = (event) => {
const data = JSON.parse(event.data)
messages.value.push(data)
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
}
})
onUnmounted(() => {
if (ws.value) {
ws.value.close()
}
})
function sendMessage(data) {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify(data))
}
}
return { messages, sendMessage }
}
}
```
## Database Helpers
The backend provides helper functions for database operations:
```javascript
import { dbHelpers } from '../../../backend/src/db.js'
const { query, queryOne, run, getDb } = dbHelpers
// query(sql, params) - Get all results
const items = query('SELECT * FROM my_drop_items WHERE name LIKE ?', ['%test%'])
// queryOne(sql, params) - Get first result
const item = queryOne('SELECT * FROM my_drop_items WHERE id = ?', [1])
// run(sql, params) - Execute INSERT/UPDATE/DELETE
const result = run('INSERT INTO my_drop_items (name, data) VALUES (?, ?)', ['test', 'data'])
console.log(result.lastInsertRowid) // Get auto-increment ID
console.log(result.changes) // Number of affected rows
// getDb() - Get raw database instance
const db = getDb()
const stmt = db.prepare('SELECT * FROM my_drop_items')
const items = stmt.all()
```
## Environment Variables
### Setting Up
1. Copy the example file:
```bash
cp .env.example .env
```
2. Add your variables:
```bash
PRODUCTION_URL=https://yourdomain.com
OPENAI_API_KEY=sk-...
WEATHER_API_KEY=...
```
3. Access in your backend code:
```javascript
const apiKey = process.env.OPENAI_API_KEY
```
### Important Notes
- `.env` is gitignored and won't be committed
- Don't put API keys in frontend code (use backend as proxy)
- Environment variables are only available in backend code, not frontend
## Example: External API Proxy
To call external APIs without exposing keys:
```javascript
// backend/api.js
export const routes = {
'POST /weather': async (req) => {
const body = await req.json()
const { city } = body
// Call external API with secret key
const response = await fetch(
`https://api.weather.com/data?city=${city}&key=${process.env.WEATHER_API_KEY}`
)
const data = await response.json()
return Response.json(data)
}
}
```
Then from your frontend:
```javascript
const response = await fetch('/api/my-drop/weather', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ city: 'London' })
})
const weather = await response.json()
```
## Production Deployment
### 1. Build Everything
```bash
npm run build
```
This will:
- Build all drops
- Build the main app
- Build the backend (if you add the script)
### 2. Deploy Static Files
Upload `app/dist/` to your static host (nginx, Apache, etc.)
### 3. Deploy Backend
```bash
# Build backend
node bin/buildBackend.js
# Copy to server
scp -r backend/dist user@server:/var/www/braindrops/backend
# On server
cd /var/www/braindrops/backend
bun install --production
./start.sh
```
### 4. Configure nginx
```nginx
server {
listen 80;
server_name yourdomain.com;
# Static files
location / {
root /var/www/braindrops/static;
try_files $uri $uri/ /index.html;
}
# Backend API
location /api/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket
location /ws/ {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
### 5. Set Production Environment Variables
On your server:
```bash
cd /var/www/braindrops/backend
nano .env
```
Add:
```bash
NODE_ENV=production
PRODUCTION_URL=https://yourdomain.com
# Your API keys...
```
### 6. Run Backend with Process Manager
Using PM2:
```bash
npm install -g pm2
pm2 start start.sh --name braindrops-backend
pm2 save
pm2 startup
```
Or use systemd, supervisor, etc.
## Best Practices
### Security
1. **Never expose API keys in frontend code**
2. **Validate all input** on the backend
3. **Use HTTPS** in production
4. **Sanitize database inputs** (use parameterized queries)
5. **Add rate limiting** if needed (use nginx or middleware)
### Database
1. **Use the naming convention** `<drop-name>_<table-name>`
2. **Add indexes** for frequently queried columns
3. **Store JSON as TEXT** using `JSON.stringify()`
4. **Use transactions** for multi-step operations
5. **Backup regularly** (copy `braindrops.db` file)
### API Design
1. **Use proper HTTP methods** (GET, POST, PUT, DELETE)
2. **Return appropriate status codes** (200, 201, 400, 404, 500)
3. **Use consistent response format** `{ success: true, data: ... }`
4. **Handle errors gracefully** with try/catch
5. **Document your endpoints** in comments
### WebSocket
1. **Validate messages** before broadcasting
2. **Use JSON** for structured data
3. **Handle disconnections** gracefully
4. **Implement reconnect logic** in frontend
5. **Namespace channels** by drop name
## Troubleshooting
### Backend not starting
Check if Bun is installed:
```bash
bun --version
```
Install if needed:
```bash
curl -fsSL https://bun.sh/install | bash
```
### Port already in use
Change the port in `.env`:
```bash
PORT=3002
```
### Database locked
SQLite is running in WAL mode which should prevent most locking issues. If you still encounter locks:
1. Ensure only one backend instance is running
2. Close any database browser tools
3. Delete `.db-wal` and `.db-shm` files
### CORS errors
Make sure your frontend URL is allowed in `backend/src/shared/middleware.js`.
For development, these are allowed by default:
- `http://localhost:3000` (drop dev)
- `http://localhost:5173` (app dev)
For production, set `PRODUCTION_URL` in `.env`.
## Examples
See `drops/image-melody/backend/` for a complete working example with:
- Database schema for storing melodies
- CRUD API for managing melodies
- WebSocket for broadcasting melody plays
- Full integration with frontend
## Getting Help
- Check `backend/README.md` for backend-specific docs
- Check `template/backend/README.md` for template usage
- Review the image-melody example
- Check Bun documentation: https://bun.sh/docs
## Next Steps
1. Try the example: `npm run dev:drops image-melody`
2. Test the API with curl or your browser
3. Create your own backend for a drop
4. Experiment with WebSocket real-time features
5. Deploy to production!

View file

@ -1,5 +1,35 @@
# Vue 3 + Vite
# Braindrops
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Experiments in technology.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
## Structure
This repo contains several npm workspaces that all combine to create a system for easily creating and deploying web experiments.
The major pieces of this system are as follows:
- **Drops**: The workspace containing all of the experiments, each one being a standalone web app created from a template.
- **CMS**: The workspace containing the content management system for each experiment.
- **App**: The workspace containing the main application for viewing all of the experiments.
- **Backend**: Optional self-hosted backend server with REST API, WebSocket, and SQLite database support for drops.
Any files that need to be shared across workspaces are stored in the `shared` directory. This includes components, utilities, and styles that are the single source of truth for the system.
## Backend Integration
Drops can optionally include a backend with:
- REST API endpoints
- WebSocket real-time communication
- SQLite database for data persistence
- Environment variables for external API keys
See [BACKEND_GUIDE.md](./BACKEND_GUIDE.md) for complete documentation.
Quick start:
```bash
npm run dev:drops <drop-name>
```
## Scripts

35
app/bin/generateMSDF.sh Normal file → Executable file
View file

@ -1,12 +1,35 @@
#!/bin/bash
BASE_DIR=/home/nicwands/Documents/Dev/freelance/portfolio/braindrops.nicwands.com/app/
BASE_DIR=/home/nicwands/Documents/Dev/freelance/portfolio/braindrops.nicwands.com/
OUT_DIR="$BASE_DIR"/app/public/splash-title/
# Settings
PX_RANGE=22
SIZE=1024
msdf-atlas-gen \
-font "$BASE_DIR"/fonts/InstrumentSerif-Italic.ttf \
-font "$BASE_DIR"/font-ttf/InstrumentSerif-Italic.ttf \
-fontname 'instrument-serif' \
-imageout instrument-serif-atlas.png \
-json instrument-serif-atlas.json \
-pxrange 14 \
-dimensions 1024 1024 \
-imageout "$OUT_DIR"/instrument-serif-atlas.png \
-json "$OUT_DIR"/instrument-serif-atlas.json \
-pxrange $PX_RANGE \
-dimensions $SIZE $SIZE \
-errorcorrection auto
msdf-atlas-gen \
-font "$BASE_DIR"/font-ttf/HankenGrotesk-SemiBold.ttf \
-fontname 'hanken-grotesk' \
-imageout "$OUT_DIR"/hanken-grotesk-atlas.png \
-json "$OUT_DIR"/hanken-grotesk-atlas.json \
-pxrange $PX_RANGE \
-dimensions $SIZE $SIZE \
-errorcorrection auto
msdf-atlas-gen \
-font "$BASE_DIR"/font-ttf/IosevkaCharonMono-Bold.ttf \
-fontname 'iosevka-charon' \
-imageout "$OUT_DIR"/iosevka-charon-atlas.png \
-json "$OUT_DIR"/iosevka-charon-atlas.json \
-pxrange $PX_RANGE \
-dimensions $SIZE $SIZE \
-errorcorrection auto

View file

@ -1,7 +1,7 @@
{
"name": "endless-journal",
"title": "Endless Journal",
"date": "2026-07-20",
"date": "2025-02-03",
"tags": [
"Process"
],
@ -121,4 +121,4 @@
]
},
"published": true
}
}

View file

@ -9,31 +9,29 @@
"content": [
{
"type": "text",
"text": "I used a fluid simulation in webgl to create this flowmap with arrows showing the direction or \"current\" within the flow field."
"text": "Using the OGL fluid simulation example as a starting point, this webgl experiment renders a flowmap of arrows that follow the \"current\" of the fluid."
}
]
},
{
"type": "paragraph"
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "The arrows are rendered by creating a buffer geometry with "
},
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://github.com/oframe/ogl/blob/master/examples/post-fluid-distortion.html",
"target": "_blank",
"rel": "noopener noreferrer nofollow",
"class": null,
"title": null
}
"type": "code"
}
],
"text": "https://github.com/oframe/ogl/blob/master/examples/post-fluid-distortion.html"
"text": "InstancedMesh"
},
{
"type": "text",
"text": " to create a grid of arrows that covers the screen. The direction and size of the arrows is then manipulated in the vector shader to follow the velocity at that point in the simulation."
}
]
}
@ -44,4 +42,4 @@
"visualization"
],
"published": true
}
}

View file

@ -1,7 +1,7 @@
{
"name": "image-melody",
"title": "Image Melody",
"date": "2026-07-20",
"date": "2026-01-30",
"tags": [
"tag1",
"tag2",
@ -15,7 +15,7 @@
"content": [
{
"type": "text",
"text": "My friend Patrick wanted to try generating music from an image. I made this simple experiment which plays notes in a scale based on some of the color values in a pixelated version of the image."
"text": "This experiment processes an image and creates a simple melody from the image data. To do this, the image is pixelated to a 5x5 resolution and sampled to determine the order of the notes. The notes for this example are chosen from the C Major pentatonic scale so that they will sound \"musical\" in any order."
}
]
}

View file

@ -4379,7 +4379,6 @@ void main() {
float aspectRatio = u_resolution.x / u_resolution.y;
vec2 st = vec2(v_uv.x * aspectRatio, v_uv.y);
vec2 st_mouse = vec2(u_mouse.x * aspectRatio, u_mouse.y);
float distance = max(0.6, distance(st, st_mouse) * 10.);

View file

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/drops/noise-shader/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Noise Shader</title>
<script type="module" crossorigin src="/drops/noise-shader/assets/index-6pTyzO1j.js"></script>
<script type="module" crossorigin src="/drops/noise-shader/assets/index-CVMeXlNb.js"></script>
<link rel="stylesheet" crossorigin href="/drops/noise-shader/assets/index-B21avM17.css">
</head>
<body>

View file

@ -1,7 +1,7 @@
{
"name": "noise-shader",
"title": "Noise Shader",
"date": "2026-07-20",
"date": "2025-05-20",
"tags": [
"tag1",
"tag2",
@ -15,7 +15,7 @@
"content": [
{
"type": "text",
"text": "I was messing around with a shader to add noise to a texture, and ended up getting this cool effect when I divided the noise value by the distance to the user's mouse."
"text": "This shader uses randomized noise combined with a two bit filter, and a mouse distance multiplier to create this interesting effect that almost feels like a flashlight in the darkness."
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/drops/testing/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing</title>
<script type="module" crossorigin src="/drops/testing/assets/index-BxlameFm.js"></script>
<link rel="stylesheet" crossorigin href="/drops/testing/assets/index-DAZLouSi.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View file

@ -1,55 +0,0 @@
{
"name": "testing",
"title": "Testing",
"description": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "This is just a test drop to make sure everything is working correctly."
}
]
},
{
"type": "heading",
"attrs": {
"level": 1
},
"content": [
{
"type": "text",
"text": "Heading"
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "more text."
}
]
},
{
"type": "paragraph"
}
]
},
"date": "2026-07-16",
"tags": [
"tag1",
"tag2",
"tag3"
],
"links": [
{
"label": "Source Code",
"url": "https://github.com/username/repo"
}
],
"notes": "Any additional notes or thoughts about this drop"
}

View file

@ -1,6 +1,6 @@
[
"endless-journal",
"flowmap",
"image-melody",
"noise-shader",
"flowmap"
"endless-journal"
]

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 354 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 396 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

View file

@ -3,8 +3,9 @@
<webgl-canvas>
<splash-title
v-if="ready"
text="Braindrops"
fontFamily="instrument-serif"
:letterSpacing="-0.05"
:letterSpacing="-0.1"
:blobStrength="1"
/>
</webgl-canvas>

5
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
*.db
*.db-shm
*.db-wal
.env

310
backend/README.md Normal file
View file

@ -0,0 +1,310 @@
# Braindrops Backend
Self-hosted Bun backend server for the Braindrops drops system.
## Features
- **REST API** - Automatic endpoint loading from drop backends
- **WebSocket** - Real-time bidirectional communication
- **SQLite Database** - Lightweight, file-based storage
- **Auto-Discovery** - Automatically loads drop backends on startup
- **Hot Reload** - Automatically reloads when code changes (dev mode)
- **CORS Support** - Configured for local development and production
## Architecture
```
backend/
├── src/
│ ├── index.js # Main server entry point
│ ├── db.js # SQLite database utilities
│ ├── loader.js # Drop backend auto-discovery
│ ├── router.js # API routing
│ ├── websocket.js # WebSocket handling
│ └── shared/
│ └── middleware.js # CORS and other middleware
├── db/
│ └── braindrops.db # SQLite database (gitignored)
└── package.json
```
## Development
### Starting the Backend
The backend is automatically started when you run:
```bash
npm run dev:drops <drop-name>
```
This starts:
- Drop dev server on port 3000
- CMS on port 8787
- Backend on port 3001 (only loads the specified drop's backend)
### Standalone Backend
To run the backend independently:
```bash
npm run dev:backend
```
This loads ALL drop backends at once.
## Drop Backend Structure
Each drop can optionally have a `backend/` folder:
```
drops/<drop-name>/
├── src/ # Frontend code
├── backend/ # Backend code (optional)
│ ├── api.js # REST endpoints
│ ├── schema.sql # Database schema
│ └── websocket.js # WebSocket handler (optional)
└── public/
```
### Example API (api.js)
```javascript
import { dbHelpers } from '../../../backend/src/db.js'
const { query, queryOne, run } = dbHelpers
export const routes = {
'GET /items': async (req) => {
const items = query('SELECT * FROM mydrop_items')
return Response.json({ items })
},
'POST /items': async (req) => {
const body = await req.json()
const result = run('INSERT INTO mydrop_items (data) VALUES (?)', [body.data])
return Response.json({ id: result.lastInsertRowid })
}
}
```
### Example Schema (schema.sql)
```sql
CREATE TABLE IF NOT EXISTS mydrop_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### Example WebSocket (websocket.js)
```javascript
export const websocket = {
open(ws) {
ws.send(JSON.stringify({ type: 'connected' }))
},
message(ws, message) {
const data = JSON.parse(message)
// Broadcast to all clients
ws.publish('mydrop', JSON.stringify(data))
}
}
```
## API Endpoints
### Health Check
```
GET /health
```
Returns server status and loaded drops.
### Drop APIs
```
/api/<drop-name>/*
```
Each drop's API is namespaced under `/api/<drop-name>/`.
Example:
```bash
# Get melodies from image-melody drop
curl http://localhost:3001/api/image-melody/melodies
# Create a new melody
curl -X POST http://localhost:3001/api/image-melody/melodies \
-H "Content-Type: application/json" \
-d '{"name":"My Melody","imageData":{},"melodyData":{}}'
```
### WebSocket
```
ws://<host>:<port>/ws/<drop-name>
```
Example:
```javascript
const ws = new WebSocket('ws://localhost:3001/ws/image-melody')
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log('Received:', data)
}
ws.send(JSON.stringify({ type: 'hello' }))
```
## Database
The backend uses SQLite for data storage:
- Database file: `backend/db/braindrops.db`
- Tables use naming convention: `<drop-name>_<table-name>`
- Schemas are auto-applied on startup
- WAL mode enabled for better concurrency
### Database Helpers
Available in drop backends via import:
```javascript
import { dbHelpers } from '../../../backend/src/db.js'
const { query, queryOne, run } = dbHelpers
// Get all rows
const items = query('SELECT * FROM mydrop_items')
// Get single row
const item = queryOne('SELECT * FROM mydrop_items WHERE id = ?', [1])
// Insert/Update/Delete
const result = run('INSERT INTO mydrop_items (data) VALUES (?)', ['test'])
console.log(result.lastInsertRowid) // Get inserted ID
```
## Environment Variables
Create a `.env` file in the root directory:
```bash
# Production URL for CORS
PRODUCTION_URL=https://yourdomain.com
# Optional: Custom port
PORT=3001
# Your API keys
OPENAI_API_KEY=your-key
SOME_SERVICE_KEY=your-key
```
Access in code:
```javascript
const apiKey = process.env.OPENAI_API_KEY
```
## Production Build
Build for production:
```bash
node bin/buildBackend.js
```
This creates `backend/dist/` with:
- All source files
- package.json
- Database directory
- Start script
### Deploying to Production
1. Copy `backend/dist/` to your server
2. Install dependencies: `cd backend/dist && bun install`
3. Set environment variables
4. Start: `./start.sh`
### nginx Configuration
```nginx
# API proxy
location /api/ {
proxy_pass http://localhost:3001;
}
# WebSocket proxy
location /ws/ {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
## Troubleshooting
### Backend not starting
1. Check if port 3001 is available:
```bash
lsof -i :3001
```
2. Check backend logs in the terminal
3. Ensure Bun is installed:
```bash
bun --version
```
### Drop backend not loading
1. Check that `drops/<drop-name>/backend/` exists
2. Check that `api.js` has a valid `export const routes = {}`
3. Look for errors in terminal output during startup
### Database errors
1. Check that `backend/db/` directory exists
2. Check file permissions
3. Review schema.sql for syntax errors
4. Delete `braindrops.db` to start fresh (will lose data)
## Development Tips
1. **Route Patterns** - Use Express-style patterns:
- `GET /items` - List all
- `GET /items/:id` - Get by ID
- `POST /items` - Create
- `PUT /items/:id` - Update
- `DELETE /items/:id` - Delete
2. **Error Handling** - Return proper status codes:
```javascript
if (!item) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
```
3. **Validation** - Always validate input:
```javascript
if (!body.name || !body.data) {
return Response.json({ error: 'Missing fields' }, { status: 400 })
}
```
4. **JSON Storage** - Store complex data as JSON:
```javascript
run('INSERT INTO items (data) VALUES (?)', [JSON.stringify(obj)])
```
5. **WebSocket Channels** - Use drop name for pub/sub:
```javascript
ws.publish('mydrop', JSON.stringify({ event: 'update' }))
```
## License
Same as parent project.

BIN
backend/bun.lockb Executable file

Binary file not shown.

16
backend/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "braindrops-backend",
"version": "1.0.0",
"description": "Backend server for Braindrops",
"type": "module",
"main": "src/index.js",
"scripts": {
"dev": "bun --watch src/index.js",
"start": "bun src/index.js",
"build": "echo 'No build step needed for JavaScript'"
},
"dependencies": {},
"devDependencies": {
"bun-types": "latest"
}
}

94
backend/src/db.js Normal file
View file

@ -0,0 +1,94 @@
import { Database } from 'bun:sqlite'
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
let db = null
// Initialize the SQLite database
// singleDropName: If provided, only load schema for this drop
export async function initDatabase(singleDropName = null) {
const dbPath = join(import.meta.dir, '../db/braindrops.db')
db = new Database(dbPath, { create: true })
// Enable WAL mode for better concurrency
db.exec('PRAGMA journal_mode = WAL')
console.log(`📊 Database initialized: ${dbPath}`)
// Load and apply schemas
await loadSchemas(singleDropName)
return db
}
// Load all drop schemas from drops/*/backend/schema.sql
// singleDropName: If provided, only load schema for this drop
async function loadSchemas(singleDropName = null) {
const dropsDir = join(import.meta.dir, '../../drops')
try {
const drops = singleDropName
? [singleDropName]
: await readdir(dropsDir)
for (const dropName of drops) {
const schemaPath = join(dropsDir, dropName, 'backend/schema.sql')
const schemaFile = Bun.file(schemaPath)
if (await schemaFile.exists()) {
const schema = await schemaFile.text()
try {
db.exec(schema)
console.log(` ✓ Applied schema for: ${dropName}`)
} catch (error) {
console.error(` ✗ Error applying schema for ${dropName}:`, error.message)
}
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
console.error('Error loading schemas:', error)
}
}
}
// Get the database instance
export function getDb() {
if (!db) {
throw new Error('Database not initialized. Call initDatabase() first.')
}
return db
}
// Execute a query and return all results
export function query(sql, params = []) {
return getDb().query(sql).all(...params)
}
// Execute a query and return first result
export function queryOne(sql, params = []) {
return getDb().query(sql).get(...params)
}
// Execute a statement (INSERT, UPDATE, DELETE)
export function run(sql, params = []) {
return getDb().query(sql).run(...params)
}
// Close the database connection
export function closeDatabase() {
if (db) {
db.close()
db = null
}
}
// Export shorthand for use in drop backends
export const dbHelpers = {
query,
queryOne,
run,
getDb
}

119
backend/src/index.js Normal file
View file

@ -0,0 +1,119 @@
import { initDatabase } from './db.js'
import { loadDropApis } from './loader.js'
import { createRouter } from './router.js'
import { createWebSocketHandler } from './websocket.js'
import { withCors, handleOptions } from './shared/middleware.js'
const PORT = process.env.PORT || 3001
const DROP_NAME = process.env.DROP_NAME // Only load specific drop in dev mode
const isDev = process.env.NODE_ENV !== 'production'
console.log('🚀 Starting Braindrops Backend...')
console.log(` Mode: ${isDev ? 'development' : 'production'}`)
if (DROP_NAME) {
console.log(` Drop: ${DROP_NAME} (single drop mode)`)
}
// Initialize database
console.log('\n📊 Initializing database...')
await initDatabase(DROP_NAME)
// Load drop APIs
console.log('\n📦 Loading drop backends...')
const dropApis = await loadDropApis(DROP_NAME)
const dropCount = Object.keys(dropApis).length
if (dropCount === 0) {
console.log(' No drop backends found')
} else {
console.log(` Loaded ${dropCount} drop backend(s)`)
}
// Create router
const router = createRouter(dropApis)
// Create WebSocket handler
const wsHandler = createWebSocketHandler(dropApis)
// Start server
const server = Bun.serve({
port: PORT,
async fetch(req, server) {
const url = new URL(req.url)
// Handle OPTIONS preflight
if (req.method === 'OPTIONS') {
return handleOptions(req)
}
// WebSocket upgrade
if (url.pathname.startsWith('/ws/')) {
const dropName = url.pathname.split('/')[2]
if (!dropName) {
return new Response('Drop name required: /ws/<drop-name>', { status: 400 })
}
const dropApi = dropApis[dropName]
if (!dropApi || !dropApi.websocket) {
return new Response(`No WebSocket handler for drop: ${dropName}`, { status: 404 })
}
const upgraded = server.upgrade(req, {
data: { dropName }
})
if (upgraded) {
return // Connection upgraded successfully
}
return new Response('WebSocket upgrade failed', { status: 500 })
}
// Health check
if (url.pathname === '/health') {
const response = new Response(JSON.stringify({
status: 'ok',
drops: Object.keys(dropApis),
timestamp: new Date().toISOString()
}), {
headers: { 'Content-Type': 'application/json' }
})
return withCors(response, req)
}
// API routing
if (url.pathname.startsWith('/api/')) {
const response = await router.handle(req)
return withCors(response, req)
}
return new Response('Braindrops Backend API\n\nEndpoints:\n GET /health\n * /api/<drop-name>/*\n WS /ws/<drop-name>', {
status: 200,
headers: { 'Content-Type': 'text/plain' }
})
},
websocket: wsHandler
})
console.log(`\n⚡ Backend server running on http://localhost:${server.port}`)
console.log(` Health check: http://localhost:${server.port}/health`)
if (DROP_NAME) {
console.log(`\n🔗 Available endpoints for ${DROP_NAME}:`)
console.log(` REST API: http://localhost:${server.port}/api/${DROP_NAME}/*`)
if (dropApis[DROP_NAME]?.websocket) {
console.log(` WebSocket: ws://localhost:${server.port}/ws/${DROP_NAME}`)
}
}
console.log('\n✨ Ready!\n')
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n👋 Shutting down backend server...')
server.stop()
process.exit(0)
})

113
backend/src/loader.js Normal file
View file

@ -0,0 +1,113 @@
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
/**
* Load drop backend APIs
* @param {string|null} singleDropName - If provided, only load this drop's backend
* @returns {Promise<Object>} Map of dropName -> { routes, websocket }
*/
export async function loadDropApis(singleDropName = null) {
const dropsDir = join(import.meta.dir, '../../drops')
const apis = {}
try {
const drops = singleDropName
? [singleDropName]
: await readdir(dropsDir)
for (const dropName of drops) {
const backendDir = join(dropsDir, dropName, 'backend')
const apiPath = join(backendDir, 'api.js')
const wsPath = join(backendDir, 'websocket.js')
const apiFile = Bun.file(apiPath)
const wsFile = Bun.file(wsPath)
const hasApi = await apiFile.exists()
const hasWebSocket = await wsFile.exists()
if (hasApi || hasWebSocket) {
apis[dropName] = {}
if (hasApi) {
try {
// Import the API module
// Add timestamp to bypass cache during hot reload
const module = await import(`${apiPath}?t=${Date.now()}`)
apis[dropName].routes = module.routes || {}
console.log(` ✓ Loaded API for: ${dropName} (${Object.keys(module.routes || {}).length} routes)`)
} catch (error) {
console.error(` ✗ Error loading API for ${dropName}:`, error.message)
}
}
if (hasWebSocket) {
try {
const module = await import(`${wsPath}?t=${Date.now()}`)
apis[dropName].websocket = module.websocket || null
console.log(` ✓ Loaded WebSocket for: ${dropName}`)
} catch (error) {
console.error(` ✗ Error loading WebSocket for ${dropName}:`, error.message)
}
}
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
console.error('Error loading drop APIs:', error)
}
}
return apis
}
/**
* Parse route pattern and extract path parameters
* @param {string} pattern - Route pattern like "GET /recordings/:id"
* @returns {Object} { method, pathPattern, paramNames }
*/
export function parseRoutePattern(pattern) {
const [method, path] = pattern.split(' ')
const paramNames = []
// Convert pattern to regex
// /recordings/:id -> /recordings/([^/]+)
const pathPattern = path.replace(/:([^/]+)/g, (_, paramName) => {
paramNames.push(paramName)
return '([^/]+)'
})
return {
method,
pathPattern: new RegExp(`^${pathPattern}$`),
paramNames
}
}
/**
* Match a request to a route pattern
* @param {string} method - HTTP method
* @param {string} path - Request path
* @param {string} pattern - Route pattern
* @returns {Object|null} { params } or null if no match
*/
export function matchRoute(method, path, pattern) {
const parsed = parseRoutePattern(pattern)
if (parsed.method !== method) {
return null
}
const match = path.match(parsed.pathPattern)
if (!match) {
return null
}
// Extract path parameters
const params = {}
parsed.paramNames.forEach((name, i) => {
params[name] = match[i + 1]
})
return { params }
}

82
backend/src/router.js Normal file
View file

@ -0,0 +1,82 @@
import { matchRoute } from './loader.js'
/**
* Create an API router
* @param {Object} dropApis - Map of dropName -> { routes }
* @returns {Object} Router with handle method
*/
export function createRouter(dropApis) {
return {
/**
* Handle incoming API requests
* @param {Request} req - Bun request object
* @returns {Promise<Response>}
*/
async handle(req) {
const url = new URL(req.url)
const method = req.method
// Expected format: /api/<drop-name>/<endpoint>
const pathParts = url.pathname.split('/').filter(Boolean)
if (pathParts[0] !== 'api') {
return jsonResponse({ error: 'Not Found' }, 404)
}
if (pathParts.length < 2) {
return jsonResponse({ error: 'Drop name required in path: /api/<drop-name>/<endpoint>' }, 400)
}
const dropName = pathParts[1]
const endpoint = '/' + pathParts.slice(2).join('/')
const dropApi = dropApis[dropName]
if (!dropApi || !dropApi.routes) {
return jsonResponse({ error: `No API found for drop: ${dropName}` }, 404)
}
// Try to match the route
for (const [pattern, handler] of Object.entries(dropApi.routes)) {
const match = matchRoute(method, endpoint, pattern)
if (match) {
try {
const response = await handler(req, match.params)
return response
} catch (error) {
console.error(`Error in ${dropName} route ${pattern}:`, error)
return jsonResponse({ error: 'Internal Server Error', message: error.message }, 500)
}
}
}
return jsonResponse({ error: 'Endpoint not found', path: url.pathname }, 404)
}
}
}
/**
* Helper to create JSON response
*/
export function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json'
}
})
}
/**
* Helper to create success response
*/
export function successResponse(data = {}) {
return jsonResponse({ success: true, ...data })
}
/**
* Helper to create error response
*/
export function errorResponse(message, status = 400) {
return jsonResponse({ error: message }, status)
}

View file

@ -0,0 +1,42 @@
/**
* Add CORS headers to response
* @param {Response} response
* @param {Request} request
* @returns {Response}
*/
export function withCors(response, request) {
const origin = request.headers.get('origin')
// Allow localhost origins in development
const allowedOrigins = [
'http://localhost:3000', // Drop dev server
'http://localhost:5173', // App dev server (Vite default)
'http://localhost:4173', // App preview server
process.env.PRODUCTION_URL // Production URL from env
].filter(Boolean)
const headers = new Headers(response.headers)
if (origin && allowedOrigins.includes(origin)) {
headers.set('Access-Control-Allow-Origin', origin)
}
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
headers.set('Access-Control-Max-Age', '86400')
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
})
}
/**
* Handle OPTIONS preflight request
* @param {Request} request
* @returns {Response}
*/
export function handleOptions(request) {
return withCors(new Response(null, { status: 204 }), request)
}

65
backend/src/websocket.js Normal file
View file

@ -0,0 +1,65 @@
/**
* Create WebSocket handler for Bun server
* @param {Object} dropApis - Map of dropName -> { websocket }
* @returns {Object} WebSocket handler
*/
export function createWebSocketHandler(dropApis) {
return {
/**
* Called when a new WebSocket connection is opened
*/
open(ws) {
const { dropName } = ws.data
const dropWs = dropApis[dropName]?.websocket
if (dropWs && dropWs.open) {
dropWs.open(ws)
}
// Subscribe to drop-specific channel
ws.subscribe(dropName)
console.log(`🔌 WebSocket connected: ${dropName}`)
},
/**
* Called when a message is received
*/
message(ws, message) {
const { dropName } = ws.data
const dropWs = dropApis[dropName]?.websocket
if (dropWs && dropWs.message) {
dropWs.message(ws, message)
}
},
/**
* Called when connection is closed
*/
close(ws, code, reason) {
const { dropName } = ws.data
const dropWs = dropApis[dropName]?.websocket
if (dropWs && dropWs.close) {
dropWs.close(ws, code, reason)
}
console.log(`🔌 WebSocket disconnected: ${dropName}`)
},
/**
* Called when there's an error
*/
error(ws, error) {
const { dropName } = ws.data
const dropWs = dropApis[dropName]?.websocket
if (dropWs && dropWs.error) {
dropWs.error(ws, error)
}
console.error(`🔌 WebSocket error (${dropName}):`, error)
}
}
}

72
bin/buildBackend.js Normal file
View file

@ -0,0 +1,72 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.join(__dirname, '..')
const backendSrc = path.join(rootDir, 'backend')
const backendDist = path.join(backendSrc, 'dist')
console.log('🔨 Building backend for production...\n')
// For JavaScript backend, we just need to copy the source files
// and ensure the database directory exists
// Create dist directory
if (fs.existsSync(backendDist)) {
fs.rmSync(backendDist, { recursive: true })
}
fs.mkdirSync(backendDist, { recursive: true })
// Copy src directory
const srcDir = path.join(backendSrc, 'src')
const distSrcDir = path.join(backendDist, 'src')
copyDir(srcDir, distSrcDir)
console.log('✓ Copied source files')
// Copy package.json
fs.copyFileSync(
path.join(backendSrc, 'package.json'),
path.join(backendDist, 'package.json')
)
console.log('✓ Copied package.json')
// Create db directory
const dbDir = path.join(backendDist, 'db')
fs.mkdirSync(dbDir, { recursive: true })
console.log('✓ Created database directory')
// Create a production start script
const startScript = `#!/bin/bash
cd "$(dirname "$0")"
NODE_ENV=production bun src/index.js
`
fs.writeFileSync(path.join(backendDist, 'start.sh'), startScript)
fs.chmodSync(path.join(backendDist, 'start.sh'), 0o755)
console.log('✓ Created start script')
console.log('\n✅ Backend build complete!')
console.log(` Output: ${backendDist}`)
console.log('\nTo run in production:')
console.log(' cd backend/dist')
console.log(' ./start.sh')
/**
* Recursively copy directory
*/
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true })
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
copyDir(srcPath, destPath)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}

View file

@ -9,9 +9,11 @@ const NO_COPY = [
'package-lock.json',
'.git',
'config.js',
'backend', // Don't copy backend by default, we'll handle it separately
]
const templateDir = path.join(process.cwd(), 'template')
const backendTemplateDir = path.join(process.cwd(), 'template/backend')
const dropsDir = path.join(process.cwd(), 'drops')
const kebabCase = (str) =>
@ -112,6 +114,45 @@ const updateMetaJson = (dir, kebabName, titleName) => {
}
}
const setupBackend = (destDir, kebabName) => {
const backendDestDir = path.join(destDir, 'backend')
if (!fs.existsSync(backendTemplateDir)) {
console.warn(
'Warning: Backend template not found, skipping backend setup',
)
return
}
console.log('Setting up backend...')
// Copy backend template
copyDir(backendTemplateDir, backendDestDir)
// Replace DROPNAME in all backend files
const backendFiles = ['api.js', 'schema.sql', 'websocket.js', 'README.md']
for (const file of backendFiles) {
const filePath = path.join(backendDestDir, file)
if (fs.existsSync(filePath)) {
let content = fs.readFileSync(filePath, 'utf-8')
// Replace all instances of DROPNAME with the actual drop name
content = content.replace(/DROPNAME/g, kebabName.replace(/-/g, '_'))
fs.writeFileSync(filePath, content)
}
}
console.log('Backend setup complete!')
}
const askQuestion = (query) => {
return new Promise((resolve) => {
rl.question(query, (answer) => {
resolve(answer.trim().toLowerCase())
})
})
}
const runCommand = (command, args, cwd = process.cwd()) => {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
@ -132,7 +173,8 @@ const rl = readline.createInterface({
output: process.stdout,
})
rl.question('Enter drop name: ', async (name) => {
const main = async () => {
const name = await askQuestion('Enter drop name: ')
const trimmedName = name.trim()
const kebabName = kebabCase(trimmedName)
const pascalName = pascalCase(trimmedName)
@ -152,8 +194,14 @@ rl.question('Enter drop name: ', async (name) => {
process.exit(1)
}
// Ask if user wants backend
const wantsBackend = await askQuestion(
'Do you want to add a backend? (y/N): ',
)
const includeBackend = wantsBackend === 'y' || wantsBackend === 'yes'
try {
console.log(`Creating drop "${kebabName}"...`)
console.log(`\nCreating drop "${kebabName}"...`)
copyDir(templateDir, destDir)
// Update template files
@ -164,15 +212,23 @@ rl.question('Enter drop name: ', async (name) => {
updateMetaJson(destDir, kebabName, titleName)
console.log(`Drop "${kebabName}" created successfully`)
console.log('Running npm install...')
// Setup backend if requested
if (includeBackend) {
setupBackend(destDir, kebabName)
}
console.log('\nRunning npm install...')
await runCommand('npm', ['install'])
console.log('npm install complete!')
await runCommand('npm', ['run dev:drops', kebabName])
console.log(`\nStarting dev environment for "${kebabName}"...\n`)
await runCommand('npm', ['run', 'dev:drops', kebabName])
} catch (err) {
console.error('Error:', err.message)
process.exit(1)
}
rl.close()
})
}
main()

View file

@ -30,7 +30,9 @@ if (!fs.existsSync(dropPath)) {
console.log(`\n🚀 Starting dev environment for: ${dropName}\n`)
console.log(`📦 Drop dev server: http://localhost:3000`)
console.log(`📝 CMS: http://localhost:8787\n`)
console.log(`📝 CMS: http://localhost:8787`)
console.log(`⚡ Backend: http://localhost:3001/api/${dropName}`)
console.log(` WebSocket: ws://localhost:3001/ws/${dropName}\n`)
// Start drop dev server
const dropProcess = spawn('npm', ['run', 'dev'], {
@ -50,11 +52,24 @@ const cmsProcess = spawn('npm', ['run', 'dev', '-w', 'cms'], {
},
})
// Start backend server with DROP_NAME environment variable
const backendProcess = spawn('npm', ['run', 'dev', '-w', 'backend'], {
cwd: path.join(__dirname, '..'),
stdio: 'inherit',
shell: true,
env: {
...process.env,
DROP_NAME: dropName,
NODE_ENV: 'development',
},
})
// Handle process cleanup
const cleanup = () => {
console.log('\n\n🛑 Shutting down dev servers...')
dropProcess.kill()
cmsProcess.kill()
backendProcess.kill()
process.exit(0)
}
@ -70,3 +85,8 @@ cmsProcess.on('error', (err) => {
console.error('❌ CMS server error:', err)
cleanup()
})
backendProcess.on('error', (err) => {
console.error('❌ Backend server error:', err)
cleanup()
})

View file

@ -0,0 +1,103 @@
# Backend for DROPNAME
This directory contains the backend API and WebSocket handlers for your drop.
## Files
- **api.js** - REST API endpoints
- **schema.sql** - Database schema (SQLite)
- **websocket.js** - WebSocket handlers (optional)
## Usage
### REST API
The backend provides REST endpoints accessible at:
```
http://localhost:3001/api/DROPNAME/*
```
Example fetch from your drop's frontend:
```javascript
// GET request
const response = await fetch('/api/DROPNAME/items')
const data = await response.json()
// POST request
const response = await fetch('/api/DROPNAME/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: { /* your data */ } })
})
```
### WebSocket
Connect to WebSocket at:
```
ws://localhost:3001/ws/DROPNAME
```
Example WebSocket connection:
```javascript
const ws = new WebSocket('ws://localhost:3001/ws/DROPNAME')
ws.onopen = () => {
console.log('Connected')
ws.send(JSON.stringify({ type: 'hello' }))
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log('Received:', data)
}
```
### Database
Tables use the naming convention: `DROPNAME_tablename`
Access the database in your API:
```javascript
import { dbHelpers } from '../../backend/src/db.js'
const { query, queryOne, run } = dbHelpers
// Query all rows
const items = query('SELECT * FROM DROPNAME_items')
// Query single row
const item = queryOne('SELECT * FROM DROPNAME_items WHERE id = ?', [id])
// Insert/Update/Delete
const result = run('INSERT INTO DROPNAME_items (data) VALUES (?)', [data])
```
## Development
When you run `npm run dev:drops DROPNAME`, the backend server automatically:
1. Loads your API routes
2. Applies your database schema
3. Sets up WebSocket handlers
4. Hot-reloads when you change backend files
## Environment Variables
Create a `.env` file in the root directory to add environment variables:
```
PRODUCTION_URL=https://yourdomain.com
MY_API_KEY=your-api-key-here
```
Access in your code:
```javascript
const apiKey = process.env.MY_API_KEY
```
## Tips
- Keep your API routes simple and focused
- Use proper HTTP status codes (200, 201, 400, 404, 500)
- Validate input data before saving to database
- Use JSON for request/response bodies
- Log errors for debugging
- Use WebSockets for real-time features (multiplayer, live updates, etc.)

View file

@ -0,0 +1,100 @@
import { dbHelpers } from '../../../backend/src/db.js'
const { query, queryOne, run } = dbHelpers
/**
* API routes for this drop
*
* Route pattern format: "METHOD /path/:param"
*
* Example routes:
* - "GET /items" - Get all items
* - "GET /items/:id" - Get single item by ID
* - "POST /items" - Create new item
* - "PUT /items/:id" - Update item
* - "DELETE /items/:id" - Delete item
*/
export const routes = {
/**
* Get all items
* GET /api/<drop-name>/items
*/
'GET /items': async (req) => {
const items = query(`SELECT * FROM testing_items ORDER BY created_at DESC`)
return Response.json({
items,
count: items.length
})
},
/**
* Get single item by ID
* GET /api/<drop-name>/items/:id
*/
'GET /items/:id': async (req, params) => {
const item = queryOne(`SELECT * FROM testing_items WHERE id = ?`, [params.id])
if (!item) {
return Response.json({ error: 'Item not found' }, { status: 404 })
}
return Response.json(item)
},
/**
* Create new item
* POST /api/<drop-name>/items
*/
'POST /items': async (req) => {
const body = await req.json()
// Validate input
if (!body.text) {
return Response.json({ error: 'Missing required field: text' }, { status: 400 })
}
const result = run(
`INSERT INTO testing_items (text) VALUES (?)`,
[body.text]
)
return Response.json({
id: result.lastInsertRowid,
success: true
}, { status: 201 })
},
/**
* Update item
* PUT /api/<drop-name>/items/:id
*/
'PUT /items/:id': async (req, params) => {
const body = await req.json()
const result = run(
`UPDATE testing_items SET data = ? WHERE id = ?`,
[JSON.stringify(body.data), params.id]
)
if (result.changes === 0) {
return Response.json({ error: 'Item not found' }, { status: 404 })
}
return Response.json({ success: true })
},
/**
* Delete item
* DELETE /api/<drop-name>/items/:id
*/
'DELETE /items/:id': async (req, params) => {
const result = run(`DELETE FROM testing_items WHERE id = ?`, [params.id])
if (result.changes === 0) {
return Response.json({ error: 'Item not found' }, { status: 404 })
}
return Response.json({ success: true })
}
}

View file

@ -0,0 +1,20 @@
-- Database schema for testing drop
-- Table naming convention: <drop-name>_<table-name>
CREATE TABLE IF NOT EXISTS testing_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Index for faster queries
CREATE INDEX IF NOT EXISTS idx_testing_items_created
ON testing_items(created_at);
-- Trigger to update updated_at timestamp
CREATE TRIGGER IF NOT EXISTS update_testing_items_timestamp
AFTER UPDATE ON testing_items
BEGIN
UPDATE testing_items SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

View file

@ -0,0 +1,73 @@
/**
* WebSocket handler for this drop
*
* Connect to: ws://localhost:3001/ws/<drop-name>
*
* The WebSocket handler provides real-time bidirectional communication.
* Use ws.publish(dropName, message) to broadcast to all connected clients.
*/
export const websocket = {
/**
* Called when a client connects
* @param {ServerWebSocket} ws - The WebSocket connection
*/
open(ws) {
console.log('Client connected')
// Send welcome message
ws.send(JSON.stringify({
type: 'connected',
message: 'Welcome to Testing!'
}))
},
/**
* Handle incoming messages
* @param {ServerWebSocket} ws - The WebSocket connection
* @param {string|Buffer} message - The message received
*/
message(ws, message) {
try {
const data = JSON.parse(message)
console.log('Received message:', data)
// Echo the message back
ws.send(JSON.stringify({
type: 'echo',
data: data
}))
// Broadcast to all connected clients on this drop's channel
// ws.publish('DROPNAME', JSON.stringify({
// type: 'broadcast',
// data: data
// }))
} catch (error) {
console.error('Error parsing message:', error)
ws.send(JSON.stringify({
type: 'error',
message: 'Invalid JSON'
}))
}
},
/**
* Called when connection is closed
* @param {ServerWebSocket} ws - The WebSocket connection
* @param {number} code - Close code
* @param {string} reason - Close reason
*/
close(ws, code, reason) {
console.log(`Client disconnected: ${code} ${reason}`)
},
/**
* Called when there's an error
* @param {ServerWebSocket} ws - The WebSocket connection
* @param {Error} error - The error
*/
error(ws, error) {
console.error('WebSocket error:', error)
}
}

13
drops/testing/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,20 @@
{
"name": "testing",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.0"
}
}

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,8 @@
{
"name": "testing",
"title": "Testing",
"date": "2026-07-22",
"tags": ["tag1", "tag2", "tag3"],
"description": "A brief description of what this drop does",
"published": false
}

17
drops/testing/src/App.vue Normal file
View file

@ -0,0 +1,17 @@
<template>
<main class="drop theme-light">
<Testing />
</main>
</template>
<script setup>
import Testing from './components/Testing.vue'
</script>
<style lang="scss">
main.drop {
background: var(--theme-bg);
color: var(--theme-fg);
height: 100vh;
}
</style>

View file

@ -0,0 +1,128 @@
<template>
<div class="testing">
<h1>Testing</h1>
<h3>Items</h3>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.text }}</span>
<btn @click="deleteItem(item.id)">Delete</btn>
</li>
</ul>
<label>
<span>Add Item: </span>
<input type="text" v-model="newItem" />
<btn @click="addItem">Add</btn>
</label>
<btn @click="sendMessage">Send Message</btn>
</div>
</template>
<script setup>
import Btn from '@shared/Btn.vue'
import { onMounted, onUnmounted, ref } from 'vue';
const items = ref([])
const newItem = ref('')
const ws = ref(null)
const isConnected = ref(false)
onMounted(async () => {
await getItems()
// WebSocket connection
ws.value = new WebSocket('ws://localhost:3001/ws/testing')
ws.value.onopen = () => {
console.log('Connected to WebSocket')
isConnected.value = true
}
ws.value.onmessage = (event) => {
const data = JSON.parse(event.data)
switch (data.type) {
case 'connected':
console.log(data.message)
break
case 'echo':
console.log('Echo received')
console.log(data)
break
}
}
ws.value.onclose = () => {
console.log('Disconnected from WebSocket')
isConnected.value = false
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
}
})
const getItems = async () => {
const response = await fetch('http://localhost:3001/api/testing/items')
const data = await response.json()
items.value = data.items
}
const addItem = async () => {
try {
const response = await fetch('http://localhost:3001/api/testing/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: newItem.value }),
})
if (!response.ok) {
throw new Error('Failed to add item')
}
await getItems()
newItem.value = ''
} catch (error) {
console.error(error)
}
}
const deleteItem = async (id) => {
try {
const response = await fetch(`http://localhost:3001/api/testing/items/${id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error('Failed to delete item')
}
await getItems()
} catch (error) {
console.error(error)
}
}
const sendMessage = () => {
if (ws.value && isConnected.value) {
ws.value.send(JSON.stringify({
type: 'test',
message: {
text: 'test'
}
}))
}
}
onUnmounted(() => {
if (ws.value) {
ws.value.close()
}
})
</script>
<style lang="scss">
.testing {
height: 100%;
}
</style>

View file

@ -0,0 +1,20 @@
export function clamp(min, input, max) {
return Math.max(min, Math.min(input, max));
}
export function mapRange(in_min, in_max, input, out_min, out_max) {
return ((input - in_min) * (out_max - out_min)) / (in_max - in_min) + out_min;
}
export function lerp(start, end, amt) {
return (1 - amt) * start + amt * end;
}
export function truncate(value, decimals) {
return parseFloat(value.toFixed(decimals));
}
export function wrapValue(value, lowBound, highBound) {
const range = highBound - lowBound;
return ((((value - lowBound) % range) + range) % range) + lowBound;
}

View file

@ -0,0 +1,6 @@
import '@shared/styles/shared.scss'
import './styles/main.scss'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View file

@ -0,0 +1 @@
/* Drop specific styles */

View file

@ -0,0 +1,21 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, mergeConfig } from 'vite'
import sharedConfig from '../../shared/vite.config.js'
export default defineConfig(
mergeConfig(sharedConfig, {
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(
new URL('../../shared', import.meta.url),
),
},
},
base: '/drops/testing/',
build: {
outDir: '../../app/public/drops/testing',
emptyOutDir: true,
},
}),
)

73
package-lock.json generated
View file

@ -11,7 +11,8 @@
"app",
"bin",
"drops/*",
"cms"
"cms",
"backend"
]
},
"app": {
@ -38,6 +39,13 @@
"vite": "^8.0.0"
}
},
"backend": {
"name": "braindrops-backend",
"version": "1.0.0",
"devDependencies": {
"bun-types": "latest"
}
},
"cms": {
"name": "braindrops-cms",
"version": "0.0.0",
@ -54,6 +62,18 @@
"vite": "^8.0.0"
}
},
"drops/backend-bitch": {
"version": "0.0.0",
"dependencies": {
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.0"
}
},
"drops/endless-journal": {
"version": "0.0.0",
"dependencies": {
@ -120,6 +140,18 @@
"vite": "^8.0.0"
}
},
"drops/testing": {
"version": "0.0.0",
"dependencies": {
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.0"
}
},
"node_modules/@babel/generator": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
@ -1454,6 +1486,16 @@
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
@ -1737,6 +1779,10 @@
"node": ">=18.2.0"
}
},
"node_modules/backend-bitch": {
"resolved": "drops/backend-bitch",
"link": true
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@ -1750,10 +1796,24 @@
"resolved": "app",
"link": true
},
"node_modules/braindrops-backend": {
"resolved": "backend",
"link": true
},
"node_modules/braindrops-cms": {
"resolved": "cms",
"link": true
},
"node_modules/bun-types": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz",
"integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/camera-controls": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz",
@ -3237,6 +3297,10 @@
"node": ">=16.0.0"
}
},
"node_modules/testing": {
"resolved": "drops/testing",
"link": true
},
"node_modules/three": {
"version": "0.185.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
@ -3326,6 +3390,13 @@
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unplugin": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz",

View file

@ -7,13 +7,15 @@
"app",
"bin",
"drops/*",
"cms"
"cms",
"backend"
],
"scripts": {
"create": "node bin/createDrop",
"build:drops": "node bin/buildDrops && node bin/buildManifest",
"dev:drops": "node bin/dev.js",
"dev": "npm run build:drops && npm run dev -w app",
"dev:backend": "npm run dev -w backend",
"build": "npm run build:drops && npm run build -w app"
}
}

103
template/backend/README.md Normal file
View file

@ -0,0 +1,103 @@
# Backend for DROPNAME
This directory contains the backend API and WebSocket handlers for your drop.
## Files
- **api.js** - REST API endpoints
- **schema.sql** - Database schema (SQLite)
- **websocket.js** - WebSocket handlers (optional)
## Usage
### REST API
The backend provides REST endpoints accessible at:
```
http://localhost:3001/api/DROPNAME/*
```
Example fetch from your drop's frontend:
```javascript
// GET request
const response = await fetch('/api/DROPNAME/items')
const data = await response.json()
// POST request
const response = await fetch('/api/DROPNAME/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: { /* your data */ } })
})
```
### WebSocket
Connect to WebSocket at:
```
ws://localhost:3001/ws/DROPNAME
```
Example WebSocket connection:
```javascript
const ws = new WebSocket('ws://localhost:3001/ws/DROPNAME')
ws.onopen = () => {
console.log('Connected')
ws.send(JSON.stringify({ type: 'hello' }))
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
console.log('Received:', data)
}
```
### Database
Tables use the naming convention: `DROPNAME_tablename`
Access the database in your API:
```javascript
import { dbHelpers } from '../../backend/src/db.js'
const { query, queryOne, run } = dbHelpers
// Query all rows
const items = query('SELECT * FROM DROPNAME_items')
// Query single row
const item = queryOne('SELECT * FROM DROPNAME_items WHERE id = ?', [id])
// Insert/Update/Delete
const result = run('INSERT INTO DROPNAME_items (data) VALUES (?)', [data])
```
## Development
When you run `npm run dev:drops DROPNAME`, the backend server automatically:
1. Loads your API routes
2. Applies your database schema
3. Sets up WebSocket handlers
4. Hot-reloads when you change backend files
## Environment Variables
Create a `.env` file in the root directory to add environment variables:
```
PRODUCTION_URL=https://yourdomain.com
MY_API_KEY=your-api-key-here
```
Access in your code:
```javascript
const apiKey = process.env.MY_API_KEY
```
## Tips
- Keep your API routes simple and focused
- Use proper HTTP status codes (200, 201, 400, 404, 500)
- Validate input data before saving to database
- Use JSON for request/response bodies
- Log errors for debugging
- Use WebSockets for real-time features (multiplayer, live updates, etc.)

111
template/backend/api.js Normal file
View file

@ -0,0 +1,111 @@
import { dbHelpers } from '../../../backend/src/db.js'
const { query, queryOne, run } = dbHelpers
/**
* API routes for this drop
*
* Route pattern format: "METHOD /path/:param"
*
* Example routes:
* - "GET /items" - Get all items
* - "GET /items/:id" - Get single item by ID
* - "POST /items" - Create new item
* - "PUT /items/:id" - Update item
* - "DELETE /items/:id" - Delete item
*/
export const routes = {
/**
* Get all items
* GET /api/<drop-name>/items
*/
'GET /items': async (req) => {
const items = query(
`SELECT * FROM DROPNAME_items ORDER BY created_at DESC`,
)
return Response.json({
items,
count: items.length,
})
},
/**
* Get single item by ID
* GET /api/<drop-name>/items/:id
*/
'GET /items/:id': async (req, params) => {
const item = queryOne(`SELECT * FROM DROPNAME_items WHERE id = ?`, [
params.id,
])
if (!item) {
return Response.json({ error: 'Item not found' }, { status: 404 })
}
return Response.json(item)
},
/**
* Create new item
* POST /api/<drop-name>/items
*/
'POST /items': async (req) => {
const body = await req.json()
// Validate input
if (!body.data) {
return Response.json(
{ error: 'Missing required field: data' },
{ status: 400 },
)
}
const result = run(`INSERT INTO DROPNAME_items (data) VALUES (?)`, [
JSON.stringify(body.data),
])
return Response.json(
{
id: result.lastInsertRowid,
success: true,
},
{ status: 201 },
)
},
/**
* Update item
* PUT /api/<drop-name>/items/:id
*/
'PUT /items/:id': async (req, params) => {
const body = await req.json()
const result = run(`UPDATE DROPNAME_items SET data = ? WHERE id = ?`, [
JSON.stringify(body.data),
params.id,
])
if (result.changes === 0) {
return Response.json({ error: 'Item not found' }, { status: 404 })
}
return Response.json({ success: true })
},
/**
* Delete item
* DELETE /api/<drop-name>/items/:id
*/
'DELETE /items/:id': async (req, params) => {
const result = run(`DELETE FROM DROPNAME_items WHERE id = ?`, [
params.id,
])
if (result.changes === 0) {
return Response.json({ error: 'Item not found' }, { status: 404 })
}
return Response.json({ success: true })
},
}

View file

@ -0,0 +1,20 @@
-- Database schema for DROPNAME drop
-- Table naming convention: <drop-name>_<table-name>
CREATE TABLE IF NOT EXISTS DROPNAME_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Index for faster queries
CREATE INDEX IF NOT EXISTS idx_DROPNAME_items_created
ON DROPNAME_items(created_at);
-- Trigger to update updated_at timestamp
CREATE TRIGGER IF NOT EXISTS update_DROPNAME_items_timestamp
AFTER UPDATE ON DROPNAME_items
BEGIN
UPDATE DROPNAME_items SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

View file

@ -0,0 +1,73 @@
/**
* WebSocket handler for this drop
*
* Connect to: ws://localhost:3001/ws/<drop-name>
*
* The WebSocket handler provides real-time bidirectional communication.
* Use ws.publish(dropName, message) to broadcast to all connected clients.
*/
export const websocket = {
/**
* Called when a client connects
* @param {ServerWebSocket} ws - The WebSocket connection
*/
open(ws) {
console.log('Client connected')
// Send welcome message
ws.send(JSON.stringify({
type: 'connected',
message: 'Welcome to DROPNAME!'
}))
},
/**
* Handle incoming messages
* @param {ServerWebSocket} ws - The WebSocket connection
* @param {string|Buffer} message - The message received
*/
message(ws, message) {
try {
const data = JSON.parse(message)
console.log('Received message:', data)
// Echo the message back
ws.send(JSON.stringify({
type: 'echo',
data: data
}))
// Broadcast to all connected clients on this drop's channel
// ws.publish('DROPNAME', JSON.stringify({
// type: 'broadcast',
// data: data
// }))
} catch (error) {
console.error('Error parsing message:', error)
ws.send(JSON.stringify({
type: 'error',
message: 'Invalid JSON'
}))
}
},
/**
* Called when connection is closed
* @param {ServerWebSocket} ws - The WebSocket connection
* @param {number} code - Close code
* @param {string} reason - Close reason
*/
close(ws, code, reason) {
console.log(`Client disconnected: ${code} ${reason}`)
},
/**
* Called when there's an error
* @param {ServerWebSocket} ws - The WebSocket connection
* @param {Error} error - The error
*/
error(ws, error) {
console.error('WebSocket error:', error)
}
}