backend integration
11
.env
Normal 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
|
|
@ -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
|
|
@ -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!
|
||||
36
README.md
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.);
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[
|
||||
"endless-journal",
|
||||
"flowmap",
|
||||
"image-melody",
|
||||
"noise-shader",
|
||||
"flowmap"
|
||||
"endless-journal"
|
||||
]
|
||||
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 376 KiB After Width: | Height: | Size: 396 KiB |
1
app/public/splash-title/iosevka-charon-atlas.json
Normal file
BIN
app/public/splash-title/iosevka-charon-atlas.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
310
backend/README.md
Normal 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
16
backend/package.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
42
backend/src/shared/middleware.js
Normal 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
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
22
bin/dev.js
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
103
drops/testing/backend/README.md
Normal 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.)
|
||||
100
drops/testing/backend/api.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
20
drops/testing/backend/schema.sql
Normal 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;
|
||||
73
drops/testing/backend/websocket.js
Normal 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
|
|
@ -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>
|
||||
20
drops/testing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
8
drops/testing/public/meta.json
Normal 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
|
|
@ -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>
|
||||
128
drops/testing/src/components/Testing.vue
Normal 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>
|
||||
20
drops/testing/src/libs/math.js
Normal 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;
|
||||
}
|
||||
6
drops/testing/src/main.js
Normal 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')
|
||||
1
drops/testing/src/styles/main.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Drop specific styles */
|
||||
21
drops/testing/vite.config.js
Normal 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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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 })
|
||||
},
|
||||
}
|
||||
20
template/backend/schema.sql
Normal 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;
|
||||
73
template/backend/websocket.js
Normal 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)
|
||||
}
|
||||
}
|
||||