diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..11619e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +node index.js # Start the server (port 3000) +npx prettier --write . # Format code +``` + +No build step. No test suite. Restart the server to pick up changes to `index.js` or `db.js`; changes to Eta templates in `views/` are picked up live (cache is disabled). + +Requires a `.env` file with `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` for web push. + +## What This Is + +Bliss is a browser-based low-code platform where users build mini web apps ("Structures") entirely through a `/workshop` UI. Each Structure has routes, templates, and SQLite databases — all stored in the main app database and executed at runtime via Node's `vm` module. + +## Architecture + +### Two layers of routes + +The Express app has two kinds of routes: + +1. **Workshop routes** (hardcoded in `index.js`) — The `/workshop/*` editor UI for managing structures, routes, templates, databases, and files. +2. **User routes** (dynamic, loaded from DB) — Caught by the `app.all("*")` handler at the bottom of `index.js`. At request time it matches the path against the `routes` in-memory array (rebuilt by `buildRoutes()`), finds the matching route record, and executes the user's `handler` JS string inside a sandboxed VM context. + +`buildRoutes()` must be called after any route is created/updated to refresh the in-memory matcher array. + +### Sandboxed execution (`bootstrapContext`) + +User route handlers run in `vm.createContext()` with a restricted API: +- `require('eta')` — Eta template instance scoped to the structure +- `require('db')` — getter for named database instances. `require('db')(alias)` returns `{ library, sql }`: `sql` is the raw `better-sqlite3` instance for that db, and `library` is `module.exports` from that db's own "library" script (arbitrary JS, edited via `/workshop/:structure_id/db/:db_id/library`, run once per request with `sql` bound in its own vm context — see `bootstrapContext` in `index.js`). Handlers typically call helper functions off `library` rather than writing raw SQL inline. +- `require('push')` — web-push library +- `require('files')` — `{ saveFile }` helper +- `console.log` — writes to the app's `logs` table (not stdout) +- `fetch`, `setTimeout`, `clearTimeout` +- `route(url)` — prefixes a URL with the structure's `route_prefix` + +Handlers call `res.render(templateName, context)` or `ws.render(templateName, context)` to render Eta templates. These are injected onto `req`/`res` after the context is created (not available inside `bootstrapContext` itself). + +WebSocket routes are bootstrapped once by `bootstrapWebsocketHandler` and stored in `wsRoutes`; subsequent saves only update the handler function without re-registering the ws path. + +### Template rendering (`bootstrapTemplateWithHTMXetc`) + +Every HTML response passes through this function, which uses Cheerio to inject HTMX, Tailwind, Hyperscript, and `bliss_inspector.js` into the `
`, and attaches `data-bliss-route` / `data-bliss-clone` / `data-bliss-copy` attributes to `` (or to fragment children for HTMX partial swaps). These attributes drive the in-page editor overlay. + +- `data-bliss-route` — link to the workshop editor page for whatever produced this element. +- `data-bliss-clone` — `hx-get` target for the clone-structure modal. +- `data-bliss-copy` — only set for GET routes, via `embedHTML(url)` (`index.js`): ``. This is the actual page-nesting primitive — the inspector's 📋 button copies this snippet to the clipboard, and pasting it into any other template lazy-loads that route as a live child page via HTMX. This is how Structures compose: pages nesting pages nesting pages, each one an independently addressable, independently editable route. + +### The inspector (`bliss_inspector.js`) and where it's headed + +Currently the 🔍 overlay (injected into every rendered page, see above) only highlights `[data-bliss-route]` elements and offers links out to the editor, a copy-embed button, and a clone-structure modal — it does not let you edit anything in place yet. + +The long-term goal is for the inspector to become a real-time, in-page structure editor: instead of jumping out to `/workshop/...`, you'd edit a nested page's route/template directly where it's embedded and see it update live. Two things that design needs and don't exist yet: +- Each embedded view needs to carry its **source** (which structure/route/template produced it) — `data-bliss-route` currently only gives a link out, not enough info to edit inline. +- Each embedded view needs to carry its **args** — the context/params it was rendered with (e.g. `res.render(template, context)`'s `context`, URL/query params) — so the in-place editor can show and modify what was actually passed in, not just the output. + +Cross-fragment interaction (nested pages talking to each other) isn't a bespoke Bliss feature — it rides on standard HTMX/Hyperscript behavior since every nested page lives in the same DOM: `hx-swap-oob` for out-of-band updates (already used for head injection), and bubbling custom events (Hyperscript `send`/`trigger`, `hx-trigger="eventName from:body"`) for one nested page to notify siblings/ancestors. + +### Database layer (`db.js`) + +- Main app DB: `./dbs/0.sqlite` — stores structures, routes, templates, dbs metadata, files, logs. +- User databases: `./dbs/{id}.sqlite` — one SQLite file per user-created DB, opened on demand and cached via LRU (max 25 connections). +- Eta template instances are also LRU-cached per structure (max 100). The cache must be invalidated if template content changes — currently the cache is not explicitly invalidated on update, relying on the LRU eviction policy. +- Migrations in `migrations/` are applied sequentially on startup (tracked in a `migrations` table). + +### Structure cloning + +`cloneStructure` (in `db.js`) deep-copies a structure's routes, templates, scaffold pages, and optionally its databases (full copy vs. aliased reference). Aliased DBs share the same SQLite file across structures. + +### Views vs. user templates + +- `views/` — Eta templates for the workshop UI itself (not user content). +- User-created templates are stored as rows in the `templates` table and resolved at render time via `getTemplater()` / `readFile`. + +### Scaffold pages + +Non-GET routes (POST, PUT, DELETE, WS) get auto-generated scaffold pages — test forms or WebSocket test UIs — stored in `scaffold_pages`. These are served at `/workshop/:structure_id/route/:route_id/preview`. + +### Frontend stack (workshop UI) + +HTMX + Hyperscript + Tailwind (CDN, preflight disabled) + `bliss_inspector.js` (the in-page editor overlay). All served from `public/js/`. diff --git a/db.js b/db.js index d6ad103..343d2d3 100644 --- a/db.js +++ b/db.js @@ -44,13 +44,13 @@ function applyMigrations() { // | | _____| || |___ | | | | // |_______||_______||_______||___| |_| -function createUser(db, username, hashedPassword) { +function createUser(username, hashedPassword) { return db .prepare("INSERT INTO users (username, password) VALUES (?, ?)") .run(username, hashedPassword).lastInsertRowid; } -function getUser(db, username) { +function getUser(username) { return db.prepare("SELECT * from users where username = ?").get(username); } @@ -76,7 +76,7 @@ function getDbInstance(dbId) { return dbInstance; } -function getAllRoutes(db) { +function getAllRoutes() { return db .prepare( ` @@ -100,7 +100,7 @@ function getTemplater(structId) { return path; }; etaInstance.readFile = function (templateAlias) { - return getTemplateContentByName(db, structId, templateAlias).content; + return getTemplateContentByName(structId, templateAlias).content; }; templateCache.set(structId, etaInstance); } @@ -108,15 +108,15 @@ function getTemplater(structId) { return etaInstance; } -function getStructures(db) { +function getStructures() { return db.prepare("SELECT * from structures").all(); } -function getStructure(db, id) { +function getStructure(id) { return db.prepare("SELECT * from structures where ID = ?").get(id); } -function createStructure(db, name, userId) { +function createStructure(name, userId) { const stmt = db.prepare( "INSERT INTO structures (name, user_id) VALUES (?, ?)", ); @@ -124,7 +124,7 @@ function createStructure(db, name, userId) { return info.lastInsertRowid; // Returns the structure_id of the newly created structure } -function createRoute(db, verb, path, structureId, handler) { +function createRoute(verb, path, structureId, handler) { path = encodeURI(path); const stmt = db.prepare( @@ -134,60 +134,40 @@ function createRoute(db, verb, path, structureId, handler) { const routeId = info.lastInsertRowid; // Get the newly created route ID if (verb !== 'GET') { - createScaffoldPage(db, routeId); + createScaffoldPage(routeId); } return routeId; // Returns the route_id of the newly created route } -function getRoutes(db, structureId) { - const query = ` - SELECT - r.*, - s.route_prefix, - sp.id AS scaffold_page_id, - sp.content AS scaffold_page_content, - sp2.id AS scaffold_params_id, - sp2.query_params, - sp2.url_params - FROM routes r - LEFT JOIN scaffold_pages sp ON r.id = sp.route_id - LEFT JOIN scaffold_params sp2 ON r.id = sp2.route_id - LEFT JOIN structures s ON r.structure_id = s.id - WHERE r.structure_id = ? - ORDER BY sp.created_at DESC, sp2.created_at DESC - `; +const ROUTE_SELECT = ` + SELECT + r.*, + s.route_prefix, + sp.id AS scaffold_page_id, + sp.content AS scaffold_page_content, + sp2.id AS scaffold_params_id, + sp2.query_params, + sp2.url_params + FROM routes r + LEFT JOIN scaffold_pages sp ON r.id = sp.route_id + LEFT JOIN scaffold_params sp2 ON r.id = sp2.route_id + LEFT JOIN structures s ON r.structure_id = s.id +`; - return db.prepare(query).all(structureId); +function getRoutes(structureId) { + return db + .prepare(`${ROUTE_SELECT} WHERE r.structure_id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC`) + .all(structureId); } -function getRoute(db, routeId) { - const query = ` - SELECT - r.*, - s.route_prefix, - sp.id AS scaffold_page_id, - sp.content AS scaffold_page_content, - sp2.id AS scaffold_params_id, - sp2.query_params, - sp2.url_params - FROM routes r - LEFT JOIN scaffold_pages sp ON r.id = sp.route_id - LEFT JOIN scaffold_params sp2 ON r.id = sp2.route_id - LEFT JOIN structures s ON r.structure_id = s.id - WHERE r.id = ? - ORDER BY sp.created_at DESC, sp2.created_at DESC - LIMIT 1 - `; - - return db.prepare(query).get(routeId); +function getRoute(routeId) { + return db + .prepare(`${ROUTE_SELECT} WHERE r.id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC LIMIT 1`) + .get(routeId); } -function defaultEndpointForRoute(db, routeId) { - -} - -function updateRoute(db, route) { +function updateRoute(route) { const fields = [ "verb", "path", @@ -204,7 +184,7 @@ function updateRoute(db, route) { db.prepare(sql).run(...values); } -function updateDb(db, appDb) { +function updateDb(appDb) { const fields = ["name", "library"]; const values = fields.map((field) => appDb[field]); const placeholders = fields.map((field) => `${field} = ?`).join(", "); @@ -214,7 +194,7 @@ function updateDb(db, appDb) { db.prepare(sql).run(...values); } -function updateStruct(db, struct) { +function updateStruct(struct) { const fields = ["name", "route_prefix", "head_injection"]; const values = fields.map((field) => struct[field]); const placeholders = fields.map((field) => `${field} = ?`).join(", "); @@ -224,7 +204,7 @@ function updateStruct(db, struct) { db.prepare(sql).run(...values); } -function updateTemplate(db, template) { +function updateTemplate(template) { const fields = ["content", "name", "test_object"]; const values = fields.map((field) => template[field]); const placeholders = fields.map((field) => `${field} = ?`).join(", "); @@ -234,17 +214,17 @@ function updateTemplate(db, template) { db.prepare(sql).run(...values); } -function getTemplates(db, structureId) { +function getTemplates(structureId) { return db .prepare("SELECT * from templates where structure_id = ?") .all(structureId); } -function getTemplate(db, templateId) { +function getTemplate(templateId) { return db.prepare("SELECT * from templates where id = ?").get(templateId); } -function getTemplateContentByName(db, structId, name) { +function getTemplateContentByName(structId, name) { return db .prepare( "SELECT content from templates where structure_id = ? AND name = ?", @@ -252,7 +232,7 @@ function getTemplateContentByName(db, structId, name) { .get(structId, name); } -function createTemplate(db, structureId, name, content, testObjectString) { +function createTemplate(structureId, name, content, testObjectString) { return db .prepare( "INSERT INTO templates (structure_id, name, content, test_object) VALUES (?, ?, ?, ?)", @@ -260,7 +240,7 @@ function createTemplate(db, structureId, name, content, testObjectString) { .run(structureId, name, content, testObjectString).lastInsertRowid; } -function getDbsForStructure(db, structureId) { +function getDbsForStructure(structureId) { return db .prepare( `SELECT @@ -280,7 +260,7 @@ function getDbsForStructure(db, structureId) { .all(structureId); } -function getDb(db, dbId) { +function getDb(dbId) { return db .prepare( `SELECT * @@ -291,7 +271,7 @@ function getDb(db, dbId) { .get(dbId); } -function getDbForStructure(db, structureId, dbId) { +function getDbForStructure(structureId, dbId) { return db .prepare( `SELECT * @@ -304,7 +284,7 @@ function getDbForStructure(db, structureId, dbId) { .get(structureId, dbId); } -function createDb(db, structId, name) { +function createDb(structId, name) { const transaction = db.transaction(() => { const insertDbStmt = db.prepare(` INSERT INTO dbs (name, structure_id) @@ -328,7 +308,7 @@ function createDb(db, structId, name) { return transaction(); } -function attachDb(db, structId, dbId, alias) { +function attachDb(structId, dbId, alias) { const insertStructureDbStmt = db.prepare(` INSERT INTO structure_dbs (db_id, structure_id, alias) VALUES (?, ?, ?) @@ -336,7 +316,7 @@ function attachDb(db, structId, dbId, alias) { insertStructureDbStmt.run(dbId, structId, alias); } -function getFilesForStruct(db, structureId) { +function getFilesForStruct(structureId) { let test = db .prepare("SELECT * FROM files WHERE structure_id = ? ORDER BY id DESC") .all(structureId); @@ -344,11 +324,11 @@ function getFilesForStruct(db, structureId) { return test; } -function getFile(db, fileId) { +function getFile(fileId) { return db.prepare("SELECT * FROM files WHERE id = ?").get(fileId); } -function createFile(db, structure_id, name, filePath, mime_type, mime_subtype) { +function createFile(structure_id, name, filePath, mime_type, mime_subtype) { return db .prepare( "INSERT INTO files (structure_id, name, path, mime_type, mime_subtype) VALUES (?, ?, ?, ?, ?)", @@ -468,7 +448,7 @@ function cloneStructure( return transaction(); } -function createLog(db, structureId, routeId, content, error = false) { +function createLog(structureId, routeId, content, error = false) { const stmt = db.prepare( "INSERT INTO logs (error, structure_id, route_id, content) VALUES (?, ?, ?, ?)", ); @@ -476,7 +456,7 @@ function createLog(db, structureId, routeId, content, error = false) { return info.lastInsertRowid; // Returns the log_id of the newly created log } -function getLogsByRoute(db, routeId) { +function getLogsByRoute(routeId) { return db .prepare( "SELECT * FROM logs WHERE route_id = ? ORDER BY created_at DESC LIMIT 50", @@ -485,7 +465,7 @@ function getLogsByRoute(db, routeId) { .reverse(); } -function getNewLogsByRoute(db, routeId, since) { +function getNewLogsByRoute(routeId, since) { return db .prepare( "SELECT * FROM logs WHERE route_id = ? AND id > ? ORDER BY created_at DESC", @@ -493,7 +473,7 @@ function getNewLogsByRoute(db, routeId, since) { .all(routeId, since); } -function getMostRecentLogIdByRoute(db, routeId) { +function getMostRecentLogIdByRoute(routeId) { const result = db .prepare("SELECT MAX(id) AS id FROM logs WHERE route_id = ?") .get(routeId); @@ -505,9 +485,9 @@ function buildScaffoldUrl(endpoint, urlParams, queryString) { return queryString ? `${populatedUrl}?${queryString}` : populatedUrl; } -function createScaffoldPage(db, routeId, content=null) { +function createScaffoldPage(routeId, content=null) { if (!content) { - const route = getRoute(db, routeId) + const route = getRoute(routeId) const endpoint = buildScaffoldUrl(route.path, route.url_params, route.query_params); content = getDefaultScaffoldContentByVerb(endpoint, route.verb); console.log("gabga", content, route, endpoint) @@ -520,14 +500,14 @@ function createScaffoldPage(db, routeId, content=null) { return info.lastInsertRowid; // Returns the scaffold_page id of the newly created scaffold page } -function getLatestScaffoldPage(db, routeId) { +function getLatestScaffoldPage(routeId) { const stmt = db.prepare( "SELECT * FROM scaffold_pages WHERE route_id = ? ORDER BY created_at DESC LIMIT 1" ); return stmt.get(routeId); } -function updateScaffoldPage(db, scaffoldPage) { +function updateScaffoldPage(scaffoldPage) { const fields = ["content"]; const values = fields.map((field) => scaffoldPage[field]); const placeholders = fields.map((field) => `${field} = ?`).join(", "); @@ -582,7 +562,7 @@ function generateModifyForm(endpoint, verb) {