diff --git a/db.js b/db.js index 343d2d3..d4c2b90 100644 --- a/db.js +++ b/db.js @@ -167,51 +167,28 @@ function getRoute(routeId) { .get(routeId); } -function updateRoute(route) { - const fields = [ - "verb", - "path", - "structure_id", - "handler", - "updated_at", - "error", - ]; - const values = fields.map((field) => route[field]); +// Update `fields` of a row by id from a plain object. Returns the run info. +function update(table, fields, obj) { const placeholders = fields.map((field) => `${field} = ?`).join(", "); + const values = fields.map((field) => obj[field]); + values.push(obj.id); + return db.prepare(`UPDATE ${table} SET ${placeholders} WHERE id = ?`).run(...values); +} - const sql = `UPDATE routes SET ${placeholders} WHERE id = ?`; - values.push(route.id); // Add routeId to the end for the WHERE clause - db.prepare(sql).run(...values); +function updateRoute(route) { + update("routes", ["verb", "path", "structure_id", "handler", "updated_at", "error"], route); } function updateDb(appDb) { - const fields = ["name", "library"]; - const values = fields.map((field) => appDb[field]); - const placeholders = fields.map((field) => `${field} = ?`).join(", "); - - const sql = `UPDATE dbs SET ${placeholders} WHERE id = ?`; - values.push(appDb.id); // Add routeId to the end for the WHERE clause - db.prepare(sql).run(...values); + update("dbs", ["name", "library"], appDb); } function updateStruct(struct) { - const fields = ["name", "route_prefix", "head_injection"]; - const values = fields.map((field) => struct[field]); - const placeholders = fields.map((field) => `${field} = ?`).join(", "); - - const sql = `UPDATE structures SET ${placeholders} WHERE id = ?`; - values.push(struct.id); // Add routeId to the end for the WHERE clause - db.prepare(sql).run(...values); + update("structures", ["name", "route_prefix", "head_injection"], struct); } function updateTemplate(template) { - const fields = ["content", "name", "test_object"]; - const values = fields.map((field) => template[field]); - const placeholders = fields.map((field) => `${field} = ?`).join(", "); - - const sql = `UPDATE templates SET ${placeholders} WHERE id = ?`; - values.push(template.id); - db.prepare(sql).run(...values); + update("templates", ["content", "name", "test_object"], template); } function getTemplates(structureId) { @@ -490,9 +467,7 @@ function createScaffoldPage(routeId, content=null) { 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) } - console.log(content, "content") const stmt = db.prepare( "INSERT INTO scaffold_pages (route_id, content) VALUES (?, ?)" ); @@ -508,17 +483,7 @@ function getLatestScaffoldPage(routeId) { } function updateScaffoldPage(scaffoldPage) { - const fields = ["content"]; - const values = fields.map((field) => scaffoldPage[field]); - const placeholders = fields.map((field) => `${field} = ?`).join(", "); - console.log(scaffoldPage, "gabababab") - - const sql = `UPDATE scaffold_pages SET ${placeholders} WHERE id = ?`; - values.push(scaffoldPage.id); // Add scaffoldPageId to the end for the WHERE clause - - const info = db.prepare(sql).run(...values); - console.log(info.changes) - return info.changes > 0; // Returns true if a row was updated, false otherwise + return update("scaffold_pages", ["content"], scaffoldPage).changes > 0; } function generatePostForm(endpoint) { diff --git a/index.js b/index.js index efa5c31..46e95bb 100644 --- a/index.js +++ b/index.js @@ -33,6 +33,15 @@ function inspectArgs(args) { return args.map((v) => util.inspect(v, INSPECT_OPTS)).join(" "); } +function formatError(e) { + return e.stack ? `${e}\n\n${e.stack}` : `${e}`; +} + +// Record a handler error in the structure's logs table. +function logError(structureId, routeId, e) { + model.createLog(structureId, routeId, formatError(e), true); +} + app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json()); app.use(express.static("public")); @@ -83,53 +92,55 @@ async function saveFile(structureId, req, uploadedFile, asset=false) { return file } -function bootstrapContext(structureId, routeId, initContext) { - const allDbInstances = {}; - function getDb(alias) { - return allDbInstances[alias]; +// A console whose log() mirrors to stdout and to the structure's logs table. +function makeConsole(structureId, routeId) { + return { + log: function (...content) { + content.forEach((c) => console.log(c)); + model.createLog(structureId, routeId, inspectArgs(content)); + }, + }; +} + +// Evaluate a db's "library" script with `sql` and `console` bound, returning +// its exports. Each library runs in its own context so nothing leaks between dbs. +function runLibrary(sql, librarySource, console) { + const libContext = vm.createContext({ sql, console, module: { exports: null } }); + vm.runInContext(librarySource, libContext); + return libContext.module.exports; +} + +// The `require(name)` targets available to a user handler. +function makeLibs(structureId, console) { + const dbs = {}; + for (let appDb of model.getDbsForStructure(structureId)) { + const sql = model.getDbInstance(appDb.id); + dbs[appDb.alias] = { library: runLibrary(sql, appDb.library, console), sql }; } + return { + eta: model.getTemplater(structureId), + db: (alias) => dbs[alias], + push: webPush, + files: { saveFile: (...args) => saveFile(structureId, ...args) }, + }; +} - // todo wrap template in data-bliss-edit-template thing since ws can't take us to the editor on a component basis?? maybe... - // for now just designing with component approach - const eta = model.getTemplater(structureId); - const structure = model.getStructure(structureId) - const __urlPrefix = structure.route_prefix; +function bootstrapContext(structureId, routeId, initContext) { + const structure = model.getStructure(structureId); + const console = makeConsole(structureId, routeId); + const libs = makeLibs(structureId, console); - const libs = { eta, db: getDb, push: webPush, files: { saveFile: (...args) => saveFile(structureId, ...args) } }; - - let dbs = model.getDbsForStructure(structureId); - let context = vm.createContext({ + return vm.createContext({ ...initContext, - require: function (str) { - return libs[str]; - }, + require: (name) => libs[name], module: { exports: null }, - console: { - log: function (...content) { - content.forEach((c) => console.log(c)); - model.createLog(structureId, routeId, inspectArgs(content)); - }, - }, + console, vapidPublicKey: vapidPublicKey, fetch: fetch, clearTimeout: clearTimeout, setTimeout: setTimeout, - route: function (url) { - return withPrefix(__urlPrefix, url); - }, + route: makeRoute(structure), }); - for (let appDb of dbs) { - let dbInstance = model.getDbInstance(appDb.id); - context.sql = dbInstance; - vm.runInContext(appDb.library, context); - allDbInstances[appDb.alias] = { - library: context.module.exports, - sql: dbInstance, - }; - context.module.exports = null; - } - - return context; } function withPrefix(prefix, url) { @@ -160,28 +171,19 @@ function bootstrapWebsocketHandler(route) { cb(...args) } catch (e) { - const error = e.stack ? `${e}\n\n${e.stack}` : `${e}`; - model.createLog(route.structure_id, route.id, error, true); + logError(route.structure_id, route.id, e); } }) } - ws.render = (template, context) => { - context = context || {}; - const eta = model.getTemplater(route.structure_id); + ws.render = (template, context = {}) => { const structure = model.getStructure(route.structure_id); - context.route = function (url) { - return withPrefix(structure.route_prefix, url); - }; - + context.route = makeRoute(structure); ws.send( - bootstrapTemplateWithHTMXetc( - eta.render(template, context), - `/workshop/${route.structure_id}/route/${route.id}`, - `/workshop/${route.structure_id}/clone_modal/`, - route.verb == "GET" ? embedHTML(req.originalUrl) : null, - structure.head_injection, - htmxRequest = true, - ), + decorate(renderTemplate(route.structure_id, template, context), { + headInjection: structure.head_injection, + source: sourceFor(route, req), + fragment: true, + }), ); }; @@ -195,14 +197,12 @@ function bootstrapWebsocketHandler(route) { try { return handler(ws, req) } catch (e) { - const error = e.stack ? `${e}\n\n${e.stack}` : `${e}`; - model.createLog(route.structure_id, route.id, error, true); + logError(route.structure_id, route.id, e); } } model.updateRoute({ ...route, error: null }); } catch (e) { - const error = e.stack ? `${e}\n\n${e.stack}` : `${e}`; - model.createLog(route.structure_id, route.id, error, true); + logError(route.structure_id, route.id, e); model.updateRoute({ ...route, error: e.stack }); } } @@ -297,30 +297,10 @@ app.all("/logout", async (req, res) => { // | _ || || | | || _ | _____| || _ || || | // |__| |__||_______||___| |_||___| |_||_______||__| |__||_______||___| // -function bootstrapTemplateWithHTMXetc( - htmlString, - blissRoute, - blissClone, - blissCopy, - headInjection, - htmxRequest = false, -) { - if ( - htmlString.toLowerCase().startsWith("") || - htmlString.toLowerCase().startsWith(""); - head = $("head"); - } - - head.attr("id", "head"); - - head.append(` +// The chrome injected into the
of every rendered page: the client-side +// stack (htmx/hyperscript/tailwind) plus the in-page editor overlay. +function headChrome(headInjection) { + return ` @@ -335,48 +315,96 @@ function bootstrapTemplateWithHTMXetc( ${headInjection || ""} - `); + `; +} - if (blissRoute) { - $("body").attr("data-bliss-route", blissRoute); - $("body").attr("data-bliss-clone", blissClone); - if (blissCopy) $("body").attr("data-bliss-copy", blissCopy); - } +// A `source` is the provenance of a rendered fragment: which structure/route +// produced it, and (for GET routes) the snippet that re-embeds it. It becomes +// the data-bliss-* attributes the inspector reads. +function blissAttrs(source) { + if (!source) return null; + const attrs = { + "data-bliss-route": `/workshop/${source.structureId}/route/${source.routeId}`, + "data-bliss-clone": `/workshop/${source.structureId}/clone_modal/`, + }; + if (source.copyUrl) attrs["data-bliss-copy"] = source.copyUrl; + return attrs; +} - htmlString = $.html(); - } else if (htmxRequest && blissRoute) { - const $ = cheerio.load(htmlString, null, false); +// Full HTML document: inject the head chrome and stamp provenance on . +function decoratePage(html, { headInjection, source } = {}) { + const $ = cheerio.load(html); + let head = $("head"); + if (head.length === 0) { + $("html").prepend(""); + head = $("head"); + } + head.attr("id", "head"); + head.append(headChrome(headInjection)); + + const attrs = blissAttrs(source); + if (attrs) { + for (const [k, v] of Object.entries(attrs)) $("body").attr(k, v); + } + return $.html(); +} + +// HTMX partial: stamp provenance on the swapped-in elements (an oob swap loses +// its own attrs, so we descend into its children) and append an out-of-band +// so head injection still reaches the page. +function decorateFragment(html, { headInjection, source } = {}) { + const $ = cheerio.load(html, null, false); + const attrs = blissAttrs(source); + if (attrs) { const targets = []; - - // when we return an oob thing from htmx, we'll lose all the attrs when it swaps - // so this adds those attrs to the children of the swap if possible - for (let child of $.root().children()) { + for (const child of $.root().children()) { if (child.attribs && child.attribs["hx-swap-oob"]) { - for (let childchild of child.children) { - if (childchild.attribs) { - targets.push(childchild); - } + for (const grandchild of child.children) { + if (grandchild.attribs) targets.push(grandchild); } } else { targets.push(child); } } - - // Directly update attributes without wrapping in Cheerio - for (let target of targets) { - target.attribs["data-bliss-route"] = blissRoute; - target.attribs["data-bliss-clone"] = blissClone; - if (blissCopy) target.attribs["data-bliss-copy"] = blissCopy; - } - - htmlString = $.html(); - - if (htmxRequest) { - htmlString += `${headInjection}`; - } + for (const target of targets) Object.assign(target.attribs, attrs); } + return ( + $.html() + + `${headInjection || ""}` + ); +} - return htmlString; +// Single entry point for turning rendered HTML into a response body. A full +// document (or any non-htmx request) becomes a decorated page; an htmx partial +// carrying provenance becomes a decorated fragment; anything else passes through. +function decorate(html, { headInjection, source, fragment } = {}) { + const lower = html.toLowerCase(); + const isFullDoc = + lower.startsWith("") || lower.startsWith(" withPrefix(structure.route_prefix, url); +} + +function renderTemplate(structureId, templateName, context) { + return model.getTemplater(structureId).render(templateName, context); +} + +// Provenance for a rendered response: GET routes are re-embeddable, so they +// carry the copy-embed snippet; other verbs don't. +function sourceFor(route, req) { + return { + structureId: route.structure_id, + routeId: route.id, + copyUrl: route.verb === "GET" ? embedHTML(req.originalUrl) : null, + }; } app.post("/workshop", (req, res) => { @@ -391,7 +419,7 @@ app.get("/workshop", (req, res) => { }); function renderWorkshop(res, template, context) { - res.send(bootstrapTemplateWithHTMXetc(eta.render(template, context))); + res.send(decorate(eta.render(template, context))); } function smartRedirect(req, res, redirectUrl) { @@ -599,24 +627,16 @@ app.get("/workshop/:structure_id/template/:template_id", (req, res) => { app.get("/workshop/:structure_id/template/:template_id/preview", (req, res) => { const template = model.getTemplate(req.params.template_id); const struct = model.getStructure(req.params.structure_id); - const eta = model.getTemplater(req.params.structure_id); const context = vm.createContext({ it: null }); vm.runInContext(template.test_object, context); - context.it.route = function (url) { - return withPrefix(struct.route_prefix, url); - }; + context.it.route = makeRoute(struct); return res.send( - bootstrapTemplateWithHTMXetc( - eta.render(template.name, context.it), - null, - null, - null, - struct.head_injection, - false, - ), + decorate(renderTemplate(req.params.structure_id, template.name, context.it), { + headInjection: struct.head_injection, + }), ); }); @@ -647,21 +667,12 @@ app.get("/workshop/:structure_id/route/:route_id/preview", (req, res) => { const route = model.getRoute(req.params.route_id); const struct = model.getStructure(req.params.structure_id); - const it = { - route: function (url) { - return withPrefix(struct.route_prefix, url); - } - } + const it = { route: makeRoute(struct) }; return res.send( - bootstrapTemplateWithHTMXetc( - eta.renderString(route.scaffold_page_content, it), - null, - null, - null, - struct.head_injection, - false, - ), + decorate(eta.renderString(route.scaffold_page_content, it), { + headInjection: struct.head_injection, + }), ); }); @@ -792,21 +803,21 @@ app.all("*", async (req, res) => { try { let routeMatch = null; + // routes[verb] is ordered most-recently-updated first (getAllRoutes sorts + // by updated_at DESC), so the first match is the most recently saved route. for (let { matcher, id } of routes[verb]) { let matchFromRoute = matcher(req_path); if (matchFromRoute) { routeMatch = { params: matchFromRoute.params, id: id }; + break; } } if (routeMatch) { req.params = routeMatch.params; - const route = db - .prepare("SELECT * FROM routes WHERE id = ?") - .get(routeMatch.id); + const route = model.getRoute(routeMatch.id); const structure = model.getStructure(route.structure_id); - const __urlPrefix = structure.route_prefix; try { // todo: only add req res to contexst when running the handler() @@ -817,21 +828,14 @@ app.all("*", async (req, res) => { eta, }); - res.render = (template, context) => { - context = context || {}; - context.route = function (url) { - return withPrefix(__urlPrefix, url); - }; - const eta = model.getTemplater(route.structure_id); + res.render = (template, context = {}) => { + context.route = makeRoute(structure); res.send( - bootstrapTemplateWithHTMXetc( - eta.render(template, context), - `/workshop/${route.structure_id}/route/${route.id}`, - `/workshop/${route.structure_id}/clone_modal/`, - verb == "GET" ? embedHTML(req.originalUrl) : null, - structure.head_injection, - req.headers["hx-request"], - ), + decorate(renderTemplate(route.structure_id, template, context), { + headInjection: structure.head_injection, + source: sourceFor(route, req), + fragment: req.headers["hx-request"], + }), ); }; const handlerScript = new vm.Script(route.handler, { filename: `handler_${route.id}.js` }); @@ -840,9 +844,8 @@ app.all("*", async (req, res) => { await handlerScript.runInContext(context); const result = await executionScript.runInContext(context); } catch (e) { - const error = e.stack ? `${e}\n\n${e.stack}` : `${e}`; - model.createLog(structure.id, route.id, error, true); - return res.status(500).send(error); + logError(structure.id, route.id, e); + return res.status(500).send(formatError(e)); } } else { res.status(404).json({ success: false, message: "Path not found" });